The bwrap engine + OCI engine that lived in internal/convert/runner.go
both leak isolation policy into Go code. Replaced with a single image-
side wrapper that drop-in-shadows pandoc and chromium-browser on PATH.
zddc-server's only contract with the image is now "exec.Command(name,
args) gets you that tool's behavior" — sandboxing, resource caps, and
namespace setup live entirely in shell scripts shipped by the image.
Architecture:
- zddc/runtime/zddc-cgroup-init runs at container start. cgroup v2's
"no internal processes" constraint forbids a cgroup from having both
children and processes; the init script moves PID 1 into a child,
enables +memory +pids in subtree_control, then exec's zddc-server.
Best-effort: degrades cleanly to "no resource caps" if cgroupfs
isn't writable.
- zddc/runtime/zddc-sandbox-exec is the per-call wrapper, symlinked
from /usr/local/bin/{pandoc,chromium-browser}. Creates a transient
cgroup v2 (memory.max + pids.max), then bubblewrap-sandboxes the
real binary at /usr/bin/<name>: --unshare-all, --ro-bind /usr,
--proc /proc, --tmpfs /tmp, --clearenv. Caller's scratch dir comes
in via ZDDC_SCRATCH env and is bind-mounted at the SAME path so
absolute paths round-trip unchanged.
Go simplifications (~250 lines net deletion):
- Runner interface: Run(ctx, binary, stdin, scratchDir, cmd) — no
ToolSpec, no mount list, no engine concept. Single localRunner
implementation; bwrapRunner + containerRunner both deleted.
- health.Probe just looks up pandoc + chromium on PATH; Capabilities
drops engine kinds.
- Convert.go: ToHTML/ToPDF write to a per-call scratch dir under
TMPDIR and pass absolute paths; the wrapper bind-mounts the dir.
No more "/tpl" / "/pdf" mount-point indirection.
- Config drops --convert-pandoc-image, --convert-chromium-image,
--convert-engine, --convert-podman-socket (OCI engine gone) and
--convert-cpus (CPU caps don't apply in the new model — wall-clock
+ memory + pids is the cap set). Defaults raised to match the new
caps the user authorized: mem 512→1024 MiB, pids 100→256,
timeout 30→60 s.
Image:
- zddc/runtime.Containerfile builds the production runtime image
(alpine + bubblewrap + pandoc + chromium + font-noto). Two
COPY statements pull in the wrapper scripts; ln -s symlinks the
shadow names.
- bitnest dev image mirrors this layout under /var/lib/zddc-dev-build/.
Container privilege required:
- Nested bwrap needs the outer container to permit user + mount
namespace creation + MS_SLAVE on root. The default seccomp +
AppArmor profiles block all of these. Quadlet adds:
--cap-add=ALL
--security-opt=seccomp=unconfined
--security-opt=apparmor=unconfined
--security-opt=unmask=ALL
Helm chart sets the equivalent via securityContext (capabilities.
add: SYS_ADMIN, seccompProfile.type: Unconfined, appArmorProfile.
type: Unconfined). Trade-off documented in AGENTS.md: zddc-server
RCE now has near-root power within the container, but the bind-
mount layout still bounds blast radius; bwrap is the real boundary
between zddc-server and untrusted markdown.
Tests: convert_test.go fully rewritten for the new Runner signature.
Drops TestBwrapArgs_* (functionality moved out of Go) and
TestImageTag (no more image refs). All 15 Go test packages green.
Verified live on bitnest: pandoc --version round-trip exits 0
through the wrapper; MD→DOCX produces a valid Word 2007+ file
end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
303 lines
8.5 KiB
Go
303 lines
8.5 KiB
Go
package convert
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeRunner records the args it was invoked with and replays canned
|
|
// responses. Lets us assert command lines + binary refs + scratch
|
|
// dirs without needing actual pandoc.
|
|
type fakeRunner struct {
|
|
mu sync.Mutex
|
|
calls [][]string
|
|
binaries []string
|
|
stdin [][]byte
|
|
scratchDir []string
|
|
resp []byte
|
|
err error
|
|
}
|
|
|
|
func (f *fakeRunner) Run(_ context.Context, binary string, stdin []byte, scratchDir string, cmd []string) ([]byte, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls = append(f.calls, append([]string(nil), cmd...))
|
|
f.binaries = append(f.binaries, binary)
|
|
f.stdin = append(f.stdin, append([]byte(nil), stdin...))
|
|
f.scratchDir = append(f.scratchDir, scratchDir)
|
|
return f.resp, f.err
|
|
}
|
|
|
|
func (f *fakeRunner) lastCall() (string, []string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if len(f.calls) == 0 {
|
|
return "", nil
|
|
}
|
|
return f.binaries[len(f.binaries)-1], f.calls[len(f.calls)-1]
|
|
}
|
|
|
|
func TestToDocx_UsesPandocBinary(t *testing.T) {
|
|
f := &fakeRunner{resp: []byte("FAKE-DOCX")}
|
|
InstallRunner(f)
|
|
t.Cleanup(func() { InstallRunner(nil) })
|
|
SetBinaries("pandoc", "chromium-browser")
|
|
|
|
out, err := ToDocx(context.Background(), []byte("# Hello\n"), Metadata{
|
|
Title: "Hello",
|
|
Client: "Acme",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ToDocx: %v", err)
|
|
}
|
|
if string(out) != "FAKE-DOCX" {
|
|
t.Errorf("unexpected output: %q", out)
|
|
}
|
|
binary, call := f.lastCall()
|
|
if binary != "pandoc" {
|
|
t.Errorf("expected pandoc binary, got %q", binary)
|
|
}
|
|
if !contains(call, "--to=docx") {
|
|
t.Errorf("missing --to=docx: %v", call)
|
|
}
|
|
if !contains(call, "title=Hello") {
|
|
t.Errorf("missing title metadata: %v", call)
|
|
}
|
|
if !contains(call, "client=Acme") {
|
|
t.Errorf("missing client metadata: %v", call)
|
|
}
|
|
// Last arg must be "-" so pandoc reads from stdin.
|
|
if call[len(call)-1] != "-" {
|
|
t.Errorf("expected stdin marker as last arg, got %q", call[len(call)-1])
|
|
}
|
|
// ToDocx is stdin → stdout — no scratch dir needed.
|
|
if f.scratchDir[len(f.scratchDir)-1] != "" {
|
|
t.Errorf("ToDocx should not need a scratch dir, got %q", f.scratchDir[len(f.scratchDir)-1])
|
|
}
|
|
}
|
|
|
|
func TestToHTML_UsesTemplateFromScratchDir(t *testing.T) {
|
|
f := &fakeRunner{resp: []byte("<html>fake</html>")}
|
|
InstallRunner(f)
|
|
t.Cleanup(func() { InstallRunner(nil) })
|
|
SetBinaries("pandoc", "chromium-browser")
|
|
|
|
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"})
|
|
if err != nil {
|
|
t.Fatalf("ToHTML: %v", err)
|
|
}
|
|
binary, call := f.lastCall()
|
|
if binary != "pandoc" {
|
|
t.Errorf("expected pandoc binary, got %q", binary)
|
|
}
|
|
// Template flag must reference an absolute path under the scratch
|
|
// dir (no /tpl indirection anymore — the wrapper bind-mounts the
|
|
// scratch dir at its own path, so absolute host paths just work).
|
|
scratch := f.scratchDir[len(f.scratchDir)-1]
|
|
if scratch == "" {
|
|
t.Fatalf("ToHTML must pass a scratch dir to the runner")
|
|
}
|
|
wantTpl := "--template=" + scratch + "/viewer-template.html"
|
|
if !contains(call, wantTpl) {
|
|
t.Errorf("template flag missing/wrong; want %q in %v", wantTpl, call)
|
|
}
|
|
if !contains(call, "--toc") {
|
|
t.Errorf("TOC flag missing (default NoTOC=false): %v", call)
|
|
}
|
|
}
|
|
|
|
func TestToHTML_NoTOCSuppressesTOC(t *testing.T) {
|
|
f := &fakeRunner{resp: []byte("<html/>")}
|
|
InstallRunner(f)
|
|
t.Cleanup(func() { InstallRunner(nil) })
|
|
|
|
_, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true})
|
|
_, call := f.lastCall()
|
|
if contains(call, "--toc") {
|
|
t.Errorf("TOC should be suppressed when NoTOC=true: %v", call)
|
|
}
|
|
if !contains(call, "no-toc=true") {
|
|
t.Errorf("no-toc metadata variable missing: %v", call)
|
|
}
|
|
}
|
|
|
|
// recordingRunner records every call and returns canned responses in
|
|
// sequence. Lets ToPDF tests assert the two-stage pipeline (pandoc
|
|
// then chromium).
|
|
type recordingRunner struct {
|
|
mu sync.Mutex
|
|
calls []recordedCall
|
|
resp [][]byte
|
|
err []error
|
|
cursor int
|
|
}
|
|
|
|
type recordedCall struct {
|
|
binary string
|
|
cmd []string
|
|
scratch string
|
|
}
|
|
|
|
func (r *recordingRunner) Run(_ context.Context, binary string, _ []byte, scratch string, cmd []string) ([]byte, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.calls = append(r.calls, recordedCall{
|
|
binary: binary,
|
|
cmd: append([]string(nil), cmd...),
|
|
scratch: scratch,
|
|
})
|
|
if r.cursor >= len(r.resp) {
|
|
return nil, nil
|
|
}
|
|
out := r.resp[r.cursor]
|
|
var e error
|
|
if r.cursor < len(r.err) {
|
|
e = r.err[r.cursor]
|
|
}
|
|
r.cursor++
|
|
return out, e
|
|
}
|
|
|
|
func TestScratchDir_UsedByToHTML(t *testing.T) {
|
|
f := &fakeRunner{resp: []byte("<html/>")}
|
|
InstallRunner(f)
|
|
t.Cleanup(func() { InstallRunner(nil); SetScratchDir("") })
|
|
|
|
scratchRoot := t.TempDir()
|
|
SetScratchDir(scratchRoot)
|
|
|
|
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{})
|
|
if err != nil {
|
|
t.Fatalf("ToHTML: %v", err)
|
|
}
|
|
if len(f.scratchDir) == 0 {
|
|
t.Fatalf("expected a scratch dir to be passed to the runner")
|
|
}
|
|
got := f.scratchDir[0]
|
|
if !strings.HasPrefix(got, scratchRoot+"/") {
|
|
t.Errorf("scratch dir not under configured root: %q (root=%q)", got, scratchRoot)
|
|
}
|
|
}
|
|
|
|
func TestToPDF_TwoStagePipeline(t *testing.T) {
|
|
// Stage 1: pandoc emits HTML. Stage 2: chromium reads HTML from
|
|
// the scratch dir and writes out.pdf there. The fake runner can't
|
|
// actually write the PDF, so we expect ToPDF to fail at the
|
|
// read-back step — but we can still assert the two-stage call
|
|
// shape and the right binary per stage.
|
|
r := &recordingRunner{
|
|
resp: [][]byte{
|
|
[]byte("<html><body>fake</body></html>"), // stage 1 stdout
|
|
nil, // stage 2 stdout (chromium writes PDF to scratch)
|
|
},
|
|
}
|
|
InstallRunner(r)
|
|
t.Cleanup(func() { InstallRunner(nil) })
|
|
SetBinaries("pandoc", "chromium-browser")
|
|
|
|
_, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{})
|
|
// PDF read-back will fail (fake runner didn't write the file) —
|
|
// that's expected for this test which only inspects the call shape.
|
|
if err == nil {
|
|
t.Fatalf("expected error from PDF read-back; got nil")
|
|
}
|
|
if len(r.calls) != 2 {
|
|
t.Fatalf("expected 2 calls (pandoc + chromium); got %d", len(r.calls))
|
|
}
|
|
if r.calls[0].binary != "pandoc" {
|
|
t.Errorf("stage 1 binary: got %q want pandoc", r.calls[0].binary)
|
|
}
|
|
if r.calls[1].binary != "chromium-browser" {
|
|
t.Errorf("stage 2 binary: got %q want chromium-browser", r.calls[1].binary)
|
|
}
|
|
// Stage 2 must include --print-to-pdf pointing at an absolute
|
|
// path under the scratch dir.
|
|
stage2 := r.calls[1]
|
|
if stage2.scratch == "" {
|
|
t.Fatalf("chromium call must have a scratch dir")
|
|
}
|
|
wantPDF := "--print-to-pdf=" + stage2.scratch + "/out.pdf"
|
|
if !contains(stage2.cmd, wantPDF) {
|
|
t.Errorf("chromium call missing --print-to-pdf=%s/out.pdf: %v", stage2.scratch, stage2.cmd)
|
|
}
|
|
if !contains(stage2.cmd, "--no-sandbox") {
|
|
t.Errorf("chromium call missing --no-sandbox: %v", stage2.cmd)
|
|
}
|
|
// Stage 2 chromium reads file://<scratch>/in.html.
|
|
wantHTML := "file://" + stage2.scratch + "/in.html"
|
|
if !contains(stage2.cmd, wantHTML) {
|
|
t.Errorf("chromium call missing file:// URL: %v", stage2.cmd)
|
|
}
|
|
}
|
|
|
|
func TestErrUnavailable_WhenNoRunner(t *testing.T) {
|
|
InstallRunner(nil)
|
|
_, err := ToDocx(context.Background(), []byte("x"), Metadata{})
|
|
if !errors.Is(err, ErrUnavailable) {
|
|
t.Errorf("expected ErrUnavailable, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMetadataArgs_OmitsEmptyAndOrdersStably(t *testing.T) {
|
|
args := metadataArgs(Metadata{
|
|
Title: "T",
|
|
Project: "P",
|
|
GenerationTime: time.Date(2026, 5, 13, 14, 30, 22, 0, time.UTC),
|
|
})
|
|
want := []string{
|
|
"-V", "title=T",
|
|
"-V", "project=P",
|
|
}
|
|
for i, w := range want {
|
|
if i >= len(args) || args[i] != w {
|
|
t.Fatalf("args[%d]: got %v want prefix %v", i, args, want)
|
|
}
|
|
}
|
|
joined := strings.Join(args, "|")
|
|
if !strings.Contains(joined, "generation_time=") || !strings.Contains(joined, "2026") {
|
|
t.Errorf("generation_time missing or malformed: %v", args)
|
|
}
|
|
if strings.Contains(joined, "client=") {
|
|
t.Errorf("empty client should not be passed: %v", args)
|
|
}
|
|
}
|
|
|
|
func TestSingleflight_Collapses(t *testing.T) {
|
|
var g singleflightGroup
|
|
const N = 50
|
|
var wg sync.WaitGroup
|
|
var hits int32
|
|
var mu sync.Mutex
|
|
|
|
wg.Add(N)
|
|
for i := 0; i < N; i++ {
|
|
go func() {
|
|
defer wg.Done()
|
|
_, _ = g.Do("k", func() (any, error) {
|
|
mu.Lock()
|
|
hits++
|
|
mu.Unlock()
|
|
time.Sleep(20 * time.Millisecond)
|
|
return "v", nil
|
|
})
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
if hits != 1 {
|
|
t.Errorf("singleflight collapse: got %d invocations, want 1", hits)
|
|
}
|
|
}
|
|
|
|
// contains reports whether haystack has needle as any of its elements.
|
|
func contains(haystack []string, needle string) bool {
|
|
for _, s := range haystack {
|
|
if s == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|