summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-15 22:11:33 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-15 22:11:33 +0200
commit77a015b96db8727bb12d0869f180a44d087f6319 (patch)
treed6e4e485f4556a676fd92a6792602fec690065be
parent6b38d302daa54140e4e26ad903297a1bba0cbd41 (diff)
downloadkrino-77a015b96db8727bb12d0869f180a44d087f6319.tar.gz
krino-77a015b96db8727bb12d0869f180a44d087f6319.zip
an undecided (stop) rule ends the search for that file
-rw-r--r--CHANGELOG.md4
-rw-r--r--cmd/krino/exclude_test.go23
-rw-r--r--cmd/krino/explain.go5
-rw-r--r--internal/engine/exclude_test.go41
-rw-r--r--internal/engine/match.go11
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