Artifact 11bed922237e434e9c58e3a1fc858d9a5b73310b8f5655145ff745441df6b008:
- File
internal/markup/markup_test.go
— part of check-in
[0ba22dea9e]
at
2026-08-23 22:31:40
on branch trunk
— markdown: mentions carry the identity mark, quotes are visible
An @mention now renders the same glyph as the person's byline, through the resolver seam so markup still knows nothing about accounts. The identicon moved to internal/idn — web and api each hashed the name separately and agreed by luck. Blockquotes had no rule at all. (user: dmestas, size: 13955) [annotate] [blame] [check-ins using]
package markup import ( "encoding/json" "os" "strings" "testing" "golang.org/x/net/html" "golang.org/x/net/html/atom" ) // --- conformance corpus (third_party/sanitizer-corpus, see ledger) --- type corpusCase struct { Name string `json:"name"` Input string `json:"input"` Deny []string `json:"deny"` Allow []string `json:"allow"` } type corpusFile struct { Source string `json:"source"` Cases []corpusCase `json:"cases"` } func TestConformanceCorpus(t *testing.T) { for _, name := range []string{"gitea", "forgejo"} { raw, err := os.ReadFile("../../third_party/sanitizer-corpus/" + name + ".json") if err != nil { t.Fatal(err) } var cf corpusFile if err := json.Unmarshal(raw, &cf); err != nil { t.Fatalf("%s: %v", name, err) } if len(cf.Cases) < 10 { t.Fatalf("%s: suspiciously small corpus (%d)", name, len(cf.Cases)) } for _, c := range cf.Cases { out := Sanitize(c.Input) for _, bad := range c.Deny { if strings.Contains(out, bad) { t.Errorf("%s/%s: output contains %q:\n%s", name, c.Name, bad, out) } } for _, good := range c.Allow { if !strings.Contains(out, good) { t.Errorf("%s/%s: output lost %q:\n%s", name, c.Name, good, out) } } } } } // --- rendering --- func TestRenderMarkdown(t *testing.T) { out, err := Render([]byte("# Title\n\nSome *emphasis* and `code`.\n\n- [ ] task\n"), nil) if err != nil { t.Fatal(err) } s := string(out) for _, want := range []string{"<h2", "<em>emphasis</em>", "<code>code</code>", `type="checkbox"`} { if !strings.Contains(s, want) { t.Errorf("missing %q in:\n%s", want, s) } } } func TestRenderStripsRawHTMLAttacks(t *testing.T) { out, err := Render([]byte("hello <script>alert(1)</script> <a href=\"javascript:x\" onclick=\"y\">link</a>\n"), nil) if err != nil { t.Fatal(err) } s := string(out) for _, bad := range []string{"<script", "javascript:", "onclick"} { if strings.Contains(s, bad) { t.Errorf("attack survived rendering: %q in\n%s", bad, s) } } } func TestRenderFencedCodeHighlights(t *testing.T) { out, err := Render([]byte("```go\npackage main\n```\n"), nil) if err != nil { t.Fatal(err) } s := string(out) // Chroma emits classed spans; the sanitizer must keep them and the // keyword must be inside one. if !strings.Contains(s, `<span class="kn">package</span>`) && !strings.Contains(s, `<span class="k">package</span>`) { t.Fatalf("fenced code not highlighted:\n%s", s) } } func TestRenderFencedCodeUnknownLanguageStaysPlain(t *testing.T) { out, err := Render([]byte("```zzznotalang\nhello\n```\n"), nil) if err != nil { t.Fatal(err) } if !strings.Contains(string(out), "hello") { t.Fatalf("code content lost:\n%s", out) } } func TestRenderTable(t *testing.T) { out, err := Render([]byte("| a | b |\n|---|---|\n| 1 | 2 |\n"), nil) if err != nil { t.Fatal(err) } if !strings.Contains(string(out), "<table>") { t.Fatalf("GFM table lost:\n%s", out) } } // --- resolver seam --- type fakeResolver struct{} func (fakeResolver) ResolveHash(prefix string) string { if strings.HasPrefix(prefix, "abc123") { return "/checkin/" + prefix } return "" } func (fakeResolver) ResolveWiki(name string) string { if name == "Design" { return "/wiki/Design" } return "" } func TestResolveWikiRef(t *testing.T) { out, err := Render([]byte("See [[Design]] for details.\n"), fakeResolver{}) if err != nil { t.Fatal(err) } if !strings.Contains(string(out), `<a href="/wiki/Design">Design</a>`) { t.Fatalf("wiki ref not resolved:\n%s", out) } } func TestResolveUnknownWikiStaysText(t *testing.T) { out, err := Render([]byte("See [[Nowhere]].\n"), fakeResolver{}) if err != nil { t.Fatal(err) } if strings.Contains(string(out), "<a") { t.Fatalf("unresolvable ref linked:\n%s", out) } if !strings.Contains(string(out), "[[Nowhere]]") { t.Fatalf("unresolved ref text lost:\n%s", out) } } func TestResolveHashRef(t *testing.T) { out, err := Render([]byte("Fixed in abc123def456.\n"), fakeResolver{}) if err != nil { t.Fatal(err) } if !strings.Contains(string(out), `<a href="/checkin/abc123def456">abc123def456</a>`) { t.Fatalf("hash ref not resolved:\n%s", out) } } func TestResolveSkipsCodeSpans(t *testing.T) { out, err := Render([]byte("`abc123def456` and [[Design]] in code: `[[Design]]`\n"), fakeResolver{}) if err != nil { t.Fatal(err) } s := string(out) if strings.Contains(s, `<code><a`) || strings.Contains(s, "code><a") { t.Fatalf("resolved inside code span:\n%s", s) } if !strings.Contains(s, `<a href="/wiki/Design">Design</a>`) { t.Fatalf("prose ref should still resolve:\n%s", s) } } func TestResolverOutputStillSafe(t *testing.T) { // A hostile resolver URL must not smuggle scripts — Render output // re-enters pages as trusted HTML, so the seam is load-bearing. out, err := Render([]byte("[[Design]]\n"), evilResolver{}) if err != nil { t.Fatal(err) } if strings.Contains(string(out), "<script") { t.Fatalf("resolver injected markup:\n%s", out) } } type evilResolver struct{} func (evilResolver) ResolveHash(string) string { return "" } func (evilResolver) ResolveWiki(string) string { return `"><script>alert(1)</script>` } // --- fuzz --- func FuzzSanitize(f *testing.F) { f.Add(`<a href="javascript:alert(1)" onmouseover=x>y</a>`) f.Add("<scrİpt>hi</scrİpt>") f.Add(`<img src=x onerror=alert(1)>`) f.Add(`<code class="language-go ui modal">x</code>`) f.Add("plain **markdown** text") f.Fuzz(func(t *testing.T, in string) { // The oracle parses the output: dangerous constructs must not // exist as MARKUP. (Substring checks false-positive on inputs // that are plain text like "onmouseover=0".) body := &html.Node{Type: html.ElementNode, Data: "body", DataAtom: atom.Body} nodes, err := html.ParseFragment(strings.NewReader(Sanitize(in)), body) if err != nil { t.Fatalf("output unparseable for %q: %v", in, err) } var walk func(*html.Node) walk = func(n *html.Node) { if n.Type == html.ElementNode { switch n.Data { case "script", "style", "iframe", "object", "embed", "form": t.Fatalf("policy leaked <%s> for input %q", n.Data, in) } for _, a := range n.Attr { if strings.HasPrefix(strings.ToLower(a.Key), "on") { t.Fatalf("policy leaked %s= on <%s> for input %q", a.Key, n.Data, in) } if a.Key == "href" || a.Key == "src" { v := strings.ToLower(strings.TrimSpace(a.Val)) if strings.HasPrefix(v, "javascript:") || strings.HasPrefix(v, "vbscript:") || strings.HasPrefix(v, "data:") { t.Fatalf("policy leaked %s=%q for input %q", a.Key, a.Val, in) } } } } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } for _, n := range nodes { walk(n) } }) } func TestHeadingsDemoted(t *testing.T) { // Ticket 110: the page template owns h1; content headings shift down. out, err := Render([]byte("# Top\n\n## Sub\n\n###### Deep\n"), nil) if err != nil { t.Fatal(err) } s := string(out) if strings.Contains(s, "<h1") { t.Fatalf("content h1 survived:\n%s", s) } for _, want := range []string{"<h2", "<h3", "<h6"} { if !strings.Contains(s, want) { t.Fatalf("missing %s (h6 must cap, not vanish):\n%s", want, s) } } } // GFM completeness: footnotes and heading anchors must survive the // sanitizer (enabling a goldmark extension is only half the job — the // policy has to admit the attributes it emits). func TestFootnotesAndHeadingIDs(t *testing.T) { out, err := Render([]byte("## A Heading Here\n\nText with a note.[^1]\n\n[^1]: The note body.\n"), nil) if err != nil { t.Fatal(err) } got := string(out) if !strings.Contains(got, `id="a-heading-here"`) { t.Fatalf("heading anchor missing or stripped:\n%s", got) } if !strings.Contains(got, "The note body") || !strings.Contains(got, "footnotes") { t.Fatalf("footnote missing or stripped:\n%s", got) } if !strings.Contains(got, "<sup") { t.Fatalf("footnote reference marker stripped:\n%s", got) } } func (fakeResolver) ResolvePath(string) string { return "" } func (evilResolver) ResolvePath(string) string { return "" } // A mailing-list-style post carries its code as an INDENTED block — // "here is the patch::" plus four spaces — which markdown parses as // ast.CodeBlock with no language. goldmark's highlighting extension // only handles FENCED blocks, so these rendered as flat <pre><code> // while a fenced block two paragraphs away was colored: the reader saw // the difference and read it as breakage. Content-sniffing covers them // — a diff is unmistakable — and a block chroma cannot identify stays // a plain block rather than being guessed wrong. func TestIndentedCodeBlocksAreHighlighted(t *testing.T) { src := []byte("a patch::\n\n --- althttpd.c.orig\n +++ althttpd.c\n @@ -318,8 +318,8 @@\n -static int nIn = 0;\n +static long nIn = 0;\n") out, err := Render(src, nil) if err != nil { t.Fatal(err) } if !strings.Contains(string(out), `class="chroma"`) { t.Fatalf("an unmistakable diff rendered unhighlighted:\n%s", out) } // Fenced blocks with a language keep working through the extension. fenced, err := Render([]byte("```go\nfunc main() {}\n```\n"), nil) if err != nil { t.Fatal(err) } if !strings.Contains(string(fenced), `class="chroma"`) { t.Fatalf("fenced block lost its highlighting:\n%s", fenced) } // Unidentifiable content stays a plain block — no wrong guesses. plain, err := Render([]byte(" zxqv 001 vryn\n 003 kwex\n"), nil) if err != nil { t.Fatal(err) } if strings.Contains(string(plain), `class="chroma"`) && !strings.Contains(string(plain), "<pre><code>") { t.Logf("note: analyse guessed a lexer for noise; acceptable if stable:\n%s", plain) } } type describingResolver struct{ fakeResolver } func (describingResolver) DescribeHash(prefix string) string { if prefix == "abc1234567" { return "fix shift key firing twice" } return "" } // A described hash renders GitHub's way: message as hover title, an // excerpt visible beside the hash. Undescribed hashes stay bare links. func TestDescribedHashCarriesComment(t *testing.T) { gotB, err := Render([]byte("see abc1234567 here"), describingResolver{}) if err != nil { t.Fatal(err) } got := string(gotB) for _, want := range []string{ `title="fix shift key firing twice"`, `<span class="cref"> (fix shift key firing twice)</span>`, `>abc1234567</a>`, } { if !strings.Contains(got, want) { t.Fatalf("missing %q in:\n%s", want, got) } } // A resolver without the describer upgrade renders exactly as before. plainB, err := Render([]byte("see abc1234567 here"), fakeResolver{}) if err != nil { t.Fatal(err) } plain := string(plainB) if strings.Contains(plain, "cref") || strings.Contains(plain, "title=") { t.Fatalf("undescribed link grew chrome:\n%s", plain) } } type userResolver struct{ fakeResolver } func (userResolver) ResolveUser(name string) (string, int, bool) { if name == "stephan" { return "/people/stephan", 123, true } return "", 0, false } // A known @mention links in the person's hue; an unknown one stays // plain text — never a dead link. An email's @ is not a mention. func TestMentionsResolveInAuthorHue(t *testing.T) { out, err := Render([]byte("ping @stephan and @nobody, mail [email protected]\n"), userResolver{}) if err != nil { t.Fatal(err) } got := string(out) if !strings.Contains(got, `<a href="/people/stephan" class="uref" style="--uh: 123">@stephan</a>`) { t.Fatalf("mention not resolved:\n%s", got) } if strings.Contains(got, `href="/people/nobody"`) || !strings.Contains(got, "@nobody") { t.Fatalf("unknown mention mishandled:\n%s", got) } if strings.Contains(got, `href="/people/b.com"`) { t.Fatalf("email linkified as mention:\n%s", got) } } // "[label] (url)" — the space-separated form — is still a link with // the bracketed text as its label; the strict form is unchanged. func TestSpacedLink(t *testing.T) { for src, want := range map[string]string{ "[link] (www.google.com)": `<a href="http://www.google.com" rel="nofollow">link</a>`, "[docs] (https://x.org/a?b=1)": `<a href="https://x.org/a?b=1" rel="nofollow">docs</a>`, "[link](https://x.org)": `<a href="https://x.org" rel="nofollow">link</a>`, "[a] (b c)": `[a] (b c)`, "see [1] (twice) and [2] (x y)": `[2] (x y)`, } { out, err := Render([]byte(src), nil) if err != nil { t.Fatal(err) } if !strings.Contains(string(out), want) { t.Errorf("%q: want %q in\n%s", src, want, out) } } } // WikiRefs must read a body exactly the way the renderer resolves it, // because a structure page built from a second, nearly-identical rule // eventually disagrees with the links on the pages it describes. func TestWikiRefsReadsBothConstructsOnce(t *testing.T) { body := []byte("See [[Design]] and [Guide|the guide] and [Roadmap].\n" + "External: [https://example.com|a site] and [[Design]] again.\n") // Markdown pages have only the double-bracket construct, so the // fossil-wiki form must stay ordinary text. md := WikiRefs(body, false) if len(md) != 1 || md[0].Name != "Design" || !md[0].Explicit { t.Fatalf("markdown refs = %+v, want [[Design]] alone, marked explicit", md) } fw := WikiRefs(body, true) want := []WikiRef{{"Design", true}, {"Guide", false}, {"Roadmap", false}} if len(fw) != len(want) { t.Fatalf("fossil-wiki refs = %+v, want %+v — no duplicates, no URLs, and nothing read out of [[...]]", fw, want) } for i, w := range want { if fw[i] != w { t.Fatalf("fossil-wiki refs = %+v, want %+v", fw, want) } } } // The test resolvers draw no identity mark: an empty answer is valid and // renders a mention as a plain link, which keeps these assertions about // the constructs they are actually testing. func (fakeResolver) UserMark(string) string { return "" } func (evilResolver) UserMark(string) string { return "" }