diff --git a/TODO.md b/TODO.md index ac052fc..721fc15 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,9 @@ # Completed Steps +- test the `-x` filesystem-boundary rules in `subdirJob` (2026-10-04, + https://git.eeqj.de/sneak/sfdupes/issues/17) + - `scan` creates the schema in one transaction; a version-0 database with a `files` table is refused with a clear schema-version error (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/11) diff --git a/scan_test.go b/scan_test.go index 99d9b15..d2560b5 100644 --- a/scan_test.go +++ b/scan_test.go @@ -6,8 +6,10 @@ import ( "crypto/sha256" "database/sql" "encoding/hex" + "errors" "fmt" "io" + "io/fs" "os" "os/signal" "path/filepath" @@ -912,6 +914,159 @@ 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) + } + + // The accepted job must carry the operand's device down, or -x + // stops checking below the first level. + want := dirJob{ + path: sub, + rootDev: tc.parent.rootDev, + rootDevOK: tc.parent.rootDevOK, + } + if ok && job != want { + t.Fatalf("job = %+v, want %+v", job, want) + } + }) + } +} + +// 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 {