How DocBunker works¶
This document follows a document from the moment you drop it in the window to the moment you see a page. If you want the spec-level details, architecture, sandbox and protocol have them; here we mostly describe what actually happens when you use the app.
The shape of the system¶
DocBunker runs (almost) nothing on your machine. There is a small trusted
side — the Tauri UI, a Rust core, the sandbox manager — and one thing on the
other side of a wall: a renderer-worker subprocess that does all the
parsing. The wall is the binary IPC protocol. The worker lives inside gVisor
on Linux; on Windows and macOS it lives in a QEMU VM with gVisor inside.
flowchart LR
subgraph Trusted["Your machine"]
UI["Tauri UI<br/>draws images only"]
CORE["Rust core<br/>sessions, cache"]
SM["Sandbox manager<br/>spawns, kills"]
end
subgraph Fence["The wall"]
P["binary protocol<br/>framed, capped"]
end
subgraph Away["Disposable sandbox"]
W["renderer-worker<br/>parses and rasterizes"]
R["decoders: png/jpeg/webp,<br/>PDF (Hayro), office"]
end
UI -->|"open/info/render/close"| CORE
CORE --> SM
SM -->|"bytes in"| P
P --> W
W --> R
R -->|"RGBA + a few numbers"| P
P --> SM
Why this shape: parsing is where attackers live, so the parser gets no access to the host. It reads document bytes, writes pixels back, and gets deleted.
Opening a document, step by step¶
sequenceDiagram
autonumber
actor You
participant UI as Tauri UI
participant Core as Rust core
participant SM as Sandbox manager
participant SB as runsc / QEMU VM
participant WK as renderer-worker
You->>UI: open file.pdf
UI->>Core: open_document(path)
Core->>SM: start_session(caps)
SM->>WK: boot, handshake (Hello / HelloOk)
Note over SM,WK: caps get negotiated down to min(both)
SM->>WK: OpenDocument { id, bytes }
WK->>WK: sniff magic bytes → PDF
WK-->>SM: DocumentOpened { page count, size }
Core-->>UI: a handle to session
Each side decides caps independently: the host advertises what it accepts, the worker replies with what it can do, and afterwards every message is checked against the smaller of the two. If a worker tries to negotiate more than the host allows (or vice versa), the session dies.
Actually, look at crates/protocol/src/message.rs and crates/sandbox —
all of this is plain Rust you can read.
Rendering a page¶
sequenceDiagram
autonumber
participant UI as Tauri UI
participant Core as Rust core
participant SM as Sandbox manager
participant WK as renderer-worker
You->>UI: click next page
UI->>Core: render_page(id, 2, WxH)
Core->>Core: LRU cache (3 pages)
Core->>SM: render_page(...)
SM->>WK: RenderPage { page, w, h }
WK->>WK: decode+raster, caps checked
WK-->>SM: PageRendered { RGBA }
SM->>SM: re-validate everything
Core->>Core: PNG-encode
Core-->>UI: data URL
UI->>UI: draw <img>
Caveats worth knowing: the cache only holds three pages (previous, current,
next), and a copy is made on read — shared memory exists for the dev
subprocess backend, but the sandboxes still transfer frames bytewise, which
is fine for the sizes in play. And the worker never sends back HTML, links,
fonts, SVG — anything that could be interpreted. Just pixels and numbers.
Which sandbox do you get?¶
There is no single "sandbox". The app picks one of four backends; the
selection depends on build type, DOCBUNKER_BACKEND and the platform.
| Backend | Parses real docs? | Isolated? | Typical use |
|---|---|---|---|
mock |
no (test pages) | no | debug default |
subprocess |
yes | no | local dev, opt-in feature |
runsc |
yes | gVisor | Linux production |
vm |
yes | QEMU → gVisor | Windows/macOS/Linux production |
flowchart TD
A[DOCBUNKER_BACKEND] --> B{release build?}
B -- yes --> V[vm only<br/>mock/subprocess rejected]
B -- no --> C{mock / subprocess / runsc / vm}
C -- mock --> M[in-process test pages]
C -- subprocess --> S[real decoding, no isolation]
C -- runsc --> R[gVisor on Linux]
A release build simply refuses mock and subprocess; it never falls back
to an unisolated backend, not even "temporarily". To use those locally in a
release build you have to opt into the development-backends cargo feature
(it exists for developer machines only).
Inside the runsc sandbox¶
On Linux the sandbox manager drives runsc itself — no Docker, no Podman.
Each session gets a fresh OCI bundle, written by OciBundle in
crates/sandbox/src/runsc_bundle.rs, and the worker is the container init,
speaking the protocol over pipes.
Hardening profile (all of this is in the OCI config):
- user: unprivileged uid/gid 65534
- rootfs: read-only, no host mounts,
/tmpa private tmpfs capped at 256 MiB - network: empty netns (
--network=none) - capabilities: none
- cgroup limits: memory, CPU quota, PID limit
- environment: empty (
env_clear()) - document bytes only over IPC — never written as a file
- per-operation wall-clock deadlines; on expiry the session is killed and
deleted (
runsc delete --force+ bundle destruction)
The rootfs image contains exactly: the static musl renderer-worker,
fonts if needed, /tmp /proc /dev /etc scaffolding. No shell, no tools, no
network utilities.
sequenceDiagram
participant SM as Sandbox manager
participant R as runsc
participant W as worker
SM->>R: runsc run --bundle=... --network=none
R->>W: worker as init (pipes)
W-->>SM: Hello / HelloOk
loop each operation
SM->>W: request
W-->>SM: response
Note over SM,W: deadline per op
end
SM->>R: delete --force on close/timeout/crash
The VM path¶
On Windows/macOS (and Linux when vm is chosen) the wall is a tiny VM:
QEMU with WHPX/HVF/KVM, booting a minimal initramfs that contains runsc
and the worker. gVisor then runs inside the VM — two boundaries because
an escape from gVisor should still not reach the host kernel.
flowchart TB
subgraph Host["Host machine"]
H["Sandbox manager"]
end
subgraph VM["QEMU VM, fresh per session"]
Q["qemu -accel whpx|hvf|kvm"]
V1["initramfs: kernel + busybox + runsc"]
V2["runsc"]
V3["renderer-worker"]
Q --> V1 --> V2 --> V3
end
H -->|virtio-serial / host loopback| Q
The guest has no network device. kernel and initramfs.cpio.gz are hash-
pinned (SHA256SUMS in the build scripts); release builds verify the bundled
images before launching QEMU.
Failure mode, in one idea¶
When anything looks wrong — a page out of range, a frame over the limit, junk in the stream, a worker that won't answer in time — the simplest correct action is to kill the session and start over; a fresh sandbox is cheaper than trusting a compromised one. So that's what happens. The specific cases and the error codes are in protocol.md, and the actual numbers (64 MiB documents, 4096×4096 pixels, 96 MiB frames) are there too.
Trying it now¶
cargo check --workspace
cargo test --workspace
# dev: real decoding, no isolation
DOCBUNKER_BACKEND=subprocess cargo run -p docbunker-app
# Linux: runsc e2e (requires runsc + a built rootfs)
sh sandbox/scripts/build-rootfs.sh "$PWD/sandbox/rootfs" \
"$PWD/target/x86_64-unknown-linux-musl/release/renderer-worker"
sudo env DOCBUNKER_RUNSC_BIN=runsc DOCBUNKER_ROOTFS="$PWD/sandbox/rootfs" \
cargo test -p docbunker-sandbox runsc_end_to_end -- --ignored
And the fuzz targets for protocol / format-detection live under
crates/protocol/fuzz and crates/renderer-api/fuzz.
Why each piece looks like this¶
History is in docs/adr/; the short version is that each crate exists because a boundary needed to be explicit (ADR-001–009): Tauri shell, the raster-only rule, gVisor as Linux isolation, the binary IPC, MuPDF vs Hayro for PDF, the VM layer, OOXML caps, embedded media, shared-memory transport. Read them in that order if you want to understand the project's reasoning instead of its state.