OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown commits / ac4d09f

test: UTF-8 fuzzer for strip, and a mutation test proving it can fail
library.md 7 asks for this by name: 'byte-vs-character offsets is the single most likely defect here and produces facets that land on the wrong characters only for non-ASCII authors. Thirty-six hand-written vectors may never catch it; a fuzzer will.' The oracle is exact rather than eyeballed: slice the output text by the facet's BYTE range and it must decode to exactly the generated label. Also asserts offsets land on UTF-8 character boundaries, and the 6 invariants on arbitrary markdown-ish input. Deterministic by seed - a fuzzer whose failures cannot be reproduced is a rumour. 2000/2000 pass. Verified the instrument can fail: injecting the exact defect (byteLen -> s.length) produces 'facet covers "\ufffd", expected "\ud83c\udf0d7"' - a slice landing mid-codepoint. Reverted after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ac4d09fe30f4ef5f3bbf1849e8374798cb16725d
by plankatron <fleeky@gmail.com> · 2026-09-06T00:45:37.000Z

Changed files (2) +160 -1

package.json +1 -1
……@@ -16,6 +16,6 @@
1616 "micromark-extension-gfm-table": "^2.1.1"
1717 },
1818 "scripts": {
19 "test": "node test/strip.test.js && node test/record.test.js"
19 "test": "node test/strip.test.js && node test/record.test.js && node test/strip-fuzz.test.js"
2020 }
2121}
test/strip-fuzz.test.js +159 -0
……@@ -0,0 +1,159 @@
1// UTF-8 fuzzer for strip() — library.md §7 item 3 asks for this by name:
2//
3// "Fuzz UTF-8 specifically. strip §8 names it: byte-vs-character offsets is 'the single most
4// likely defect here and produces facets that land on the wrong characters only for non-ASCII
5// authors.' Thirty-six hand-written vectors may never catch it; a fuzzer will."
6//
7// The oracle is not "does it look right" — it is EXACT: for a generated `[LABEL](url)`, slicing the
8// output text by the facet's BYTE range must decode to exactly LABEL. A character-offset
9// implementation passes every ASCII case and fails the moment one multi-byte character precedes a
10// facet, which is why the hand-written vectors cannot be trusted alone here.
11//
12// Deterministic by seed. A fuzzer whose failures cannot be reproduced is a rumour, not a test, so
13// the seed is printed and any failure reports the exact source that produced it.
14
15import test from 'brittle'
16import { strip } from '../lib/strip.js'
17
18// xorshift32 — tiny, deterministic, dependency-free. Reproducibility is the point.
19function rng (seed) {
20 let s = seed >>> 0 || 1
21 return () => {
22 s ^= s << 13; s >>>= 0
23 s ^= s >> 17
24 s ^= s << 5; s >>>= 0
25 return s / 0x100000000
26 }
27}
28
29// Alphabet chosen to cover every UTF-8 width, plus the cases that break naive implementations.
30const ALPHABET = [
31 'a', 'Z', '7', ' ', '-', // 1 byte
32 'é', 'ü', 'ß', 'ñ', // 2 bytes
33 '日', '本', 'א', 'ه', // 3 bytes
34 '🎉', '👩', '🌍', '🧀', // 4 bytes (surrogate pair in JS)
35 'é', // combining acute — 1 grapheme, 3 bytes, 2 code units
36 '👩‍💻', // ZWJ sequence — 1 grapheme, 11 bytes
37 ' ' // NBSP — whitespace that is NOT ASCII space
38]
39
40const pick = (r, xs) => xs[Math.floor(r() * xs.length) % xs.length]
41
42function randomText (r, n) {
43 let s = ''
44 for (let i = 0; i < n; i++) s += pick(r, ALPHABET)
45 return s
46}
47
48// A label that cannot contain markdown-significant characters, so the expected facet span is exact.
49function randomLabel (r) {
50 let s = randomText(r, 1 + Math.floor(r() * 6))
51 s = s.replace(/[[\]()*_~`#|\\<>\n]/g, 'x').trim()
52 return s.length ? s : 'x'
53}
54
55const byteLen = (s) => Buffer.byteLength(s, 'utf8')
56
57// Does this byte index fall on a UTF-8 character boundary? A continuation byte is 0b10xxxxxx.
58function onBoundary (buf, i) {
59 if (i <= 0 || i >= buf.length) return true
60 return (buf[i] & 0xc0) !== 0x80
61}
62
63test('fuzz: facet byte ranges decode to exactly the label they cover', (t) => {
64 const SEED = 0x5eed1234
65 const r = rng(SEED)
66 t.comment('seed ' + SEED.toString(16) + ' — change it to explore, keep it to reproduce')
67
68 for (let i = 0; i < 600; i++) {
69 // A run of arbitrary UTF-8 BEFORE the facet is what shifts a char-offset implementation.
70 const prefix = randomText(r, Math.floor(r() * 8))
71 const label = randomLabel(r)
72 const suffix = randomText(r, Math.floor(r() * 5))
73 const url = 'https://example.com/' + (i % 97)
74 const src = prefix + '[' + label + '](' + url + ')' + suffix
75
76 const { text, facets } = strip(src)
77 const buf = Buffer.from(text, 'utf8')
78
79 if (facets.length !== 1) {
80 t.fail('expected exactly one facet for ' + JSON.stringify(src) + ' — got ' + facets.length)
81 continue
82 }
83 const { byteStart, byteEnd } = facets[0].index
84
85 // ★ THE ORACLE. Slice by BYTES and decode: it must be the label, exactly.
86 const covered = buf.subarray(byteStart, byteEnd).toString('utf8')
87 if (covered !== label) {
88 t.fail('facet covers ' + JSON.stringify(covered) + ', expected ' + JSON.stringify(label) +
89 '\n source: ' + JSON.stringify(src) + '\n text: ' + JSON.stringify(text))
90 continue
91 }
92
93 t.ok(onBoundary(buf, byteStart) && onBoundary(buf, byteEnd),
94 'offsets land on UTF-8 character boundaries')
95 }
96})
97
98test('fuzz: hashtag facets cover "#tag" including the hash (§3.7)', (t) => {
99 const r = rng(0xbeef01)
100 for (let i = 0; i < 300; i++) {
101 const prefix = randomText(r, Math.floor(r() * 8))
102 const tag = 'tag' + i
103 const src = prefix + ' #' + tag + ' rest'
104
105 const { text, facets } = strip(src)
106 const buf = Buffer.from(text, 'utf8')
107 const tagFacets = facets.filter((f) => f.features[0].$type.endsWith('#tag'))
108 if (tagFacets.length !== 1) { t.fail('expected one tag facet for ' + JSON.stringify(src)); continue }
109
110 const { byteStart, byteEnd } = tagFacets[0].index
111 t.is(buf.subarray(byteStart, byteEnd).toString('utf8'), '#' + tag,
112 'the facet spans #tag; the VALUE drops the hash')
113 }
114})
115
116test('fuzz: invariants hold on arbitrary markdown-ish input (§6, §2)', (t) => {
117 const r = rng(0xc0ffee)
118 const NOISE = ['**', '*', '~~', '`', '# ', '> ', '- ', '1. ', '|', '[[', ']]', ':::', '![', '](',
119 '\n', '\n\n', '\n\n\n', '](https://x.com)', '[^1]', '\\\n', ' ', '---\n', '<b>', '$$']
120
121 for (let i = 0; i < 800; i++) {
122 let src = ''
123 const parts = 1 + Math.floor(r() * 10)
124 for (let p = 0; p < parts; p++) src += (r() < 0.5 ? randomText(r, 1 + Math.floor(r() * 5)) : pick(r, NOISE))
125
126 let out
127 try {
128 out = strip(src)
129 } catch (err) {
130 t.fail('threw on ' + JSON.stringify(src) + ': ' + err.message)
131 continue
132 }
133
134 const buf = Buffer.from(out.text, 'utf8')
135 let prevEnd = -1
136 let ok = true
137 for (const f of out.facets) {
138 const { byteStart, byteEnd } = f.index
139 if (!(byteStart >= 0 && byteEnd <= buf.length)) { ok = false; t.fail('out of bounds: ' + JSON.stringify(src)); break }
140 if (!(byteEnd > byteStart)) { ok = false; t.fail('empty facet not dropped (§6.3): ' + JSON.stringify(src)); break }
141 if (byteStart < prevEnd) { ok = false; t.fail('overlapping facets (§6.2): ' + JSON.stringify(src)); break }
142 if (!onBoundary(buf, byteStart) || !onBoundary(buf, byteEnd)) { ok = false; t.fail('offset mid-character: ' + JSON.stringify(src)); break }
143 if (f.features.length !== 1) { ok = false; t.fail('multi-feature facet (§6.5): ' + JSON.stringify(src)); break }
144 prevEnd = byteEnd
145 }
146 t.ok(ok, 'invariants hold')
147 }
148})
149
150test('fuzz: §5 normalisation is idempotent on its own output (§7 property law)', (t) => {
151 const r = rng(0xd0d0)
152 for (let i = 0; i < 300; i++) {
153 const src = randomText(r, 1 + Math.floor(r() * 20)) + pick(r, ['\n\n\n', ' \n', '\n ', ' '])
154 const once = strip(src).text
155 // Plain text is valid markdown, so stripping the output again must be a fixed point:
156 // trailing whitespace is gone, newline runs are collapsed, the ends are trimmed.
157 t.is(strip(once).text, once, 'strip(strip(x).text).text === strip(x).text')
158 }
159})