Skip to content
quarry Artifact [25669750ee]
Login

Artifact [25669750ee]

Artifact 25669750ee4d20875df647f188abc5daa55d96193b27bcccb40538f88681a886:


// 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)
	})
}