From b44222fc2b061382dc601014cde287cc41b857ca Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 17 Sep 2026 13:59:52 +0200 Subject: a duplicate test in an exclude protects like one in a rule The scopes that drive "no rule deletes a file krino found to be a duplicate" were collected from rules only. A directory whose duplicate tests lived in (exclude ...) forms had no scopes at all, so the protection never engaged: krino explain said "yes duplicate" and the next rule permanently deleted every copy. The README's promise was false in that shape, and the spec's wording permitted it. What matters is what krino knows, not which form taught it. The spec and krino.conf(5) now say so too. --- internal/engine/nodelete_test.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) (limited to 'internal/engine/nodelete_test.go') 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") + } +} -- cgit v1.3