// SPDX-License-Identifier: GPL-3.0-or-later package engine import ( "context" "io/fs" "os" "path/filepath" "sort" "strings" "testing" "time" "git.labunix.xyz/krino/internal/journal" "git.labunix.xyz/krino/internal/plan" ) // sameBytes is the content every file in these tests shares. const sameBytes = "%PDF identical bytes" // dlTree creates ~/dl in a sandbox. Each file holds body and is modified // the given number of hours after a fixed old time, so the smallest number // is the oldest file. PATH is emptied so no extraction tool runs. func dlTree(t *testing.T, files map[string]int, body string) (home, dl string) { t.Helper() home = sandbox(t) t.Setenv("PATH", t.TempDir()) dl = filepath.Join(home, "dl") old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for rel, hours := range files { p := filepath.Join(dl, rel) 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) } mt := old.Add(time.Duration(hours) * time.Hour) if err := os.Chtimes(p, mt, mt); err != nil { t.Fatal(err) } } return home, dl } // planAndApply configures ~/dl (recursive, min-age 0s) with rules, plans // it, approves and applies every chain, and returns the plan. func planAndApply(t *testing.T, home, rules string) *DirPlan { t.Helper() conf := "(path \"~/dl\")\n(recursive yes)\n(min-age 0s)\n" + rules main := writeConfig(t, home, `(include "dl")`, map[string]string{"dl": conf}) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims()) if err != nil { t.Fatal(err) } approved := map[string]bool{} for _, c := range dp.Chains { approved[c.File.Rel] = true } j, err := journal.Open(filepath.Join(home, ".local", "state", "krino", "krino.log")) if err != nil { t.Fatal(err) } defer j.Close() res, err := e.Apply(context.Background(), dp, approved, j, journal.NewRunID(time.Now())) if err != nil { t.Fatal(err) } if res.Failed != 0 { t.Fatalf("%d files failed: %+v", res.Failed, res) } return dp } // assertCopies checks that exactly the files want (paths relative to dl, // in any order) hold body after the run. func assertCopies(t *testing.T, dl, body string, want []string) { t.Helper() var got []string err := filepath.WalkDir(dl, func(p string, d fs.DirEntry, err error) error { if err != nil || !d.Type().IsRegular() { return err } if b, err := os.ReadFile(p); err == nil && string(b) == body { rel, _ := filepath.Rel(dl, p) got = append(got, filepath.ToSlash(rel)) } return nil }) if err != nil { t.Fatal(err) } sort.Strings(got) w := append([]string(nil), want...) sort.Strings(w) if strings.Join(got, "\n") != strings.Join(w, "\n") { t.Errorf("copies on disk = %q, want %q", got, w) } } // assertSkip checks that rel's chain has a step from rule skipped with a // reason starting with want. func assertSkip(t *testing.T, dp *DirPlan, rel, rule, want string) { t.Helper() for _, c := range dp.Chains { if c.File.Rel != rel { continue } for _, s := range c.Steps { if s.Rule == rule && strings.HasPrefix(s.Skip, want) { return } } t.Errorf("%s: no step of rule %q skipped with %q; steps %+v", rel, rule, want, c.Steps) return } t.Errorf("%s: no chain in the plan", rel) } // TestNoDeleteThroughMatched: a later rule deleting through (matched) // cannot delete what an earlier duplicate rule found. func TestNoDeleteThroughMatched(t *testing.T) { h, dl := dlTree(t, map[string]int{"a.pdf": 0, "b.pdf": 1}, sameBytes) dp := planAndApply(t, h, ` (rule "dupes" (when (duplicate)) (move "Dupes")) (rule "cleanup" (when (matched)) (delete permanent)) `) assertCopies(t, dl, sameBytes, []string{"a.pdf", "Dupes/b.pdf"}) assertSkip(t, dp, "b.pdf", "cleanup", NeverDeleted) } // TestNoDeleteThroughAnotherRulesCondition: a rule with no duplicate test // of its own deletes the original, but not the duplicate. func TestNoDeleteThroughAnotherRulesCondition(t *testing.T) { h, dl := dlTree(t, map[string]int{"a.pdf": 0, "b.pdf": 1}, sameBytes) dp := planAndApply(t, h, ` (rule "pdfs" (when (type pdf)) (delete permanent)) (rule "dupes" (when (duplicate)) (move "Dupes")) `) assertCopies(t, dl, sameBytes, []string{"Dupes/b.pdf"}) assertSkip(t, dp, "b.pdf", "pdfs", NeverDeleted) } // TestNoDeleteWhenEvaluationSkippedTheDuplicateTest: "dupes" tests size // first, which is false, so its duplicate test is never evaluated; b.pdf // is still a duplicate under that scope, so "pdfs" cannot delete it. func TestNoDeleteWhenEvaluationSkippedTheDuplicateTest(t *testing.T) { h, dl := dlTree(t, map[string]int{"a.pdf": 0, "b.pdf": 1}, sameBytes) dp := planAndApply(t, h, ` (rule "dupes" (when (size > 1G) (duplicate)) (move "Dupes")) (rule "pdfs" (when (type pdf)) (delete permanent)) `) assertCopies(t, dl, sameBytes, []string{"b.pdf"}) assertSkip(t, dp, "b.pdf", "pdfs", NeverDeleted) } // TestNoDeleteWithTwoScopesWhoseOriginalsDiffer: under "Archive" the // original is Archive/x.pdf, under the plain scope it is the older // x-copy.pdf, so each file is a duplicate somewhere. Neither is deleted; // both are moved aside. func TestNoDeleteWithTwoScopesWhoseOriginalsDiffer(t *testing.T) { h, dl := dlTree(t, map[string]int{"Archive/x.pdf": 1, "x-copy.pdf": 0}, sameBytes) dp := planAndApply(t, h, ` (rule "pdfs" (when (type pdf)) (delete permanent)) (rule "archive-dupes" (when (duplicate "Archive")) (move "Dupes") (stop)) (rule "local-dupes" (when (duplicate)) (move "Dupes") (stop)) `) assertCopies(t, dl, sameBytes, []string{"Dupes/x.pdf", "Dupes/x-copy.pdf"}) assertSkip(t, dp, "Archive/x.pdf", "pdfs", NeverDeleted) assertSkip(t, dp, "x-copy.pdf", "pdfs", NeverDeleted) } // TestDeleteWithoutDuplicateTestsStillDeletes: the guarantee applies only // where a directory's rules use (duplicate); a plain delete rule still does // what it says. func TestDeleteWithoutDuplicateTestsStillDeletes(t *testing.T) { h, dl := dlTree(t, map[string]int{"a.pdf": 0, "b.pdf": 1}, sameBytes) planAndApply(t, h, `(rule "pdfs" (when (type pdf)) (delete permanent))`) assertCopies(t, dl, sameBytes, nil) } // TestNoDeleteWhenTheDuplicateCheckFails: b.pdf cannot be read, so krino // cannot show it is not a duplicate; its delete is skipped rather than // guessed. a.pdf, whose only candidate could not be hashed, is not a // duplicate and is deleted. func TestNoDeleteWhenTheDuplicateCheckFails(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root reads files regardless of their mode") } h, dl := dlTree(t, map[string]int{"a.pdf": 0, "b.pdf": 1}, sameBytes) b := filepath.Join(dl, "b.pdf") if err := os.Chmod(b, 0); err != nil { t.Fatal(err) } t.Cleanup(func() { os.Chmod(b, 0o644) }) dp := planAndApply(t, h, ` (rule "dupes" (when (size > 1G) (duplicate)) (move "Dupes")) (rule "pdfs" (when (type pdf)) (delete)) `) assertSkip(t, dp, "b.pdf", "pdfs", "duplicate check failed, so not deleted: ") if _, err := os.Lstat(b); err != nil { t.Errorf("b.pdf is gone: %v", err) } if _, err := os.Lstat(filepath.Join(dl, "a.pdf")); !os.IsNotExist(err) { t.Errorf("a.pdf was not deleted: %v", err) } } // TestDuplicateInAnExcludeStillProtects: the promise is that no rule may // delete a file krino has found to be a duplicate under any scope its // directory uses (README, "Safety"). The scopes were collected from rules // only, so a directory whose duplicate tests live in (exclude ...) forms // had no scopes at all and the protection never engaged - a later rule // permanently deleted every copy. func TestDuplicateInAnExcludeStillProtects(t *testing.T) { home, _ := dlTree(t, map[string]int{"a.pdf": 1, "b.pdf": 2, "c.pdf": 3}, sameBytes) conf := "(path \"~/dl\")\n(recursive yes)\n(min-age 0s)\n" + "(exclude (not (duplicate)))\n" + "(rule \"purge\" (when (type pdf)) (delete permanent))\n" main := writeConfig(t, home, `(include "dl")`, map[string]string{"dl": conf}) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims()) if err != nil { t.Fatal(err) } planned := 0 for _, c := range dp.Chains { for _, st := range c.Steps { if st.Kind == plan.DeletePermanent && st.Skip == "" { planned++ t.Errorf("%s: a duplicate is planned for permanent deletion", c.File.Rel) } } } if planned == 0 && len(dp.Chains) == 0 { t.Skip("no chains were planned at all; the fixture does not exercise the rule") } }