Files
mfer/internal/cli/freshen.go
sneak 3bfbb3fbe2
All checks were successful
check / check (push) Successful in 35s
Update golangci-lint to v2.12.2 with canonical config (closes #60)
- Add canonical .golangci.yml (v2 schema, default: all, project
  thresholds for lll/funlen/cyclop/dupl)
- Bump golangci-lint pins from v2.0.2 to v2.12.2 in Makefile
  (go install, new /v2 module path) and Dockerfile (tagged+digest
  Debian image pin)
- Fix all lint findings surfaced by the new linter set across
  cmd/mfer, internal/bork, internal/cli, internal/log, and mfer:
  static sentinel errors (err113), context-aware HTTP and exec
  (noctx), guarded integer conversions and stricter permissions
  (gosec), named constants (mnd, goconst), function decomposition
  (funlen, cyclop, gocognit, nestif), declaration ordering
  (funcorder), t.Parallel/t.TempDir/t.Setenv adoption in tests
  (paralleltest, usetesting), protobuf getters (protogetter), plus
  formatting and style cleanups (wsl_v5, nlreturn, lll, revive,
  testifylint, and others)
- Serialize CLI runs in tests behind a mutex so parallel tests do
  not cross-wire the process-global logger's captured output

The decompositions are behavior-preserving. In particular:

- REPO_POLICIES.md is untouched and stays byte-identical to the
  authoritative copy in the prompts repo
- the mfer.manifest type stays unexported; whether to export it is an
  open owner design question (README question 13)
- directories created by fetch keep mode 0755, because fetched trees
  are content meant to be readable by other uids
- an absent MFFilePath.Mtime is handled explicitly and identically in
  freshen, list, and export rather than being read as the Unix epoch,
  which would classify every entry as changed and rewrite the manifest
  on every freshen
- every user-visible error message renders byte-identically to what it
  did before, with the err113 sentinels wrapped mid-sentence where
  needed; the rendered strings are now pinned by tests

Also fixes an argument-injection defect the lint pass surfaced: key IDs
reach gpg as bare positional arguments, so a key ID beginning with "-"
was parsed by gpg as an option. All positional arguments now follow an
explicit "--" end-of-options marker.

The symlink-escape gap in fetch's path handling, which sanitizePath
does not and cannot address, is filed separately as #86.
2026-08-09 02:16:37 +00:00

618 lines
15 KiB
Go

package cli
import (
"crypto/sha256"
"errors"
"fmt"
"io"
"io/fs"
"path/filepath"
"time"
"github.com/dustin/go-humanize"
"github.com/multiformats/go-multihash"
"github.com/spf13/afero"
"github.com/urfave/cli/v2"
"sneak.berlin/go/mfer/internal/log"
"sneak.berlin/go/mfer/mfer"
)
const (
// hashBufSize is the read buffer size used when hashing files.
hashBufSize = 64 * 1024
// scanProgressInterval is how many scanned files pass between
// progress updates.
scanProgressInterval = 100
)
// errEntryMissingMtime indicates a manifest entry that carries no
// modification time where one is required to carry it forward unchanged.
var errEntryMissingMtime = errors.New("manifest entry has no mtime")
// FreshenStatus contains progress information for the freshen operation.
type FreshenStatus struct {
Phase string // "scan" or "hash"
TotalFiles int64 // Total files to process in current phase
CurrentFiles int64 // Files processed so far
TotalBytes int64 // Total bytes to hash (hash phase only)
CurrentBytes int64 // Bytes hashed so far
BytesPerSec float64 // Current throughput rate
ETA time.Duration // Estimated time to completion
}
// freshenEntry tracks a file's status during freshen
type freshenEntry struct {
path string
size int64
mtime time.Time
needsHash bool // true if new or changed
existing *mfer.MFFilePath // existing manifest entry if unchanged
}
// freshenScanner walks the filesystem and compares it against the
// entries of an existing manifest.
type freshenScanner struct {
fs afero.Fs
absBase string
manifestBase string
includeDotfiles bool
followSymlinks bool
showProgress bool
existingByPath map[string]*mfer.MFFilePath
entries []*freshenEntry
scanCount int64
changed int64
added int64
unchanged int64
}
// resolveSymlink resolves a symlink to its target's FileInfo. The
// second return value is false when the entry should be skipped.
func (s *freshenScanner) resolveSymlink(path string) (fs.FileInfo, bool) {
if !s.followSymlinks {
return nil, false
}
realPath, err := filepath.EvalSymlinks(path)
if err != nil {
return nil, false // Skip broken symlinks
}
realInfo, err := s.fs.Stat(realPath)
if err != nil || realInfo.IsDir() {
return nil, false
}
return realInfo, true
}
// recordEntry classifies a scanned file as changed, unchanged, or added
// relative to the existing manifest.
func (s *freshenScanner) recordEntry(relPath string, info fs.FileInfo) {
existing, inManifest := s.existingByPath[relPath]
if !inManifest {
s.added++
log.Verbosef("A %s", relPath)
s.entries = append(s.entries, &freshenEntry{
path: relPath,
size: info.Size(),
mtime: info.ModTime(),
needsHash: true,
})
return
}
// Check if changed (size or mtime). An entry with no recorded mtime
// cannot be compared, so it counts as changed and gets re-hashed;
// silently treating the absent mtime as the Unix epoch would classify
// every such entry as changed without saying why.
existingMtime, haveMtime := entryMtime(existing)
if !haveMtime {
log.Debugf("%s: manifest entry has no mtime, treating as changed",
relPath)
}
if !haveMtime || existing.GetSize() != info.Size() ||
!existingMtime.Equal(info.ModTime()) {
s.changed++
log.Verbosef("M %s", relPath)
s.entries = append(s.entries, &freshenEntry{
path: relPath,
size: info.Size(),
mtime: info.ModTime(),
needsHash: true,
})
} else {
s.unchanged++
s.entries = append(s.entries, &freshenEntry{
path: relPath,
size: info.Size(),
mtime: info.ModTime(),
needsHash: false,
existing: existing,
})
}
// Mark as seen
delete(s.existingByPath, relPath)
}
// walk is the afero.Walk callback for the scan phase.
func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
// Get relative path
relPath, err := filepath.Rel(s.absBase, path)
if err != nil {
return fmt.Errorf(
"freshen: failed to compute relative path for %s: %w", path, err)
}
// Skip the manifest file itself
if relPath == s.manifestBase || relPath == "."+s.manifestBase {
return nil
}
// Handle dotfiles
if !s.includeDotfiles && mfer.IsHiddenPath(filepath.ToSlash(relPath)) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
// Skip directories
if info.IsDir() {
return nil
}
// Handle symlinks
if info.Mode()&fs.ModeSymlink != 0 {
realInfo, keep := s.resolveSymlink(path)
if !keep {
return nil
}
info = realInfo
}
s.scanCount++
// Check against existing manifest
s.recordEntry(relPath, info)
// Report scan progress
if s.showProgress && s.scanCount%scanProgressInterval == 0 {
log.Progressf("Scanning: %d files found", s.scanCount)
}
return nil
}
// resolveFreshenManifestPath determines the manifest path from the CLI
// arguments, searching directories for a manifest where needed.
func (mfa *CLIApp) resolveFreshenManifestPath(ctx *cli.Context) (string, error) {
if ctx.Args().Len() == 0 {
return findManifest(mfa.Fs, ".")
}
arg := ctx.Args().Get(0)
info, statErr := mfa.Fs.Stat(arg)
if statErr == nil && info.IsDir() {
return findManifest(mfa.Fs, arg)
}
return arg, nil
}
// freshenHasher hashes changed and added files and feeds all entries to
// a manifest builder.
type freshenHasher struct {
fs afero.Fs
absBase string
showProgress bool
totalHashBytes int64
filesToHash int64
startHash time.Time
builder *mfer.Builder
hashedFiles int64
hashedBytes int64
}
// reportProgress renders hashing progress for the current byte count.
func (h *freshenHasher) reportProgress(n int64) {
if !h.showProgress {
return
}
currentBytes := h.hashedBytes + n
elapsed := time.Since(h.startHash)
var (
rate float64
eta time.Duration
)
if elapsed > 0 && currentBytes > 0 {
rate = float64(currentBytes) / elapsed.Seconds()
remaining := h.totalHashBytes - currentBytes
if rate > 0 {
eta = time.Duration(float64(remaining)/rate) * time.Second
}
}
if eta > 0 {
log.Progressf("Hashing: %d/%d files, %s/s, ETA %s",
h.hashedFiles, h.filesToHash, humanize.IBytes(safeRateUint64(rate)),
eta.Round(time.Second))
} else {
log.Progressf("Hashing: %d/%d files, %s/s",
h.hashedFiles, h.filesToHash, humanize.IBytes(safeRateUint64(rate)))
}
}
// processEntry hashes the entry if needed and adds it to the builder.
func (h *freshenHasher) processEntry(e *freshenEntry) error {
if !e.needsHash {
// Use existing entry
err := addExistingToBuilder(h.builder, e.existing)
if err != nil {
return fmt.Errorf("failed to add %s: %w", e.path, err)
}
return nil
}
// Need to read and hash the file
absPath := filepath.Join(h.absBase, e.path)
f, err := h.fs.Open(absPath)
if err != nil {
return fmt.Errorf("failed to open %s: %w", e.path, err)
}
hash, bytesRead, err := hashFile(f, h.reportProgress)
_ = f.Close()
if err != nil {
return fmt.Errorf("failed to hash %s: %w", e.path, err)
}
h.hashedBytes += bytesRead
h.hashedFiles++
// Add to builder with computed hash
err = addFileToBuilder(h.builder, e.path, e.size, e.mtime, hash)
if err != nil {
return fmt.Errorf("failed to add %s: %w", e.path, err)
}
return nil
}
// writeFreshenedManifest writes the manifest atomically (write to a
// temp file, then rename over the target).
func writeFreshenedManifest(
afs afero.Fs, builder *mfer.Builder, manifestPath string,
) error {
tmpPath := manifestPath + ".tmp"
outFile, err := afs.Create(tmpPath)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
err = builder.Build(outFile)
_ = outFile.Close()
if err != nil {
_ = afs.Remove(tmpPath)
return fmt.Errorf("failed to write manifest: %w", err)
}
// Rename temp to final
err = afs.Rename(tmpPath, manifestPath)
if err != nil {
_ = afs.Remove(tmpPath)
return fmt.Errorf("failed to rename manifest: %w", err)
}
return nil
}
// newFreshenBuilder constructs the manifest builder configured from CLI
// flags.
func newFreshenBuilder(ctx *cli.Context) *mfer.Builder {
builder := mfer.NewBuilder()
if ctx.Bool("include-timestamps") {
builder.SetIncludeTimestamps(true)
}
// Set up signing options if sign-key is provided
if signKey := ctx.String("sign-key"); signKey != "" {
builder.SetSigningOptions(&mfer.SigningOptions{
KeyID: mfer.GPGKeyID(signKey),
})
log.Infof("signing manifest with GPG key: %s", signKey)
}
return builder
}
// freshenScan runs the scan phase against the loaded manifest entries
// and returns the populated scanner and the count of removed files.
func (mfa *CLIApp) freshenScan(
ctx *cli.Context, manifestPath, absBase string,
existingByPath map[string]*mfer.MFFilePath,
) (*freshenScanner, int64, error) {
log.Infof("scanning filesystem...")
startScan := time.Now()
showProgress := ctx.Bool("progress")
scanner := &freshenScanner{
fs: mfa.Fs,
absBase: absBase,
manifestBase: filepath.Base(manifestPath),
includeDotfiles: ctx.Bool("include-dotfiles"),
followSymlinks: ctx.Bool("follow-symlinks"),
showProgress: showProgress,
existingByPath: existingByPath,
}
err := afero.Walk(mfa.Fs, absBase, scanner.walk)
if showProgress {
log.ProgressDone()
}
if err != nil {
return nil, 0, fmt.Errorf("failed to scan filesystem: %w", err)
}
// Remaining entries in existingByPath are removed files
removed := int64(len(existingByPath))
for path := range existingByPath {
log.Verbosef("D %s", path)
}
scanDuration := time.Since(startScan)
log.Infof("scan complete in %s: %d unchanged, %d changed, %d added, %d removed",
scanDuration.Round(time.Millisecond), scanner.unchanged, scanner.changed,
scanner.added, removed)
return scanner, removed, nil
}
// hashTotals returns the total byte count and file count of entries
// that need hashing.
func hashTotals(entries []*freshenEntry) (int64, int64) {
var (
totalHashBytes int64
filesToHash int64
)
for _, e := range entries {
if e.needsHash {
totalHashBytes += e.size
filesToHash++
}
}
return totalHashBytes, filesToHash
}
// runFreshenHash processes every entry through the hasher, aborting if
// the context is canceled.
func runFreshenHash(
ctx *cli.Context, hasher *freshenHasher, entries []*freshenEntry,
) error {
for _, e := range entries {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
err := hasher.processEntry(e)
if err != nil {
return err
}
}
return nil
}
// loadExistingEntries loads the manifest and indexes its file entries
// by path.
func (mfa *CLIApp) loadExistingEntries(
manifestPath string,
) (map[string]*mfer.MFFilePath, error) {
log.Infof("loading manifest from %s", manifestPath)
// Load existing manifest
manifest, err := mfer.NewManifestFromFile(mfa.Fs, manifestPath)
if err != nil {
return nil, fmt.Errorf("failed to load manifest: %w", err)
}
existingFiles := manifest.Files()
log.Infof("manifest contains %d files", len(existingFiles))
// Build map of existing entries by path
existingByPath := make(map[string]*mfer.MFFilePath, len(existingFiles))
for _, f := range existingFiles {
existingByPath[f.GetPath()] = f
}
return existingByPath, nil
}
func (mfa *CLIApp) freshenManifestOperation(ctx *cli.Context) error {
log.Debug("freshenManifestOperation()")
basePath := ctx.String("base")
showProgress := ctx.Bool("progress")
// Find manifest file
manifestPath, err := mfa.resolveFreshenManifestPath(ctx)
if err != nil {
return fmt.Errorf("freshen: %w", err)
}
existingByPath, err := mfa.loadExistingEntries(manifestPath)
if err != nil {
return err
}
absBase, err := filepath.Abs(basePath)
if err != nil {
return fmt.Errorf("freshen: invalid base path: %w", err)
}
// Phase 1: Scan filesystem
scanner, removed, err := mfa.freshenScan(ctx, manifestPath, absBase,
existingByPath)
if err != nil {
return err
}
// Calculate total bytes to hash
totalHashBytes, filesToHash := hashTotals(scanner.entries)
// Phase 2: Hash changed and new files
if filesToHash > 0 {
log.Infof("hashing %d files (%s)...", filesToHash,
humanize.IBytes(safeUint64(totalHashBytes)))
}
hasher := &freshenHasher{
fs: mfa.Fs,
absBase: absBase,
showProgress: showProgress,
totalHashBytes: totalHashBytes,
filesToHash: filesToHash,
startHash: time.Now(),
builder: newFreshenBuilder(ctx),
}
err = runFreshenHash(ctx, hasher, scanner.entries)
if err != nil {
return err
}
if showProgress && filesToHash > 0 {
log.ProgressDone()
}
// Print summary
log.Infof("freshen complete: %d unchanged, %d changed, %d added, %d removed",
scanner.unchanged, scanner.changed, scanner.added, removed)
// Skip writing if nothing changed
if scanner.changed == 0 && scanner.added == 0 && removed == 0 {
log.Infof("manifest unchanged, skipping write")
return nil
}
// Write updated manifest atomically (write to temp, then rename)
err = writeFreshenedManifest(mfa.Fs, hasher.builder, manifestPath)
if err != nil {
return err
}
totalDuration := time.Since(mfa.startupTime)
if hasher.hashedBytes > 0 {
hashDuration := time.Since(hasher.startHash)
hashRate := float64(hasher.hashedBytes) / hashDuration.Seconds()
log.Infof("hashed %s in %.1fs (%s/s)",
humanize.IBytes(safeUint64(hasher.hashedBytes)),
totalDuration.Seconds(), humanize.IBytes(safeRateUint64(hashRate)))
}
log.Infof("wrote %d files to %s", len(scanner.entries), manifestPath)
return nil
}
// hashFile reads a file and computes its SHA256 multihash.
// Progress callback is called with bytes read so far.
func hashFile(r io.Reader, progress func(int64)) ([]byte, int64, error) {
h := sha256.New()
buf := make([]byte, hashBufSize)
var total int64
for {
n, err := r.Read(buf)
if n > 0 {
h.Write(buf[:n])
total += int64(n)
if progress != nil {
progress(total)
}
}
if err == io.EOF {
break
}
// Returned unwrapped: the caller renders this as
// "failed to hash <path>: <err>" and adding a second layer here
// would change that message.
if err != nil {
return nil, total, err
}
}
mh, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256)
if err != nil {
return nil, total, err
}
return mh, total, nil
}
// addFileToBuilder adds a new file entry to the builder
func addFileToBuilder(
b *mfer.Builder, path string, size int64, mtime time.Time, hash []byte,
) error {
return b.AddFileWithHash(
mfer.RelFilePath(path), mfer.FileSize(size), mfer.ModTime(mtime), hash)
}
// addExistingToBuilder adds an existing manifest entry to the builder.
//
// Entries reach this path only when recordEntry classified them as
// unchanged, which requires a recorded mtime, so an absent mtime here is
// an error rather than something to paper over with the Unix epoch.
func addExistingToBuilder(b *mfer.Builder, entry *mfer.MFFilePath) error {
mtime, ok := entryMtime(entry)
if !ok {
return fmt.Errorf("%w: %s", errEntryMissingMtime, entry.GetPath())
}
if len(entry.GetHashes()) == 0 {
return nil
}
return b.AddFileWithHash(mfer.RelFilePath(entry.GetPath()),
mfer.FileSize(entry.GetSize()), mfer.ModTime(mtime),
entry.GetHashes()[0].GetMultiHash())
}