diff options
| -rw-r--r-- | CHANGELOG.md | 4 | ||||
| -rw-r--r-- | cmd/krino/exclude_test.go | 23 | ||||
| -rw-r--r-- | cmd/krino/explain.go | 5 | ||||
| -rw-r--r-- | internal/engine/exclude_test.go | 41 | ||||
| -rw-r--r-- | internal/engine/match.go | 11 |
5 files changed, 83 insertions, 1 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 81b7379..10aa9b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ - A `(duplicate)` test whose lookup fails (an unreadable file) is unknown, like unreadable content: an exclude holds, marked "(duplicate check failed)", and no rule acts on it, `(not (duplicate))` included. +- A `(stop)` rule that cannot be decided ends the search for that file: a + "keep" rule like `(rule "keep" (when (content "confidential")) (stop))` + protects an unreadable file from the rules below it. `explain` shows the + rule as "undecided" and the later rules as stopped by it. - `make ci` passes on OpenBSD 7.9 (OpenBSD make) and FreeBSD 15.0 (bmake) with Go 1.26.8; the design notes that OpenBSD's `go` package needs `GOTOOLCHAIN=auto` to use it. diff --git a/cmd/krino/exclude_test.go b/cmd/krino/exclude_test.go index e9d4433..f76be0c 100644 --- a/cmd/krino/exclude_test.go +++ b/cmd/krino/exclude_test.go @@ -165,3 +165,26 @@ func TestVerboseListsDirectoriesLeftOutOfTheWalk(t *testing.T) { t.Errorf("listed without -v:\n%s", out) } } + +// TestExplainShowsAnUndecidedRule: explain names a rule it could not decide +// "undecided", not "no" (plan 12). +func TestExplainShowsAnUndecidedRule(t *testing.T) { + h := home(t) + dl := filepath.Join(h, "dl") + os.MkdirAll(dl, 0o755) + p := filepath.Join(dl, "big.txt") + os.WriteFile(p, []byte("confidential "+strings.Repeat("x", 2048)), 0o644) + old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + os.Chtimes(p, old, old) + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { + t.Fatal(errOut) + } + os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte("(path \"~/dl\")\n(max-read 1K)\n(rule \"keep\" (when (content \"confidential\")) (stop))\n(rule \"all\" (move \"Out\"))\n"), 0o644) + _, out, errOut := runCLI(t, "explain", p) + if !strings.Contains(out, "rule keep: undecided") || !strings.Contains(out, "rule all: not evaluated, stopped by rule keep, which could not be decided") { + t.Errorf("explain output:\n%s\n%s", out, errOut) + } +} diff --git a/cmd/krino/explain.go b/cmd/krino/explain.go index f65df42..09145b9 100644 --- a/cmd/krino/explain.go +++ b/cmd/krino/explain.go @@ -72,8 +72,11 @@ func cmdExplain(g *globals, args []string, stdout, stderr io.Writer) int { continue } status := "no" - if rt.Match { + switch { + case rt.Match: status = "MATCH" + case rt.Trace != nil && rt.Trace.Unknown: + status = "undecided" } fmt.Fprintf(stdout, "rule %s: %s\n", rt.Rule.Name, status) printTrace(stdout, rt.Trace) diff --git a/internal/engine/exclude_test.go b/internal/engine/exclude_test.go index 7069623..7d9ce2a 100644 --- a/internal/engine/exclude_test.go +++ b/internal/engine/exclude_test.go @@ -576,3 +576,44 @@ func TestDuplicateExcludeFailsClosed(t *testing.T) { } } } + +// TestUndecidedStopRuleStops: a (stop) rule whose condition cannot be +// decided ends the search for that file, so a later rule does not act on a +// file the stop rule was written to keep (plan 12). +func TestUndecidedStopRuleStops(t *testing.T) { + big := "confidential " + strings.Repeat("x", 2048) + h, dl := excludeTree(t, map[string]string{"big.txt": big, "small.txt": "nothing"}) + main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": ` +(path "~/dl") +(max-read 1K) +(rule "keep" (when (content "confidential")) (stop)) +(rule "all" (move "Out")) +`}) + e, errs := Load(main) + if len(errs) > 0 { + t.Fatal(errs) + } + r, err := e.Match(context.Background(), e.Dirs[0]) + if err != nil { + t.Fatal(err) + } + for _, fm := range append(append([]FileMatch{}, r.Matched...), r.Unmatched...) { + switch fm.File.Rel { + case "big.txt": + if len(fm.Rules) != 0 || len(fm.Warnings) == 0 { + t.Errorf("big.txt: rules %d, warnings %v; want no rule and the warning", len(fm.Rules), fm.Warnings) + } + case "small.txt": + if len(fm.Rules) != 1 { + t.Errorf("small.txt: rules %d; want the move", len(fm.Rules)) + } + } + } + x, err := e.Explain(context.Background(), filepath.Join(dl, "big.txt")) + if err != nil { + t.Fatal(err) + } + if len(x.Rules) != 2 || x.Rules[1].Stopped != "stopped by rule keep, which could not be decided" { + t.Errorf("explain rules = %+v; want all stopped by the undecided keep", x.Rules) + } +} diff --git a/internal/engine/match.go b/internal/engine/match.go index f6e1706..67f0c06 100644 --- a/internal/engine/match.go +++ b/internal/engine/match.go @@ -171,6 +171,12 @@ func evalFile(run *matchRun, file scan.File) FileMatch { } if res.Unreadable { f.undecided = true + if r.Conf.Stop { + // A (stop) rule that cannot be decided ends the search too: + // it may be the rule written to keep this file from the + // ones below (plan 12). + break + } } if !res.Match { continue @@ -321,6 +327,11 @@ func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error) match := trace.Value if trace.Unknown { f.undecided = true + if r.Conf.Stop { + rules = append(rules, RuleTrace{Rule: r, Trace: trace}) + stoppedBy = r.Name + ", which could not be decided" + continue + } } if match { f.matched = true |
