// 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) } } // TestTooBig: with MaxSize set, a file larger than it is skipped as too // big; a file of exactly MaxSize is kept; MaxSize 0 means no limit. func TestTooBig(t *testing.T) { root := tree(t) for name, size := range map[string]int{"small.bin": 10, "exact.bin": 100, "large.bin": 101} { p := filepath.Join(root, name) if err := os.WriteFile(p, make([]byte, size), 0o644); err != nil { t.Fatal(err) } old := now.Add(-time.Hour) if err := os.Chtimes(p, old, old); err != nil { t.Fatal(err) } } r, err := Walk(root, Options{MaxSize: 100, Now: now}) if err != nil { t.Fatal(err) } if want := []string{"exact.bin", "small.bin"}; !reflect.DeepEqual(rels(r), want) { t.Fatalf("files = %v, want %v", rels(r), want) } if got := skipped(r); !reflect.DeepEqual(got, map[string]Reason{"large.bin": TooBig}) { t.Fatalf("skipped = %v", got) } if TooBig.String() != "too big" { t.Errorf("TooBig reads %q", TooBig.String()) } if r, _ := Walk(root, Options{Now: now}); len(rels(r)) != 3 { t.Errorf("MaxSize 0 skipped files: %v", skipped(r)) } } // TestFilesCarryInode: a walked file carries its device and inode, which a // rename keeps. func TestFilesCarryInode(t *testing.T) { root := tree(t) p := filepath.Join(root, "a.txt") if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } old := now.Add(-time.Hour) os.Chtimes(p, old, old) first, err := Walk(root, Options{Now: now}) if err != nil || len(first.Files) != 1 { t.Fatalf("walk: %v %+v", err, first) } if first.Files[0].Ino == 0 { t.Fatal("no inode on a unix filesystem") } if err := os.Rename(p, filepath.Join(root, "b.txt")); err != nil { t.Fatal(err) } second, _ := Walk(root, Options{Now: now}) if len(second.Files) != 1 || second.Files[0].Ino != first.Files[0].Ino || second.Files[0].Dev != first.Files[0].Dev { t.Errorf("rename changed the identity: %+v then %+v", first.Files[0], second.Files) } } // TestFutureFileIsNotTooNewAtMinAgeZero: a file whose modification time is // ahead of the clock counts as brand new - skipped while min-age is above // zero, considered at min-age 0 (review cli F5). func TestFutureFileIsNotTooNewAtMinAgeZero(t *testing.T) { root := tree(t) p := filepath.Join(root, "future.txt") if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } ahead := now.Add(time.Hour) if err := os.Chtimes(p, ahead, ahead); err != nil { t.Fatal(err) } if r, _ := Walk(root, Options{Now: now}); len(rels(r)) != 1 { t.Errorf("min-age 0: files %v, skipped %v; want future.txt considered", rels(r), skipped(r)) } if r, _ := Walk(root, Options{Now: now, MinAge: time.Minute}); skipped(r)["future.txt"] != TooNew { t.Errorf("min-age 1m: skipped %v; want future.txt too new", skipped(r)) } }