| … | … | @@ -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 | |
| 15 | import test from 'brittle' |
| 16 | import { strip } from '../lib/strip.js' |
| 17 | |
| 18 | // xorshift32 — tiny, deterministic, dependency-free. Reproducibility is the point. |
| 19 | function 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. |
| 30 | const 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 | |
| 40 | const pick = (r, xs) => xs[Math.floor(r() * xs.length) % xs.length] |
| 41 | |
| 42 | function 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. |
| 49 | function 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 | |
| 55 | const byteLen = (s) => Buffer.byteLength(s, 'utf8') |
| 56 | |
| 57 | // Does this byte index fall on a UTF-8 character boundary? A continuation byte is 0b10xxxxxx. |
| 58 | function onBoundary (buf, i) { |
| 59 | if (i <= 0 || i >= buf.length) return true |
| 60 | return (buf[i] & 0xc0) !== 0x80 |
| 61 | } |
| 62 | |
| 63 | test('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 | |
| 98 | test('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 | |
| 116 | test('fuzz: invariants hold on arbitrary markdown-ish input (§6, §2)', (t) => { |
| 117 | const r = rng(0xc0ffee) |
| 118 | const NOISE = ['**', '*', '~~', '`', '# ', '> ', '- ', '1. ', '|', '[[', ']]', ':::', '', '[^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 | |
| 150 | test('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 | }) |