Replaces the always-spawn-an-OCI-container model with a per-call
bubblewrap sandbox. Pandoc and chromium binaries are baked into the
zddc-server runtime image; each conversion runs them under bwrap's
Linux-namespace isolation. No daemon, no socket, no privileged outer
container, no OCI image pull at conversion time.
Why: the OCI engine paid ≈ 350 MB image pulls + 400 MB persistent
storage + ~300 ms per-conversion startup, plus required either an
on-host daemon socket (zddc-RCE → host-RCE in one hop) or nested
container privileges. bwrap gets the same sandbox properties
(--unshare-all, ro-bind /usr, tmpfs /tmp, clearenv, no-network) at
~5 ms per call and zero external dependencies. This is the same
primitive Flatpak uses for every app launch — battle-tested at scale
for "untrusted-input, short-lived, isolated."
Runner abstraction:
- `Runner.Run` signature: image string → ToolSpec{Image, Binary}.
Both fields populated by entry points; whichever engine is
installed reads the one it needs.
- `bwrapRunner` (new): assembles bwrap argv via `buildBwrapArgs`
helper (testable in isolation), spawns bwrap with the binary.
- `containerRunner` (renamed conceptually to "legacy fallback"):
unchanged behavior, still reachable for hosts that prefer OCI
containers per conversion.
Probe order in health.Probe: bwrap → podman → docker. First hit wins.
Engine kinds in Capabilities: "bwrap" | "podman" | "docker". The
no-engine error message now lists all three.
Config (cmd/zddc-server):
- new --convert-pandoc-binary / ZDDC_CONVERT_PANDOC_BINARY (default "pandoc")
- new --convert-chromium-binary / ZDDC_CONVERT_CHROMIUM_BINARY (default "chromium-browser")
- existing --convert-pandoc-image / --convert-chromium-image kept
for the OCI engine, doc updated to clarify they only apply there.
- --convert-engine helptext lists bwrap first.
Images:
- New `zddc/runtime.Containerfile` — alpine + bubblewrap + pandoc-cli +
chromium + font-noto. Documents build/publish workflow.
- helm/zddc-server-prod/values.yaml.example: runtimeImage default
switched to a placeholder for the new bundled runtime image; bare
alpine NO LONGER works for /.convert (clearly called out in the
comment).
- bitnest dev: /var/lib/zddc-dev-build/Containerfile mirrors the
production runtime image. Quadlet at /etc/containers/systemd/
zddc.container drops the podman-socket mount (no longer needed)
and sets ZDDC_CONVERT_ENGINE=bwrap explicitly to avoid silent
downgrades if a stray podman ends up on PATH.
Tests:
- convert_test.go: fakeRunner / recordingRunner now record ToolSpec.
- New TestToolSpecPopulation pins that both Image and Binary are
filled by every entry point.
- New TestBwrapArgs_SandboxFlagsPresent / MountTranslation /
RejectsBadMountSpec lock in the bwrap argv shape — a refactor that
drops a hardening flag or misroutes a mount fails this loud.
Docs:
- AGENTS.md § "Server-side document conversion" rewritten around
the bwrap-first model with podman/docker as legacy fallbacks.
- ARCHITECTURE.md convert reference updated.
- internal/convert package doc reflects the two-engine probe order.
Verified end-to-end on bitnest: probe reports
engine=bwrap pandoc_binary=pandoc chromium_binary=chromium-browser
on startup. All 15 Go test packages green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
345 lines
11 KiB
Go
345 lines
11 KiB
Go
// Package convert turns a markdown source byte-buffer into DOCX, HTML,
|
|
// or PDF. Pandoc handles MD↔DOCX and MD→HTML; headless Chromium handles
|
|
// HTML→PDF. Each conversion runs inside an isolating sandbox so an
|
|
// untrusted source-markdown can't reach the host's filesystem or
|
|
// network even if it drives the binary to RCE.
|
|
//
|
|
// Engine probe order (call Probe once at startup, first hit wins):
|
|
//
|
|
// 1. bwrap (production default). Runs the pandoc/chromium binaries
|
|
// baked into the zddc-server runtime image directly under
|
|
// bubblewrap: namespace-isolated, no network, read-only /usr, a
|
|
// 256 MiB tmpfs /tmp, minimal proc/dev. Configure binary names
|
|
// via SetBinaries; defaults are `pandoc` and `chromium-browser`.
|
|
// 2. podman / docker (legacy fallback). Runs each conversion inside
|
|
// an OCI container pulled lazily via `--pull=missing`. Defaults
|
|
// `docker.io/pandoc/latex:latest` + `docker.io/zenika/alpine-
|
|
// chrome:latest`; configure via SetImages.
|
|
//
|
|
// Public surface:
|
|
//
|
|
// ToDocx(ctx, source, meta) → []byte (DOCX bytes)
|
|
// ToHTML(ctx, source, meta) → []byte (standalone HTML)
|
|
// ToPDF (ctx, source, meta) → []byte (PDF, via HTML + chromium)
|
|
//
|
|
// Probe(ctx, override) → Capabilities (call once at startup)
|
|
// Available() → (Capabilities, bool)
|
|
// SetImages(pandoc, chromium) — install OCI image refs from config
|
|
// SetBinaries(pandoc, chromium) — install bwrap binary names from config
|
|
//
|
|
// All three converters are safe for concurrent use; each call gets a
|
|
// fresh sandbox. The pandoc binary (or pandoc/latex image's entrypoint)
|
|
// reads pandoc flags directly; the chromium binary (or alpine-chrome
|
|
// image's entrypoint) reads chromium-browser flags. No `sh -c`
|
|
// wrappers, no shell quoting.
|
|
//
|
|
// Metadata maps to the placeholders consumed by viewer-template.html.
|
|
// title/tracking_number/revision/status/is_draft typically come from
|
|
// the source filename (zddc.ParseFilename); client/project/contractor/
|
|
// project_number from the .zddc cascade `convert:` block.
|
|
package convert
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Metadata is the variable bag passed to pandoc as `--variable k=v`
|
|
// pairs. Fields with zero values are omitted. The viewer-template.html
|
|
// uses `$if(field)$ … $endif$` blocks so absent fields render cleanly.
|
|
type Metadata struct {
|
|
Title string
|
|
TrackingNumber string
|
|
Revision string
|
|
Status string
|
|
Client string
|
|
Project string
|
|
Contractor string
|
|
ProjectNumber string
|
|
GenerationTime time.Time
|
|
IsDraft bool
|
|
NoTOC bool
|
|
}
|
|
|
|
// Default tool refs. The bwrap engine (default since v0.0.x) reads the
|
|
// Binary fields below; the legacy containerRunner reads the Image
|
|
// fields. The convert entry points populate both into a ToolSpec so
|
|
// whichever engine is installed picks the field it needs.
|
|
//
|
|
// pandoc/latex carries TeX Live for native PDF too, so the image is a
|
|
// superset of pandoc/core. The bwrap engine doesn't pay that cost —
|
|
// each binary is installed from the host's package manager (alpine:
|
|
// pandoc-cli + chromium) and the image grows by ≈ 200 MB once.
|
|
const (
|
|
DefaultPandocImage = "docker.io/pandoc/latex:latest"
|
|
DefaultChromiumImage = "docker.io/zenika/alpine-chrome:latest"
|
|
DefaultPandocBinary = "pandoc"
|
|
// Alpine's chromium package installs the binary as "chromium-browser".
|
|
// Debian/Ubuntu ships "chromium". Operators override via
|
|
// --convert-chromium-binary when the package on their image differs.
|
|
DefaultChromiumBinary = "chromium-browser"
|
|
)
|
|
|
|
var (
|
|
pandocImage atomic.Pointer[string]
|
|
chromiumImage atomic.Pointer[string]
|
|
pandocBinary atomic.Pointer[string]
|
|
chromiumBinary atomic.Pointer[string]
|
|
scratchDir atomic.Pointer[string]
|
|
)
|
|
|
|
// SetImages installs the OCI image refs used by the legacy
|
|
// containerRunner engine. The bwrap engine ignores these and reads
|
|
// the binary names installed via SetBinaries instead. Empty values
|
|
// keep the previous setting (or the DefaultPandocImage /
|
|
// DefaultChromiumImage constants on first call). Called from
|
|
// cmd/zddc-server/main.go after flag parsing.
|
|
func SetImages(pandoc, chromium string) {
|
|
if pandoc != "" {
|
|
s := pandoc
|
|
pandocImage.Store(&s)
|
|
}
|
|
if chromium != "" {
|
|
s := chromium
|
|
chromiumImage.Store(&s)
|
|
}
|
|
}
|
|
|
|
// SetBinaries installs the host-binary names used by the bwrap engine.
|
|
// Empty values keep the previous setting (or the DefaultPandocBinary /
|
|
// DefaultChromiumBinary constants on first call). The values are
|
|
// PATH-resolved names (e.g. "pandoc", "chromium-browser") or absolute
|
|
// paths. Called from cmd/zddc-server/main.go after flag parsing.
|
|
func SetBinaries(pandoc, chromium string) {
|
|
if pandoc != "" {
|
|
s := pandoc
|
|
pandocBinary.Store(&s)
|
|
}
|
|
if chromium != "" {
|
|
s := chromium
|
|
chromiumBinary.Store(&s)
|
|
}
|
|
}
|
|
|
|
// SetScratchDir installs the host-side scratch root used for per-call
|
|
// intermediates (template, HTML, PDF). Empty means "use $TMPDIR" — the
|
|
// local-mode default. In remote mode this MUST be a path the podman-
|
|
// service sidecar can see at the same mountpoint, typically a shared
|
|
// emptyDir mounted at /work in both containers. Called from
|
|
// cmd/zddc-server/main.go after flag parsing.
|
|
func SetScratchDir(dir string) {
|
|
s := dir
|
|
scratchDir.Store(&s)
|
|
}
|
|
|
|
func currentScratchDir() string {
|
|
if p := scratchDir.Load(); p != nil {
|
|
return *p
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func currentPandocImage() string {
|
|
if p := pandocImage.Load(); p != nil && *p != "" {
|
|
return *p
|
|
}
|
|
return DefaultPandocImage
|
|
}
|
|
|
|
func currentChromiumImage() string {
|
|
if p := chromiumImage.Load(); p != nil && *p != "" {
|
|
return *p
|
|
}
|
|
return DefaultChromiumImage
|
|
}
|
|
|
|
func currentPandocBinary() string {
|
|
if p := pandocBinary.Load(); p != nil && *p != "" {
|
|
return *p
|
|
}
|
|
return DefaultPandocBinary
|
|
}
|
|
|
|
func currentChromiumBinary() string {
|
|
if p := chromiumBinary.Load(); p != nil && *p != "" {
|
|
return *p
|
|
}
|
|
return DefaultChromiumBinary
|
|
}
|
|
|
|
// pandocTool / chromiumTool build the ToolSpec passed to Runner.Run.
|
|
// Both fields are populated so whichever engine is installed picks
|
|
// the one it needs (bwrap reads Binary; containerRunner reads Image).
|
|
func pandocTool() ToolSpec {
|
|
return ToolSpec{Image: currentPandocImage(), Binary: currentPandocBinary()}
|
|
}
|
|
|
|
func chromiumTool() ToolSpec {
|
|
return ToolSpec{Image: currentChromiumImage(), Binary: currentChromiumBinary()}
|
|
}
|
|
|
|
// ToDocx renders source markdown to DOCX bytes. One container run via
|
|
// the pandoc image. Caller passes the full file content (envelope +
|
|
// body); pandoc handles `markdown+yaml_metadata_block` natively.
|
|
func ToDocx(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
|
r := currentRunner()
|
|
if r == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
cmd := []string{
|
|
"--from=markdown+yaml_metadata_block",
|
|
"--to=docx",
|
|
"--output=-",
|
|
}
|
|
cmd = append(cmd, metadataArgs(m)...)
|
|
cmd = append(cmd, "-")
|
|
return r.Run(ctx, pandocTool(), source, nil, cmd)
|
|
}
|
|
|
|
// ToHTML renders source markdown to standalone HTML using
|
|
// viewer-template.html. Embeds CSS + images via --embed-resources.
|
|
// Template + custom.css are bind-mounted into the container at /tpl
|
|
// from a per-call scratch dir.
|
|
func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
|
r := currentRunner()
|
|
if r == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
scratch, err := writeAssetsToScratch(currentScratchDir())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scratch: %w", err)
|
|
}
|
|
defer os.RemoveAll(scratch)
|
|
|
|
cmd := []string{
|
|
"--from=markdown+yaml_metadata_block",
|
|
"--to=html5",
|
|
"--standalone",
|
|
"--embed-resources",
|
|
"--section-divs",
|
|
"--id-prefix=",
|
|
"--html-q-tags",
|
|
"--template=/tpl/viewer-template.html",
|
|
}
|
|
if !m.NoTOC {
|
|
cmd = append(cmd, "--toc", "--toc-depth=6")
|
|
}
|
|
cmd = append(cmd, metadataArgs(m)...)
|
|
cmd = append(cmd, "--output=-", "-")
|
|
|
|
mounts := []string{scratch + ":/tpl:ro"}
|
|
return r.Run(ctx, pandocTool(), source, mounts, cmd)
|
|
}
|
|
|
|
// ToPDF renders source markdown to PDF in two stages: pandoc produces
|
|
// HTML using viewer-template.html (stage 1, pandoc image), then headless
|
|
// Chromium prints that HTML to PDF (stage 2, chromium image). The
|
|
// two-stage choice preserves the print-media CSS already authored in
|
|
// viewer-template.html — pandoc's native --pdf-engine path uses LaTeX
|
|
// which would bypass it entirely.
|
|
//
|
|
// Chromium runs from the alpine-chrome image whose entrypoint is
|
|
// `chromium-browser`; our cmd is the flag list passed straight to that
|
|
// binary. The host scratch dir is bind-mounted read-write at /pdf so
|
|
// chromium can write out.pdf and we read it back afterward.
|
|
func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
|
html, err := ToHTML(ctx, source, m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r := currentRunner()
|
|
if r == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
|
|
scratch, err := os.MkdirTemp(currentScratchDir(), "zddc-pdf-")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scratch: %w", err)
|
|
}
|
|
defer os.RemoveAll(scratch)
|
|
htmlPath := filepath.Join(scratch, "in.html")
|
|
pdfPath := filepath.Join(scratch, "out.pdf")
|
|
if err := os.WriteFile(htmlPath, html, 0o644); err != nil {
|
|
return nil, fmt.Errorf("write html: %w", err)
|
|
}
|
|
if err := chmodTree(scratch, 0o755, 0o644); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
mounts := []string{scratch + ":/pdf:rw"}
|
|
// alpine-chrome's entrypoint is `chromium-browser`. --no-sandbox is
|
|
// required because the container drops CAP_SYS_ADMIN; the threat
|
|
// model is "malicious markdown drives chromium RCE", contained by
|
|
// --network=none + --cap-drop=ALL + --read-only + tmpfs.
|
|
//
|
|
// --disable-dev-shm-usage: without this, chromium tries to allocate
|
|
// shared memory under /dev/shm, which our --read-only container
|
|
// can't write to. The flag tells chromium to fall back to /tmp,
|
|
// which is a writable tmpfs (sized in runner.go). Standard fix for
|
|
// chromium-in-container; required by every CI/headless setup.
|
|
cmd := []string{
|
|
"--headless",
|
|
"--disable-gpu",
|
|
"--no-sandbox",
|
|
"--disable-dev-shm-usage",
|
|
"--user-data-dir=/tmp/chrome",
|
|
"--no-pdf-header-footer",
|
|
"--virtual-time-budget=10000",
|
|
"--print-to-pdf=/pdf/out.pdf",
|
|
"file:///pdf/in.html",
|
|
}
|
|
if _, err := r.Run(ctx, chromiumTool(), nil, mounts, cmd); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out, err := os.ReadFile(pdfPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read pdf: %w", err)
|
|
}
|
|
if len(out) < 4 || string(out[:4]) != "%PDF" {
|
|
return nil, &ConvertError{
|
|
Tool: "chromium",
|
|
ExitCode: 0,
|
|
Stderr: "chromium did not produce a valid PDF",
|
|
Cause: fmt.Errorf("invalid PDF magic in output (got %d bytes)", len(out)),
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// metadataArgs renders Metadata into pandoc -V flags. Order is stable
|
|
// so test fixtures don't churn. Empty values are omitted (the template
|
|
// uses $if(...)$ blocks).
|
|
func metadataArgs(m Metadata) []string {
|
|
var out []string
|
|
add := func(k, v string) {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return
|
|
}
|
|
out = append(out, "-V", k+"="+v)
|
|
}
|
|
add("title", m.Title)
|
|
add("tracking_number", m.TrackingNumber)
|
|
add("revision", m.Revision)
|
|
add("status", m.Status)
|
|
add("client", m.Client)
|
|
add("project", m.Project)
|
|
add("contractor", m.Contractor)
|
|
add("project_number", m.ProjectNumber)
|
|
if !m.GenerationTime.IsZero() {
|
|
add("generation_time", m.GenerationTime.Format("January 02, 2006 at 3:04:05 PM MST"))
|
|
}
|
|
if m.IsDraft {
|
|
add("is_draft", "true")
|
|
}
|
|
if m.NoTOC {
|
|
add("no-toc", "true")
|
|
}
|
|
return out
|
|
}
|