package convert import ( "bytes" "context" "errors" "fmt" "io" "io/fs" "os" "os/exec" "path/filepath" "sync" "time" ) // Runner executes a conversion binary and returns its stdout. The // production implementation (localRunner) just exec's the binary // directly. Tests use a fake. // // binary is the PATH-resolvable name (or absolute path) of the // conversion tool — typically "pandoc" or "chromium-browser". In the // production runtime image those names resolve to wrapper scripts at // /usr/local/bin/ that put the real binary into a cgroup + bwrap // sandbox before exec'ing it. From zddc-server's perspective, that // indirection is invisible: it just sees pandoc behavior. // // stdin is piped to the binary's stdin. scratchDir is an optional // host directory the binary needs to read from / write to (template // + intermediate HTML + PDF output); passed to the child via the // ZDDC_SCRATCH env var, which the wrapper script bind-mounts into // the sandbox at the same path. Empty means "no scratch dir // needed" (DOCX flow — stdin to stdout, no files). // // cmd is the argv passed to the binary. Same shape across all // runners; no shell quoting; no engine-specific flags. // // All exec calls in this package go through Runner.Run. type Runner interface { Run(ctx context.Context, binary string, stdin []byte, scratchDir string, cmd []string) ([]byte, error) } // ErrUnavailable means the conversion binary couldn't be found on // PATH. Handlers translate to HTTP 503. var ErrUnavailable = errors.New("conversion unavailable") // ConvertError carries the failure surface from a non-zero exit. // Stderr is captured (truncated to 4 KiB by the runner) so callers // can surface the binary's own complaint. type ConvertError struct { Tool string // binary name, used only for logging ExitCode int Stderr string Cause error } func (e *ConvertError) Error() string { if e == nil { return "" } if e.Stderr != "" { return fmt.Sprintf("%s exit %d: %s", e.Tool, e.ExitCode, e.Stderr) } return fmt.Sprintf("%s exit %d: %v", e.Tool, e.ExitCode, e.Cause) } func (e *ConvertError) Unwrap() error { return e.Cause } // localRunner exec's the conversion binary directly. The runtime // image's wrapper script (at /usr/local/bin/) handles // sandboxing + resource limits BETWEEN this exec and the real // binary — invisible to this Runner. // // Resource limits stored here are advisory only; the wrapper reads // them via env (ZDDC_CONV_MEM_MAX, ZDDC_CONV_PIDS_MAX) and applies // them to its transient cgroup. Wall-clock timeout IS enforced // here via context.WithTimeout. type localRunner struct { mu sync.RWMutex memMiB int pids int timeout time.Duration } func newLocalRunner() *localRunner { return &localRunner{ memMiB: 1024, // 1 GiB — matches the wrapper's default pids: 256, timeout: 60 * time.Second, } } // SetLimits updates the resource ceilings advertised to the wrapper // script via env vars + the wall-clock timeout enforced here. // Zero values keep the previous setting (or constructor defaults). // Safe to call from multiple goroutines. func (lr *localRunner) SetLimits(memMiB int, pids int, timeout time.Duration) { lr.mu.Lock() defer lr.mu.Unlock() if memMiB > 0 { lr.memMiB = memMiB } if pids > 0 { lr.pids = pids } if timeout > 0 { lr.timeout = timeout } } func (lr *localRunner) Run(ctx context.Context, binary string, stdin []byte, scratchDir string, cmd []string) ([]byte, error) { lr.mu.RLock() memMiB := lr.memMiB pids := lr.pids timeout := lr.timeout lr.mu.RUnlock() if binary == "" { return nil, ErrUnavailable } runCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() c := exec.CommandContext(runCtx, binary, cmd...) c.Cancel = func() error { if c.Process == nil { return nil } return c.Process.Kill() } c.WaitDelay = 2 * time.Second c.SysProcAttr = sysProcAttr() // Minimal env passed to the wrapper. The wrapper does // --clearenv inside the bwrap sandbox so the real binary // sees only what bwrap re-injects (HOME, PATH, LANG). These // vars are read by the WRAPPER itself, not the binary, to // drive its cgroup setup + scratch-dir bind mount. env := []string{ "PATH=" + os.Getenv("PATH"), "HOME=" + os.TempDir(), fmt.Sprintf("ZDDC_CONV_MEM_MAX=%dM", memMiB), fmt.Sprintf("ZDDC_CONV_PIDS_MAX=%d", pids), } if scratchDir != "" { env = append(env, "ZDDC_SCRATCH="+scratchDir) } c.Env = env c.Stdin = bytes.NewReader(stdin) var stdoutBuf bytes.Buffer c.Stdout = &limitWriter{w: &stdoutBuf, max: 128 << 20} stderr := newRingWriter(4 << 10) c.Stderr = stderr if err := c.Run(); err != nil { exitCode := -1 if ee, ok := err.(*exec.ExitError); ok { exitCode = ee.ExitCode() } if runCtx.Err() == context.DeadlineExceeded { return nil, &ConvertError{ Tool: binary, ExitCode: exitCode, Stderr: stderr.String(), Cause: fmt.Errorf("timeout after %s: %w", timeout, runCtx.Err()), } } return nil, &ConvertError{ Tool: binary, ExitCode: exitCode, Stderr: stderr.String(), Cause: err, } } return stdoutBuf.Bytes(), nil } var ( // shared default runner, populated by InstallRunner (called from // the health probe at startup once the binaries are confirmed). defaultRunnerMu sync.RWMutex defaultRunner Runner ) // InstallRunner sets the package-level Runner used by ToDocx/ToHTML/ // ToPDF. Tests inject a fake; production code lets the health probe // install a localRunner. Safe to call from multiple goroutines. func InstallRunner(r Runner) { defaultRunnerMu.Lock() defaultRunner = r defaultRunnerMu.Unlock() } // ConfigureLimits applies resource limits to the package-level // Runner, if it's a localRunner. No-op when no runner is installed // yet (the probe failed) or when the installed runner doesn't accept // limits (e.g. a test fake). Zero values keep the previous setting. // // Called from cmd/zddc-server/main.go after Probe so the limits // from the operator's flags take effect before any conversion // request lands. func ConfigureLimits(memMiB int, pids int, timeout time.Duration) { defaultRunnerMu.RLock() r := defaultRunner defaultRunnerMu.RUnlock() if lr, ok := r.(*localRunner); ok { lr.SetLimits(memMiB, pids, timeout) } } func currentRunner() Runner { defaultRunnerMu.RLock() r := defaultRunner defaultRunnerMu.RUnlock() return r } // limitWriter caps the underlying buffer at max bytes. Writes past // the cap return an error which surfaces as a Run() error — the // caller then maps to 422 (output too large) at the handler edge. type limitWriter struct { w io.Writer max int64 n int64 } func (l *limitWriter) Write(p []byte) (int, error) { if l.n >= l.max { return 0, fmt.Errorf("output exceeded %d bytes", l.max) } rem := l.max - l.n if int64(len(p)) > rem { n, _ := l.w.Write(p[:rem]) l.n += int64(n) return n, fmt.Errorf("output exceeded %d bytes", l.max) } n, err := l.w.Write(p) l.n += int64(n) return n, err } // ringWriter keeps only the tail of what's written — useful for // stderr capture where the most-recent bytes carry the actual error // message and earlier output is usually progress noise. type ringWriter struct { mu sync.Mutex buf []byte max int } func newRingWriter(max int) *ringWriter { return &ringWriter{max: max} } func (r *ringWriter) Write(p []byte) (int, error) { r.mu.Lock() defer r.mu.Unlock() if len(p) >= r.max { r.buf = append(r.buf[:0], p[len(p)-r.max:]...) return len(p), nil } r.buf = append(r.buf, p...) if len(r.buf) > r.max { r.buf = r.buf[len(r.buf)-r.max:] } return len(p), nil } func (r *ringWriter) String() string { r.mu.Lock() defer r.mu.Unlock() return string(r.buf) } // writeScratchFiles materialises a set of named byte buffers (template + // partials, or a lua filter) into a fresh scratch dir and returns the host // path. Caller is responsible for os.RemoveAll(dir) when done. pandoc resolves // `$partial()$` includes and --lua-filter paths from this dir, so everything // lands flat alongside the entry file. // // scratchRoot controls where the temp dir lands. Empty means "use $TMPDIR". // // Files are written world-readable so the binary's default user can read them // through the wrapper's bind mount regardless of the host's umask. Keys are // reduced to base names only (no path separators). func writeScratchFiles(scratchRoot string, files map[string][]byte) (string, error) { dir, err := os.MkdirTemp(scratchRoot, "zddc-convert-") if err != nil { return "", fmt.Errorf("scratch dir: %w", err) } for name, b := range files { if err := os.WriteFile(filepath.Join(dir, filepath.Base(name)), b, 0o644); err != nil { os.RemoveAll(dir) return "", fmt.Errorf("write scratch file %q: %w", name, err) } } if err := chmodTree(dir, 0o755, 0o644); err != nil { os.RemoveAll(dir) return "", err } return dir, nil } func chmodTree(root string, dirMode, fileMode os.FileMode) error { return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return os.Chmod(p, dirMode) } return os.Chmod(p, fileMode) }) }