Files
mfer/internal/cli/check.go
T
sneak 89adac874b
check / check (push) Waiting to run
Never report index.mf at the top of the base as unlisted in check (closes #229)
gen and fetch write the manifest as index.mf at the top of the tree.
check left out only the manifest file it was given, so given a URL, or a
manifest stored elsewhere, it reported that index.mf as not in the
manifest, and --no-extra-files failed a tree fetch had just verified.
check now drops that one report for index.mf at the top of the base
directory. A manifest that lists index.mf still has it checked, and
index.mf in a subdirectory or fetch's leftover .index.mf.tmp are still
reported. The change is in package cli, which owns the default manifest
name; the Checker in package mfer is unchanged.

Model: opus-5-5
2026-10-15 22:12:08 +00:00

357 lines
9.7 KiB
Go

// Package cli implements the mfer command-line interface.
package cli
import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"io"
"math"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/dustin/go-humanize"
"github.com/spf13/afero"
"github.com/urfave/cli/v3"
"sneak.berlin/go/mfer/internal/log"
"sneak.berlin/go/mfer/mfer"
)
// fingerprintHexLen is the length in hex characters of the fingerprint of
// an OpenPGP version 4 key, the only version mfer signs with.
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 returns the path of the manifest with the default name in
// dir, or an error if there is none.
func findManifest(fs afero.Fs, dir string) (string, error) {
path := filepath.Join(dir, defaultManifestName)
exists, err := afero.Exists(fs, path)
if err != nil {
return "", err
}
if !exists {
return "", fmt.Errorf("%w in %s (looked for %s)",
errNoManifestFound, dir, defaultManifestName)
}
return path, nil
}
// downloadManifest downloads the manifest at url into memory. It refuses a
// manifest larger than mfa.maxManifestSize.
func (mfa *CLIApp) downloadManifest(ctx context.Context, url string) ([]byte, error) {
rc, err := mfa.openManifestReader(ctx, url)
if err != nil {
return nil, err
}
defer func() { _ = rc.Close() }()
// Reading stops one byte past mfa.maxManifestSize, which is enough to
// tell that the manifest is too large.
manifestData, err := io.ReadAll(io.LimitReader(rc, mfa.maxManifestSize+1))
if err != nil {
return nil, fmt.Errorf("download manifest: %w", err)
}
if int64(len(manifestData)) > mfa.maxManifestSize {
return nil, fmt.Errorf("download manifest: %w of %d bytes",
errManifestTooLarge, mfa.maxManifestSize)
}
return manifestData, nil
}
// verifyRequiredSigner enforces the --require-signature fingerprint
// against signer, the fingerprint of the key that made the manifest's
// signature, which is empty when the manifest is not signed. Loading the
// manifest checked that signer is that key's fingerprint.
func verifyRequiredSigner(signer, 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: %w", err)
}
if signer == "" {
return fmt.Errorf("%w, but signature from %s is required",
errManifestNotSigned, requiredSigner)
}
// Compare fingerprints - must be exact match (case-insensitive)
if !strings.EqualFold(signer, requiredSigner) {
return fmt.Errorf("embedded signing key fingerprint %s %w %s",
signer, errSignerMismatch, requiredSigner)
}
log.Infof("manifest signature verified (signer: %s)", signer)
return nil
}
// reportCheckProgress renders progress updates until the channel closes.
func reportCheckProgress(progress <-chan mfer.CheckStatus, wg *sync.WaitGroup) {
defer wg.Done()
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 failures and logging
// each at error level so that --quiet still shows them, 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.Errorf("%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, and anything the search cannot read: each is a failure, logged
// at error level, under --no-extra-files, otherwise a warning. index.mf at
// the top of the base directory is never reported as absent from the
// manifest, even when check is given another manifest or a URL, since gen
// and fetch write the manifest there.
func findExtraFiles(
ctx context.Context, cmd *cli.Command, chk *mfer.Checker, failures *int64,
) error {
extraResults := make(chan mfer.Result, 1)
extraDone := make(chan struct{})
go func() {
for result := range extraResults {
if result.Status == mfer.StatusExtra && result.Path == defaultManifestName {
continue
}
if cmd.Bool("no-extra-files") {
*failures++
log.Errorf("%s: %s (%s)", result.Status, result.Path, result.Message)
} else {
log.Warnf("%s: %s (%s)", result.Status, result.Path, result.Message)
}
}
close(extraDone)
}()
err := chk.FindExtraFiles(ctx, extraResults)
if err != nil {
return fmt.Errorf("find 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 context.Context, cmd *cli.Command, 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
progressWg sync.WaitGroup
)
if showProgress {
progress = make(chan mfer.CheckStatus, 1)
progressWg.Add(1)
go reportCheckProgress(progress, &progressWg)
}
// Process results in a goroutine
var failures int64
done := make(chan struct{})
go countCheckFailures(results, &failures, done)
// Run check
err := chk.Check(ctx, results, progress)
progressWg.Wait()
if err != nil {
return 0, fmt.Errorf("check files: %w", err)
}
// Wait for results processing to complete
<-done
err = findExtraFiles(ctx, cmd, chk, &failures)
if err != nil {
return 0, err
}
return failures, nil
}
func (mfa *CLIApp) checkManifestOperation(
ctx context.Context, cmd *cli.Command,
) error {
log.Debug("checkManifestOperation()")
manifestPath, err := mfa.resolveManifestArg(cmd)
if err != nil {
return err
}
basePath := resolveBasePath(cmd, manifestPath)
opts := &mfer.CheckerOptions{BasePath: basePath, Fs: mfa.Fs}
// A manifest given by URL is downloaded into memory and read from
// there.
if isHTTPURL(manifestPath) {
manifestData, downloadErr := mfa.downloadManifest(ctx, manifestPath)
if downloadErr != nil {
return downloadErr
}
opts.ManifestReader = bytes.NewReader(manifestData)
} else {
opts.ManifestPath = manifestPath
}
showProgress := cmd.Bool("progress")
log.Infof("checking manifest %s with base %s", manifestPath, basePath)
chk, err := mfer.NewChecker(opts)
if err != nil {
return fmt.Errorf("load manifest: %w", err)
}
// Check signature requirement. An empty fingerprint is checked too,
// and refused as one of the wrong length.
if cmd.IsSet(flagRequireSignature) {
err = verifyRequiredSigner(string(chk.Signer()), cmd.String(flagRequireSignature))
if err != nil {
return err
}
}
log.Infof("manifest contains %d files, %s", chk.FileCount(),
humanize.IBytes(safeUint64(int64(chk.TotalBytes()))))
failures, err := runCheck(ctx, cmd, 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
}