aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md9
-rw-r--r--Makefile11
-rw-r--r--docs/design.md5
-rw-r--r--internal/config/fuzz_test.go51
-rw-r--r--internal/config/skel.go6
-rw-r--r--internal/config/skel_test.go4
-rw-r--r--internal/engine/match.go10
-rw-r--r--internal/extract/fuzz_test.go56
-rw-r--r--internal/ignore/fuzz_test.go44
-rw-r--r--internal/kwcache/fuzz_test.go50
-rw-r--r--internal/norm/fuzz_test.go30
-rw-r--r--internal/norm/norm.go14
-rw-r--r--internal/norm/norm_test.go2
-rw-r--r--internal/norm/testdata/fuzz/FuzzTextIdempotent/d26a0358dee954b32
-rw-r--r--internal/plan/fuzz_test.go33
-rw-r--r--internal/sexp/fuzz_test.go28
-rw-r--r--man/krino.conf.52
17 files changed, 347 insertions, 10 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 74ff1e3..b856f3e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
## Unreleased
+- Folding maps the capital sharp s (ẞ) to "SS", as the other capitals
+ already were: "STRAẞE" in a document now matches the keyword "straße".
+- Folding replaces invalid UTF-8 with U+FFFD first, so a stray invalid byte
+ no longer stops the letter after it from losing its accent.
+- `krino new` refuses a directory whose path is not valid UTF-8 with that
+ reason, instead of reporting a broken template.
+- The keyword cache is also discarded when normalisation changes, so answers
+ cached before these fixes are recomputed once.
+
- Builds need Go 1.25 or newer and use the Go 1.26.8 toolchain, which an
older `go` downloads itself. `golang.org/x/text` is updated to v0.41.0.
This fixes an infinite loop a crafted file's text could cause
diff --git a/Makefile b/Makefile
index 3cfc8f1..4713ecb 100644
--- a/Makefile
+++ b/Makefile
@@ -87,7 +87,16 @@ FUZZ_TARGETS = \
internal/journal:FuzzParseLine \
internal/journal:FuzzEntryRoundTrip \
internal/trash:FuzzPercentRoundTrip \
- internal/trash:FuzzParsePath
+ internal/trash:FuzzParsePath \
+ internal/sexp:FuzzQuoteRoundTrip \
+ internal/config:FuzzParseSize \
+ internal/config:FuzzParseDuration \
+ internal/norm:FuzzTextIdempotent \
+ internal/plan:FuzzExpand \
+ internal/ignore:FuzzMatch \
+ internal/kwcache:FuzzLoad \
+ internal/extract:FuzzStripMarkup \
+ internal/extract:FuzzZipText
fuzz: ## run every fuzz target for FUZZTIME each (default 20s); a crasher is saved under testdata/fuzz
@for t in $(FUZZ_TARGETS); do \
diff --git a/docs/design.md b/docs/design.md
index 411eeba..11e859e 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -233,7 +233,8 @@ quantifiers, and inline flags `(?i)` `(?-i)`; no backreferences or lookaround.
data and the pattern. `type` is always case-insensitive. A regex can
override `case` locally with `(?i)` or `(?-i)`. Folding decomposes letters
(Unicode NFD), drops combining marks, and maps the letters that do not
-decompose (ł ø đ ħ ß æ œ and their capitals) to ASCII.
+decompose (ł ø đ ħ ß æ œ and their capitals, ẞ included) to ASCII. Invalid
+UTF-8 is replaced by U+FFFD before folding.
### 5.4 Evaluation
@@ -348,7 +349,7 @@ appear in the config.
Failures (unreadable, tool missing, timeout) are never cached.
- The cache is discarded whole when the extractor fingerprint changes: a
tool installed, removed or replaced, or krino's extraction code changing
- (`extract.Version`).
+ (`extract.Version`), or its text normalisation changing (`norm.Version`).
- Planning a run (`-n` included) reads the cache and writes it back holding
only files still in the directory, under the directory's lock, via a
temporary file renamed into place; the directory is 0700, the file 0600.
diff --git a/internal/config/fuzz_test.go b/internal/config/fuzz_test.go
new file mode 100644
index 0000000..32926d6
--- /dev/null
+++ b/internal/config/fuzz_test.go
@@ -0,0 +1,51 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "fmt"
+ "strconv"
+ "testing"
+ "time"
+)
+
+// FuzzParseSize: a size either fails to parse or is a non-negative number
+// of bytes that reads back the same written as plain digits - no overflow
+// ever wraps a huge size negative.
+func FuzzParseSize(f *testing.F) {
+ for _, s := range []string{"50M", "0", "1T", "9223372036854775807", "8388608T", "8388607T", "-1", "1.5G", "", "G", "007K"} {
+ f.Add(s)
+ }
+ f.Fuzz(func(t *testing.T, s string) {
+ v, err := ParseSize(s)
+ if err != nil {
+ return
+ }
+ if v < 0 {
+ t.Fatalf("ParseSize(%q) = %d, negative", s, v)
+ }
+ if back, err := ParseSize(strconv.FormatInt(v, 10)); err != nil || back != v {
+ t.Fatalf("ParseSize(%q) = %d, which reads back as %d, %v", s, v, back, err)
+ }
+ })
+}
+
+// FuzzParseDuration: a duration either fails to parse or is a non-negative
+// whole number of seconds that reads back the same written in seconds.
+func FuzzParseDuration(f *testing.F) {
+ for _, s := range []string{"30d", "0s", "2m", "1w", "15250284452w", "9223372036s", "d", "", "-1d", "1.5h"} {
+ f.Add(s)
+ }
+ f.Fuzz(func(t *testing.T, s string) {
+ d, err := ParseDuration(s)
+ if err != nil {
+ return
+ }
+ if d < 0 || d%time.Second != 0 {
+ t.Fatalf("ParseDuration(%q) = %v", s, d)
+ }
+ if back, err := ParseDuration(fmt.Sprintf("%ds", d/time.Second)); err != nil || back != d {
+ t.Fatalf("ParseDuration(%q) = %v, which reads back as %v, %v", s, d, back, err)
+ }
+ })
+}
diff --git a/internal/config/skel.go b/internal/config/skel.go
index 65d9f95..ca914e3 100644
--- a/internal/config/skel.go
+++ b/internal/config/skel.go
@@ -10,6 +10,7 @@ import (
"io/fs"
"os"
"path/filepath"
+ "unicode/utf8"
"krino/internal/sexp"
"krino/internal/xdg"
@@ -66,6 +67,11 @@ func NewDir(mainFile, name, path string) (string, error) {
if err != nil {
return "", err
}
+ // The path is written into the new file, and config files are UTF-8
+ // text (the reader refuses anything else).
+ if !utf8.ValidString(abs) {
+ return "", fmt.Errorf("%q is not valid UTF-8; krino's config is UTF-8 text, so rename the directory first", abs)
+ }
if fi, err := os.Stat(abs); err != nil || !fi.IsDir() {
return "", fmt.Errorf("%s is not a directory", abs)
}
diff --git a/internal/config/skel_test.go b/internal/config/skel_test.go
index 476af65..3815336 100644
--- a/internal/config/skel_test.go
+++ b/internal/config/skel_test.go
@@ -5,6 +5,7 @@ package config
import (
"bytes"
"errors"
+ "fmt"
"io/fs"
"os"
"path/filepath"
@@ -242,7 +243,7 @@ func TestNewDirErrors(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
writeFiles(t, home, map[string]string{
- "krino.conf": `(include "a")`, "dirs/a.conf": `(path "~")`, "x/.keep": "", "f": "",
+ "krino.conf": `(include "a")`, "dirs/a.conf": `(path "~")`, "x/.keep": "", "f": "", "lat\xe9n/.keep": "",
})
main := filepath.Join(home, "krino.conf")
tests := []struct{ name, path, want string }{
@@ -251,6 +252,7 @@ func TestNewDirErrors(t *testing.T) {
{"b", "~/f", filepath.Join(home, "f") + " is not a directory"},
{"b", "~/missing", filepath.Join(home, "missing") + " is not a directory"},
{"a", "~/x", `"a" is already included`},
+ {"b", "~/lat\xe9n", fmt.Sprintf("%q is not valid UTF-8; krino's config is UTF-8 text, so rename the directory first", filepath.Join(home, "lat\xe9n"))},
}
for _, tt := range tests {
if _, err := NewDir(main, tt.name, tt.path); err == nil || err.Error() != tt.want {
diff --git a/internal/engine/match.go b/internal/engine/match.go
index 1a8077a..b922829 100644
--- a/internal/engine/match.go
+++ b/internal/engine/match.go
@@ -17,6 +17,7 @@ import (
"krino/internal/cond"
"krino/internal/config"
"krino/internal/kwcache"
+ "krino/internal/norm"
"krino/internal/plan"
"krino/internal/scan"
"krino/internal/xdg"
@@ -296,6 +297,13 @@ func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error)
return &Explanation{Dir: d, File: sf, Skip: skip, Excludes: excludes, Excluded: excluded, Rules: rules}, nil
}
+// cacheFingerprint identifies what a cached keyword answer depends on
+// besides the file: the extractor (its version and tools) and the
+// normalisation version. A cache written under any other is discarded.
+func (e *Engine) cacheFingerprint() string {
+ return fmt.Sprintf("%s norm%d", e.Extract.Fingerprint(), norm.Version)
+}
+
// cacheFile is d's keyword cache file.
func (e *Engine) cacheFile(d *Dir) string {
return filepath.Join(e.CacheDir, d.Name+".cache")
@@ -308,7 +316,7 @@ func (e *Engine) openCache(run *matchRun) []string {
if e.CacheDir == "" || len(run.d.ContentKeywords) == 0 {
return nil
}
- c, err := kwcache.Load(e.cacheFile(run.d), e.Extract.Fingerprint())
+ c, err := kwcache.Load(e.cacheFile(run.d), e.cacheFingerprint())
run.cache = c
if err != nil {
return []string{"cache: " + err.Error() + " (starting a new one)"}
diff --git a/internal/extract/fuzz_test.go b/internal/extract/fuzz_test.go
new file mode 100644
index 0000000..5f707d2
--- /dev/null
+++ b/internal/extract/fuzz_test.go
@@ -0,0 +1,56 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package extract
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// FuzzStripMarkup: decoding and stripping any bytes as HTML never panics.
+func FuzzStripMarkup(f *testing.F) {
+ for _, s := range []string{"<p>a &amp; b</p>", "<script>x</script>y", "<!-- c", "<", "&#x110000;", "<a href='>'>t</a>", "\xff\xfe<\x00p\x00>\x00", "\xef\xbb\xbf\xff"} {
+ f.Add([]byte(s))
+ }
+ f.Fuzz(func(t *testing.T, data []byte) {
+ stripMarkup(decode(data))
+ })
+}
+
+// FuzzZipText: reading any bytes as a .docx never panics and never returns
+// more text than the budget allows - each entry adds at most one separating
+// newline beyond it, and an entry takes more than one byte of the archive.
+func FuzzZipText(f *testing.F) {
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ w, err := zw.Create("word/document.xml")
+ if err != nil {
+ f.Fatal(err)
+ }
+ if _, err := w.Write([]byte(`<w:document><w:body><w:p><w:r><w:t>acme ltd</w:t></w:r></w:p></w:body></w:document>`)); err != nil {
+ f.Fatal(err)
+ }
+ if err := zw.Close(); err != nil {
+ f.Fatal(err)
+ }
+ f.Add(buf.Bytes())
+ f.Add([]byte("PK\x03\x04"))
+ f.Add([]byte{})
+ old := zipBudget
+ zipBudget = 4 << 10
+ f.Cleanup(func() { zipBudget = old })
+ f.Fuzz(func(t *testing.T, data []byte) {
+ p := filepath.Join(t.TempDir(), "f.docx")
+ if err := os.WriteFile(p, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ text, err := zipText(context.Background(), p, "docx", 0)
+ if err == nil && int64(len(text)) > zipBudget+int64(len(data)) {
+ t.Fatalf("%d bytes of text from a %d-byte archive, budget %d", len(text), len(data), zipBudget)
+ }
+ })
+}
diff --git a/internal/ignore/fuzz_test.go b/internal/ignore/fuzz_test.go
new file mode 100644
index 0000000..0e9f0a3
--- /dev/null
+++ b/internal/ignore/fuzz_test.go
@@ -0,0 +1,44 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package ignore
+
+import (
+ "strings"
+ "testing"
+)
+
+// FuzzMatch: any pattern either fails to compile or matches any walked
+// path without panicking, and gives the same answer twice. The oracle test
+// checks answers against git; this checks everything else.
+func FuzzMatch(f *testing.F) {
+ for _, p := range []string{"*.log", "/a.txt", "build/", "**/readme.md", "[!a]*.txt", "[[:digit:]]*", `\`, "[", "a[/]b", "!", "**/**/**", "*a*a*a*b"} {
+ f.Add(p, "dir/sub/a.txt")
+ }
+ f.Fuzz(func(t *testing.T, pattern, rel string) {
+ m, err := New([]string{pattern})
+ if err != nil {
+ return
+ }
+ rel = walkedRel(rel)
+ if rel == "" {
+ return
+ }
+ if m.Match(rel, false) != m.Match(rel, false) {
+ t.Fatalf("Match(%q) under %q is not deterministic", rel, pattern)
+ }
+ m.Match(rel, true)
+ })
+}
+
+// walkedRel turns any string into a path of the shape Match accepts, as a
+// directory walk produces it: slash-separated, with no empty, "." or ".."
+// components.
+func walkedRel(s string) string {
+ var parts []string
+ for _, p := range strings.Split(s, "/") {
+ if p != "" && p != "." && p != ".." {
+ parts = append(parts, p)
+ }
+ }
+ return strings.Join(parts, "/")
+}
diff --git a/internal/kwcache/fuzz_test.go b/internal/kwcache/fuzz_test.go
new file mode 100644
index 0000000..0b0eced
--- /dev/null
+++ b/internal/kwcache/fuzz_test.go
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package kwcache
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// FuzzLoad: a cache file of any content loads as a usable cache - never
+// nil, never a panic - and one that reports an error answers nothing. Its
+// answers can then be saved again.
+func FuzzLoad(f *testing.F) {
+ seed := New("fp")
+ seed.Store(a, map[string]bool{"k:acme": true, "k:x": false})
+ path := filepath.Join(f.TempDir(), "seed.cache")
+ if err := seed.Save(path, []ID{a}); err != nil {
+ f.Fatal(err)
+ }
+ good, err := os.ReadFile(path)
+ if err != nil {
+ f.Fatal(err)
+ }
+ f.Add(good)
+ f.Add([]byte(`{"version":1,"fingerprint":"fp","keywords":[["b","a"]],"files":[{"dev":1,"ino":10,"size":100,"mtime":1000,"keywords":0,"hits":[5]}]}`))
+ f.Add([]byte(`{"version":1,"fingerprint":"fp","keywords":[],"files":[{"dev":1,"ino":10,"size":100,"mtime":1000,"keywords":-1,"hits":[]}]}`))
+ f.Add([]byte("{}"))
+ f.Add([]byte("null"))
+ f.Fuzz(func(t *testing.T, data []byte) {
+ p := filepath.Join(t.TempDir(), "dl.cache")
+ if err := os.WriteFile(p, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ c, err := Load(p, "fp")
+ if c == nil {
+ t.Fatal("Load returned a nil cache")
+ }
+ hits, ok := c.Lookup(a, []string{"k:acme", "k:x"})
+ if ok && len(hits) != 2 {
+ t.Fatalf("Lookup answered %d of 2 keywords", len(hits))
+ }
+ if err != nil && ok {
+ t.Fatalf("a cache that failed to load (%v) answered", err)
+ }
+ if err := c.Save(filepath.Join(t.TempDir(), "out.cache"), []ID{a, b}); err != nil {
+ t.Fatal(err)
+ }
+ })
+}
diff --git a/internal/norm/fuzz_test.go b/internal/norm/fuzz_test.go
new file mode 100644
index 0000000..fde62a0
--- /dev/null
+++ b/internal/norm/fuzz_test.go
@@ -0,0 +1,30 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package norm
+
+import "testing"
+
+// FuzzTextIdempotent: normalising text twice changes nothing more than
+// normalising it once, under every case and fold setting, and so for names.
+// Keywords are normalised at load and text at match time by the same
+// functions; a second pass that still changed something would mean the two
+// could disagree about what one normalised string is.
+func FuzzTextIdempotent(f *testing.F) {
+ for _, s := range []string{"Spółka Z O.O.", "ẞ straße", "a\u0301", " tabs\tand\nlines ", "\xff", "İstanbul", "Dž", "Æsir Œuvre"} {
+ f.Add(s)
+ }
+ f.Fuzz(func(t *testing.T, s string) {
+ for _, opt := range [][2]bool{{false, false}, {true, false}, {false, true}, {true, true}} {
+ once := Text(s, opt[0], opt[1])
+ if twice := Text(once, opt[0], opt[1]); twice != once {
+ t.Fatalf("Text(%q, %v, %v) = %q, again %q", s, opt[0], opt[1], once, twice)
+ }
+ }
+ for _, fold := range []bool{false, true} {
+ once := Name(s, fold)
+ if twice := Name(once, fold); twice != once {
+ t.Fatalf("Name(%q, %v) = %q, again %q", s, fold, once, twice)
+ }
+ }
+ })
+}
diff --git a/internal/norm/norm.go b/internal/norm/norm.go
index e7933ec..77bb604 100644
--- a/internal/norm/norm.go
+++ b/internal/norm/norm.go
@@ -11,6 +11,11 @@ import (
unorm "golang.org/x/text/unicode/norm"
)
+// Version identifies what Text and Name produce. Bump it whenever a change
+// could make any string normalise differently: keyword answers cached under
+// another version are discarded (spec §6.1).
+const Version = 1
+
// special holds the letters that do not decompose under Unicode NFD, so
// Fold maps them explicitly.
var special = map[rune]string{
@@ -18,15 +23,17 @@ var special = map[rune]string{
'ø': "o", 'Ø': "O",
'đ': "d", 'Đ': "D",
'ħ': "h", 'Ħ': "H",
- 'ß': "ss",
+ 'ß': "ss", 'ẞ': "SS",
'æ': "ae", 'Æ': "AE",
'œ': "oe", 'Œ': "OE",
'ı': "i",
}
// Fold strips diacritics: Unicode NFD, drop combining marks (category Mn),
-// then map the letters that do not decompose. ASCII input is returned
-// unchanged without allocating.
+// then map the letters that do not decompose. Invalid UTF-8 becomes U+FFFD
+// first: left in, an incomplete sequence can keep NFD from decomposing the
+// letter after it, and folding the result again would change it. ASCII
+// input is returned unchanged without allocating.
func Fold(s string) string {
ascii := true
for i := 0; i < len(s); i++ {
@@ -39,6 +46,7 @@ func Fold(s string) string {
return s
}
+ s = strings.ToValidUTF8(s, "\ufffd")
var b strings.Builder
b.Grow(len(s))
for _, r := range unorm.NFD.String(s) {
diff --git a/internal/norm/norm_test.go b/internal/norm/norm_test.go
index 2fc35b6..885c6f7 100644
--- a/internal/norm/norm_test.go
+++ b/internal/norm/norm_test.go
@@ -12,6 +12,8 @@ func TestFold(t *testing.T) {
"Łódź": "Lodz",
"ZAŻÓŁĆ GĘŚLĄ JAŹŃ": "ZAZOLC GESLA JAZN",
"Straße": "Strasse",
+ "STRAẞE": "STRASSE",
+ "\xf3Á": "\ufffdA", // an invalid byte must not stop the next letter folding
"Øresund": "Oresund",
"Ærø": "AEro",
"œuvre": "oeuvre",
diff --git a/internal/norm/testdata/fuzz/FuzzTextIdempotent/d26a0358dee954b3 b/internal/norm/testdata/fuzz/FuzzTextIdempotent/d26a0358dee954b3
new file mode 100644
index 0000000..4b3fbfe
--- /dev/null
+++ b/internal/norm/testdata/fuzz/FuzzTextIdempotent/d26a0358dee954b3
@@ -0,0 +1,2 @@
+go test fuzz v1
+string("\xf3Á")
diff --git a/internal/plan/fuzz_test.go b/internal/plan/fuzz_test.go
new file mode 100644
index 0000000..fb68f94
--- /dev/null
+++ b/internal/plan/fuzz_test.go
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package plan
+
+import (
+ "testing"
+ "time"
+)
+
+// FuzzExpand: expanding any template against any file name never panics,
+// gives the same answer twice, and never succeeds with a {N} beyond the
+// capture groups there are - MaxIndex and Expand read placeholders alike.
+func FuzzExpand(f *testing.F) {
+ for _, s := range []string{"{name}", "{stem}{ext}", "{mtime:%Y/%m}", "{1}_{2}", "{{literal}}", "{", "}", "{now:%", "{0}", "{9}", "{99999999999999999999}"} {
+ f.Add(s, "a.b.pdf")
+ }
+ f.Fuzz(func(t *testing.T, tmpl, name string) {
+ facts := Facts{
+ Name: name,
+ Captures: []string{name, "x", ".."},
+ ModTime: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC),
+ Now: time.Date(2026, 9, 14, 0, 0, 0, 0, time.UTC),
+ }
+ a, errA := Expand(tmpl, facts)
+ b, errB := Expand(tmpl, facts)
+ if a != b || (errA == nil) != (errB == nil) {
+ t.Fatalf("Expand(%q) differs between two calls: %q/%v, %q/%v", tmpl, a, errA, b, errB)
+ }
+ if n, err := MaxIndex(tmpl); err == nil && n > 2 && errA == nil {
+ t.Fatalf("Expand(%q) = %q, though it uses {%d} and only 2 groups exist", tmpl, a, n)
+ }
+ })
+}
diff --git a/internal/sexp/fuzz_test.go b/internal/sexp/fuzz_test.go
index 01d436b..c64145a 100644
--- a/internal/sexp/fuzz_test.go
+++ b/internal/sexp/fuzz_test.go
@@ -2,7 +2,10 @@
package sexp
-import "testing"
+import (
+ "testing"
+ "unicode/utf8"
+)
// FuzzParse checks that Parse never panics and that every node it returns
// spans valid bytes, with lists pointing at their own parentheses.
@@ -25,3 +28,26 @@ func FuzzParse(f *testing.F) {
})
})
}
+
+// FuzzQuoteRoundTrip: Quote gives one string atom that parses back to
+// exactly the text quoted, for any valid UTF-8 - quotes, backslashes and
+// newlines included. krino new writes a directory's path into its config
+// with Quote; config files are UTF-8 text, so krino new refuses a path that
+// is not, and invalid UTF-8 is out of this property's scope.
+func FuzzQuoteRoundTrip(f *testing.F) {
+ for _, s := range []string{"plain", `with "quotes"`, `back\slash`, "new\nline", "zażółć", "", "\xff", `\bacme\b`} {
+ f.Add(s)
+ }
+ f.Fuzz(func(t *testing.T, s string) {
+ if !utf8.ValidString(s) {
+ return
+ }
+ nodes, err := Parse("f", []byte(Quote(s)))
+ if err != nil {
+ t.Fatalf("Quote(%q) = %s does not parse: %v", s, Quote(s), err)
+ }
+ if len(nodes) != 1 || nodes[0].Kind != String || nodes[0].Text != s {
+ t.Fatalf("Quote(%q) = %s reads back as %+v", s, Quote(s), nodes)
+ }
+ })
+}
diff --git a/man/krino.conf.5 b/man/krino.conf.5
index c89fbd9..cd16ada 100644
--- a/man/krino.conf.5
+++ b/man/krino.conf.5
@@ -539,7 +539,7 @@ Failures are never cached, and a file above
.Ic max-read
is refused before the cache is read.
The whole cache is discarded when an extraction tool is installed, removed
-or replaced.
+or replaced, or when krino's extraction or normalisation changes.
Each run keeps entries only for files still in the directory.
A file edited in place with its size and modification time preserved keeps
its old answers; deleting the cache resets everything.