Artifact 9a42780b561da5385887e09fa7980ae8b7ad518da53ab84b86a5734fc6682f28:
- File
internal/markup/markup.go
— part of check-in
[58fe4cf214]
at
2026-08-16 12:53:54
on branch trunk
— markup: Pikchr diagrams via WebAssembly (fossil's third markup)
pikchr.c built to a standalone WASI module (-DPIKCHR_SHELL -sSTANDALONE_WASM) and run under wazero, so the CGO-free constraint holds. Its own CLI reads - and writes SVG, so the host ABI is a pipe: no memory marshalling to get wrong. Renders text/x-pikchr artifacts and pikchr fences inside markdown; a diagram that will not compile degrades to its source plus the compiler's message.
SVG does NOT ride the document policy. It gets a narrow allow-list derived by enumerating what the module actually emits (six elements, a closed geometry/paint attribute set, no script/href/foreignObject), so even a hole in Pikchr's own label escaping cannot reach a page. Fence placeholders are alphanumeric with a per-render nonce: an HTML parser rewrites control bytes, so a NUL-based marker does not survive sanitizing (caught by test). Provenance and the rebuild recipe are in the ledger. (user: dmestas, size: 6991) [annotate] [blame] [check-ins using]
// Package markup renders wiki/ticket/chat text to safe HTML (0002 §2): // goldmark for markdown, a CLEANROOM bluemonday policy for // sanitization — written fresh, copied from neither the Gitea nor the // Forgejo lineage — and one Resolver seam for zit reference resolution // (artifact hashes, [[wiki]] links) so the markdown assembly stays free // of index knowledge. // // The policy's conformance gate is the two-lineage vector corpus under // third_party/sanitizer-corpus (inputs are data, recorded in the // provenance ledger); markup_test runs it, and FuzzSanitize hammers the // policy beyond it. package markup import ( "bytes" "regexp" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/microcosm-cc/bluemonday" "github.com/yuin/goldmark" gast "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" highlighting "github.com/yuin/goldmark-highlighting/v2" ) // Resolver turns zit references found in text into links. Empty return // means "not resolvable — leave as plain text". Implementations sit on // the index; markup never touches it directly. type Resolver interface { // ResolveHash resolves a hex token (artifact-name shaped, 10–64 // chars) to a URL — a check-in page, a ticket page, whatever the // index says the artifact is. ResolveHash(prefix string) string // ResolveWiki resolves a [[Wiki Name]] reference to a URL. ResolveWiki(name string) string } // sanitize is the cleanroom policy. Everything here is written from the // question "what does markdown rendered by goldmark plus quarry's own // features actually need", not from either lineage's list. var sanitize = func() *bluemonday.Policy { p := bluemonday.NewPolicy() // Block structure. p.AllowElements("p", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "ul", "ol", "li", "dl", "dt", "dd", "table", "thead", "tbody", "tr", "th", "td", "details", "summary", "picture", "source") // Inline structure. p.AllowElements("em", "strong", "b", "i", "s", "del", "ins", "u", "code", "kbd", "sub", "sup", "mark", "q", "cite", "abbr") // Links: safe schemes plus relative URLs; rel=nofollow is forced on // every absolute link. p.AllowAttrs("href").OnElements("a") p.AllowStandardURLs() p.RequireNoFollowOnLinks(true) // Images. p.AllowAttrs("src", "alt", "title", "width", "height").OnElements("img") p.AllowAttrs("loading").Matching(regexp.MustCompile(`^lazy$`)).OnElements("img") p.AllowAttrs("media", "srcset").OnElements("source") // Syntax-highlight hooks: language classes on code, chroma's short // token classes on span, and the chroma wrapper class on pre — // nothing framework-shaped gets through (the class-injection attacks // in the corpus). p.AllowAttrs("class").Matching(regexp.MustCompile(`^language-[a-zA-Z0-9_+#.-]+$`)).OnElements("code") p.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-z][a-z0-9]{0,3}$`)).OnElements("span") p.AllowAttrs("class").Matching(regexp.MustCompile(`^chroma$`)).OnElements("pre") // Task lists. p.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") p.AllowAttrs("checked", "disabled").Matching(regexp.MustCompile(`^$|^checked$|^disabled$`)).OnElements("input") p.AllowAttrs("class").Matching(regexp.MustCompile(`^(checked|unchecked|indeterminate)$`)).OnElements("li") // Emoji spans and the two color properties, on non-code elements. p.AllowAttrs("class").Matching(regexp.MustCompile(`^emoji$`)).OnElements("span") p.AllowAttrs("aria-label", "data-alias").OnElements("span") p.AllowStyles("color", "background-color").OnElements("span", "p", "th", "td", "div") // Table alignment emitted by the GFM extension. p.AllowAttrs("align").Matching(regexp.MustCompile(`^(left|center|right)$`)).OnElements("th", "td") // Heading anchors (WithAutoHeadingID) and footnote plumbing. The id // pattern is deliberately narrow — a slug or goldmark's fnref form — // so an id cannot be shaped to collide with page chrome or a // framework hook. Footnote back-references need href (already // allowed on a), role, and the section wrapper. slug := regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_:.-]{0,127}$`) p.AllowAttrs("id").Matching(slug).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "li", "sup", "a") // goldmark wraps footnotes in <div class="footnotes" role="doc-endnotes"> // (not <section>), with role/class on the ref and backref anchors. p.AllowElements("div") p.AllowAttrs("class").Matching(regexp.MustCompile(`^footnotes$`)).OnElements("div") p.AllowAttrs("class").Matching(regexp.MustCompile(`^footnote-(ref|backref)$`)).OnElements("a") p.AllowAttrs("role").Matching(regexp.MustCompile(`^doc-(endnotes|backlink|noteref)$`)).OnElements("div", "a") return p }() // demoteHeadings shifts every markdown heading one level down (h1→h2, // capped at h6): the page template owns the single h1, so content // headings slot beneath it and the document outline stays unambiguous. type demoteHeadings struct{} func (demoteHeadings) Transform(doc *gast.Document, _ text.Reader, _ parser.Context) { gast.Walk(doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) { if h, ok := n.(*gast.Heading); ok && entering && h.Level < 6 { h.Level++ } return gast.WalkContinue, nil }) } var md = goldmark.New( goldmark.WithExtensions( // GFM covers tables, autolinks, strikethrough and task lists; // footnotes are also GFM but ship as their own extension. extension.GFM, extension.Strikethrough, extension.TaskList, extension.Footnote, highlighting.NewHighlighting( highlighting.WithStyle("github"), highlighting.WithFormatOptions(chromahtml.WithClasses(true)), ), ), goldmark.WithParserOptions( // Heading anchors, as GitHub emits them, so deep links into long // documents work. parser.WithAutoHeadingID(), parser.WithASTTransformers( util.Prioritized(demoteHeadings{}, 100), ), ), goldmark.WithRendererOptions(html.WithUnsafe()), // sanitizer is the gate, not the renderer ) // Render converts markdown to sanitized HTML. r may be nil (no // reference resolution). func Render(src []byte, r Resolver) ([]byte, error) { // Pikchr fences are lifted before goldmark sees them and restored // after sanitizing: the diagram needs the narrow SVG policy, not the // document policy (which has no business admitting <svg> generally). src, diagrams, nonce := liftPikchrFences(src) var buf bytes.Buffer if err := md.Convert(src, &buf); err != nil { return nil, err } out := sanitize.SanitizeBytes(buf.Bytes()) if r != nil { out = resolveRefs(out, r) } return restorePikchrFences(out, diagrams, nonce), nil } // Sanitize applies the policy alone — the surface the conformance // corpus and the fuzz target exercise directly. func Sanitize(in string) string { return sanitize.Sanitize(in) }