Some checks failed
check / check (push) Has been cancelled
Adopts golangci-lint v2.12.2 and the canonical .golangci.yml (default: all), and fixes all resulting findings across the tree. Two intended behavior changes: absent MFFilePath.Mtime is handled explicitly in freshen, list and export rather than dereferenced (main panicked); gpg positional key IDs now follow an explicit -- end-of-options marker. All twelve reworded user-visible error messages restored to byte-identical parity with main and pinned by tests.
288 lines
7.1 KiB
Go
288 lines
7.1 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"sync"
|
|
"syscall"
|
|
"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"
|
|
)
|
|
|
|
var (
|
|
// errPathNotExist indicates an input path that does not exist.
|
|
errPathNotExist = errors.New("path does not exist")
|
|
// errOutputExists indicates the output file already exists and
|
|
// --force was not given. It is wrapped mid-sentence so that the
|
|
// rendered message stays exactly as mfer has always printed it.
|
|
errOutputExists = errors.New(
|
|
"already exists (use --force to overwrite)")
|
|
)
|
|
|
|
// reportEnumProgress renders enumeration progress until the channel
|
|
// closes.
|
|
func reportEnumProgress(progress <-chan mfer.EnumerateStatus, wg *sync.WaitGroup) {
|
|
defer wg.Done()
|
|
|
|
for status := range progress {
|
|
log.Progressf("Enumerating: %d files, %s",
|
|
status.FilesFound,
|
|
humanize.IBytes(safeUint64(int64(status.BytesFound))))
|
|
}
|
|
|
|
log.ProgressDone()
|
|
}
|
|
|
|
// reportScanProgress renders scan progress until the channel closes.
|
|
func reportScanProgress(progress <-chan mfer.ScanStatus, wg *sync.WaitGroup) {
|
|
defer wg.Done()
|
|
|
|
for status := range progress {
|
|
if status.ETA > 0 {
|
|
log.Progressf("Scanning: %d/%d files, %s/s, ETA %s",
|
|
status.ScannedFiles,
|
|
status.TotalFiles,
|
|
humanize.IBytes(safeRateUint64(status.BytesPerSec)),
|
|
status.ETA.Round(time.Second))
|
|
} else {
|
|
log.Progressf("Scanning: %d/%d files, %s/s",
|
|
status.ScannedFiles,
|
|
status.TotalFiles,
|
|
humanize.IBytes(safeRateUint64(status.BytesPerSec)))
|
|
}
|
|
}
|
|
|
|
log.ProgressDone()
|
|
}
|
|
|
|
// collectInputPaths validates the input path arguments and returns them
|
|
// as absolute paths.
|
|
func (mfa *CLIApp) collectInputPaths(args cli.Args) ([]string, error) {
|
|
paths := make([]string, 0, args.Len())
|
|
|
|
for i := range args.Len() {
|
|
inputPath := args.Get(i)
|
|
|
|
ap, err := filepath.Abs(inputPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate: invalid path %q: %w", inputPath, err)
|
|
}
|
|
// Validate path exists before adding to list
|
|
if exists, _ := afero.Exists(mfa.Fs, ap); !exists {
|
|
return nil, fmt.Errorf("%w: %s", errPathNotExist, inputPath)
|
|
}
|
|
|
|
log.Debugf("enumerating path: %s", ap)
|
|
paths = append(paths, ap)
|
|
}
|
|
|
|
return paths, nil
|
|
}
|
|
|
|
// buildScannerOptions constructs scanner options from the CLI flags.
|
|
func (mfa *CLIApp) buildScannerOptions(ctx *cli.Context) *mfer.ScannerOptions {
|
|
opts := &mfer.ScannerOptions{
|
|
IncludeDotfiles: ctx.Bool("include-dotfiles"),
|
|
FollowSymLinks: ctx.Bool("follow-symlinks"),
|
|
IncludeTimestamps: ctx.Bool("include-timestamps"),
|
|
Fs: mfa.Fs,
|
|
}
|
|
|
|
// Set seed for deterministic UUID if provided
|
|
if seed := ctx.String("seed"); seed != "" {
|
|
opts.Seed = seed
|
|
|
|
log.Infof("using deterministic seed for manifest UUID")
|
|
}
|
|
|
|
// Set up signing options if sign-key is provided
|
|
if signKey := ctx.String("sign-key"); signKey != "" {
|
|
opts.SigningOptions = &mfer.SigningOptions{
|
|
KeyID: mfer.GPGKeyID(signKey),
|
|
}
|
|
log.Infof("signing manifest with GPG key: %s", signKey)
|
|
}
|
|
|
|
return opts
|
|
}
|
|
|
|
// enumerateInputs runs the enumeration phase over the argument paths,
|
|
// or the current directory when no arguments are given.
|
|
func (mfa *CLIApp) enumerateInputs(
|
|
s *mfer.Scanner, args cli.Args, enumProgress chan mfer.EnumerateStatus,
|
|
) error {
|
|
if args.Len() == 0 {
|
|
// Default to current directory
|
|
err := s.EnumeratePath(".", enumProgress)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"generate: failed to enumerate current directory: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Collect and validate all paths first
|
|
paths, err := mfa.collectInputPaths(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = s.EnumeratePaths(enumProgress, paths...)
|
|
if err != nil {
|
|
return fmt.Errorf("generate: failed to enumerate paths: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// cleanupOnSignal installs a handler that removes the temp output file
|
|
// and exits when the process is interrupted. It returns the signal
|
|
// channel so the caller can stop and close it when done.
|
|
func (mfa *CLIApp) cleanupOnSignal(outFile afero.File, tmpPath string) chan os.Signal {
|
|
sigChan := make(chan os.Signal, 1)
|
|
|
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
|
|
|
go func() {
|
|
sig, ok := <-sigChan
|
|
if !ok || sig == nil {
|
|
return // Channel closed normally, not a signal
|
|
}
|
|
|
|
_ = outFile.Close()
|
|
_ = mfa.Fs.Remove(tmpPath)
|
|
|
|
os.Exit(1)
|
|
}()
|
|
|
|
return sigChan
|
|
}
|
|
|
|
// runEnumeratePhase enumerates all input paths with optional progress
|
|
// reporting and logs the totals.
|
|
func (mfa *CLIApp) runEnumeratePhase(ctx *cli.Context, s *mfer.Scanner) error {
|
|
// Set up enumeration progress reporting
|
|
var (
|
|
enumProgress chan mfer.EnumerateStatus
|
|
enumWg sync.WaitGroup
|
|
)
|
|
|
|
if ctx.Bool("progress") {
|
|
enumProgress = make(chan mfer.EnumerateStatus, 1)
|
|
|
|
enumWg.Add(1)
|
|
|
|
go reportEnumProgress(enumProgress, &enumWg)
|
|
}
|
|
|
|
err := mfa.enumerateInputs(s, ctx.Args(), enumProgress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
enumWg.Wait()
|
|
|
|
log.Infof("enumerated %d files, %s total", s.FileCount(),
|
|
humanize.IBytes(safeUint64(int64(s.TotalBytes()))))
|
|
|
|
return nil
|
|
}
|
|
|
|
func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
|
|
log.Debug("generateManifestOperation()")
|
|
|
|
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(ctx))
|
|
|
|
// Phase 1: Enumeration - collect paths and stat files
|
|
err := mfa.runEnumeratePhase(ctx, s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
showProgress := ctx.Bool("progress")
|
|
|
|
// Check if output file exists
|
|
outputPath := ctx.String("output")
|
|
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !ctx.Bool("force") {
|
|
return fmt.Errorf("output file %s %w", outputPath, errOutputExists)
|
|
}
|
|
|
|
// Create temp file for atomic write
|
|
tmpPath := outputPath + ".tmp"
|
|
|
|
outFile, err := mfa.Fs.Create(tmpPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
|
|
// Set up signal handler to clean up temp file on Ctrl-C
|
|
sigChan := mfa.cleanupOnSignal(outFile, tmpPath)
|
|
|
|
// Clean up temp file on any error or interruption
|
|
success := false
|
|
|
|
defer func() {
|
|
signal.Stop(sigChan)
|
|
close(sigChan)
|
|
|
|
_ = outFile.Close()
|
|
|
|
if !success {
|
|
_ = mfa.Fs.Remove(tmpPath)
|
|
}
|
|
}()
|
|
|
|
// Phase 2: Scan - read file contents and generate manifest
|
|
var (
|
|
scanProgress chan mfer.ScanStatus
|
|
scanWg sync.WaitGroup
|
|
)
|
|
|
|
if showProgress {
|
|
scanProgress = make(chan mfer.ScanStatus, 1)
|
|
|
|
scanWg.Add(1)
|
|
|
|
go reportScanProgress(scanProgress, &scanWg)
|
|
}
|
|
|
|
err = s.ToManifest(ctx.Context, outFile, scanProgress)
|
|
|
|
scanWg.Wait()
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate manifest: %w", err)
|
|
}
|
|
|
|
// Close file before rename to ensure all data is flushed
|
|
err = outFile.Close()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to close temp file: %w", err)
|
|
}
|
|
|
|
// Atomic rename
|
|
err = mfa.Fs.Rename(tmpPath, outputPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
|
}
|
|
|
|
success = true
|
|
|
|
elapsed := time.Since(mfa.startupTime).Seconds()
|
|
rate := float64(s.TotalBytes()) / elapsed
|
|
log.Infof("wrote %d files (%s) to %s in %.1fs (%s/s)", s.FileCount(),
|
|
humanize.IBytes(safeUint64(int64(s.TotalBytes()))), outputPath, elapsed,
|
|
humanize.IBytes(safeRateUint64(rate)))
|
|
|
|
return nil
|
|
}
|