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
313 lines
8.1 KiB
Go
313 lines
8.1 KiB
Go
// Package cli implements the mfer command-line interface.
|
|
package cli
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
"github.com/spf13/afero"
|
|
"github.com/urfave/cli/v2"
|
|
"sneak.berlin/go/mfer/internal/log"
|
|
"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(
|
|
"%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 {
|
|
log.Debug("checkManifestOperation()")
|
|
|
|
manifestPath, err := mfa.resolveManifestArg(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("check: %w", err)
|
|
}
|
|
|
|
// URL manifests need to be downloaded to a temp file for the checker
|
|
if isHTTPURL(manifestPath) {
|
|
tmpPath, tmpErr := mfa.fetchManifestToTemp(manifestPath)
|
|
if tmpErr != nil {
|
|
return fmt.Errorf("check: %w", tmpErr)
|
|
}
|
|
|
|
defer func() { _ = mfa.Fs.Remove(tmpPath) }()
|
|
|
|
manifestPath = tmpPath
|
|
}
|
|
|
|
basePath := ctx.String("base")
|
|
showProgress := ctx.Bool("progress")
|
|
|
|
log.Infof("checking manifest %s with base %s", manifestPath, basePath)
|
|
|
|
// Create checker
|
|
chk, err := mfer.NewChecker(manifestPath, basePath, mfa.Fs)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load manifest: %w", err)
|
|
}
|
|
|
|
// Check signature requirement
|
|
requiredSigner := ctx.String("require-signature")
|
|
if requiredSigner != "" {
|
|
err = verifyRequiredSigner(chk, requiredSigner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
log.Infof("manifest contains %d files, %s", chk.FileCount(),
|
|
humanize.IBytes(safeUint64(int64(chk.TotalBytes()))))
|
|
|
|
failures, err := runCheck(ctx, chk, showProgress)
|
|
if err != nil {
|
|
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(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(safeUint64(int64(chk.TotalBytes()))),
|
|
elapsed, humanize.IBytes(uint64(rate)), failures)
|
|
}
|
|
|
|
if failures > 0 {
|
|
mfa.exitCode = 1
|
|
}
|
|
|
|
return nil
|
|
}
|