1package sanitize23import (4 "strings"5 "testing"6)78func 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}1415func 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}2021func TestInlineText(t *testing.T) {22 cases := []struct{ in, want string }{23 {"plain", "plain"},24 {"a*b", `a\*b`},25 {"a|b", "a|b"}, // | NOT escaped26 {"a=b", "a=b"}, // = NOT escaped27 {"line1\nline2", "line1 line2"}, // newline folded28 {"line1\r\nline2", "line1 line2"}, // CRLF normalized then folded29 {"a\u2028b", "a b"}, // U+2028 folded30 {"a\u0085b", "a b"}, // NEL folded31 {"a\u200Bb", "ab"}, // ZWSP stripped32 {"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}4041func TestBlock(t *testing.T) {42 // Block wraps every non-empty output with "\n\n" — CM §4.8 blank43 // lines — on BOTH sides, so user content is paragraph-isolated44 // from any realm chrome that precedes OR follows it. Bounds CM45 // §4.6 HTML block types 6/7 (`<div>`, `<table>`, …) which are not46 // 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 mode52 // 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 EOF58 {"[x](javascript:alert)\n", "\n\n[x](%26#x6a;avascript:alert)\n\n"},59 {"[x](˜avascript:alert)\n", "\n\n[x](%26#0000152;avascript:alert)\n\n"}, // octal reference60 // A renderer unescapes `\#` before resolving references, so this61 // reaches it as `j` 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, over64 // the same buffer, so `&colon;` reaches the named pass as65 // `:` and resolves to `:`.66 {"[x](javascript&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 to78 // 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}8586func TestBlockRich(t *testing.T) {87 // BlockRich wraps every non-empty output with "\n\n" — CM §4.888 // 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-pass98 {"***\n", "\n\n***\n\n"}, // thematic break NOT setext-h2 — preserved99 {"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 paragraph101 // 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 too107 // (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 stray116 // blank line.117 {"[x]: y\n", ""},118 {"", ""}, // empty input → empty output, no stray blank line119 {"```\ncode\n", "\n\n```\ncode\n```\n\n"},120 {"[x](javascript:alert)\n", "\n\n[x](javascript%26colon;alert)\n\n"},121 // `\;` on a named reference — unescaped to `:` 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&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-identical133 // 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}140141func 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 same144 // paragraph as the realm's last line (which would let a deeper145 // `===` or `|---|` in user content retroactively promote realm146 // 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 continue151 }152 if !strings.HasPrefix(got, "\n\n") {153 t.Errorf("BlockRich(%q) = %q; expected leading '\\n\\n'", in, got)154 }155 }156}157158func 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's161 // next line (CM §5.2 lazy continuation, or a realm-supplied162 // `|---|` row retroactively promoting user's last line into a163 // `<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 continue168 }169 if !strings.HasSuffix(got, "\n\n") {170 t.Errorf("BlockRich(%q) = %q; expected trailing '\\n\\n'", in, got)171 }172 }173}174175func TestBlockquoteRich(t *testing.T) {176 // BlockquoteRich strips BlockRich's leading "\n", line-prefixes177 // each remaining line with "> ", and emits "\n" on both ends:178 // leading "\n" prevents `chrome + BlockquoteRich(user)` from179 // landing the first `>` mid-line; trailing "\n\n" (blank line)180 // prevents `BlockquoteRich(user) + chrome` from pulling chrome181 // 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 the196 // resulting bare "\n" leaves empty input and the helper197 // 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 (rendered202 // 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 trailing211 // 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 user216 // wrote literal spaces). The line-prefix loop emits `> ` plus217 // 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}227228func TestBlockquoteRich_DoubleWrapNestsQuote(t *testing.T) {229 // Not idempotent: calling twice nests the quote one level230 // deeper. The aggressive TrimRight in BlockquoteRich also231 // collapses the inner trailing blank line, so the second pass232 // produces a clean `> > foo` nesting (no `> ` quoted blank in233 // 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}240241func TestBlockquoteRich_LeadingNewlineGuaranteed(t *testing.T) {242 // Every non-empty result starts with "\n" so realm concatenation243 // 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 continue248 }249 if got[0] != '\n' {250 t.Errorf("BlockquoteRich(%q) = %q; expected leading '\\n'", in, got)251 }252 }253}254255func TestBlockquoteRich_TrailingBlankLineGuaranteed(t *testing.T) {256 // Every non-empty result ends with "\n\n" so realm concatenation257 // like `BlockquoteRich(user) + "more text"` doesn't pull "more258 // 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 continue263 }264 if !strings.HasSuffix(got, "\n\n") {265 t.Errorf("BlockquoteRich(%q) = %q; expected trailing '\\n\\n'", in, got)266 }267 }268}269270func TestBlockquoteRich_NoStrayEmptyQuotedLine(t *testing.T) {271 // BlockRich's own leading "\n" must be stripped before272 // line-prefixing — otherwise the output would start with a273 // 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}279280func 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 code286 {"leading-setext-trailing-ws", "=== \nbody\n", "\\=== \nbody\n"},287 {"leading-setext-mixed-chars", "=-=-\nbody\n", "=-=-\nbody\n"}, // mixed; not setext288 {"leading-setext-has-content", "=== text\nbody\n", "=== text\nbody\n"}, // mixed content289 {"setext-deeper-untouched", "title\n===\nfoo\n", "title\n===\nfoo\n"},290 {"leading-thematic-asterisk", "***\nbody\n", "***\nbody\n"}, // not setext, untouched291 {"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-authored295 {"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}305306func 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}318319func 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}332333func TestHTMLEscape(t *testing.T) {334 cases := []struct{ in, want string }{335 {"plain", "plain"},336 {"<script>", "<script>"},337 {`a & b`, "a & b"},338 {`"quoted"`, ""quoted""},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}346347func 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:a@b.com", "mailto:a@b.com"},352 {"mailto:a@b.com?body=phish", ""}, // any query is a prefill-phishing vector353 {"mailto:a@b.com?BODY=phish", ""}, // header name case is irrelevant354 {"mailto:a@b.com?Body=phish", ""}, // mixed case355 {"mailto:a@b.com?%62ody=phish", ""}, // percent-encoded header (%62 == b)356 {"mailto:a@b.com?subject=hi&body=phish", ""}, // body as second param357 {"mailto:a@b.com?subject=hi&BODY=phish", ""}, // mixed case second param358 {"mailto:a@b.com?subject=hello", ""}, // subject alone still rejected359 {"mailto:a@b.com?cc=x@y.com", ""}, // cc rejected360 {"mailto:a@b.com?bcc=x@y.com", ""}, // bcc rejected361 {"mailto:a@b.com?", ""}, // bare trailing query marker rejected362 {"mailto:a@b.com?body=phish", ""}, // hex char-ref ? — renderer decodes to ?body=363 {"mailto:a@b.com?body=phish", ""}, // decimal char-ref ? — same bypass364 {"mailto:a@b.com?body=phish", ""}, // named char-ref ? — same bypass365 {"mailto:a@b.com&body=phish", ""}, // bare & gateway rejected366 {"//evil.com", ""}, // protocol-relative rejected367 {"javascript:alert(1)", ""}, // bad scheme368 {"/r/foo", "/r/foo"}, // relative369 {"./local", "./local"},370 {"#section", "#section"}, // fragment-only371 {"", ""},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}381382func TestImageURL(t *testing.T) {383 cases := []struct{ in, want string }{384 {"https://example.com/img.png", "https://example.com/img.png"},385 {"mailto:a@b.com", ""}, // mailto rejected for images386 {"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 subset389 {"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}398399func 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 first405 {"1alice", ""}, // digit first406 {"", ""},407 {"a\u200Blice", "alice"}, // bidi stripped, then matches408 }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}415416func TestBechString(t *testing.T) {417 addrG := "g1abc123def456ghi789jkl012mno345p"418 cases := []struct {419 s, prefix string420 want string421 }{422 {addrG, "g", addrG},423 {addrG, "", addrG}, // any prefix424 {addrG, "gpub", ""}, // wrong prefix425 {"gpub1abc123def456ghijklmn", "gpub", "gpub1abc123def456ghijklmn"},426 {"gpub1abc123def456ghijklmn", "", "gpub1abc123def456ghijklmn"},427 {"b1xyz789abc123def456", "", "b1xyz789abc123def456"}, // any-prefix mode allows b1...428 {"g1ABC", "g", ""}, // uppercase rejected429 {"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}438439func 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}452453func 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}468469func TestNestedPrefix(t *testing.T) {470 cases := []struct{ in, want string }{471 {"", ""},472 {" ", " "},473 {"\t", "\t"},474 {"> ", "> "},475 {"> > ", "> > "},476 {"## ", ""}, // markdown-active prefix rejected477 {"- ", ""},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}485486func 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}494Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.