// 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"
gohtml "html"
"craftdesign.group/quarry/internal/highlight"
"regexp"
"strings"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/microcosm-cc/bluemonday"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
// 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
})
}
// codeBlockHighlighter renders INDENTED code blocks (ast.CodeBlock)
// through chroma's content sniffing. The highlighting extension only
// handles fenced blocks, so a mailing-list-style post — "the patch::"
// plus indentation, which is how forum bodies in this corpus carry
// diffs — rendered flat next to colored fenced blocks. A block the
// sniffer cannot identify stays a plain <pre><code>: no guess is
// better than a wrong one.
// looksLikeDiff recognizes unified-diff shape: a hunk header, or the
// ---/+++ file pair. Nothing else writes lines like these at column 0.
func looksLikeDiff(b []byte) bool {
var minus, plus bool
for _, line := range bytes.Split(b, []byte("\n")) {
switch {
case bytes.HasPrefix(line, []byte("@@ -")):
return true
case bytes.HasPrefix(line, []byte("--- ")):
minus = true
case bytes.HasPrefix(line, []byte("+++ ")):
plus = true
}
if minus && plus {
return true
}
}
return false
}
type codeBlockHighlighter struct{}
func (codeBlockHighlighter) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(gast.KindCodeBlock, renderCodeBlock)
}
func renderCodeBlock(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
var body bytes.Buffer
for i := 0; i < node.Lines().Len(); i++ {
line := node.Lines().At(i)
body.Write(line.Value(source))
}
// chroma's Analyse has no analyzer for diffs — the one format these
// blocks nearly always carry — so the unmistakable shape is named
// outright and everything else may still sniff.
lang := ""
if looksLikeDiff(body.Bytes()) {
lang = "diff"
}
if hl, ok := highlight.Code(body.Bytes(), lang, ""); ok {
_, _ = w.WriteString(hl)
return gast.WalkSkipChildren, nil
}
_, _ = w.WriteString("<pre><code>")
_, _ = w.WriteString(gohtml.EscapeString(body.String()))
_, _ = w.WriteString("</code></pre>\n")
return gast.WalkSkipChildren, 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),
),
parser.WithInlineParsers(util.Prioritized(spacedLink{}, 150)),
),
goldmark.WithRendererOptions(html.WithUnsafe(), // sanitizer is the gate, not the renderer
renderer.WithNodeRenderers(util.Prioritized(codeBlockHighlighter{}, 100))),
)
// 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)
})
}