check / check (push) Successful in 1m0s
Every gpg invocation went through runGPG, which built its command with exec.CommandContext(context.Background(), ...). That is the right call with the wrong context: context.Background() never expires, so no deadline was ever enforced on any of the five gpg call sites. runGPG now takes a context and derives a gpgTimeout deadline from it, honouring an earlier caller deadline when there is one. The gpg-touching library entry points take a ctx as their first argument so cancellation propagates from above: Builder.Build, NewManifestFromReader, NewManifestFromFile, NewChecker, Checker.ExtractEmbeddedSigningKeyFP. Scanner.ToManifest already had a ctx and now passes it down, which withdraws the //nolint:contextcheck claiming signing was "not cancellable by design" -- it is, and now it is. A deadline alone is not enough, and the added test proves it. gpg delegates to helpers (gpg-agent, pinentry) that inherit the captured stdout and stderr pipes. Go's default cancellation kills only the direct child, so the helper keeps the pipes open and Cmd.Wait blocks on the output-copying goroutines forever -- a dead process and a call that still never returns. Two additions fix that: the child runs in its own process group and cancellation kills the group, and Cmd.WaitDelay caps how long Wait will hold on for the pipes if something escapes the group anyway. Measured with the stand-in gpg from the new test: neither mechanism, hangs until `go test` gives up; WaitDelay only, returns in 2.2s; both, returns in 0.20s. Timeout errors now name the operation and how long gpg ran instead of surfacing a bare "signal: killed" or "context deadline exceeded", and a cancellation from above is reported as a cancellation rather than a timeout, so an abort is distinguishable from a stall. The test helper's own keygen invocations had the same unbounded context.Background() and the same pipe-inheriting agent problem, which makes them the actual mechanism behind the intermittent suite timeout noted in the issue: keygen starts gpg-agent, and a stalled agent hung the suite rather than failing it. They now run under a deadline with the same hardening, so a broken gpg environment skips instead of hanging. Verified with a cold `docker buildx build --no-cache`: prettier, gofmt, `make lint` (0 issues) and `make test` all executed and passed. The golang:1.23 image ships gpg, so the real signing, export, fingerprint, import and verify tests run against real gpg there, not skipped. The process-group kill is unix-only and lives in a build-tagged file; on other platforms the deadline is still enforced via cancellation plus WaitDelay, only the group kill of helpers is unavailable.
342 lines
9.1 KiB
Go
342 lines
9.1 KiB
Go
// Package cli implements the mfer command-line interface.
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"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(
|
|
ctx context.Context, 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(ctx)
|
|
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(ctx.Context, 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(ctx.Context, 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
|
|
}
|