summaryrefslogtreecommitdiff
path: root/internal/engine/nodelete_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 10:56:21 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 10:56:21 +0200
commit1506c7dcd6032c04c1df6f5785b6dd3dfe4511cc (patch)
treefb6d573ca8726c1b1d91ba273a284ba666cde3f6 /internal/engine/nodelete_test.go
parentebdd7bb254a0f19f815a644d26ce232de7be0adb (diff)
downloadkrino-1506c7dcd6032c04c1df6f5785b6dd3dfe4511cc.tar.gz
krino-1506c7dcd6032c04c1df6f5785b6dd3dfe4511cc.zip
krino: duplicates are found, never deleted
A rule combining (duplicate) with a delete action is refused at load, and a file that is a duplicate under any duplicate scope its directory uses gets no delete step from any rule: the plan shows it skipped and the chain continues. A failed duplicate check blocks the delete too. Tests cover (matched), another rule's own condition, a test evaluation skipped, two scopes and a failed check, each checking every copy is still on disk.
Diffstat (limited to 'internal/engine/nodelete_test.go')
-rw-r--r--internal/engine/nodelete_test.go212
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)
+ }
+}