package cli import ( "encoding/hex" "fmt" "io" "path/filepath" "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" ) // 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("no manifest found in %s (looked for index.mf and .index.mf)", dir) } 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) { rc, fetchErr := mfa.openManifestReader(manifestPath) if fetchErr != nil { return fmt.Errorf("check: %w", fetchErr) } tmpFile, tmpErr := afero.TempFile(mfa.Fs, "", "mfer-manifest-*.mf") if tmpErr != nil { _ = rc.Close() return fmt.Errorf("check: 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("check: failed to download manifest: %w", cpErr) } 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 != "" { // Validate fingerprint format: must be exactly 40 hex characters if len(requiredSigner) != 40 { return fmt.Errorf("invalid fingerprint: must be exactly 40 hex characters, got %d", len(requiredSigner)) } if _, err := hex.DecodeString(requiredSigner); err != nil { return fmt.Errorf("invalid fingerprint: must be valid hex: %w", err) } if !chk.IsSigned() { return fmt.Errorf("manifest is not signed, but signature from %s is required", 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 does not match required %s", embeddedFP, requiredSigner) } log.Infof("manifest signature verified (signer: %s)", embeddedFP) } log.Infof("manifest contains %d files, %s", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes()))) // 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 func() { 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(uint64(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(uint64(status.BytesPerSec)), status.Failures) } } log.ProgressDone() }() } // Process results in a goroutine var failures int64 done := make(chan struct{}) go func() { 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) }() // Run check err = chk.Check(ctx.Context, results, progress) if err != nil { return 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") { 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 } 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(uint64(chk.TotalBytes())), elapsed, humanize.IBytes(uint64(rate))) } else { log.Infof("checked %d files (%s) in %.1fs (%s/s): %d failed", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes())), elapsed, humanize.IBytes(uint64(rate)), failures) } if failures > 0 { mfa.exitCode = 1 } return nil }