Artifact a0516aaeb95d65978f538958a493dc63ece7bef7e652c1353a39994f65079bf9:
- File
internal/markup/pikchr.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: 4994) [annotate] [blame] [check-ins using]
// pikchr.go renders Pikchr diagrams to sanitized inline SVG — the third // markup Fossil dispatches on (text/x-pikchr), and the ```pikchr fence // inside markdown. // // SVG is a real XSS surface (script, event handlers, foreignObject, // javascript: in xlink:href), so the diagram does NOT ride the general // document policy. It gets its own narrow allow-list, derived by // enumerating what the Pikchr module actually emits — six elements and a // closed set of geometry attributes, none of them script-bearing. That // is defence in depth: even if Pikchr's own label escaping had a hole, // nothing outside this vocabulary survives. package markup import ( "crypto/rand" "encoding/hex" "fmt" "regexp" "strings" "github.com/microcosm-cc/bluemonday" "craftdesign.group/quarry/internal/pikchr" ) // svgPolicy admits exactly Pikchr's output vocabulary. Deliberately // absent: script, foreignObject, use, image, a, and every on* handler — // none of which Pikchr emits, so refusing them costs nothing. var svgPolicy = func() *bluemonday.Policy { p := bluemonday.NewPolicy() p.AllowElements("svg", "path", "polygon", "circle", "ellipse", "text") number := regexp.MustCompile(`^-?[0-9.]+$`) // Geometry and path data: digits, separators and path commands only. p.AllowAttrs("x", "y", "cx", "cy", "r", "rx", "ry").Matching(number). OnElements("svg", "path", "polygon", "circle", "ellipse", "text") p.AllowAttrs("d", "points").Matching(regexp.MustCompile(`^[MmLlHhVvCcSsQqTtAaZz0-9eE,.\s+-]*$`)). OnElements("path", "polygon") p.AllowAttrs("viewBox").Matching(regexp.MustCompile(`^[0-9.\s-]+$`)).OnElements("svg") p.AllowAttrs("width", "height").Matching(number).OnElements("svg") p.AllowAttrs("xmlns").Matching(regexp.MustCompile(`^http://www\.w3\.org/2000/svg$`)).OnElements("svg") // Presentation: a fixed set of paint properties, plus the text // alignment hints and the class Pikchr tags its root with. p.AllowAttrs("fill").Matching(regexp.MustCompile(`^(none|rgb\([0-9, ]+\)|#[0-9a-fA-F]{3,8}|[a-z]+)$`)). OnElements("path", "polygon", "circle", "ellipse", "text", "svg") p.AllowAttrs("text-anchor").Matching(regexp.MustCompile(`^(start|middle|end)$`)).OnElements("text") p.AllowAttrs("dominant-baseline").Matching(regexp.MustCompile(`^[a-z-]+$`)).OnElements("text") p.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-zA-Z0-9 _-]{0,64}$`)). OnElements("svg", "path", "polygon", "circle", "ellipse", "text") p.AllowAttrs("data-pikchr-date").Matching(regexp.MustCompile(`^[0-9T:.Z -]{0,32}$`)).OnElements("svg") p.AllowStyles("fill", "stroke", "stroke-width", "stroke-dasharray", "font-size", "font-weight"). OnElements("path", "polygon", "circle", "ellipse", "text", "svg") return p }() // RenderPikchr compiles Pikchr source to a sanitized inline SVG. A // syntax error (or an unavailable module) comes back as an error; the // caller decides how to show it, which is always as escaped text rather // than a broken page. func RenderPikchr(src []byte) ([]byte, error) { svg, err := pikchr.Render(src) if err != nil { return nil, err } return svgPolicy.SanitizeBytes(svg), nil } // pikchrFence matches a ```pikchr fenced block — how a diagram is // embedded in a markdown document (fossil supports the same fence). var pikchrFence = regexp.MustCompile("(?s)```pikchr[ \t]*\r?\n(.*?)```") // The placeholder must be plain alphanumerics: an HTML parser rewrites // control bytes (a NUL becomes U+FFFD), so a marker built from them does // not survive sanitization. A per-render nonce keeps user text from // colliding with it. func pikchrHold(nonce string, i int) string { return fmt.Sprintf("qpikchr%sx%dx", nonce, i) } // liftPikchrFences replaces each ```pikchr block with a placeholder, // returning the rendered diagrams (or an escaped-source fallback when a // diagram will not compile) and the nonce that keys them. func liftPikchrFences(src []byte) ([]byte, []string, string) { if !pikchrFence.Match(src) { return src, nil, "" } var buf [8]byte rand.Read(buf[:]) nonce := hex.EncodeToString(buf[:]) var out []string replaced := pikchrFence.ReplaceAllFunc(src, func(m []byte) []byte { body := pikchrFence.FindSubmatch(m)[1] svg, err := RenderPikchr(body) if err != nil { out = append(out, `<pre>`+escapeHTML(string(body))+`</pre>`+ `<p><small>pikchr: `+escapeHTML(err.Error())+`</small></p>`) } else { out = append(out, string(svg)) } return []byte(pikchrHold(nonce, len(out)-1)) }) return replaced, out, nonce } // restorePikchrFences swaps the diagrams back in. A placeholder sits // alone in its paragraph, so the wrapping <p> is unwrapped to keep the // SVG a block element rather than phrasing content inside a paragraph. func restorePikchrFences(html []byte, diagrams []string, nonce string) []byte { s := string(html) for i, d := range diagrams { h := pikchrHold(nonce, i) s = strings.ReplaceAll(s, "<p>"+h+"</p>", d) s = strings.ReplaceAll(s, h, d) } return []byte(s) }