// SPDX-License-Identifier: GPL-3.0-or-later package engine import ( "context" "fmt" "os" "path/filepath" "reflect" "strings" "testing" "time" ) const dlConf = ` (path "~/dl") (recursive yes) (min-age 0s) (ignore "*.part") (rule "dups" (when (duplicate)) (move "Dupes") (stop)) (rule "acme" (when (type document) (content "acme ltd")) (move "Work/Acme") (stop)) (rule "images" (when (type image)) (move "Pictures")) (rule "rest" (when (not (matched)) (type text)) (move "Other")) ` // fixture builds ~/dl and returns a loaded engine. PATH is empty, so no // extraction tool exists and PDFs and .doc files are unreadable. func fixture(t *testing.T) (*Engine, *Dir, string) { t.Helper() h := sandbox(t) t.Setenv("PATH", t.TempDir()) dl := filepath.Join(h, "dl") files := map[string]string{ "inv1.txt": "Invoice from ACME LTD, tax 0000000000", "notes.txt": "shopping list", "photo.jpg": "\xff\xd8\xff\xe0 jpeg bytes", "report.pdf": "%PDF same bytes", "report (1).pdf": "%PDF same bytes", "brochure.doc": "\xd0\xcf\x11\xe0 doc bytes", "movie.mkv": "video", "movie.mkv.part": "partial", "Work/Acme/filed.txt": "acme ltd, already filed", "Pictures/old.jpg": "\xff\xd8 old", } old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for name, body := range files { p := filepath.Join(dl, name) os.MkdirAll(filepath.Dir(p), 0o755) if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } os.Chtimes(p, old, old) } newer := old.Add(time.Hour) os.Chtimes(filepath.Join(dl, "report (1).pdf"), newer, newer) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": dlConf}) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } return e, e.Dirs[0], dl } // summary renders a result compactly for comparison. func summary(r *Result) []string { var out []string for _, m := range r.Matched { var rs []string for _, rm := range m.Rules { rs = append(rs, rm.Rule.Name+"["+strings.Join(rm.Reasons, "; ")+"]") } out = append(out, "match "+m.File.Rel+" "+strings.Join(rs, " ")) } for _, m := range r.Unmatched { out = append(out, "none "+m.File.Rel+" "+strings.Join(m.Warnings, " | ")) } for _, s := range r.Skipped { out = append(out, "skip "+s.Rel+" "+s.Reason.String()) } return out } func TestMatch(t *testing.T) { e, d, _ := fixture(t) r, err := e.Match(context.Background(), d) if err != nil { t.Fatal(err) } want := []string{ `match inv1.txt acme[type txt; content "acme ltd"]`, `match notes.txt rest[not matched; type txt]`, `match photo.jpg images[type jpg]`, `match report (1).pdf dups[duplicate of report.pdf]`, `none brochure.doc acme: content unreadable: needs antiword or catdoc, not installed`, `none report.pdf acme: content unreadable: needs pdftotext, not installed`, `skip movie.mkv busy`, `skip movie.mkv.part ignored`, } if got := summary(r); !reflect.DeepEqual(got, want) { t.Fatalf("got\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) } again, _ := e.Match(context.Background(), d) if !reflect.DeepEqual(summary(again), summary(r)) { t.Fatal("a second run gave a different result") } } func TestMatchMissingRoot(t *testing.T) { e, d, dl := fixture(t) os.RemoveAll(dl) if _, err := e.Match(context.Background(), d); err == nil { t.Fatal("no error for a missing root") } } func TestExplain(t *testing.T) { e, _, dl := fixture(t) x, err := e.Explain(context.Background(), filepath.Join(dl, "inv1.txt")) if err != nil { t.Fatal(err) } var got []string for _, rt := range x.Rules { got = append(got, fmt.Sprintf("%s match=%v stopped=%q trace=%v", rt.Rule.Name, rt.Match, rt.Stopped, rt.Trace != nil)) } want := []string{ `dups match=false stopped="" trace=true`, `acme match=true stopped="" trace=true`, `images match=false stopped="stopped by rule acme" trace=false`, `rest match=false stopped="stopped by rule acme" trace=false`, } if !reflect.DeepEqual(got, want) || x.Skip != "" { t.Fatalf("skip=%q\n%s", x.Skip, strings.Join(got, "\n")) } for name, skip := range map[string]string{ "movie.mkv": "busy", "movie.mkv.part": "ignored", "Work/Acme/filed.txt": "inside a rule destination, which krino never scans", } { x, err := e.Explain(context.Background(), filepath.Join(dl, name)) if err != nil || x.Skip != skip { t.Errorf("%s: skip %q, %v; want %q", name, x.Skip, err, skip) } } if _, err := e.Explain(context.Background(), "/etc/hostname"); err == nil || !strings.HasSuffix(err.Error(), "is not inside any included directory") { t.Errorf("outside: %v", err) } } // TestMatchExcludesOnlyRuleDest checks that a rule destination with no // placeholder excludes exactly that directory, not its parent: a sibling // subdirectory of the destination's parent must still be scanned. func TestMatchExcludesOnlyRuleDest(t *testing.T) { h := sandbox(t) dl := filepath.Join(h, "dl") files := map[string]string{ "Work/Acme/filed.txt": "already filed", "Work/Other/keep.txt": "keep me", } for name, body := range files { p := filepath.Join(dl, name) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } } conf := ` (path "~/dl") (recursive yes) (min-age 0s) (rule "acme" (when (name "nope")) (move "Work/Acme")) ` main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": conf}) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } r, err := e.Match(context.Background(), e.Dirs[0]) if err != nil { t.Fatal(err) } seen := map[string]bool{} for _, m := range r.Matched { seen[m.File.Rel] = true } for _, m := range r.Unmatched { seen[m.File.Rel] = true } for _, s := range r.Skipped { seen[s.Rel] = true } if !seen["Work/Other/keep.txt"] { t.Error("Work/Other/keep.txt should have been scanned: only the rule's own destination (Work/Acme) may be excluded") } if seen["Work/Acme/filed.txt"] { t.Error("Work/Acme/filed.txt should have been excluded as inside the rule's destination") } } // TestMatchWarningsSorted checks that Result.Warnings is sorted, not in // whatever order concurrent workers happened to build the duplicate // indexes that failed: rule "b" (declared first, with a stop that must // not skip rule "a" since it never matches) names a missing directory // that sorts after rule "a"'s, and rule "c" names the very same missing // directory as rule "a" - which must fold into one warning, not two. func TestMatchWarningsSorted(t *testing.T) { h := sandbox(t) dl := filepath.Join(h, "dl") if err := os.MkdirAll(dl, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dl, "only.txt"), []byte("hello"), 0o644); err != nil { t.Fatal(err) } conf := ` (path "~/dl") (recursive yes) (min-age 0s) (rule "b" (when (duplicate "~/zz-missing")) (stop)) (rule "a" (when (duplicate "~/aa-missing")) (stop)) (rule "c" (when (duplicate "~/aa-missing")) (stop)) ` main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": conf}) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } r, err := e.Match(context.Background(), e.Dirs[0]) if err != nil { t.Fatal(err) } if len(r.Warnings) != 2 { t.Fatalf("got %d warnings, want 2 (the shared aa-missing dir should fold into one):\n%s", len(r.Warnings), strings.Join(r.Warnings, "\n")) } wantPrefix := []string{"duplicate: ~/aa-missing:", "duplicate: ~/zz-missing:"} for i, want := range wantPrefix { if !strings.HasPrefix(r.Warnings[i], want) { t.Errorf("Warnings[%d] = %q, want prefix %q", i, r.Warnings[i], want) } } } // TestExcludeDirs is a table check of excludeDirs's rule-destination // handling, in rule order: a plain destination excludes itself exactly; a // destination with a placeholder excludes only the static part before it, // cut back to a full path component; a destination (after that cut) equal // to the root itself, or outside the root, excludes nothing; an absolute // destination inside the root excludes that directory; copy counts like // move; rename is not a destination at all. func TestExcludeDirs(t *testing.T) { h := sandbox(t) root := filepath.Join(h, "root") absDest := filepath.Join(root, "AbsDest") conf := ` (path "` + root + `") (rule "r1" (move "Work/Acme")) (rule "r2" (move "Photos/{mtime:%Y}")) (rule "r3" (move "Work/Acme-{mtime:%Y}")) (rule "r4" (move "{ext}")) (rule "r5" (move ".")) (rule "r6" (move "~/elsewhere")) (rule "r7" (move "` + absDest + `")) (rule "r8" (copy "Backup")) (rule "r9" (rename "x-{name}")) (rule "r10" (move "Lit/{{a}}/{mtime:%Y}")) ` main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": conf}) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } got := e.excludeDirs(e.Dirs[0]) want := []string{ filepath.Join(root, "Work", "Acme"), // r1: no placeholder, exact filepath.Join(root, "Photos"), // r2: cut back to "Photos/" filepath.Join(root, "Work"), // r3: cut back past "Acme-" // r4 "{ext}": nothing before "{" at all -> resolves to the root // itself -> not strictly inside it -> excludes nothing. // r5 ".": no placeholder, resolves to the root itself -> nothing. // r6 "~/elsewhere": outside the root -> nothing. absDest, // r7: absolute, already inside root filepath.Join(root, "Backup"), // r8: copy counts like move // r9 rename "x-{name}": rename is never a destination. filepath.Join(root, "Lit", "{a}"), // r10: "{{a}}" is literal text } if !reflect.DeepEqual(got, want) { t.Fatalf("excludeDirs =\n%v\nwant\n%v", got, want) } } // TestMatchDrainsDupCandidateErrors: A1 plumbing across the dup/engine // boundary. Within one directory's own scan (no extra directories), a // candidate that cannot be hashed must not poison the duplicate answer for // its size-mates, and must surface exactly once in Result.Warnings, its // path abbreviated the way every other user-visible path is. func TestMatchDrainsDupCandidateErrors(t *testing.T) { if os.Geteuid() == 0 { t.Skip("permissions are not enforced running as root") } h := sandbox(t) dl := filepath.Join(h, "dl") old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) files := map[string]string{ "a.txt": "same content", // older: the original "b.txt": "same content", // newer: reported as the duplicate "c.txt": "diff content", // same size as a/b, different bytes } for name, body := range files { p := filepath.Join(dl, name) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } } os.Chtimes(filepath.Join(dl, "a.txt"), old, old) os.Chtimes(filepath.Join(dl, "b.txt"), old.Add(time.Hour), old.Add(time.Hour)) os.Chtimes(filepath.Join(dl, "c.txt"), old, old) cPath := filepath.Join(dl, "c.txt") if err := os.Chmod(cPath, 0o000); err != nil { t.Fatal(err) } t.Cleanup(func() { os.Chmod(cPath, 0o644) }) conf := ` (path "~/dl") (recursive yes) (min-age 0s) (rule "dup" (when (duplicate)) (stop)) ` main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": conf}) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } r, err := e.Match(context.Background(), e.Dirs[0]) if err != nil { t.Fatal(err) } var bReasons []string for _, m := range r.Matched { if m.File.Rel == "b.txt" { for _, rm := range m.Rules { bReasons = append(bReasons, rm.Reasons...) } } } if len(bReasons) == 0 || bReasons[0] != "duplicate of a.txt" { t.Errorf("b.txt duplicate pair with a.txt broken by unreadable sibling c.txt: %v", summary(r)) } want := "duplicate: ~/dl/c.txt: " found := 0 for _, w := range r.Warnings { if strings.HasPrefix(w, want) { found++ } } if found != 1 { t.Errorf("got %d warnings with prefix %q, want 1; warnings: %v", found, want, r.Warnings) } } // TestDuplicateWarningsShareOneFormat: a missing extra directory and a // candidate that cannot be hashed are both reported as "duplicate: PATH: // cause", the path abbreviated and not repeated raw inside the cause // (triage 6). func TestDuplicateWarningsShareOneFormat(t *testing.T) { if os.Getuid() == 0 { t.Skip("root reads a chmod 000 file") } h := sandbox(t) dl := filepath.Join(h, "dl") extra := filepath.Join(h, "extra") os.MkdirAll(dl, 0o755) os.MkdirAll(extra, 0o755) old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for _, p := range []string{filepath.Join(dl, "a.txt"), filepath.Join(extra, "c.txt")} { os.WriteFile(p, []byte("same size"), 0o644) os.Chtimes(p, old, old) } os.Chmod(filepath.Join(extra, "c.txt"), 0) t.Cleanup(func() { os.Chmod(filepath.Join(extra, "c.txt"), 0o644) }) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": ` (path "~/dl") (rule "d" (when (duplicate "~/extra" "~/nonexistent")) (move "Dupes")) `}) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } r, err := e.Match(context.Background(), e.Dirs[0]) if err != nil { t.Fatal(err) } want := map[string]bool{ "duplicate: ~/extra/c.txt: open: permission denied": false, "duplicate: ~/nonexistent: lstat: no such file or directory": false, } for _, w := range r.Warnings { if strings.Contains(w, h) { t.Errorf("warning repeats the raw path: %q", w) } if _, ok := want[w]; ok { want[w] = true } } for w, seen := range want { if !seen { t.Errorf("missing warning %q in %q", w, r.Warnings) } } }