Make scan paths required operands; add -x/--one-file-system
All checks were successful
check / check (push) Successful in 4s

scan now takes one or more PATH operands (directories or regular
files) via cobra flags instead of the -root flag with its /srv
default; invoking scan with no operand is a usage error and a
nonexistent operand is fatal. Filesystem boundaries are crossed by
default; the new -x/--one-file-system flag (GNU du/rsync convention)
stops the walk at each operand's filesystem, implemented by comparing
lstat device IDs with build-tagged helpers for darwin's int32 Dev.
Verified against a real mounted disk image: default crosses, -x does
not, --one-file-system is identical to -x.
This commit is contained in:
2026-07-23 08:55:17 +07:00
parent 3391207f8d
commit 4b1c3cbf70
7 changed files with 262 additions and 84 deletions

157
scan.go
View File

@@ -5,12 +5,11 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"syscall"
)
// chunk is the number of bytes hashed from each end of a file.
@@ -31,63 +30,66 @@ type fileRec struct {
mtime int64
}
// runScan implements the scan subcommand: three sequential passes (walk,
// stat, hash) over the tree under -root, emitting one NUL-terminated
// record per regular file on stdout.
func runScan(args []string) {
fl := flag.NewFlagSet("scan", flag.ExitOnError)
root := fl.String("root", "/srv", "directory tree to scan")
workers := fl.Int("workers", runtime.NumCPU(),
"concurrent workers for the stat and hash passes")
// The flag set uses ExitOnError, so Parse cannot return a non-nil
// error; the check keeps the error handled explicitly.
err := fl.Parse(args)
if err != nil {
os.Exit(exitUsage)
// runScan implements the scan subcommand: three sequential passes
// (walk, stat, hash) over the trees named by the PATH operands,
// emitting one NUL-terminated record per regular file on stdout. Flag
// parsing and the at-least-one-operand check are done by cobra.
func runScan(roots []string, workers int, oneFS bool) {
if workers < 1 {
workers = 1
}
if fl.NArg() > 0 {
fmt.Fprintf(os.Stderr, "scan: unexpected argument %q\n", fl.Arg(0))
os.Exit(exitUsage)
// A nonexistent operand is a fatal error before any scanning.
for _, root := range roots {
_, err := os.Lstat(root)
if err != nil {
fatalf("%v", err)
}
}
if *workers < 1 {
*workers = 1
}
st, err := os.Stat(*root)
if err != nil {
fatalf("root %s: %v", *root, err)
}
if !st.IsDir() {
fatalf("root %s: not a directory", *root)
}
paths, walkErrs := walkPass(*root)
recs, statErrs := statPass(paths, *workers)
emitted, hashErrs := hashPass(recs, *workers)
paths, walkErrs := walkPass(roots, oneFS)
recs, statErrs := statPass(paths, workers)
emitted, hashErrs := hashPass(recs, workers)
skipped := walkErrs + statErrs + hashErrs
fmt.Fprintf(os.Stderr, "scan: %d files emitted, %d skipped\n",
emitted, skipped)
}
// walkPass enumerates every regular file under root. It never follows
// symlinks, never descends into directories named .zfs, and warns and
// continues on any per-path error.
func walkPass(root string) ([]string, int) {
var (
paths []string
errs int
)
// treeWalker carries the walk-pass state shared by all PATH operands.
type treeWalker struct {
prog *progress
oneFS bool
paths []string
errs int
}
// walkPass enumerates every regular file under each root operand in
// order. It never follows symlinks, never descends into directories
// named .zfs, and warns and continues on any per-path error. With
// oneFS set it never descends into a directory on a different
// filesystem than its root operand.
func walkPass(roots []string, oneFS bool) ([]string, int) {
w := &treeWalker{prog: newProgress("walk", -1), oneFS: oneFS}
for _, root := range roots {
w.walkRoot(root)
}
w.prog.finish()
return w.paths, w.errs
}
// walkRoot walks a single PATH operand, appending regular-file paths.
func (w *treeWalker) walkRoot(root string) {
rootDev, rootDevOK := deviceOf(root)
prog := newProgress("walk", -1)
walkErr := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
errs++
w.errs++
prog.warnf("walk %s: %v", p, err)
w.prog.warnf("walk %s: %v", p, err)
if d != nil && d.IsDir() {
return filepath.SkipDir
@@ -97,13 +99,7 @@ func walkPass(root string) ([]string, int) {
}
if d.IsDir() {
// ZFS snapshot pseudo-dirs would list every file
// once per snapshot; never descend.
if d.Name() == ".zfs" {
return filepath.SkipDir
}
return nil
return w.dirAction(p, d, rootDev, rootDevOK)
}
// Regular files only: skip symlinks, sockets, FIFOs, and
// device nodes.
@@ -111,20 +107,65 @@ func walkPass(root string) ([]string, int) {
return nil
}
paths = append(paths, p)
w.paths = append(w.paths, p)
prog.increment()
w.prog.increment()
return nil
})
prog.finish()
if walkErr != nil {
fatalf("walk %s: %v", root, walkErr)
}
}
return paths, errs
// dirAction decides whether the walk descends into directory p.
func (w *treeWalker) dirAction(p string, d fs.DirEntry, rootDev uint64, rootDevOK bool) error {
// ZFS snapshot pseudo-dirs would list every file once per
// snapshot; never descend.
if d.Name() == ".zfs" {
return filepath.SkipDir
}
if !w.oneFS || !rootDevOK {
return nil
}
info, err := d.Info()
if err != nil {
w.errs++
w.prog.warnf("walk %s: %v", p, err)
return filepath.SkipDir
}
if dev, ok := deviceOfInfo(info); ok && dev != rootDev {
return filepath.SkipDir
}
return nil
}
// deviceOf returns the filesystem device ID of path without following
// symlinks.
func deviceOf(path string) (uint64, bool) {
fi, err := os.Lstat(path)
if err != nil {
return 0, false
}
return deviceOfInfo(fi)
}
// deviceOfInfo extracts the filesystem device ID from a FileInfo, when
// the platform exposes one.
func deviceOfInfo(fi fs.FileInfo) (uint64, bool) {
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return statDev(st), true
}
// statPass lstats every collected path in a worker pool, recording size