Update golangci-lint to v2.12.2 with canonical config
All checks were successful
check / check (push) Successful in 39s
All checks were successful
check / check (push) Successful in 39s
- Add canonical .golangci.yml (v2 schema, default: all, project thresholds for lll/funlen/cyclop/dupl) - Bump golangci-lint pins from v2.0.2 to v2.12.2 in Makefile (go install, new /v2 module path) and Dockerfile (tagged+digest Debian image pin) - Fix all lint findings surfaced by the new linter set across cmd/mfer, internal/bork, internal/cli, internal/log, and mfer: static sentinel errors (err113), context-aware HTTP and exec (noctx), guarded integer conversions and stricter permissions (gosec), named constants (mnd, goconst), function decomposition (funlen, cyclop, gocognit, nestif), declaration ordering (funcorder), t.Parallel/t.TempDir/t.Setenv adoption in tests (paralleltest, usetesting), protobuf getters (protogetter), plus formatting and style cleanups (wsl_v5, nlreturn, lll, revive, testifylint, and others) - Serialize CLI runs in tests behind a mutex so parallel tests do not cross-wire the process-global logger's captured output
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
// Package cli implements the mfer command-line interface.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
@@ -15,21 +17,230 @@ 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.
|
||||
errInvalidFingerprint = errors.New(
|
||||
"invalid fingerprint: must be exactly 40 hex characters")
|
||||
// errManifestNotSigned indicates a signature was required but the
|
||||
// manifest is unsigned.
|
||||
errManifestNotSigned = errors.New(
|
||||
"manifest is not signed, but a signature is required")
|
||||
// errSignerMismatch indicates the embedded signing key fingerprint
|
||||
// does not match the required signer.
|
||||
errSignerMismatch = errors.New(
|
||||
"embedded signing key fingerprint does not match required signer")
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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: %s", 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("%w: %s != %s", errSignerMismatch, embeddedFP,
|
||||
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(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()
|
||||
}
|
||||
|
||||
// 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 +253,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 +277,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(uint64(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(uint64(rate)), failures)
|
||||
}
|
||||
|
||||
if failures > 0 {
|
||||
|
||||
Reference in New Issue
Block a user