// 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 }