aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-15 22:10:44 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-15 22:10:44 +0200
commit6b38d302daa54140e4e26ad903297a1bba0cbd41 (patch)
tree2f32472e751de41f8fbbbeed739f0e66a4cb5211
parentbbc9b103acac96b83b150b81f2effe1b65c3f8a2 (diff)
downloadkrino-6b38d302daa54140e4e26ad903297a1bba0cbd41.tar.gz
krino-6b38d302daa54140e4e26ad903297a1bba0cbd41.zip
a failed duplicate lookup is unknown, like unreadable content
-rw-r--r--CHANGELOG.md3
-rw-r--r--internal/cond/eval.go41
-rw-r--r--internal/cond/eval_test.go29
-rw-r--r--internal/engine/exclude_test.go29
-rw-r--r--internal/engine/match.go2
5 files changed, 99 insertions, 5 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b641ec0..81b7379 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
## Unreleased
+- 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.
- `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/internal/cond/eval.go b/internal/cond/eval.go
index 31c54e3..1147c05 100644
--- a/internal/cond/eval.go
+++ b/internal/cond/eval.go
@@ -44,6 +44,9 @@ type Result struct {
// text holds - (and (content "x") (type txt)) on a pdf - is not
// unknown (plan 11).
Unreadable bool
+ // Undecided says what made the value unknown: "content unreadable",
+ // "duplicate check failed", or both, comma-separated (plan 12).
+ Undecided string
}
// Trace is the full evaluation of every node, for krino explain.
@@ -73,6 +76,29 @@ type evalCtx struct {
captures []string
warned map[string]bool
warnings []string
+ // unknownContent and unknownDup record which kinds of test came back
+ // unknown, for Result.Undecided.
+ unknownContent, unknownDup bool
+}
+
+// undecidedLabel names the kinds of test that came back unknown.
+func (ctx *evalCtx) undecidedLabel() string {
+ switch {
+ case ctx.unknownContent && ctx.unknownDup:
+ return "content unreadable, duplicate check failed"
+ case ctx.unknownDup:
+ return "duplicate check failed"
+ case ctx.unknownContent:
+ return "content unreadable"
+ }
+ return ""
+}
+
+// undecidable reports whether a leaf whose fact could not be read is
+// unknown rather than false: a content test (review M11) or a duplicate
+// test (plan 12).
+func undecidable(k kind) bool {
+ return k == kContent || k == kDuplicate
}
// warn records msg unless it has already been recorded.
@@ -98,7 +124,11 @@ func (c *Cond) Eval(f Facts) Result {
}
ctx := &evalCtx{}
v, reasons := c.eval(c.root, f, ctx, false)
- return Result{Match: v == yes, Captures: ctx.captures, Reasons: reasons, Warnings: ctx.warnings, Unreadable: v == unknown}
+ r := Result{Match: v == yes, Captures: ctx.captures, Reasons: reasons, Warnings: ctx.warnings, Unreadable: v == unknown}
+ if r.Unreadable {
+ r.Undecided = ctx.undecidedLabel()
+ }
+ return r
}
// eval evaluates one node against f, short-circuiting and/or in child
@@ -163,7 +193,12 @@ func (c *Cond) eval(n *node, f Facts, ctx *evalCtx, negated bool) (tri, []string
}
ok, reason, warn, caps := c.evalLeaf(n, f)
ctx.warn(warn)
- if warn != "" && n.kind == kContent {
+ if warn != "" && undecidable(n.kind) {
+ if n.kind == kContent {
+ ctx.unknownContent = true
+ } else {
+ ctx.unknownDup = true
+ }
return unknown, nil
}
if !ok {
@@ -355,7 +390,7 @@ func (c *Cond) explain(n *node, f Facts) *Trace {
default:
ok, _, warn, _ := c.evalLeaf(n, f)
t := &Trace{Label: n.label, Value: ok, Err: warn}
- if m, undecided := f.Matched(); (warn != "" && n.kind == kContent) || (n.kind == kMatched && !m && undecided) {
+ if m, undecided := f.Matched(); (warn != "" && undecidable(n.kind)) || (n.kind == kMatched && !m && undecided) {
t.set(unknown)
}
return t
diff --git a/internal/cond/eval_test.go b/internal/cond/eval_test.go
index 9352b82..7ebe038 100644
--- a/internal/cond/eval_test.go
+++ b/internal/cond/eval_test.go
@@ -24,6 +24,7 @@ type fake struct {
matchedUnknown bool
dupOrig string
dupOK bool
+ dupErr error
contentCalls int
}
@@ -51,7 +52,7 @@ func (f *fake) ContentContains(opt Options, keywords []string) (int, error) {
}
return -1, nil
}
-func (f *fake) Duplicate(dirs []string) (string, bool, error) { return f.dupOrig, f.dupOK, nil }
+func (f *fake) Duplicate(dirs []string) (string, bool, error) { return f.dupOrig, f.dupOK, f.dupErr }
func eval(t *testing.T, src string, opt Options, f Facts) Result {
t.Helper()
@@ -300,3 +301,29 @@ func TestMatchedIsUnknownAfterAnUnknownRule(t *testing.T) {
t.Errorf("(matched) after a rule that did match: %+v, want true", r)
}
}
+
+// TestFailedDuplicateLookupIsUnknown: a duplicate test whose lookup fails is
+// unknown, like unreadable content, and says why (plan 12).
+func TestFailedDuplicateLookupIsUnknown(t *testing.T) {
+ f := &fake{name: "a.pdf", dupErr: errors.New("~/dl/a.pdf: open: permission denied")}
+ for _, c := range []struct {
+ src string
+ match, unreadable bool
+ }{
+ {`(duplicate)`, false, true},
+ {`(not (duplicate))`, false, true},
+ {`(and (duplicate) (type txt))`, false, false},
+ } {
+ r := eval(t, c.src, Options{}, f)
+ if r.Match != c.match || r.Unreadable != c.unreadable {
+ t.Errorf("%s: Match %v Unreadable %v; want %v, %v", c.src, r.Match, r.Unreadable, c.match, c.unreadable)
+ }
+ if c.unreadable && r.Undecided != "duplicate check failed" {
+ t.Errorf("%s: Undecided %q", c.src, r.Undecided)
+ }
+ }
+ g := &fake{name: "a.pdf", rawErr: errors.New("larger than max-read"), dupErr: errors.New("x")}
+ if r := eval(t, `(or (content "x") (duplicate))`, Options{}, g); r.Undecided != "content unreadable, duplicate check failed" {
+ t.Errorf("both unknown: Undecided %q", r.Undecided)
+ }
+}
diff --git a/internal/engine/exclude_test.go b/internal/engine/exclude_test.go
index 6381a04..7069623 100644
--- a/internal/engine/exclude_test.go
+++ b/internal/engine/exclude_test.go
@@ -547,3 +547,32 @@ func TestNotMatchedAfterAnUnknownRuleDoesNotAct(t *testing.T) {
}
}
}
+
+// TestDuplicateExcludeFailsClosed: an exclude whose duplicate lookup fails
+// holds, marked as such (plan 12).
+func TestDuplicateExcludeFailsClosed(t *testing.T) {
+ if os.Getuid() == 0 {
+ t.Skip("root reads a chmod 000 file")
+ }
+ h, dl := excludeTree(t, map[string]string{"a.txt": "same size", "b.txt": "same size"})
+ os.Chmod(filepath.Join(dl, "a.txt"), 0)
+ t.Cleanup(func() { os.Chmod(filepath.Join(dl, "a.txt"), 0o644) })
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
+(path "~/dl")
+(exclude (duplicate))
+(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 r.Matched {
+ if fm.File.Rel == "a.txt" && fm.Excluded != "(exclude (duplicate)) (duplicate check failed)" {
+ t.Errorf("a.txt: Excluded %q, rules %d; want set aside as duplicate check failed", fm.Excluded, len(fm.Rules))
+ }
+ }
+}
diff --git a/internal/engine/match.go b/internal/engine/match.go
index 2ef3ae1..f6e1706 100644
--- a/internal/engine/match.go
+++ b/internal/engine/match.go
@@ -367,7 +367,7 @@ func excludedBy(x *Exclude, res cond.Result) string {
case res.Match:
return x.Text
case res.Unreadable:
- return x.Text + " (content unreadable)"
+ return x.Text + " (" + res.Undecided + ")"
}
return ""
}