diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 01:22:12 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 01:22:12 +0200 |
| commit | 3b36a48b7ce5a53a9366f3b31f94311f178e2553 (patch) | |
| tree | ecbb277ff916b719f2ee45fba017792b85d5faf9 /internal/scan/scan_test.go | |
| parent | 42b02c47be9b285099203e44a2570636d4ca6f03 (diff) | |
| download | krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.tar.gz krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.zip | |
krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run
Diffstat (limited to 'internal/scan/scan_test.go')
| -rw-r--r-- | internal/scan/scan_test.go | 263 |
1 files changed, 263 insertions, 0 deletions
diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go new file mode 100644 index 0000000..94f922b --- /dev/null +++ b/internal/scan/scan_test.go @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package scan + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "reflect" + "syscall" + "testing" + "time" + + "krino/internal/ignore" +) + +var now = time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC) + +// tree creates files (with an mtime one hour before now) and returns the root. +func tree(t *testing.T, files ...string) string { + t.Helper() + root := t.TempDir() + for _, f := range files { + p := filepath.Join(root, f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(f), 0o644); err != nil { + t.Fatal(err) + } + old := now.Add(-time.Hour) + if err := os.Chtimes(p, old, old); err != nil { + t.Fatal(err) + } + } + return root +} + +func rels(r *Result) []string { + var out []string + for _, f := range r.Files { + out = append(out, f.Rel) + } + return out +} + +func skipped(r *Result) map[string]Reason { + m := map[string]Reason{} + for _, s := range r.Skipped { + m[s.Rel] = s.Reason + } + return m +} + +func TestTopLevelOnly(t *testing.T) { + root := tree(t, "a.pdf", "b.txt", "sub/c.txt") + r, err := Walk(root, Options{Now: now}) + if err != nil { + t.Fatal(err) + } + if want := []string{"a.pdf", "b.txt"}; !reflect.DeepEqual(rels(r), want) { + t.Fatalf("files = %v, want %v", rels(r), want) + } + f := r.Files[0] + if f.Name != "a.pdf" || f.Size != int64(len("a.pdf")) || f.Path != filepath.Join(root, "a.pdf") || !f.ModTime.Equal(now.Add(-time.Hour)) { + t.Fatalf("file = %+v", f) + } +} + +func TestRecursiveDepthExcludeIgnore(t *testing.T) { + root := tree(t, "a.txt", "one/b.txt", "one/two/c.txt", "Work/filed.pdf", "skip/x.txt", "keep/y.log") + m, _ := ignore.New([]string{"skip/", "*.log"}) + r, err := Walk(root, Options{Recursive: true, Ignore: m, Exclude: []string{filepath.Join(root, "Work")}, Now: now}) + if err != nil { + t.Fatal(err) + } + if want := []string{"a.txt", "one/b.txt", "one/two/c.txt"}; !reflect.DeepEqual(rels(r), want) { + t.Fatalf("files = %v, want %v", rels(r), want) + } + if got := skipped(r); got["keep/y.log"] != Ignored { + t.Fatalf("skipped = %v", got) + } + r, _ = Walk(root, Options{Recursive: true, MaxDepth: 2, Now: now}) + for _, rel := range rels(r) { + if rel == "one/two/c.txt" { + t.Fatalf("MaxDepth 2 reached depth 3: %v", rels(r)) + } + } +} + +func TestBusyTooNewSymlinkFifo(t *testing.T) { + root := tree(t, "movie.mkv", "movie.mkv.aria2", "doc.pdf", "doc.pdf.part", "plain.txt") + fresh := filepath.Join(root, "fresh.txt") + if err := os.WriteFile(fresh, nil, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(fresh, now.Add(-30*time.Second), now.Add(-30*time.Second)); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(root, "plain.txt"), filepath.Join(root, "link.txt")); err != nil { + t.Fatal(err) + } + fifo := filepath.Join(root, "pipe") + haveFifo := syscall.Mkfifo(fifo, 0o644) == nil + m, _ := ignore.New([]string{"*.part", "*.aria2"}) + r, err := Walk(root, Options{Ignore: m, Busy: []string{".part", ".aria2"}, MinAge: 2 * time.Minute, Now: now}) + if err != nil { + t.Fatal(err) + } + if want := []string{"plain.txt"}; !reflect.DeepEqual(rels(r), want) { + t.Fatalf("files = %v, want %v", rels(r), want) + } + want := map[string]Reason{ + "movie.mkv": Busy, "doc.pdf": Busy, "movie.mkv.aria2": Ignored, "doc.pdf.part": Ignored, + "fresh.txt": TooNew, "link.txt": Symlink, + } + if haveFifo { + want["pipe"] = NotRegular + } + if got := skipped(r); !reflect.DeepEqual(got, want) { + t.Fatalf("skipped = %v, want %v", got, want) + } +} + +func TestSymlinkedDirNotFollowed(t *testing.T) { + root := tree(t, "real/x.txt") + if err := os.Symlink(filepath.Join(root, "real"), filepath.Join(root, "alias")); err != nil { + t.Fatal(err) + } + r, err := Walk(root, Options{Recursive: true, Now: now}) + if err != nil { + t.Fatal(err) + } + if want := []string{"real/x.txt"}; !reflect.DeepEqual(rels(r), want) { + t.Fatalf("files = %v, want %v", rels(r), want) + } + if skipped(r)["alias"] != Symlink { + t.Fatalf("skipped = %v", skipped(r)) + } +} + +func TestReasonString(t *testing.T) { + want := map[Reason]string{Ignored: "ignored", Busy: "busy", TooNew: "too new", Symlink: "symlink", NotRegular: "not a regular file", Unreadable: "unreadable"} + for r, s := range want { + if r.String() != s { + t.Errorf("%d = %q, want %q", r, r.String(), s) + } + } +} + +func TestUnreadableSubdir(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root: permissions are not enforced") + } + root := tree(t, "a.txt", "locked/secret.txt", "one/b.txt") + locked := filepath.Join(root, "locked") + if err := os.Chmod(locked, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chmod(locked, 0o755); err != nil { + t.Fatal(err) + } + }) + r, err := Walk(root, Options{Recursive: true, Now: now}) + if err != nil { + t.Fatal(err) + } + if want := []string{"a.txt", "one/b.txt"}; !reflect.DeepEqual(rels(r), want) { + t.Fatalf("files = %v, want %v", rels(r), want) + } + if got := skipped(r)["locked"]; got != Unreadable { + t.Fatalf("skipped[locked] = %v, want Unreadable", got) + } +} + +func TestRootMustBeDirectory(t *testing.T) { + root := tree(t, "f") + if _, err := Walk(filepath.Join(root, "f"), Options{Now: now}); err == nil { + t.Fatal("walking a file succeeded") + } + if _, err := Walk(filepath.Join(root, "missing"), Options{Now: now}); err == nil { + t.Fatal("walking a missing path succeeded") + } +} + +// fakeDirEntry is an os.DirEntry whose Info() returns a canned result, +// simulating a race scan_test cannot otherwise provoke portably: a file +// renamed or removed between being listed by ReadDir and having Info() +// called on it (A2), or some other Info() failure. +type fakeDirEntry struct { + name string + info fs.FileInfo + infoErr error +} + +func (f fakeDirEntry) Name() string { return f.name } +func (f fakeDirEntry) IsDir() bool { return false } +func (f fakeDirEntry) Type() fs.FileMode { return 0 } +func (f fakeDirEntry) Info() (fs.FileInfo, error) { return f.info, f.infoErr } + +// TestFileInfoFailureMidWalk: A2. A vanished file's Info() failure +// (fs.ErrNotExist) is skipped silently, with no trace in Skipped; any +// other Info() failure is reported as Unreadable; and the walk continues +// to later entries in either case rather than aborting. +func TestFileInfoFailureMidWalk(t *testing.T) { + root := t.TempDir() + okPath := filepath.Join(root, "ok.txt") + if err := os.WriteFile(okPath, []byte("ok"), 0o644); err != nil { + t.Fatal(err) + } + old := now.Add(-time.Hour) + if err := os.Chtimes(okPath, old, old); err != nil { + t.Fatal(err) + } + okInfo, err := os.Lstat(okPath) + if err != nil { + t.Fatal(err) + } + + entries := []os.DirEntry{ + fakeDirEntry{name: "vanished.txt", infoErr: fmt.Errorf("stat vanished.txt: %w", fs.ErrNotExist)}, + fakeDirEntry{name: "denied.txt", infoErr: errors.New("stat denied.txt: permission denied")}, + fakeDirEntry{name: "ok.txt", info: okInfo}, + } + + w := &walker{root: root, opt: Options{Now: now}} + if err := w.walk(root, "", 1, entries); err != nil { + t.Fatalf("walk aborted: %v", err) + } + + if want := []string{"ok.txt"}; !reflect.DeepEqual(rels(&w.result), want) { + t.Fatalf("files = %v, want %v", rels(&w.result), want) + } + got := skipped(&w.result) + if _, vanished := got["vanished.txt"]; vanished { + t.Errorf("vanished file recorded as skipped: %v", got) + } + if got["denied.txt"] != Unreadable { + t.Errorf("skipped[denied.txt] = %v, want Unreadable", got["denied.txt"]) + } +} + +// TestIgnoredDirectoryReportedOnce: C2. An ignored directory with files +// inside it is reported once, for the directory itself, not once per file +// — pruning it without descending is the point — so its contents are not +// simply invisible to every count and to -v. +func TestIgnoredDirectoryReportedOnce(t *testing.T) { + root := tree(t, "keep.txt", "skip/a.txt", "skip/b.txt", "skip/c.txt") + m, _ := ignore.New([]string{"skip/"}) + r, err := Walk(root, Options{Recursive: true, Ignore: m, Now: now}) + if err != nil { + t.Fatal(err) + } + if want := []string{"keep.txt"}; !reflect.DeepEqual(rels(r), want) { + t.Fatalf("files = %v, want %v", rels(r), want) + } + if want := (map[string]Reason{"skip": Ignored}); !reflect.DeepEqual(skipped(r), want) { + t.Fatalf("skipped = %v, want %v (the directory once, not its three files)", skipped(r), want) + } +} |
