diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-15 22:10:44 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-15 22:10:44 +0200 |
| commit | 6b38d302daa54140e4e26ad903297a1bba0cbd41 (patch) | |
| tree | 2f32472e751de41f8fbbbeed739f0e66a4cb5211 | |
| parent | bbc9b103acac96b83b150b81f2effe1b65c3f8a2 (diff) | |
| download | krino-6b38d302daa54140e4e26ad903297a1bba0cbd41.tar.gz krino-6b38d302daa54140e4e26ad903297a1bba0cbd41.zip | |
a failed duplicate lookup is unknown, like unreadable content
| -rw-r--r-- | CHANGELOG.md | 3 | ||||
| -rw-r--r-- | internal/cond/eval.go | 41 | ||||
| -rw-r--r-- | internal/cond/eval_test.go | 29 | ||||
| -rw-r--r-- | internal/engine/exclude_test.go | 29 | ||||
| -rw-r--r-- | internal/engine/match.go | 2 |
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 "" } |
