diff --git a/TODO.md b/TODO.md index 3da0b6c..48f941d 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,12 @@ # Completed Steps +- test the `-x` filesystem-boundary rejection branch by calling + `subdirJob` directly: reject across a boundary, bypass when the root + device is unknown, stat-error warning, and default crossing + (2026-09-21, branch `next`, closes + https://git.eeqj.de/sneak/sfdupes/issues/17) + - fix the lint-image pin comments and `FROM` form in `Dockerfile` and `Dockerfile.lint` (2026-08-10, branch `next`, closes https://git.eeqj.de/sneak/sfdupes/issues/25): dropped the false diff --git a/scan_test.go b/scan_test.go index cc1fbf1..f41d43b 100644 --- a/scan_test.go +++ b/scan_test.go @@ -6,7 +6,9 @@ import ( "crypto/sha256" "database/sql" "encoding/hex" + "errors" "fmt" + "io/fs" "os" "path/filepath" "runtime" @@ -324,6 +326,152 @@ func TestDeviceOfInfo(t *testing.T) { } } +// dirEntryFor returns the fs.DirEntry for name within dir, obtained via +// the same os.ReadDir the walk uses, so it carries a real Info(). +func dirEntryFor(t *testing.T, dir, name string) fs.DirEntry { + t.Helper() + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + + for _, e := range entries { + if e.Name() == name { + return e + } + } + + t.Fatalf("entry %q not found in %q", name, dir) + + return nil +} + +// callSubdirJob runs subdirJob against e under parent, collecting any +// warning events it emits (subdirJob emits at most one). +func callSubdirJob(t *testing.T, p string, e fs.DirEntry, + parent dirJob, oneFS bool, +) (dirJob, bool, []walkEvent) { + t.Helper() + + events := make(chan walkEvent, 1) + job, ok := subdirJob(t.Context(), p, e, parent, oneFS, events) + close(events) + + var evs []walkEvent + for ev := range events { + evs = append(evs, ev) + } + + return job, ok, evs +} + +// TestSubdirJobOneFilesystem exercises the -x boundary check in +// subdirJob directly, so no second real filesystem is needed. The +// subdirectory's real device is compared against a fabricated operand +// device. +func TestSubdirJobOneFilesystem(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + sub := filepath.Join(dir, "sub") + + err := os.Mkdir(sub, 0o750) + if err != nil { + t.Fatal(err) + } + + info, err := os.Lstat(sub) + if err != nil { + t.Fatal(err) + } + + dev, ok := deviceOfInfo(info) + if !ok { + t.Skip("platform exposes no device id") + } + + // A device the subdirectory is not on, standing in for an operand + // rooted on a different filesystem. + otherDev := dev + 1 + e := dirEntryFor(t, dir, "sub") + + cases := []struct { + name string + oneFS bool + parent dirJob + wantOK bool + }{ + // -x on, subdirectory on a different device than its operand: + // descent is refused. + {"reject across boundary", true, + dirJob{rootDev: otherDev, rootDevOK: true}, false}, + // -x on but the operand's own device is unknown: the boundary + // check is bypassed and descent proceeds. + {"bypass when root device unknown", true, + dirJob{rootDev: otherDev, rootDevOK: false}, true}, + // Default (no -x): boundaries are crossed even onto a different + // device. + {"cross by default", false, + dirJob{rootDev: otherDev, rootDevOK: true}, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + job, ok, evs := callSubdirJob(t, sub, e, tc.parent, tc.oneFS) + if ok != tc.wantOK { + t.Fatalf("accepted = %v, want %v", ok, tc.wantOK) + } + + if len(evs) != 0 { + t.Fatalf("unexpected events: %+v", evs) + } + + if ok && job.path != sub { + t.Fatalf("job.path = %q, want %q", job.path, sub) + } + }) + } +} + +// errInfoUnavailable is returned by errDirEntry.Info(). +var errInfoUnavailable = errors.New("info unavailable") + +// errDirEntry is a directory entry whose Info() always fails, driving +// subdirJob's stat-error branch deterministically. +type errDirEntry struct{ name string } + +func (e errDirEntry) Name() string { return e.name } +func (errDirEntry) IsDir() bool { return true } +func (errDirEntry) Type() fs.FileMode { + return fs.ModeDir +} + +func (errDirEntry) Info() (fs.FileInfo, error) { + return nil, errInfoUnavailable +} + +// TestSubdirJobStatError asserts that when a subdirectory's Info() +// fails under -x, subdirJob warns and refuses descent. +func TestSubdirJobStatError(t *testing.T) { + t.Parallel() + + p := "/does/not/matter/sub" + + _, ok, evs := callSubdirJob(t, p, errDirEntry{name: "sub"}, + dirJob{rootDev: 1, rootDevOK: true}, true) + if ok { + t.Fatal("descent accepted after stat error, want refused") + } + + if len(evs) != 1 || !evs[0].fail || !strings.Contains(evs[0].warn, p) { + t.Fatalf("want one warning naming the path, got %+v", evs) + } +} + // buildSmokeTree recreates the README smoke-test filesystem layout // with deterministic content and returns the tree root. func buildSmokeTree(t *testing.T) string {