From aa24cfb344b1b3eaef7217d996359023cd72ba28 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 14 Sep 2026 20:10:01 +0200 Subject: plan 8: fuzz decoders; fold ẞ and invalid UTF-8 correctly, refuse non-UTF-8 paths in krino new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/fuzz_test.go | 51 ++++++++++++++++++++ internal/config/skel.go | 6 +++ internal/config/skel_test.go | 4 +- internal/engine/match.go | 10 +++- internal/extract/fuzz_test.go | 56 ++++++++++++++++++++++ internal/ignore/fuzz_test.go | 44 +++++++++++++++++ internal/kwcache/fuzz_test.go | 50 +++++++++++++++++++ internal/norm/fuzz_test.go | 30 ++++++++++++ internal/norm/norm.go | 14 ++++-- internal/norm/norm_test.go | 2 + .../fuzz/FuzzTextIdempotent/d26a0358dee954b3 | 2 + internal/plan/fuzz_test.go | 33 +++++++++++++ internal/sexp/fuzz_test.go | 28 ++++++++++- 13 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 internal/config/fuzz_test.go create mode 100644 internal/extract/fuzz_test.go create mode 100644 internal/ignore/fuzz_test.go create mode 100644 internal/kwcache/fuzz_test.go create mode 100644 internal/norm/fuzz_test.go create mode 100644 internal/norm/testdata/fuzz/FuzzTextIdempotent/d26a0358dee954b3 create mode 100644 internal/plan/fuzz_test.go (limited to 'internal') 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{"

a & b

", "y", "