aboutsummaryrefslogtreecommitdiff
path: root/internal/engine
diff options
context:
space:
mode:
Diffstat (limited to 'internal/engine')
-rw-r--r--internal/engine/engine.go32
-rw-r--r--internal/engine/nodelete_test.go34
2 files changed, 57 insertions, 9 deletions
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
index 0c3186d..61c310e 100644
--- a/internal/engine/engine.go
+++ b/internal/engine/engine.go
@@ -159,7 +159,7 @@ func LoadWith(mainFile string, overrides map[string][]byte, names ...string) (*E
}
}
dir.ContentKeywords = contentKeywords(dir.Rules, dir.Excludes)
- dir.DupScopes = dupScopes(dir.Rules)
+ dir.DupScopes = dupScopes(dir.Rules, dir.Excludes)
dirs = append(dirs, dir)
}
@@ -267,16 +267,24 @@ func contentKeywords(rules []*Rule, excludes []*Exclude) []cond.Keyword {
return out
}
-// dupScopes returns the distinct Cond.DupDirs lists of rules, in first-seen
-// order. Lists are compared as written, with their length in the key so
-// (duplicate) and (duplicate "") stay apart; two spellings of one directory
-// stay two entries, which costs a second lookup but never a wrong answer,
-// since facts.Duplicate resolves and shares the index itself.
-func dupScopes(rules []*Rule) [][]string {
+// dupScopes returns the distinct Cond.DupDirs lists a directory uses, in
+// first-seen order, from its rules and its excludes alike. Lists are
+// compared as written, with their length in the key so (duplicate) and
+// (duplicate "") stay apart; two spellings of one directory stay two
+// entries, which costs a second lookup but never a wrong answer, since
+// facts.Duplicate resolves and shares the index itself.
+//
+// The excludes count because the protection these scopes drive - no rule
+// deletes a file krino has found to be a duplicate - is about what krino
+// knows, not about which form taught it. A directory whose only duplicate
+// test sat in an (exclude ...) had no scopes at all, so a later rule could
+// permanently delete every copy of a file krino had just called a
+// duplicate.
+func dupScopes(rules []*Rule, excludes []*Exclude) [][]string {
var out [][]string
seen := map[string]bool{}
- for _, r := range rules {
- for _, dirs := range r.Cond.DupDirs {
+ add := func(lists [][]string) {
+ for _, dirs := range lists {
key := fmt.Sprintf("%d\x00%s", len(dirs), strings.Join(dirs, "\x00"))
if seen[key] {
continue
@@ -285,6 +293,12 @@ func dupScopes(rules []*Rule) [][]string {
out = append(out, dirs)
}
}
+ for _, r := range rules {
+ add(r.Cond.DupDirs)
+ }
+ for _, x := range excludes {
+ add(x.Cond.DupDirs)
+ }
return out
}
diff --git a/internal/engine/nodelete_test.go b/internal/engine/nodelete_test.go
index af7597c..6b8ffa9 100644
--- a/internal/engine/nodelete_test.go
+++ b/internal/engine/nodelete_test.go
@@ -210,3 +210,37 @@ func TestNoDeleteWhenTheDuplicateCheckFails(t *testing.T) {
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")
+ }
+}