Skip to content
quarry Artifact [fb89da2a7c]
Login

Artifact [fb89da2a7c]

Artifact fb89da2a7c0226a05944cd8bc0721769d5a6e579d74e4e5ab9748bed736ee950:


// 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"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/renderer/html"
	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")

	return p
}()

var md = goldmark.New(
	goldmark.WithExtensions(
		extension.GFM, extension.Strikethrough, extension.TaskList,
		highlighting.NewHighlighting(
			highlighting.WithStyle("github"),
			highlighting.WithFormatOptions(chromahtml.WithClasses(true)),
		),
	),
	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) {
	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 out, 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)
}