check / check (push) Waiting to run
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
357 lines
9.7 KiB
Go
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
|
|
}
|