Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

sanitize_test.gno

18.67 Kb · 493 lines
  1package sanitize
  2
  3import (
  4	"strings"
  5	"testing"
  6)
  7
  8func TestStripBidiAndZeroWidthRe(t *testing.T) {
  9	// Re-export sanity check.
 10	if got := StripBidiAndZeroWidth("a\u200Bb"); got != "ab" {
 11		t.Errorf("re-export StripBidiAndZeroWidth failed: got %q", got)
 12	}
 13}
 14
 15func TestNormalizeBreaksRe(t *testing.T) {
 16	if got := NormalizeBreaks("a\r\nb"); got != "a\nb" {
 17		t.Errorf("re-export NormalizeBreaks failed: got %q", got)
 18	}
 19}
 20
 21func TestInlineText(t *testing.T) {
 22	cases := []struct{ in, want string }{
 23		{"plain", "plain"},
 24		{"a*b", `a\*b`},
 25		{"a|b", "a|b"},                    // | NOT escaped
 26		{"a=b", "a=b"},                    // = NOT escaped
 27		{"line1\nline2", "line1 line2"},   // newline folded
 28		{"line1\r\nline2", "line1 line2"}, // CRLF normalized then folded
 29		{"a\u2028b", "a b"},               // U+2028 folded
 30		{"a\u0085b", "a b"},               // NEL folded
 31		{"a\u200Bb", "ab"},                // ZWSP stripped
 32		{"hello *world*", `hello \*world\*`},
 33	}
 34	for _, c := range cases {
 35		if got := InlineText(c.in); got != c.want {
 36			t.Errorf("InlineText(%q) = %q, want %q", c.in, got, c.want)
 37		}
 38	}
 39}
 40
 41func TestBlock(t *testing.T) {
 42	// Block wraps every non-empty output with "\n\n" — CM §4.8 blank
 43	// lines — on BOTH sides, so user content is paragraph-isolated
 44	// from any realm chrome that precedes OR follows it. Bounds CM
 45	// §4.6 HTML block types 6/7 (`<div>`, `<table>`, …) which are not
 46	// escaped in any mode and would otherwise consume appended chrome.
 47	cases := []struct{ in, want string }{
 48		{"hello world\n", "\n\nhello world\n\n"},
 49		{"# heading\n", "\n\n\\# heading\n\n"},
 50		{"> quote\n", "\n\n\\> quote\n\n"},
 51		{"| a | b |\n", "\n\n\\| a | b |\n\n"}, // GFM table-row escaped in strict mode
 52		// CM §4.6 HTML block types 1-5 — escaped (blank-line-NON-terminating).
 53		{"<script>x</script>\n", "\n\n\\<script>x</script>\n\n"},
 54		{"<!-- comment -->\n", "\n\n\\<!-- comment -->\n\n"},
 55		{"<?php x ?>\n", "\n\n\\<?php x ?>\n\n"},
 56		{"<!DOCTYPE html>\n", "\n\n\\<!DOCTYPE html>\n\n"},
 57		{"```\ncode\n", "\n\n```\ncode\n```\n\n"}, // fence auto-close at EOF
 58		{"[x](&#x6a;avascript:alert)\n", "\n\n[x](%26#x6a;avascript:alert)\n\n"},
 59		{"[x](&#0000152;avascript:alert)\n", "\n\n[x](%26#0000152;avascript:alert)\n\n"}, // octal reference
 60		// A renderer unescapes `\#` before resolving references, so this
 61		// reaches it as `&#x6a;` unless the `&` is encoded here.
 62		{"[x](&\\#x6a;avascript:alert)\n", "\n\n[x](%26\\#x6a;avascript:alert)\n\n"},
 63		// A renderer resolves numeric references before named ones, over
 64		// the same buffer, so `&#38;colon;` reaches the named pass as
 65		// `&colon;` and resolves to `:`.
 66		{"[x](javascript&#38;colon;alert)\n", "\n\n[x](javascript%26#38;colon;alert)\n\n"},
 67		{"[x](?x=1&y=2)\n", "\n\n[x](?x=1&y=2)\n\n"},
 68		// Empty / strip-to-empty inputs short-circuit (no stray blank line).
 69		{"[x]: y\n", ""},
 70		{"", ""},
 71	}
 72	for _, c := range cases {
 73		got := Block(c.in)
 74		if got != c.want {
 75			t.Errorf("Block(%q) = %q, want %q", c.in, got, c.want)
 76		}
 77		// Idempotency: Block(Block(in)) must be byte-identical to
 78		// Block(in) for every input in the table.
 79		twice := Block(got)
 80		if twice != got {
 81			t.Errorf("Block not idempotent for %q: Block(once)=%q, Block(twice)=%q", c.in, got, twice)
 82		}
 83	}
 84}
 85
 86func TestBlockRich(t *testing.T) {
 87	// BlockRich wraps every non-empty output with "\n\n" — CM §4.8
 88	// blank lines — on BOTH sides, so user content is paragraph-
 89	// isolated from any realm chrome that precedes OR follows it.
 90	cases := []struct{ in, want string }{
 91		// Block-level markdown that Block escapes — preserved by BlockRich.
 92		{"hello world\n", "\n\nhello world\n\n"},
 93		{"# heading\n", "\n\n# heading\n\n"},
 94		{"> quote\n", "\n\n> quote\n\n"},
 95		{"- item\n", "\n\n- item\n\n"},
 96		{"1. item\n", "\n\n1. item\n\n"},
 97		{"---\n", "\n\n\\---\n\n"},                       // first-line setext-h2 → escaped by qualifying-setext pre-pass
 98		{"***\n", "\n\n***\n\n"},                         // thematic break NOT setext-h2 — preserved
 99		{"text\n===\n", "\n\ntext\n===\n\n"},             // deeper setext preserved (user-authored above)
100		{"body\n===\nmore\n", "\n\nbody\n===\nmore\n\n"}, // cross-paragraph backward attack: realm `chrome\n\nbody\n===\nmore` keeps realm in its own paragraph
101		// GFM tables PRESERVED in Rich mode.
102		{"| a | b |\n", "\n\n| a | b |\n\n"},
103		{"| H |\n|---|\n| a |\n", "\n\n| H |\n|---|\n| a |\n\n"}, // full table renders as <table>
104		// Realm-binding defenses STILL ON.
105		{"<gno-card>\n", "\n\n\\<gno-card>\n\n"},
106		// CM §4.6 HTML block types 1-5 — escaped in Rich mode too
107		// (defense is mode-independent).
108		{"<script>x</script>\n", "\n\n\\<script>x</script>\n\n"},
109		{"<!-- comment -->\n", "\n\n\\<!-- comment -->\n\n"},
110		{"<?php x ?>\n", "\n\n\\<?php x ?>\n\n"},
111		{"<!DOCTYPE html>\n", "\n\n\\<!DOCTYPE html>\n\n"},
112		{"[t][l]\n", "\n\n\\[t\\]\\[l\\]\n\n"},
113		{"[^name]\n", "\n\n\\[^name\\]\n\n"},
114		// LRD-only input strips to nothing; empty-after-escape short-
115		// circuits to "" so realm concatenation doesn't leak a stray
116		// blank line.
117		{"[x]: y\n", ""},
118		{"", ""}, // empty input → empty output, no stray blank line
119		{"```\ncode\n", "\n\n```\ncode\n```\n\n"},
120		{"[x](javascript&colon;alert)\n", "\n\n[x](javascript%26colon;alert)\n\n"},
121		// `\;` on a named reference — unescaped to `&colon;` by a renderer.
122		{"[x](javascript&colon\\;alert)\n", "\n\n[x](javascript%26colon\\;alert)\n\n"},
123		// Numeric reference feeding the named pass — see TestBlock.
124		{"[x](javascript&#x26;colon;alert)\n", "\n\n[x](javascript%26#x26;colon;alert)\n\n"},
125		{"[x](/r/boards$help&func=DeleteThread&boardID=2)\n", "\n\n[x](/r/boards$help&func=DeleteThread&boardID=2)\n\n"},
126	}
127	for _, c := range cases {
128		got := BlockRich(c.in)
129		if got != c.want {
130			t.Errorf("BlockRich(%q) = %q, want %q", c.in, got, c.want)
131		}
132		// Idempotency: BlockRich(BlockRich(in)) must be byte-identical
133		// to BlockRich(in) for every input in the table.
134		twice := BlockRich(got)
135		if twice != got {
136			t.Errorf("BlockRich not idempotent for %q: BlockRich(once)=%q, BlockRich(twice)=%q", c.in, got, twice)
137		}
138	}
139}
140
141func TestBlockRich_LeadingBlankLineGuaranteed(t *testing.T) {
142	// Every non-empty result begins with "\n\n" so `chrome +
143	// BlockRich(user)` cannot place the user's first line in the same
144	// paragraph as the realm's last line (which would let a deeper
145	// `===` or `|---|` in user content retroactively promote realm
146	// chrome).
147	for _, in := range []string{"x", "x\n", "# h", "- i\n- j", "| a |\n|---|\n| 1 |"} {
148		got := BlockRich(in)
149		if got == "" {
150			continue
151		}
152		if !strings.HasPrefix(got, "\n\n") {
153			t.Errorf("BlockRich(%q) = %q; expected leading '\\n\\n'", in, got)
154		}
155	}
156}
157
158func TestBlockRich_TrailingBlankLineGuaranteed(t *testing.T) {
159	// Every non-empty result ends with "\n\n" so `BlockRich(user) +
160	// chrome` cannot extend user's last paragraph into the realm's
161	// next line (CM §5.2 lazy continuation, or a realm-supplied
162	// `|---|` row retroactively promoting user's last line into a
163	// `<thead>` header).
164	for _, in := range []string{"x", "x\n", "# h", "- i\n- j", "| H |\n|---|\n| a |"} {
165		got := BlockRich(in)
166		if got == "" {
167			continue
168		}
169		if !strings.HasSuffix(got, "\n\n") {
170			t.Errorf("BlockRich(%q) = %q; expected trailing '\\n\\n'", in, got)
171		}
172	}
173}
174
175func TestBlockquoteRich(t *testing.T) {
176	// BlockquoteRich strips BlockRich's leading "\n", line-prefixes
177	// each remaining line with "> ", and emits "\n" on both ends:
178	// leading "\n" prevents `chrome + BlockquoteRich(user)` from
179	// landing the first `>` mid-line; trailing "\n\n" (blank line)
180	// prevents `BlockquoteRich(user) + chrome` from pulling chrome
181	// into the quote via CM §5.2 lazy continuation.
182	cases := []struct{ name, in, want string }{
183		{"plain text", "hello world\n", "\n> hello world\n\n"},
184		{"atx heading preserved", "# heading\n", "\n> # heading\n\n"},
185		{"nested blockquote", "> nested\n", "\n> > nested\n\n"},
186		{"list item preserved", "- item\n", "\n> - item\n\n"},
187		{"ordered list preserved", "1. item\n", "\n> 1. item\n\n"},
188		{"first-line setext escaped", "---\n", "\n> \\---\n\n"},
189		{"deeper setext preserved", "text\n===\n", "\n> text\n> ===\n\n"},
190		{"thematic break asterisks preserved", "***\n", "\n> ***\n\n"},
191		// Realm-binding defenses still on.
192		{"gno extension escaped", "<gno-card>\n", "\n> \\<gno-card>\n\n"},
193		{"ref-link use escaped", "[t][l]\n", "\n> \\[t\\]\\[l\\]\n\n"},
194		{"footnote escaped", "[^name]\n", "\n> \\[^name\\]\n\n"},
195		// LRDs are stripped by BlockRich entirely; trimming the
196		// resulting bare "\n" leaves empty input and the helper
197		// returns "" without emitting a blockquote.
198		{"lrd alone strips to empty", "[x]: y\n", ""},
199		// Code fence autoclose at EOF lands inside the quote.
200		{"unclosed fence autoclosed", "```\ncode\n", "\n> ```\n> code\n> ```\n\n"},
201		// Multi-paragraph preserves the inner blank line (rendered
202		// as a quoted blank line `> \n`).
203		{"multi-paragraph preserves blank", "a\n\nb\n", "\n> a\n> \n> b\n\n"},
204		// Empty / blank-only input collapses cleanly to "".
205		{"empty input", "", ""},
206		// CRLF normalized through BlockRich.
207		{"crlf normalized", "a\r\nb\n", "\n> a\n> b\n\n"},
208		// NUL replaced with U+FFFD by BlockRich.
209		{"nul replaced", "x\x00y\n", "\n> x\uFFFDy\n\n"},
210		// Multiple trailing newlines collapse to the single trailing
211		// blank line shape — output never ends with more than `\n\n`.
212		{"multiple trailing newlines collapse", "foo\n\n\n", "\n> foo\n\n"},
213		// Internal tabs are preserved (BlockRich does not touch them).
214		{"internal tab preserved", "a\tb\n", "\n> a\tb\n\n"},
215		// Whitespace-only input still yields a blockquote (the user
216		// wrote literal spaces). The line-prefix loop emits `> ` plus
217		// the original two spaces.
218		{"whitespace-only input", "  ", "\n>   \n\n"},
219	}
220	for _, c := range cases {
221		got := BlockquoteRich(c.in)
222		if got != c.want {
223			t.Errorf("%s: BlockquoteRich(%q) = %q, want %q", c.name, c.in, got, c.want)
224		}
225	}
226}
227
228func TestBlockquoteRich_DoubleWrapNestsQuote(t *testing.T) {
229	// Not idempotent: calling twice nests the quote one level
230	// deeper. The aggressive TrimRight in BlockquoteRich also
231	// collapses the inner trailing blank line, so the second pass
232	// produces a clean `> > foo` nesting (no `> ` quoted blank in
233	// the middle) plus the outer trailing blank line.
234	once := BlockquoteRich("foo\n")
235	twice := BlockquoteRich(once)
236	if want := "\n> > foo\n\n"; twice != want {
237		t.Errorf("BlockquoteRich(BlockquoteRich(%q)) = %q, want %q", "foo\n", twice, want)
238	}
239}
240
241func TestBlockquoteRich_LeadingNewlineGuaranteed(t *testing.T) {
242	// Every non-empty result starts with "\n" so realm concatenation
243	// like `chrome + BlockquoteRich(user)` doesn't place `>` mid-line.
244	for _, in := range []string{"x", "x\n", "# h", "- i\n- j"} {
245		got := BlockquoteRich(in)
246		if got == "" {
247			continue
248		}
249		if got[0] != '\n' {
250			t.Errorf("BlockquoteRich(%q) = %q; expected leading '\\n'", in, got)
251		}
252	}
253}
254
255func TestBlockquoteRich_TrailingBlankLineGuaranteed(t *testing.T) {
256	// Every non-empty result ends with "\n\n" so realm concatenation
257	// like `BlockquoteRich(user) + "more text"` doesn't pull "more
258	// text" into the quote via CM §5.2 lazy continuation.
259	for _, in := range []string{"x", "x\n", "# h", "- i\n- j", "para\n\nmore"} {
260		got := BlockquoteRich(in)
261		if got == "" {
262			continue
263		}
264		if !strings.HasSuffix(got, "\n\n") {
265			t.Errorf("BlockquoteRich(%q) = %q; expected trailing '\\n\\n'", in, got)
266		}
267	}
268}
269
270func TestBlockquoteRich_NoStrayEmptyQuotedLine(t *testing.T) {
271	// BlockRich's own leading "\n" must be stripped before
272	// line-prefixing — otherwise the output would start with a
273	// useless `> \n` empty quoted line.
274	got := BlockquoteRich("foo\n")
275	if strings.HasPrefix(got, "\n> \n") {
276		t.Errorf("BlockquoteRich leaked BlockRich's leading '\\n' as `> \\n`: %q", got)
277	}
278}
279
280func TestNeuterLeadingSetextIfQualifying(t *testing.T) {
281	cases := []struct{ name, in, want string }{
282		{"leading-setext-h1", "===\nbody\n", "\\===\nbody\n"},
283		{"leading-setext-h2", "---\nbody\n", "\\---\nbody\n"},
284		{"leading-setext-indented", "   ===\nbody\n", "   \\===\nbody\n"},
285		{"leading-setext-indented-4", "    ===\nbody\n", "    ===\nbody\n"}, // indented code
286		{"leading-setext-trailing-ws", "===   \nbody\n", "\\===   \nbody\n"},
287		{"leading-setext-mixed-chars", "=-=-\nbody\n", "=-=-\nbody\n"},         // mixed; not setext
288		{"leading-setext-has-content", "=== text\nbody\n", "=== text\nbody\n"}, // mixed content
289		{"setext-deeper-untouched", "title\n===\nfoo\n", "title\n===\nfoo\n"},
290		{"leading-thematic-asterisk", "***\nbody\n", "***\nbody\n"}, // not setext, untouched
291		{"leading-thematic-underscore", "___\nbody\n", "___\nbody\n"},
292		{"leading-blank-then-setext", "\n===\nbody\n", "\n\\===\nbody\n"},
293		{"leading-blank-ws-then-setext", "   \n===\nbody\n", "   \n\\===\nbody\n"},
294		{"text-first", "hello\n===\n", "hello\n===\n"}, // text before === — user-authored
295		{"empty", "", ""},
296		{"all-blank", "   \n\n", "   \n\n"},
297		{"only-equals", "===", "\\==="},
298	}
299	for _, c := range cases {
300		if got := neuterLeadingSetextIfQualifying(c.in); got != c.want {
301			t.Errorf("%s: neuterLeadingSetextIfQualifying(%q) = %q, want %q", c.name, c.in, got, c.want)
302		}
303	}
304}
305
306func TestLinkTitle(t *testing.T) {
307	cases := []struct{ in, want string }{
308		{`he said "hi"`, `he said \"hi\"`},
309		{`it's nice`, `it\'s nice`},
310		{"line1\nline2", "line1 line2"},
311	}
312	for _, c := range cases {
313		if got := LinkTitle(c.in); got != c.want {
314			t.Errorf("LinkTitle(%q) = %q, want %q", c.in, got, c.want)
315		}
316	}
317}
318
319func TestTableCell(t *testing.T) {
320	cases := []struct{ in, want string }{
321		{"plain cell", "plain cell"},
322		{"a|b", `a\|b`},
323		{"a\tb", "a b"},
324		{"a*b|c", `a\*b\|c`},
325	}
326	for _, c := range cases {
327		if got := TableCell(c.in); got != c.want {
328			t.Errorf("TableCell(%q) = %q, want %q", c.in, got, c.want)
329		}
330	}
331}
332
333func TestHTMLEscape(t *testing.T) {
334	cases := []struct{ in, want string }{
335		{"plain", "plain"},
336		{"<script>", "&lt;script&gt;"},
337		{`a & b`, "a &amp; b"},
338		{`"quoted"`, "&#34;quoted&#34;"},
339	}
340	for _, c := range cases {
341		if got := HTMLEscape(c.in); got != c.want {
342			t.Errorf("HTMLEscape(%q) = %q, want %q", c.in, got, c.want)
343		}
344	}
345}
346
347func TestURL(t *testing.T) {
348	cases := []struct{ in, want string }{
349		{"https://example.com/x", "https://example.com/x"},
350		{"http://example.com", "http://example.com"},
351		{"mailto:[email protected]", "mailto:[email protected]"},
352		{"mailto:[email protected]?body=phish", ""},            // any query is a prefill-phishing vector
353		{"mailto:[email protected]?BODY=phish", ""},            // header name case is irrelevant
354		{"mailto:[email protected]?Body=phish", ""},            // mixed case
355		{"mailto:[email protected]?%62ody=phish", ""},          // percent-encoded header (%62 == b)
356		{"mailto:[email protected]?subject=hi&body=phish", ""}, // body as second param
357		{"mailto:[email protected]?subject=hi&BODY=phish", ""}, // mixed case second param
358		{"mailto:[email protected]?subject=hello", ""},         // subject alone still rejected
359		{"mailto:[email protected][email protected]", ""},            // cc rejected
360		{"mailto:[email protected][email protected]", ""},           // bcc rejected
361		{"mailto:[email protected]?", ""},                      // bare trailing query marker rejected
362		{"mailto:[email protected]&#x3f;body=phish", ""},       // hex char-ref ? — renderer decodes to ?body=
363		{"mailto:[email protected]&#63;body=phish", ""},        // decimal char-ref ? — same bypass
364		{"mailto:[email protected]&quest;body=phish", ""},      // named char-ref ? — same bypass
365		{"mailto:[email protected]&body=phish", ""},            // bare & gateway rejected
366		{"//evil.com", ""},                           // protocol-relative rejected
367		{"javascript:alert(1)", ""},                  // bad scheme
368		{"/r/foo", "/r/foo"},                         // relative
369		{"./local", "./local"},
370		{"#section", "#section"}, // fragment-only
371		{"", ""},
372		{"   ", ""},
373		{"https://a.com/path with space", "https://a.com/path%20with%20space"},
374	}
375	for _, c := range cases {
376		if got := URL(c.in); got != c.want {
377			t.Errorf("URL(%q) = %q, want %q", c.in, got, c.want)
378		}
379	}
380}
381
382func TestImageURL(t *testing.T) {
383	cases := []struct{ in, want string }{
384		{"https://example.com/img.png", "https://example.com/img.png"},
385		{"mailto:[email protected]", ""}, // mailto rejected for images
386		{"data:image/svg+xml,<svg/>", "data:image/svg+xml,%3Csvg/%3E"},
387		{"data:image/png;base64,XXX", "data:image/png;base64,XXX"},
388		{"data:text/html,<script>", ""}, // bad data subset
389		{"javascript:alert(1)", ""},
390		{"", ""},
391	}
392	for _, c := range cases {
393		if got := ImageURL(c.in); got != c.want {
394			t.Errorf("ImageURL(%q) = %q, want %q", c.in, got, c.want)
395		}
396	}
397}
398
399func TestUserName(t *testing.T) {
400	cases := []struct{ in, want string }{
401		{"alice", "alice"},
402		{"alice123", "alice123"},
403		{"alice_bob-cat", "alice_bob-cat"},
404		{"Alice", ""},  // uppercase first
405		{"1alice", ""}, // digit first
406		{"", ""},
407		{"a\u200Blice", "alice"}, // bidi stripped, then matches
408	}
409	for _, c := range cases {
410		if got := UserName(c.in); got != c.want {
411			t.Errorf("UserName(%q) = %q, want %q", c.in, got, c.want)
412		}
413	}
414}
415
416func TestBechString(t *testing.T) {
417	addrG := "g1abc123def456ghi789jkl012mno345p"
418	cases := []struct {
419		s, prefix string
420		want      string
421	}{
422		{addrG, "g", addrG},
423		{addrG, "", addrG},  // any prefix
424		{addrG, "gpub", ""}, // wrong prefix
425		{"gpub1abc123def456ghijklmn", "gpub", "gpub1abc123def456ghijklmn"},
426		{"gpub1abc123def456ghijklmn", "", "gpub1abc123def456ghijklmn"},
427		{"b1xyz789abc123def456", "", "b1xyz789abc123def456"}, // any-prefix mode allows b1...
428		{"g1ABC", "g", ""}, // uppercase rejected
429		{"x", "g", ""},
430		{"", "g", ""},
431	}
432	for _, c := range cases {
433		if got := BechString(c.s, c.prefix); got != c.want {
434			t.Errorf("BechString(%q,%q) = %q, want %q", c.s, c.prefix, got, c.want)
435		}
436	}
437}
438
439func TestFootnoteLabel(t *testing.T) {
440	cases := []struct{ in, want string }{
441		{"note1", "note1"},
442		{"Note_A-1", "Note_A-1"},
443		{"with space", ""},
444		{"", ""},
445	}
446	for _, c := range cases {
447		if got := FootnoteLabel(c.in); got != c.want {
448			t.Errorf("FootnoteLabel(%q) = %q, want %q", c.in, got, c.want)
449		}
450	}
451}
452
453func TestLanguageName(t *testing.T) {
454	cases := []struct{ in, want string }{
455		{"go", "go"},
456		{"c++", "c++"},
457		{"python3", "python3"},
458		{"objective-c", "objective-c"},
459		{"with space", ""},
460		{"", ""},
461	}
462	for _, c := range cases {
463		if got := LanguageName(c.in); got != c.want {
464			t.Errorf("LanguageName(%q) = %q, want %q", c.in, got, c.want)
465		}
466	}
467}
468
469func TestNestedPrefix(t *testing.T) {
470	cases := []struct{ in, want string }{
471		{"", ""},
472		{"  ", "  "},
473		{"\t", "\t"},
474		{"> ", "> "},
475		{"> > ", "> > "},
476		{"## ", ""}, // markdown-active prefix rejected
477		{"- ", ""},
478	}
479	for _, c := range cases {
480		if got := NestedPrefix(c.in); got != c.want {
481			t.Errorf("NestedPrefix(%q) = %q, want %q", c.in, got, c.want)
482		}
483	}
484}
485
486func TestCodeFence(t *testing.T) {
487	if got := CodeFence("```", 3); got != "````" {
488		t.Errorf("CodeFence: got %q, want %q", got, "````")
489	}
490	if got := CodeFence("", 3); got != "```" {
491		t.Errorf("CodeFence empty: got %q, want %q", got, "```")
492	}
493}