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.
339 lines
9.1 KiB
Go
339 lines
9.1 KiB
Go
// Package cli implements the mfer command-line interface.
|
|
package cli
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"path/filepath"
|
|
"strconv"
|
|
"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. 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(
|
|
"%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 {
|
|
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(safeRateUint64(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(safeRateUint64(rate)), failures)
|
|
}
|
|
|
|
if failures > 0 {
|
|
mfa.exitCode = 1
|
|
}
|
|
|
|
return nil
|
|
}
|