Update golangci-lint to v2.12.2 with canonical config (closes #60)
Some checks failed
check / check (push) Has been cancelled

Adopts golangci-lint v2.12.2 and the canonical .golangci.yml (default: all), and fixes all resulting findings across the tree.

Two intended behavior changes: absent MFFilePath.Mtime is handled explicitly in freshen, list and export rather than dereferenced (main panicked); gpg positional key IDs now follow an explicit -- end-of-options marker.

All twelve reworded user-visible error messages restored to byte-identical parity with main and pinned by tests.
This commit was merged in pull request #59.
This commit is contained in:
2026-08-10 16:06:12 +02:00
parent 6d19de74e7
commit de476708e9
40 changed files with 4012 additions and 1798 deletions

View File

@@ -1,10 +1,14 @@
// Package cli implements the mfer command-line interface.
package cli
import (
"encoding/hex"
"errors"
"fmt"
"io"
"math"
"path/filepath"
"strconv"
"strings"
"time"
@@ -15,21 +19,254 @@ import (
"sneak.berlin/go/mfer/mfer"
)
// fingerprintHexLen is the length of a full GPG key fingerprint in hex
// characters.
const fingerprintHexLen = 40
var (
// errNoManifestFound indicates no manifest file was found in the
// searched directory.
errNoManifestFound = errors.New("no manifest found")
// errInvalidFingerprint indicates a malformed --require-signature
// fingerprint argument. The length is spliced in from
// fingerprintHexLen so the two cannot drift apart.
errInvalidFingerprint = errors.New(
"invalid fingerprint: must be exactly " +
strconv.Itoa(fingerprintHexLen) + " hex characters")
// errManifestNotSigned indicates a signature was required but the
// manifest is unsigned. It is wrapped mid-sentence so that the
// rendered message stays exactly as mfer has always printed it.
errManifestNotSigned = errors.New("manifest is not signed")
// errSignerMismatch indicates the embedded signing key fingerprint
// does not match the required signer. Its text is the mid-sentence
// fragment of the rendered message, which users grep for in CI and
// which must therefore not change; match it with errors.Is rather
// than by reading it.
errSignerMismatch = errors.New("does not match required")
)
// safeUint64 converts a non-negative int64 to uint64, clamping negative
// values to zero.
func safeUint64(n int64) uint64 {
if n < 0 {
return 0
}
return uint64(n)
}
// safeRateUint64 converts a bytes-per-second rate to uint64 for display.
//
// A rate is computed as bytes/elapsed, so it is +Inf when the elapsed
// time rounds to zero and NaN when zero bytes were processed in zero
// time. Neither has a defined conversion to uint64, and on amd64 +Inf
// converts to a number that renders as "8.0 EiB/s"; both display as zero
// instead.
func safeRateUint64(rate float64) uint64 {
if math.IsNaN(rate) || math.IsInf(rate, 0) || rate <= 0 {
return 0
}
if rate >= math.MaxUint64 {
return math.MaxUint64
}
return uint64(rate)
}
// findManifest looks for a manifest file in the given directory.
// It checks for index.mf and .index.mf, returning the first one found.
func findManifest(fs afero.Fs, dir string) (string, error) {
candidates := []string{"index.mf", ".index.mf"}
for _, name := range candidates {
path := filepath.Join(dir, name)
exists, err := afero.Exists(fs, path)
if err != nil {
return "", err
}
if exists {
return path, nil
}
}
return "", fmt.Errorf("no manifest found in %s (looked for index.mf and .index.mf)", dir)
return "", fmt.Errorf(
"%w in %s (looked for index.mf and .index.mf)", errNoManifestFound, dir)
}
// fetchManifestToTemp downloads a manifest URL to a temporary file and
// returns the temp file path. The caller is responsible for removing it.
func (mfa *CLIApp) fetchManifestToTemp(url string) (string, error) {
rc, fetchErr := mfa.openManifestReader(url)
if fetchErr != nil {
return "", fetchErr
}
tmpFile, tmpErr := afero.TempFile(mfa.Fs, "", "mfer-manifest-*.mf")
if tmpErr != nil {
_ = rc.Close()
return "", fmt.Errorf("failed to create temp file: %w", tmpErr)
}
tmpPath := tmpFile.Name()
_, cpErr := io.Copy(tmpFile, rc)
_ = rc.Close()
_ = tmpFile.Close()
if cpErr != nil {
_ = mfa.Fs.Remove(tmpPath)
return "", fmt.Errorf("failed to download manifest: %w", cpErr)
}
return tmpPath, nil
}
// verifyRequiredSigner enforces the --require-signature fingerprint
// against the manifest's embedded signing key.
func verifyRequiredSigner(chk *mfer.Checker, requiredSigner string) error {
// Validate fingerprint format: must be exactly 40 hex characters
if len(requiredSigner) != fingerprintHexLen {
return fmt.Errorf("%w, got %d", errInvalidFingerprint, len(requiredSigner))
}
_, err := hex.DecodeString(requiredSigner)
if err != nil {
return fmt.Errorf("invalid fingerprint: must be valid hex: %w", err)
}
if !chk.IsSigned() {
return fmt.Errorf("%w, but signature from %s is required",
errManifestNotSigned, requiredSigner)
}
// Extract fingerprint from the embedded public key (not from the
// signer field). This validates the key is importable and gets its
// actual fingerprint.
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP()
if err != nil {
return fmt.Errorf(
"failed to extract fingerprint from embedded signing key: %w", err)
}
// Compare fingerprints - must be exact match (case-insensitive)
if !strings.EqualFold(embeddedFP, requiredSigner) {
return fmt.Errorf("embedded signing key fingerprint %s %w %s",
embeddedFP, errSignerMismatch, requiredSigner)
}
log.Infof("manifest signature verified (signer: %s)", embeddedFP)
return nil
}
// reportCheckProgress renders progress updates until the channel closes.
func reportCheckProgress(progress <-chan mfer.CheckStatus) {
for status := range progress {
if status.ETA > 0 {
log.Progressf("Checking: %d/%d files, %s/s, ETA %s, %d failures",
status.CheckedFiles,
status.TotalFiles,
humanize.IBytes(safeRateUint64(status.BytesPerSec)),
status.ETA.Round(time.Second),
status.Failures)
} else {
log.Progressf("Checking: %d/%d files, %s/s, %d failures",
status.CheckedFiles,
status.TotalFiles,
humanize.IBytes(safeRateUint64(status.BytesPerSec)),
status.Failures)
}
}
log.ProgressDone()
}
// countCheckFailures consumes check results, counting and logging
// failures, then closes done.
func countCheckFailures(
results <-chan mfer.Result, failures *int64, done chan<- struct{},
) {
for result := range results {
if result.Status != mfer.StatusOK {
*failures++
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
} else {
log.Verbosef("%s: %s", result.Status, result.Path)
}
}
close(done)
}
// findExtraFiles reports files present on disk but absent from the
// manifest, counting each as a failure.
func findExtraFiles(ctx *cli.Context, chk *mfer.Checker, failures *int64) error {
extraResults := make(chan mfer.Result, 1)
extraDone := make(chan struct{})
go func() {
for result := range extraResults {
*failures++
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
}
close(extraDone)
}()
err := chk.FindExtraFiles(ctx.Context, extraResults)
if err != nil {
return fmt.Errorf("failed to check for extra files: %w", err)
}
<-extraDone
return nil
}
// runCheck runs the manifest check with progress and result reporting
// and returns the number of failures.
func runCheck(ctx *cli.Context, chk *mfer.Checker, showProgress bool) (int64, error) {
// Set up results channel
results := make(chan mfer.Result, 1)
// Set up progress channel
var progress chan mfer.CheckStatus
if showProgress {
progress = make(chan mfer.CheckStatus, 1)
go reportCheckProgress(progress)
}
// Process results in a goroutine
var failures int64
done := make(chan struct{})
go countCheckFailures(results, &failures, done)
// Run check
err := chk.Check(ctx.Context, results, progress)
if err != nil {
return 0, fmt.Errorf("check failed: %w", err)
}
// Wait for results processing to complete
<-done
// Check for extra files if requested
if ctx.Bool("no-extra-files") {
err = findExtraFiles(ctx, chk, &failures)
if err != nil {
return 0, err
}
}
return failures, nil
}
func (mfa *CLIApp) checkManifestOperation(ctx *cli.Context) error {
@@ -42,24 +279,13 @@ func (mfa *CLIApp) checkManifestOperation(ctx *cli.Context) error {
// URL manifests need to be downloaded to a temp file for the checker
if isHTTPURL(manifestPath) {
rc, fetchErr := mfa.openManifestReader(manifestPath)
if fetchErr != nil {
return fmt.Errorf("check: %w", fetchErr)
}
tmpFile, tmpErr := afero.TempFile(mfa.Fs, "", "mfer-manifest-*.mf")
tmpPath, tmpErr := mfa.fetchManifestToTemp(manifestPath)
if tmpErr != nil {
_ = rc.Close()
return fmt.Errorf("check: failed to create temp file: %w", tmpErr)
}
tmpPath := tmpFile.Name()
_, cpErr := io.Copy(tmpFile, rc)
_ = rc.Close()
_ = tmpFile.Close()
if cpErr != nil {
_ = mfa.Fs.Remove(tmpPath)
return fmt.Errorf("check: failed to download manifest: %w", cpErr)
return fmt.Errorf("check: %w", tmpErr)
}
defer func() { _ = mfa.Fs.Remove(tmpPath) }()
manifestPath = tmpPath
}
@@ -77,111 +303,31 @@ func (mfa *CLIApp) checkManifestOperation(ctx *cli.Context) error {
// Check signature requirement
requiredSigner := ctx.String("require-signature")
if requiredSigner != "" {
// Validate fingerprint format: must be exactly 40 hex characters
if len(requiredSigner) != 40 {
return fmt.Errorf("invalid fingerprint: must be exactly 40 hex characters, got %d", len(requiredSigner))
}
if _, err := hex.DecodeString(requiredSigner); err != nil {
return fmt.Errorf("invalid fingerprint: must be valid hex: %w", err)
}
if !chk.IsSigned() {
return fmt.Errorf("manifest is not signed, but signature from %s is required", requiredSigner)
}
// Extract fingerprint from the embedded public key (not from the signer field)
// This validates the key is importable and gets its actual fingerprint
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP()
err = verifyRequiredSigner(chk, requiredSigner)
if err != nil {
return fmt.Errorf("failed to extract fingerprint from embedded signing key: %w", err)
return err
}
// Compare fingerprints - must be exact match (case-insensitive)
if !strings.EqualFold(embeddedFP, requiredSigner) {
return fmt.Errorf("embedded signing key fingerprint %s does not match required %s", embeddedFP, requiredSigner)
}
log.Infof("manifest signature verified (signer: %s)", embeddedFP)
}
log.Infof("manifest contains %d files, %s", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes())))
log.Infof("manifest contains %d files, %s", chk.FileCount(),
humanize.IBytes(safeUint64(int64(chk.TotalBytes()))))
// Set up results channel
results := make(chan mfer.Result, 1)
// Set up progress channel
var progress chan mfer.CheckStatus
if showProgress {
progress = make(chan mfer.CheckStatus, 1)
go func() {
for status := range progress {
if status.ETA > 0 {
log.Progressf("Checking: %d/%d files, %s/s, ETA %s, %d failures",
status.CheckedFiles,
status.TotalFiles,
humanize.IBytes(uint64(status.BytesPerSec)),
status.ETA.Round(time.Second),
status.Failures)
} else {
log.Progressf("Checking: %d/%d files, %s/s, %d failures",
status.CheckedFiles,
status.TotalFiles,
humanize.IBytes(uint64(status.BytesPerSec)),
status.Failures)
}
}
log.ProgressDone()
}()
}
// Process results in a goroutine
var failures int64
done := make(chan struct{})
go func() {
for result := range results {
if result.Status != mfer.StatusOK {
failures++
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
} else {
log.Verbosef("%s: %s", result.Status, result.Path)
}
}
close(done)
}()
// Run check
err = chk.Check(ctx.Context, results, progress)
failures, err := runCheck(ctx, chk, showProgress)
if err != nil {
return fmt.Errorf("check failed: %w", err)
}
// Wait for results processing to complete
<-done
// Check for extra files if requested
if ctx.Bool("no-extra-files") {
extraResults := make(chan mfer.Result, 1)
extraDone := make(chan struct{})
go func() {
for result := range extraResults {
failures++
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
}
close(extraDone)
}()
err = chk.FindExtraFiles(ctx.Context, extraResults)
if err != nil {
return fmt.Errorf("failed to check for extra files: %w", err)
}
<-extraDone
return err
}
elapsed := time.Since(mfa.startupTime).Seconds()
rate := float64(chk.TotalBytes()) / elapsed
if failures == 0 {
log.Infof("checked %d files (%s) in %.1fs (%s/s): all OK", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes())), elapsed, humanize.IBytes(uint64(rate)))
log.Infof("checked %d files (%s) in %.1fs (%s/s): all OK",
chk.FileCount(), humanize.IBytes(safeUint64(int64(chk.TotalBytes()))),
elapsed, humanize.IBytes(safeRateUint64(rate)))
} else {
log.Infof("checked %d files (%s) in %.1fs (%s/s): %d failed", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes())), elapsed, humanize.IBytes(uint64(rate)), failures)
log.Infof("checked %d files (%s) in %.1fs (%s/s): %d failed",
chk.FileCount(), humanize.IBytes(safeUint64(int64(chk.TotalBytes()))),
elapsed, humanize.IBytes(safeRateUint64(rate)), failures)
}
if failures > 0 {