Artifact d6b1b8289dd5d333a915305e8692849bb12d01709d15497518318616f6998307:
- File
internal/markup/markup.go
— part of check-in
[1d680372e5]
at
2026-08-16 13:44:53
on branch trunk
— markup: map fossil site-relative links; theme-neutral pikchr ink
Two bugs found by browsing real content on a phone.
Imported wiki/technote/forum bodies are full of fossil's own routes (libfossil's wiki alone: 500 /finfo, 211 /dir, 105 /timeline, 23 /doc). Quarry passed those hrefs through verbatim, so libfossil's home page offered a README.md link that went nowhere. Resolver grows ResolvePath: /doc/<ver>/<path> maps to quarry's file route, /info to the artifact, /timeline /wiki /forum /technote to their pages; a verb quarry has no page for drops the link and keeps the label, since a dead link is worse than plain text. file.get now lets zit resolve symbolic versions (trunk, tip, tags) instead of demanding a hash — that is what /doc links use, and zit is the authority on names. The href rewrite runs BEFORE reference resolution so the two passes never see each other's output.
Pikchr hardcodes rgb(0,0,0), which is invisible on quarry's dark theme — the DOM assertions all passed while the diagram was unreadable, and only a screenshot caught it. Its default ink now becomes currentColor so a diagram follows the page theme; author-specified colours are untouched. Fossil solves this with a server-side --dark-mode flag, which cannot work for a CSS-themed page. (user: dmestas, size: 8570) [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" "strings" 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 // ResolvePath maps a Fossil site-relative URL found in imported // content (/doc/trunk/README.md, /info/<hash>, /timeline…) to the // equivalent quarry path. Empty means "quarry has no equivalent", // and the caller drops the link rather than leaving a dead one. ResolvePath(fossilPath 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 { // Order matters: rewrite the author's own hrefs BEFORE // resolveRefs mints quarry links, so the two passes never see // each other's output. out = rewriteFossilHrefs(out, r) 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) } // siteRelHref matches a rendered link whose target is a site-relative // path — in imported content those are Fossil's own routes, which mean // nothing under quarry's URL space. var siteRelHref = regexp.MustCompile(`<a href="(/[^"]*)"([^>]*)>(.*?)</a>`) // rewriteFossilHrefs maps Fossil site-relative links onto quarry routes, // and unwraps the ones quarry cannot serve into plain text — a reader // should never be handed a link that 404s. func rewriteFossilHrefs(html []byte, r Resolver) []byte { return siteRelHref.ReplaceAllFunc(html, func(m []byte) []byte { sub := siteRelHref.FindSubmatch(m) href, rest, label := string(sub[1]), string(sub[2]), string(sub[3]) // quarry's own routes are already correct; only /verb paths that // quarry does not serve need mapping. if strings.HasPrefix(href, "/r/") || strings.HasPrefix(href, "/docs") || strings.HasPrefix(href, "#") { return m } if mapped := r.ResolvePath(href); mapped != "" { return []byte(`<a href="` + mapped + `"` + rest + `>` + label + `</a>`) } return []byte(label) }) }