Skip to main content

max / makenotwork

Fuzz tagtree: an oracle, two targets, and the seeds to replay them on stable Row 8 of astra-soak-overview. The witchbroom side landed in witchbroom@560c058; this is the harness it schedules. THE DOORS WERE COUNTED FIRST, as the task requires and as git_ssh taught. 12 files across 5 repos call tagtree -- MNW server and multithreaded, audiofiles, balanced_breakfast, goingson -- and every one reaches the same implementation. Nothing needed extracting, so one target covers the whole string surface. What the count did change is the input: five distinct TagConfig constants are live in the tree, so oracle::CONFIGS enumerates all five plus validate's default and four degenerate ends, and every input is checked against all ten. Two targets, because the input shape differs and not because the grammar does. A tag string and an operation sequence over a mutable TagIndex cannot share a corpus without most of its bytes landing on the wrong half. Both take plain text rather than a derived Arbitrary struct, so the seed corpus is the 130 production slugs from MNW's migrations as files a reviewer can read in a diff. THE ORACLE FIRED ON CORRECT BEHAVIOUR ON ITS FIRST REPLAY, which is the useful part. common_ancestor("a.b.c", "a.b.c") returns Some("a.b"), not None: the byte scan runs to the end of both strings, so the last separator it recorded is the one before the final segment. The model had generalised from the crate's documented depth-1 example, and it was corrected to the crate's behaviour rather than exempted from it. Silence was then confirmed the hard way -- all 16,900 ordered pairs of the 130 slugs, and the whole taxonomy queried at every prefix at three limits -- and both are committed tests. No bug was found in tagtree, so regressions/ ships with its README and nothing else. The teeth are three full reimplementations rather than invariants. model_suggest filters the whole tag list where the crate binary searches and breaks early; model_common_ancestor counts segments where the crate scans bytes; and escape_like is checked by un-escaping under its own documented rule, which is the property the seven live SQL call sites lean on and the one reached by unvalidated user text in audiofiles' free-text search. model_suggest_fuzzy scores with a naive full-matrix Levenshtein against edit_distance's single-row DP, aimed at the mutants that survive there. It works on BYTES because edit_distance calls as_bytes; a chars() reference would disagree on every non-ASCII input and report correct behaviour as a finding. One invariant is deliberately weaker than it looks: the segment index is a SUPERSET of the segments of the live tags, never an equality, because remove documents that it does not prune orphaned segments. An equality assertion would fire on the first removal and every report after it would be noise. Also corrects docs/architecture.md, which had the semantic_depth column wrong in both directions -- AF listed 1 and is 0, MNW listed 0 and is 2. Verified against the constants themselves. The 2 is what forces the three-segment type.category.value slug shape on the platform, which the oracle had to know to enumerate the configs at all.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01KD2dJJETtYs7R5kku6YyLk
Author: Max Johnson <me@maxj.phd> · 2026-08-30 16:08 UTC
Signed with PGP, not checked
Commit: 7e635152368e7ebad21b182cc289b47bb928b9c4
Parent: c265e71
212 files changed, +2512 insertions, -2 deletions
@@ -2,6 +2,25 @@
2 2
3 3 Notable changes to tagtree. Versions follow semver.
4 4
5 + ## Unreleased
6 +
7 + ### Added
8 +
9 + - `pub mod oracle`: the property set the astra soak tier asserts, and the same set
10 + `tests/regressions.rs` replays on stable. Structural models for the hierarchy
11 + decomposition, a round-trip model for `escape_like`, an independent model of
12 + `validate_with`'s accept decision, and full reimplementations of `TagIndex::suggest`
13 + and `suggest_fuzzy`. The last of those scores with a naive full-matrix Levenshtein
14 + over bytes, which is a genuine differential against `edit_distance`'s single-row DP.
15 + - `fuzz/`: two libFuzzer targets, `tag` (the string grammar and its decomposition) and
16 + `index` (a line-oriented operation script over one `TagIndex`). Both take plain text,
17 + so the seed corpus is readable in a diff.
18 + - `fuzz/seeds/`: 130 production tag slugs taken from MNW's migrations, nine per-namespace
19 + family files, and 25 adversarial inputs, plus 36 index scripts.
20 + - `tests/regressions.rs`: replays every committed seed and every crash artifact through
21 + the oracle on stable, with a floor on the seed count so a silently emptied corpus
22 + cannot pass.
23 +
5 24 ## 0.4.1 (2026-08-21)
6 25
7 26 ### Changed
@@ -16,11 +16,11 @@
16 16
17 17 | App | max_depth | max_length | semantic_depth | Notes |
18 18 |-----|-----------|------------|----------------|-------|
19 - | AF | 5 | 100 | 1 | Deep hierarchy, namespace-driven (`genre.electronic.house`) |
19 + | AF | 5 | 100 | 0 | Deep hierarchy, namespace-driven (`genre.electronic.house`) |
20 20 | GO | 3 | 60 | 0 | Shallow free-form tags |
21 21 | BB | 3 | 80 | 0 | Shallow free-form tags |
22 22 | MT | 3 | 64 | 0 | Community-scoped thread tags |
23 - | MNW | 5 | 100 | 0 | Content/project tags |
23 + | MNW | 5 | 100 | 2 | Content/project tags. The 2 is what forces the three-segment `type.category.value` slug shape on the platform |
24 24
25 25 - `max_depth`: maximum number of segments allowed
26 26 - `max_length`: maximum character length of the entire tag string
@@ -33,6 +33,8 @@
33 33
34 34 use std::fmt;
35 35
36 + pub mod oracle;
37 +
36 38 /// Separator between tag levels.
37 39 pub const SEPARATOR: char = '.';
38 40
@@ -1,0 +1,4 @@
1 + target
2 + corpus
3 + artifacts
4 + coverage
@@ -1,0 +1,207 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "arbitrary"
7 + version = "1.4.2"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
10 + dependencies = [
11 + "derive_arbitrary",
12 + ]
13 +
14 + [[package]]
15 + name = "cc"
16 + version = "1.4.4"
17 + source = "registry+https://github.com/rust-lang/crates.io-index"
18 + checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
19 + dependencies = [
20 + "find-msvc-tools",
21 + "jobserver",
22 + "libc",
23 + "shlex",
24 + ]
25 +
26 + [[package]]
27 + name = "cfg-if"
28 + version = "1.0.4"
29 + source = "registry+https://github.com/rust-lang/crates.io-index"
30 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
31 +
32 + [[package]]
33 + name = "derive_arbitrary"
34 + version = "1.4.2"
35 + source = "registry+https://github.com/rust-lang/crates.io-index"
36 + checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
37 + dependencies = [
38 + "proc-macro2",
39 + "quote",
40 + "syn",
41 + ]
42 +
43 + [[package]]
44 + name = "find-msvc-tools"
45 + version = "0.1.11"
46 + source = "registry+https://github.com/rust-lang/crates.io-index"
47 + checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
48 +
49 + [[package]]
50 + name = "getrandom"
51 + version = "0.4.3"
52 + source = "registry+https://github.com/rust-lang/crates.io-index"
53 + checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
54 + dependencies = [
55 + "cfg-if",
56 + "libc",
57 + "r-efi",
58 + ]
59 +
60 + [[package]]
61 + name = "jobserver"
62 + version = "0.1.35"
63 + source = "registry+https://github.com/rust-lang/crates.io-index"
64 + checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
65 + dependencies = [
66 + "getrandom",
67 + "libc",
68 + ]
69 +
70 + [[package]]
71 + name = "libc"
72 + version = "0.2.189"
73 + source = "registry+https://github.com/rust-lang/crates.io-index"
74 + checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
75 +
76 + [[package]]
77 + name = "libfuzzer-sys"
78 + version = "0.4.13"
79 + source = "registry+https://github.com/rust-lang/crates.io-index"
80 + checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
81 + dependencies = [
82 + "arbitrary",
83 + "cc",
84 + ]
85 +
86 + [[package]]
87 + name = "proc-macro2"
88 + version = "1.0.107"
89 + source = "registry+https://github.com/rust-lang/crates.io-index"
90 + checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
91 + dependencies = [
92 + "unicode-ident",
93 + ]
94 +
95 + [[package]]
96 + name = "quote"
97 + version = "1.0.47"
98 + source = "registry+https://github.com/rust-lang/crates.io-index"
99 + checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
100 + dependencies = [
101 + "proc-macro2",
102 + ]
103 +
104 + [[package]]
105 + name = "r-efi"
106 + version = "6.0.0"
107 + source = "registry+https://github.com/rust-lang/crates.io-index"
108 + checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
109 +
110 + [[package]]
111 + name = "shlex"
112 + version = "2.0.1"
113 + source = "registry+https://github.com/rust-lang/crates.io-index"
114 + checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
115 +
116 + [[package]]
117 + name = "syn"
118 + version = "2.0.119"
119 + source = "registry+https://github.com/rust-lang/crates.io-index"
120 + checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
121 + dependencies = [
122 + "proc-macro2",
123 + "quote",
124 + "unicode-ident",
125 + ]
126 +
127 + [[package]]
128 + name = "tagtree"
129 + version = "0.4.1"
130 +
131 + [[package]]
132 + name = "tagtree-fuzz"
133 + version = "0.0.0"
134 + dependencies = [
135 + "libfuzzer-sys",
136 + "tagtree",
137 + ]
138 +
139 + [[package]]
140 + name = "unicode-ident"
141 + version = "1.0.24"
142 + source = "registry+https://github.com/rust-lang/crates.io-index"
143 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
144 +
145 + [[patch.unused]]
146 + name = "quasi-type"
147 + version = "0.1.3"
148 +
149 + [[patch.unused]]
150 + name = "kberg"
151 + version = "0.1.0"
152 +
153 + [[patch.unused]]
154 + name = "ops-status"
155 + version = "0.1.0"
156 +
157 + [[patch.unused]]
158 + name = "painhours"
159 + version = "0.1.0"
160 +
161 + [[patch.unused]]
162 + name = "quasi-axum"
163 + version = "0.81.0"
164 +
165 + [[patch.unused]]
166 + name = "quasi-basics"
167 + version = "0.81.0"
168 +
169 + [[patch.unused]]
170 + name = "quasi-http"
171 + version = "0.81.0"
172 +
173 + [[patch.unused]]
174 + name = "quasi-immediate"
175 + version = "0.81.0"
176 +
177 + [[patch.unused]]
178 + name = "quasi-notifs"
179 + version = "0.81.0"
180 +
181 + [[patch.unused]]
182 + name = "quasi-router"
183 + version = "0.81.0"
184 +
185 + [[patch.unused]]
186 + name = "quasi-store"
187 + version = "0.1.0"
188 +
189 + [[patch.unused]]
190 + name = "quasi-tauri"
191 + version = "0.81.0"
192 +
193 + [[patch.unused]]
194 + name = "quasi-webview"
195 + version = "0.81.0"
196 +
197 + [[patch.unused]]
198 + name = "synckit-client"
199 + version = "0.10.0"
200 +
201 + [[patch.unused]]
202 + name = "synckit-config"
203 + version = "0.2.0"
204 +
205 + [[patch.unused]]
206 + name = "docengine"
207 + version = "0.7.0"
@@ -1,0 +1,31 @@
1 + [package]
2 + name = "tagtree-fuzz"
3 + version = "0.0.0"
4 + publish = false
5 + edition = "2024"
6 +
7 + [package.metadata]
8 + cargo-fuzz = true
9 +
10 + [dependencies]
11 + # `arbitrary` with `derive` is what lets the targets take `&str` instead of
12 + # `&[u8]`. Both grammars are text, and byte input would spend most of the
13 + # fuzzer's budget rediscovering UTF-8 before it reached a dot.
14 + libfuzzer-sys = { version = "0.4", features = ["arbitrary-derive"] }
15 +
16 + [dependencies.tagtree]
17 + path = ".."
18 +
19 + [[bin]]
20 + name = "tag"
21 + path = "fuzz_targets/tag.rs"
22 + test = false
23 + doc = false
24 + bench = false
25 +
26 + [[bin]]
27 + name = "index"
28 + path = "fuzz_targets/index.rs"
29 + test = false
30 + doc = false
31 + bench = false
@@ -1,0 +1,41 @@
1 + //! Structured fuzz over `TagIndex`: the mutable half of row 8.
2 + //!
3 + //! Split from the `tag` target because the input shape is different, not
4 + //! because the grammar is. There is one grammar, so this is not the
5 + //! `custom-pages` html/css split. But a tag string and an operation sequence
6 + //! over a mutable index cannot share a corpus without most of its bytes being
7 + //! wasted on the wrong half, and `tagtree` has zero runtime dependencies, so
8 + //! the second target costs a `witchbroom.toml` row and nothing else.
9 + //!
10 + //! The input is a line-oriented script, one operation per line, dispatched on
11 + //! the first character (`+` insert, `-` remove, `=` rebuild, `?` suggest, `~`
12 + //! suggest_fuzzy, `!` rename, `x` remove_subtree, `m` merge). One character of
13 + //! dispatch means a random mutation usually leaves a valid script, and it means
14 + //! the seeds are readable.
15 + //!
16 + //! ## Where the teeth are
17 + //!
18 + //! `suggest` and `suggest_fuzzy` are asserted against full reimplementations,
19 + //! not against invariants. `oracle::model_suggest` filters the whole tag list
20 + //! where the crate binary searches and breaks early. `model_suggest_fuzzy`
21 + //! scores tier 3 with a naive full-matrix Levenshtein where the crate uses a
22 + //! single-row DP with a length short-circuit and a per-row early exit. That
23 + //! last one is the only genuine differential the crate offers, and it aims
24 + //! directly at the mutants that survived in `edit_distance`.
25 + //!
26 + //! The reference Levenshtein works on **bytes**, because `edit_distance` calls
27 + //! `as_bytes`. A `chars()` reference would disagree on every non-ASCII input
28 + //! and would report correct behaviour as a finding.
29 + //!
30 + //! One invariant is deliberately weaker than it looks: the segment index is a
31 + //! *superset* of the segments of the live tags, never an equality. `remove`
32 + //! documents that it does not prune orphaned segments, so an equality assertion
33 + //! would fire on the first removal and every report after it would be noise.
34 +
35 + #![no_main]
36 +
37 + use libfuzzer_sys::fuzz_target;
38 +
39 + fuzz_target!(|text: &str| {
40 + tagtree::oracle::check_index_script(text);
41 + });
@@ -1,0 +1,49 @@
1 + //! Structured fuzz over the tag grammar and its hierarchy decomposition.
2 + //!
3 + //! Row 8 of `astra-soak-overview`. The doors were counted before this was
4 + //! written, as infra `8910f917` instructs, and the count decided the shape:
5 + //! twelve files across five repos call `tagtree`, and every one of them reaches
6 + //! the same implementation. There is no second parser to extract, so one target
7 + //! covers the whole string surface. What the count did change is the *input*:
8 + //! five distinct `TagConfig` constants are live in the tree, and
9 + //! `oracle::CONFIGS` enumerates all five plus the degenerate ends.
10 + //!
11 + //! ## Why plain text, and not a derived `Arbitrary` struct
12 + //!
13 + //! The obvious shape is `(u8, u8, u8, &str, &str)`, mapping three bytes onto a
14 + //! config. It was rejected. The config is three small integers whose live
15 + //! values are known, so enumerating them costs one loop and dominates spending
16 + //! fuzzer budget rediscovering them; and taking text means the seed corpus is
17 + //! the 130 production tag slugs committed in MNW's migrations, as files a
18 + //! reviewer can read in a diff, rather than a directory of little-endian blobs
19 + //! with a hand-written decoder in the replay test.
20 + //!
21 + //! The input is one tag per line. Each line is checked against every config,
22 + //! and each adjacent pair of lines is checked through the relational half, so a
23 + //! two-line seed reaches `common_ancestor`, `subtree` and `rename_prefix`.
24 + //!
25 + //! ## The oracle lives in the crate, not here
26 + //!
27 + //! Everything asserted is `tagtree::oracle`. The committed replay in
28 + //! `tests/regressions.rs` calls the same functions on stable, so a crash found
29 + //! here becomes a unit test by copying one file, and neither side can drift
30 + //! into checking less than the other.
31 + //!
32 + //! What it asserts, in short: `depth`, `segment`, `prefix_at_depth`,
33 + //! `ancestors` and `parent`/`leaf` all agree with one segment split;
34 + //! `semantic_prefix` and `free_suffix` rejoin to the input; `escape_like`
35 + //! round-trips under its own documented rule and leaves no unescaped wildcard;
36 + //! and `validate_with`'s accept decision matches an independent model.
37 + //!
38 + //! Not-panicking is the weakest thing a fuzz target can assert. `validate_with`
39 + //! already has a proptest that never panics on arbitrary input, so a target
40 + //! that asserted only that would report clean forever while `escape_like`
41 + //! handed Postgres a bare `%`.
42 +
43 + #![no_main]
44 +
45 + use libfuzzer_sys::fuzz_target;
46 +
47 + fuzz_target!(|text: &str| {
48 + tagtree::oracle::check_tag_text(text);
49 + });
@@ -1,0 +1,7 @@
1 + # Regression inputs
2 +
3 + One file per fuzz input that once found a bug. `tests/regressions.rs` replays
4 + every file here through both oracles on stable, so a fixed crash cannot come
5 + back unnoticed and there is no test function to remember to write.
6 +
7 + Empty is the good state. None yet.
@@ -1,0 +1,73 @@
1 + # Seed corpora
2 +
3 + Hand-written and production-derived starting inputs, one directory per fuzz
4 + target. These are committed; `corpus/` is not.
5 +
6 + The split follows `astra-soak-overview`, which calls a minimized corpus
7 + "accumulated compute, not a build artifact":
8 +
9 + - **These seeds are human intent.** `tag/` holds the 130 distinct tag slugs
10 + committed in MNW's migrations (`002_seed_data.sql`, `056_expand_tags.sql`,
11 + `111_tag_taxonomy_overhaul.sql`) as `real-*`, the nine top-level namespaces as
12 + multi-line `family-*` files so the relational half of the oracle sees real
13 + siblings, and one `adv-*` file per class of thing the grammar has to refuse.
14 + `index/` holds operation scripts, half built from the real taxonomy and half
15 + aimed at the paths that have no unit test.
16 + - **`corpus/` is machine output and lives on astra**, under the soak runner's
17 + persistent directory. Minimize with `cargo +nightly fuzz cmin <target>` and
18 + commit it once it represents real soak hours, not before.
19 +
20 + Run against these on a machine with no corpus:
21 +
22 + mkdir -p fuzz/corpus/tag
23 + cargo +nightly fuzz run tag fuzz/corpus/tag fuzz/seeds/tag
24 +
25 + The `mkdir` is needed once. `cargo fuzz` creates the default corpus directory
26 + for you only when you name no directories at all; pass them explicitly and
27 + libFuzzer requires every one to exist already.
28 +
29 + **Name the corpus directory first and this one second.** libFuzzer writes new
30 + inputs into whichever directory it is given first and treats the rest as
31 + read-only. Passing `fuzz/seeds/tag` alone dumps hundreds of machine-generated
32 + files in here and buries the hand-written ones, which is exactly the split this
33 + directory exists to keep.
34 +
35 + ## The index script grammar
36 +
37 + One operation per line, dispatched on the first character. Anything else is
38 + ignored, which is what makes `# a comment` work.
39 +
40 + | Line | Operation |
41 + |------|-----------|
42 + | `+tag` | `insert` |
43 + | `-tag` | `remove` |
44 + | `=a,b,c` | `rebuild` with that list; `=` alone rebuilds empty |
45 + | `?input limit` | `suggest` |
46 + | `~input limit` | `suggest_fuzzy` |
47 + | `!old>new` | `rename_prefix_bulk` |
48 + | `x prefix` | `remove_subtree` |
49 + | `m old>new` | `merge_tags` |
50 +
51 + ## What the accept/reject seeds are for
52 +
53 + Most of `tag/` is input the grammar refuses under at least one of the ten
54 + configs the oracle enumerates, and that is not wasted budget. The structural
55 + half of the oracle runs on every string, valid or not, because MNW server calls
56 + `parent`, `leaf` and `depth` on strings that never went through `validate_with`.
57 + A rejected seed also sits one mutation away from an accepted one.
58 +
59 + Two are worth knowing. `adv-11-backslash-percent` is the escaping shape the
60 + round-trip oracle exists to catch, and it is reached by unvalidated user text:
61 + `audiofiles-core/src/search.rs` wraps a free-text query in `%...%` after
62 + escaping it. `adv-12-multibyte` is the shape of the 0.4.1 character-versus-byte
63 + `max_length` fix, and it is also what separates the byte-level reference
64 + Levenshtein from a `chars()` one.
65 +
66 + Thirty-two of the 130 production slugs are two-segment category headers
67 + (`audio.format`, `audio.genre`) that MNW's `semantic_depth: 2` config refuses.
68 + They stay: an almost-valid tag is the most useful seed there is.
69 +
70 + ## Crash seeds
71 +
72 + An input that once found a bug stays here forever, and also becomes a file under
73 + `fuzz/regressions/`, which `tests/regressions.rs` replays on stable. None yet.