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 }