diff options
Diffstat (limited to 'internal/engine/nodelete_test.go')
| -rw-r--r-- | internal/engine/nodelete_test.go | 212 |
1 files changed, 212 insertions, 0 deletions
diff --git a/internal/engine/nodelete_test.go b/internal/engine/nodelete_test.go new file mode 100644 index 0000000..80d9ed9 --- /dev/null +++ b/internal/engine/nodelete_test.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package engine + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "krino/internal/journal" + "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) + } +} |
