Compare commits
2 Commits
91ae2077fa
...
d43d0402b8
| Author | SHA1 | Date |
|---|---|---|
|
|
d43d0402b8 | |
|
|
067bb7c362 |
|
|
@ -19,6 +19,18 @@ compile_commands.json
|
||||||
vcpkg/
|
vcpkg/
|
||||||
vcpkg_installed/
|
vcpkg_installed/
|
||||||
|
|
||||||
|
# Rust / cargo (Spike F leaf-decoder probe, ADR-0005)
|
||||||
|
# Keep the Cargo.toml/lock + src; ignore the build output.
|
||||||
|
**/target/
|
||||||
|
**/*.rs.bk
|
||||||
|
Cargo.lock.bak
|
||||||
|
|
||||||
|
# Cap'n Proto generated output (Spike E). The build generates these into the
|
||||||
|
# build dir; never commit generated capnp C++ to the source tree.
|
||||||
|
/ipc.capnp.c++
|
||||||
|
/ipc.capnp.h
|
||||||
|
**/E_sandbox_generated/
|
||||||
|
|
||||||
# OS junk
|
# OS junk
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
# ADR-0003 — Memory safety posture for the parser layers
|
# ADR-0003 — Memory safety posture for the parser layers
|
||||||
|
|
||||||
* **Status**: Accepted (decision on Rust-vs-C++ for L1/L2 leaf decoders is
|
* **Status**: Accepted. The Rust-vs-C++ decision deferred here is **resolved
|
||||||
*deferred to end of M0* per §15; this ADR records the posture that holds
|
by ADR-0005** (Rust for the image-codec leaf decoders; C++ elsewhere).
|
||||||
regardless of that decision)
|
|
||||||
* **Date**: 2025-07-25
|
* **Date**: 2025-07-25
|
||||||
* **Plan reference**: §7.2, §15
|
* **Plan reference**: §7.2, §15
|
||||||
|
* **Superseded by**: the deferred-decision portion is superseded by ADR-0005;
|
||||||
|
the hardening posture below stands.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
# ADR-0005 — Rust for the image-codec leaf decoders; C++ throughout elsewhere
|
||||||
|
|
||||||
|
* **Status**: Accepted
|
||||||
|
* **Date**: 2026-07-25
|
||||||
|
* **Plan reference**: §7.2, §15, §2.4
|
||||||
|
* **Supersedes**: the deferred-decision note in ADR-0003 (which is now resolved).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
§15 lists this as an open decision with a hard deadline: "End of M0. Decide
|
||||||
|
once; do not revisit at M4." The decision must be made before M2 begins,
|
||||||
|
because the L1/L2 leaf decoders (filters, image codec glue, CMap parsing)
|
||||||
|
are written in M2, and retrofitting a second language mid-project is worse
|
||||||
|
than either choice made early (§7.2).
|
||||||
|
|
||||||
|
The threat model (§7.1) identifies parser memory corruption as the main risk.
|
||||||
|
The leaf decoders — the code that actually decompresses and decodes the bytes
|
||||||
|
of a hostile PDF's streams and images — are where the historical CVEs live.
|
||||||
|
The question is whether the memory-safety benefit of writing *those* leaves in
|
||||||
|
Rust (behind a C ABI) justifies the build-friction cost of a second language in
|
||||||
|
the tree.
|
||||||
|
|
||||||
|
ADR-0003 recorded the posture that holds regardless of this decision
|
||||||
|
(hardening on in release, bounded views, sanitizers in CI, hardened allocator,
|
||||||
|
resource budgets). This ADR records the language choice.
|
||||||
|
|
||||||
|
## Evidence gathered at M0
|
||||||
|
|
||||||
|
### Leaf-decoder CVE history (the §15 "CVE history" input)
|
||||||
|
|
||||||
|
Surveyed via the CVE record database (2026-07-25):
|
||||||
|
|
||||||
|
* **OpenJPEG** (the JPX/JPEG2000 decoder): **~40 buffer-overflow CVEs**, spanning
|
||||||
|
2016 → 2024, including `CVE-2024-56827` and `CVE-2024-56826` (heap buffer
|
||||||
|
overflow, Red Hat CNAs, recent). Several Chrome CVEs are explicitly
|
||||||
|
"OpenJPEG, as used in PDFium" (`CVE-2016-5157`, `CVE-2016-5158`,
|
||||||
|
`CVE-2016-5159`) — the *exact* leaf decoder the plan bundles (§2.4:
|
||||||
|
"JBIG2 and JPX come from PDFium's bundled decoders"). This is a high-CVE,
|
||||||
|
still-actively-found-bug surface.
|
||||||
|
* **libpng / zlib-ng / libjpeg-turbo**: comparatively mature, few recent
|
||||||
|
memory-safety CVEs (libpng: 2 CVEs in the survey, both old; zlib-ng is a
|
||||||
|
hardening fork of zlib which itself has had ~no memory-safety CVEs in years).
|
||||||
|
* **CMap parsing / PDF object parsing**: smaller surface, but it is *our* new
|
||||||
|
code (no upstream CVE history to lean on) — and it is the most reachable
|
||||||
|
surface from a hostile PDF.
|
||||||
|
|
||||||
|
### Build-friction cost (measured empirically, not estimated)
|
||||||
|
|
||||||
|
`spike/F_rust_ffi_probe/` builds a Rust leaf decoder as a `staticlib` behind a
|
||||||
|
C ABI and links it into a C++ driver:
|
||||||
|
|
||||||
|
* Rust 1.85 `cargo build --release` produces `libfpe_rust_leaf.a` in **~10.5 s**.
|
||||||
|
* The C++ driver links the `.a` by absolute path; the FFI is one `extern "C"`
|
||||||
|
function with bounded (ptr, len) arguments matching the §7.2 bounded-view
|
||||||
|
rule.
|
||||||
|
* The round-trip (C++ → Rust → C++) is **clean under ASan+UBSan** — the FFI
|
||||||
|
boundary is a normal memory-safe-by-construction interface.
|
||||||
|
* The CMake integration is one `add_custom_command` invoking `cargo` plus a
|
||||||
|
`target_link_libraries` with the `.a` path. Corrosion/FetchContent is
|
||||||
|
*not* required for a staticlib leaf.
|
||||||
|
* `panic = "abort"` + `overflow-checks = true` in the release profile mirrors
|
||||||
|
the C++ side's `-fstack-protector-strong` / overflow checks.
|
||||||
|
|
||||||
|
The measured friction is **a `cargo build` step and a `.a` link** — real, but
|
||||||
|
not the multi-day toolchain fight a full Rust integration used to be. The
|
||||||
|
decision is not blocked by build cost.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Rust for the image-codec leaf decoders; C++ throughout everywhere else.**
|
||||||
|
|
||||||
|
Concretely:
|
||||||
|
|
||||||
|
1. **In Rust** (built as `staticlib`, behind a C ABI, linked from C++):
|
||||||
|
the **image codec glue** that wraps OpenJPEG, libjpeg-turbo, libpng,
|
||||||
|
OpenJPEG/JBIG2 — the leaf where the CVE history is concentrated. The Rust
|
||||||
|
layer owns the byte-buffer sizing, the allocation, and the call into the C
|
||||||
|
codec; the C codec's output is bounded-checked on the Rust side before it
|
||||||
|
crosses back. This is the narrowest place a second language buys the most:
|
||||||
|
it sits between hostile input and the C decoders that have the CVEs.
|
||||||
|
|
||||||
|
2. **In C++** (the rest of the tree, unchanged): the L1 file layer, the L2
|
||||||
|
object model, the L3 content model, the L4 layout engine, the L5 semantic
|
||||||
|
model, the L6 command layer, the UI, the rendering, the sandbox, the IPC.
|
||||||
|
No second language enters these. CMap parsing and the PDF object parser
|
||||||
|
stay in C++ with the ADR-0003 hardening (bounded views, sanitizers, checked
|
||||||
|
arithmetic) — they are our new code with no upstream CVE history, and the
|
||||||
|
bounded-view discipline plus fuzzing (§8.2) is the right posture for them.
|
||||||
|
|
||||||
|
3. **The image codec glue is the *only* Rust in the tree.** No Rust in the UI,
|
||||||
|
no Rust in the rendering pipeline, no Rust in the model. The build stays
|
||||||
|
single-language for everyone who isn't touching the codec glue.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive.**
|
||||||
|
|
||||||
|
* The highest-CVE leaf (the image decoders, especially OpenJPEG/JPX where the
|
||||||
|
40-CVE history lives and where "as used in PDFium" appears) gets a
|
||||||
|
memory-safe wrapper. A buffer overflow in the C codec is caught at the
|
||||||
|
Rust boundary's bounds check rather than corrupting the document process
|
||||||
|
heap. This is a real reduction in the §7.1 "parser memory corruption" risk
|
||||||
|
for exactly the surface that has historically been most exploited.
|
||||||
|
* The decision is narrow and defensible: a second language only where its
|
||||||
|
benefit (memory safety for the highest-CVE leaf) clearly exceeds its cost.
|
||||||
|
The rest of the tree pays no Rust tax.
|
||||||
|
* The build-friction cost was measured, not guessed: ~10 s for a `cargo
|
||||||
|
build` of a leaf crate, one `.a` link. CI gets one more build step; local
|
||||||
|
builds for engineers not touching the codec glue are unaffected (the Rust
|
||||||
|
staticlib is a build dependency like any other).
|
||||||
|
|
||||||
|
**Negative.**
|
||||||
|
|
||||||
|
* A second language enters the tree. CI needs `cargo` on the runners (the
|
||||||
|
Gitea Actions matrix already runs on Debian where `apt install rustc cargo`
|
||||||
|
is one line; the nightly packaging pipeline must bundle the Rust staticlib
|
||||||
|
into the artifacts). Contributor friction for the small number of people
|
||||||
|
who touch the codec glue: they need both toolchains.
|
||||||
|
* Debugging across the FFI boundary is harder than debugging within one
|
||||||
|
language — stack traces cross the boundary, and a `panic = "abort"` Rust
|
||||||
|
leaf crashes the process without unwinding. The §7.2 sanitizers + Crashpad
|
||||||
|
still work; the crash is just louder. This is acceptable for a leaf that
|
||||||
|
should never panic in practice (it does bounded byte work).
|
||||||
|
* The Rust leaf must be kept *leaf*: if it grows a dependency on the C++
|
||||||
|
object model it stops being a leaf and the boundary erodes. CODEOWNERS and
|
||||||
|
review enforce this; the C ABI is the contract.
|
||||||
|
|
||||||
|
**Neutral.**
|
||||||
|
|
||||||
|
* `panic = "abort"` matches the C++ side's no-unwinding-across-FFI stance.
|
||||||
|
* The bounded-view (ptr, len) FFI shape is the same shape the §7.2 rule
|
||||||
|
mandates for C++ parsing, so the two sides agree on the discipline.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
* **C++ throughout (no Rust).** Rejected. The OpenJPEG CVE history
|
||||||
|
(~40 buffer overflows, including 2024 CVEs, several "as used in PDFium") is
|
||||||
|
exactly the surface the §7.1 threat model worries about, and it is the
|
||||||
|
surface where C++ has demonstrably, repeatedly failed. The measured
|
||||||
|
build-friction cost (~10 s `cargo build`, one `.a` link) does not justify
|
||||||
|
forgoing the memory-safe wrapper for that specific leaf. The §15 instruction
|
||||||
|
to "decide once" means the C++-throughout choice would also be permanent;
|
||||||
|
accepting 40 more years of OpenJPEG-class CVEs in our highest-reach surface
|
||||||
|
is the wrong permanent choice.
|
||||||
|
|
||||||
|
* **Rust for all of L1/L2 (the whole parser surface).** Rejected. CMap
|
||||||
|
parsing and the object parser are *our* new code with no upstream CVE
|
||||||
|
history; the ADR-0003 hardening posture (bounded views, sanitizers,
|
||||||
|
resource budgets, fuzzing) is the right tool for new C++ code, and rewriting
|
||||||
|
it all in Rust would spread the second language across the most-coupled
|
||||||
|
part of the tree (the object model touches everything). The marginal
|
||||||
|
safety benefit over hardened+sanitized+fuzzed new C++ does not justify the
|
||||||
|
coupling cost. The leaf is where the benefit concentrates; this decision
|
||||||
|
keeps Rust there.
|
||||||
|
|
||||||
|
* **Rust for the whole document process.** Rejected strongly; Qt, Skia,
|
||||||
|
PDFium, HarfBuzz, FreeType, ICU, OpenSSL are all C/C++. A Rust document
|
||||||
|
process would FFI into all of them anyway, gaining nothing over a C++
|
||||||
|
process with a Rust leaf, and losing the Qt/Skia integration the UI needs.
|
||||||
|
|
||||||
|
## Reproducing the evidence
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build-friction probe:
|
||||||
|
cmake --build build/manual --target spike_f_rust_ffi
|
||||||
|
build/manual/bin/spike_f_rust_ffi 1024 # round-trips 1024 bytes via Rust C ABI
|
||||||
|
|
||||||
|
# CVE history (surveyed 2026-07-25):
|
||||||
|
# OpenJPEG: https://www.cve.org/CVERecord/SearchResults?query=openjpeg+buffer+overflow (~40)
|
||||||
|
# libpng: https://www.cve.org/CVERecord/SearchResults?query=libpng+memory+corruption (2)
|
||||||
|
```
|
||||||
|
|
@ -28,3 +28,6 @@ Per §13.4 and §14 step 9, the following ADRs exist from M0:
|
||||||
(§7.2)
|
(§7.2)
|
||||||
* [ADR-0004 — Two-process model with the IPC as a bidirectional trust
|
* [ADR-0004 — Two-process model with the IPC as a bidirectional trust
|
||||||
boundary](0004-two-process-trust-boundary.md) (§2.1)
|
boundary](0004-two-process-trust-boundary.md) (§2.1)
|
||||||
|
* [ADR-0005 — Rust for the image-codec leaf decoders; C++ throughout
|
||||||
|
elsewhere](0005-rust-leaf-decoders.md) (§7.2, §15) — resolves the M0
|
||||||
|
Rust-vs-C++ decision deferred in ADR-0003.
|
||||||
|
|
@ -5,7 +5,8 @@ SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
|
||||||
# Spike E — sandbox + IPC bring-up: M0 result
|
# Spike E — sandbox + IPC bring-up: M0 result
|
||||||
|
|
||||||
* **Spike**: E — sandbox + IPC bring-up (engineering plan §14 step 8)
|
* **Spike**: E — sandbox + IPC bring-up (engineering plan §14 step 8)
|
||||||
* **Date**: 2025-07-25
|
* **Date**: 2025-07-25 (M0); **updated 2026-07-27** — Cap'n Proto socketpair
|
||||||
|
stall resolved, RPC transport wired in.
|
||||||
* **Status**: Complete. **Gate MET.**
|
* **Status**: Complete. **Gate MET.**
|
||||||
* **Gate**: the sandboxed document process round-trips IPC requests with the
|
* **Gate**: the sandboxed document process round-trips IPC requests with the
|
||||||
seccomp filter installed (proving the allow-list is correct and the channel
|
seccomp filter installed (proving the allow-list is correct and the channel
|
||||||
|
|
@ -19,18 +20,22 @@ SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
|
||||||
Default-deny (`SCMP_ACT_KILL_PROCESS`), then permit only: memory management
|
Default-deny (`SCMP_ACT_KILL_PROCESS`), then permit only: memory management
|
||||||
(mmap/munmap/mprotect/brk/...), thread sync (futex), the already-open IPC
|
(mmap/munmap/mprotect/brk/...), thread sync (futex), the already-open IPC
|
||||||
socket fds (read/write/close/readv/writev/poll/epoll/eventfd/timerfd/dup),
|
socket fds (read/write/close/readv/writev/poll/epoll/eventfd/timerfd/dup),
|
||||||
|
`ioctl` (FIONBIO, needed by KJ's `wrapSocketFd` to set non-blocking mode),
|
||||||
and exit/signal. **DENIED**: `socket`, `connect`, `open`/`openat`, `unlink`,
|
and exit/signal. **DENIED**: `socket`, `connect`, `open`/`openat`, `unlink`,
|
||||||
`fork`, `exec` — the document process cannot reach the network or the
|
`fork`, `exec` — the document process cannot reach the network or the
|
||||||
filesystem, and cannot spawn children. A forbidden syscall kills the process
|
filesystem, and cannot spawn children. A forbidden syscall kills the process
|
||||||
loudly rather than silently falling back.
|
loudly rather than silently falling back.
|
||||||
- `ipc.capnp` — a Cap'n Proto schema for the IPC protocol (kept for the M2
|
- `ipc.capnp` — a Cap'n Proto schema for the IPC protocol. **Now the actual
|
||||||
RPC work; see "Findings" for why the spike uses a raw protocol instead).
|
transport** (see "Findings" for the resolution of the original stall).
|
||||||
- `DocumentProcess.cpp` — the sandboxed child: installs the seccomp filter,
|
- `DocumentProcess.cpp` — the sandboxed child: installs the seccomp filter,
|
||||||
then serves Parse requests over the socketpair. Trivial length-prefixed
|
then serves Parse requests over the socketpair via Cap'n Proto two-party
|
||||||
binary protocol: `[u64 request_id][u64 payload_len][payload]` →
|
RPC. Uses the low-level `LowLevelAsyncIoProvider::wrapSocketFd(fd)` +
|
||||||
`[u64 request_id][u64 byte_count][u8 ok]`. Bounds-checks the payload length
|
`TwoPartyVatNetwork(SIDE_SERVER)` + `makeRpcServer(network, bootstrap)` path
|
||||||
(16 MiB cap) per ADR-0004's "the document process is untrusted" rule.
|
(NOT `EzRpcServer` — see Findings). The `DocumentProcess::Server` capability
|
||||||
- `UIProcess.cpp` — the UI process: sends Parse requests, measures round-trip
|
bounds-checks the payload length (16 MiB cap) per ADR-0004's "the document
|
||||||
|
process is untrusted" rule.
|
||||||
|
- `UIProcess.cpp` — the UI process: uses `EzRpcClient(fd)` (designed for an
|
||||||
|
already-connected socket), calls `parse()` N times, measures round-trip
|
||||||
latency, and **validates every response** (request id, byte count, ok flag)
|
latency, and **validates every response** (request id, byte count, ok flag)
|
||||||
per ADR-0004's "IPC is a trust boundary in both directions" rule.
|
per ADR-0004's "IPC is a trust boundary in both directions" rule.
|
||||||
- `main.cpp` — forks, sets up the socketpair, runs both sides, measures.
|
- `main.cpp` — forks, sets up the socketpair, runs both sides, measures.
|
||||||
|
|
@ -43,46 +48,74 @@ SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
|
||||||
|
|
||||||
| Configuration | avg latency | min | max | child exit |
|
| Configuration | avg latency | min | max | child exit |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| sandbox ON, Release, 1KB payload, 1000 reqs | **18.0 µs** | 7.0 µs | 330.6 µs | 0 (clean) |
|
| sandbox ON, Release, 1KB payload, 1000 reqs (Cap'n Proto RPC) | **~58 µs** | ~28 µs | ~350 µs | 0 (clean) |
|
||||||
| sandbox OFF, Release, 1KB payload, 1000 reqs | 20.1 µs | 7.8 µs | 630.9 µs | 0 |
|
| sandbox OFF, Release, 1KB payload, 1000 reqs (Cap'n Proto RPC) | ~50 µs | ~28 µs | ~1.5 ms | 0 |
|
||||||
| sandbox ON, ASan+UBSan, 512B payload, 200 reqs | 33.7 µs | 7.7 µs | 2005.9 µs | 0 |
|
| sandbox OFF, ASan+UBSan, 512B payload, 200 reqs (Cap'n Proto RPC) | ~480 µs | ~100 µs | ~4.3 ms | 0 |
|
||||||
|
|
||||||
The sandbox adds **no measurable latency overhead** (18.0 vs 20.1 µs is within
|
The sandbox adds no measurable latency overhead. The Cap'n Proto RPC framing
|
||||||
noise; seccomp-bpf is a per-syscall filter checked in the kernel, not per-byte).
|
(~50–58 µs/round-trip) is ~3× the raw length-prefixed protocol it replaced
|
||||||
Verified clean under ASan+UBSan.
|
(~18 µs) — expected, since RPC adds message framing, the bootstrap
|
||||||
|
handshake, and the KJ event loop — and still far inside the §5 budget (the
|
||||||
|
document process's open latency budget is < 800 ms for a 1000-page file →
|
||||||
|
first page; the per-request IPC cost is negligible against that). Verified
|
||||||
|
clean under ASan+UBSan (with the sandbox disabled — see Findings for why ASan
|
||||||
|
and seccomp don't combine in this spike).
|
||||||
|
|
||||||
## Findings
|
## Findings
|
||||||
|
|
||||||
1. **The process split works and is cheap.** 18 µs average round-trip per IPC
|
1. **The process split works and is cheap.** ~50 µs average round-trip per
|
||||||
request, well under the §5 performance targets (the document process's
|
Cap'n Proto RPC request over the socketpair, well under the §5 performance
|
||||||
open latency budget is < 800 ms for a 1000-page file → first page; the
|
targets. The sandbox is free. The §14 step 8 concern ("measure the cost of
|
||||||
per-request IPC cost is negligible against that). The sandbox is free. The
|
the process split") is answered: the cost is tens of microseconds per
|
||||||
§14 step 8 concern ("measure the cost of the process split") is answered:
|
request, not milliseconds.
|
||||||
the cost is ~tens of microseconds per request, not milliseconds.
|
|
||||||
|
|
||||||
2. **The seccomp allow-list is correct.** The document process runs its event
|
2. **The seccomp allow-list is correct for the RPC event loop.** The document
|
||||||
loop (poll/epoll), reads/writes the IPC socket, allocates memory, and exits
|
process runs the KJ event loop (epoll/eventfd/timerfd), reads/writes the
|
||||||
— all under the filter, with no denials. A forbidden syscall
|
IPC socket, allocates memory, and exits — all under the filter, with no
|
||||||
(`open`/`socket`/`connect`/`fork`/`exec`) would trigger
|
denials. A forbidden syscall (`open`/`socket`/`connect`/`fork`/`exec`)
|
||||||
`SCMP_ACT_KILL_PROCESS`, crashing the process loudly (a sandbox escape
|
would trigger `SCMP_ACT_KILL_PROCESS`, crashing the process loudly. The
|
||||||
attempt is visible, not silent). The Cap'n Proto event loop needed
|
one addition over the raw-protocol variant was `ioctl` (FIONBIO), which
|
||||||
`epoll_create1`/`epoll_ctl`/`epoll_wait`/`eventfd2`/`timerfd_*` in the
|
KJ's `wrapSocketFd` calls to set non-blocking mode; strace identified it
|
||||||
allow-list; the raw protocol only needs `read`/`write`.
|
as the denied syscall in the first sandboxed run.
|
||||||
|
|
||||||
3. **Cap'n Proto two-party RPC over a socketpair stalled** in this
|
3. **The Cap'n Proto socketpair stall is RESOLVED.** The original M0 spike
|
||||||
environment (Cap'n Proto 1.1.0, seccomp 2.6, Debian 13). The server
|
recorded that `EzRpcServer(int fd, ...)` over a socketpair stalled: the
|
||||||
received and processed requests (the parse handler ran), but the responses
|
server processed requests but responses never reached the client. Root
|
||||||
never reached the client — both processes blocked on `read` with no
|
cause, confirmed with a standalone reproduction: **`EzRpcServer(int fd,
|
||||||
`write`/`sendmsg` to the socketpair. This is a real integration issue,
|
...)` expects a LISTENING socket and calls `accept()` on it.** A
|
||||||
**not** a sandbox issue (it reproduced with the sandbox disabled). It is
|
socketpair end is already CONNECTED, so `accept()` fails with `EINVAL`
|
||||||
recorded here for M2 to debug with the full event-loop integration; the
|
("Invalid argument") and the bootstrap never completes — the server
|
||||||
spike uses a raw length-prefixed protocol to measure the channel cost
|
aborts and the client sees a disconnect. The fix is the low-level path on
|
||||||
without that blocker. The `ipc.capnp` schema is kept for the M2 work. The
|
the server side:
|
||||||
most likely cause is a subtlety in `EzRpcClient(int fd)` / the low-level
|
```cpp
|
||||||
`TwoPartyVatNetwork` over an `AF_UNIX` socketpair end that needs
|
kj::AsyncIoContext io = kj::setupAsyncIo();
|
||||||
investigation with a debugger, not a spike-time detour.
|
kj::Own<kj::AsyncIoStream> stream = io.lowLevelProvider->wrapSocketFd(fd);
|
||||||
|
capnp::TwoPartyVatNetwork network(*stream, capnp::rpc::twoparty::Side::SERVER);
|
||||||
|
auto server = capnp::makeRpcServer(network, kj::heap<DocumentProcessImpl>());
|
||||||
|
network.onDisconnect().wait(io.waitScope);
|
||||||
|
```
|
||||||
|
`wrapSocketFd` wraps an already-connected socket as an `AsyncIoStream`
|
||||||
|
without calling `accept()`. The client uses `EzRpcClient(int fd)`, which
|
||||||
|
*is* designed for an already-connected socket and needs no change. This is
|
||||||
|
the shape the production M2 transport uses; the raw length-prefixed
|
||||||
|
protocol the spike previously used as a workaround has been removed.
|
||||||
|
|
||||||
4. **The bidirectional trust boundary is exercisable.** The UI validates every
|
4. **ASan and seccomp don't combine in this spike.** ASan's runtime uses
|
||||||
|
`pipe2` (and other syscalls) for its own bookkeeping, which the seccomp
|
||||||
|
allow-list denies — so the sandboxed child is killed by SIGSYS under ASan.
|
||||||
|
This is an ASan-only artefact (the Release build runs cleanly under the
|
||||||
|
sandbox); the spike verifies ASan-cleanliness with the sandbox disabled
|
||||||
|
(`FPE_SPIKE_E_NO_SANDBOX=1`), matching the original M0 verification
|
||||||
|
approach. The production M2 code uses a hardened allocator + sanitizers in
|
||||||
|
CI rather than running ASan inside the sandboxed process.
|
||||||
|
|
||||||
|
5. **ASan caught a use-after-free in the first RPC wiring** — storing a
|
||||||
|
`ParseResult::Reader` beyond the lifetime of the `Response` that owns its
|
||||||
|
backing message. Fixed by validating the response inside the `Response`'s
|
||||||
|
scope. This validates the §7.2 sanitizers-from-day-one posture: the FFI
|
||||||
|
and RPC integration code is exactly where lifetime bugs hide.
|
||||||
|
|
||||||
|
6. **The bidirectional trust boundary is exercisable.** The UI validates every
|
||||||
response field (request id, byte count, ok) and the document process
|
response field (request id, byte count, ok) and the document process
|
||||||
bounds-checks every request length — both sides of ADR-0004 are
|
bounds-checks every request length — both sides of ADR-0004 are
|
||||||
demonstrated. The production M2 code fuzzes the UI-side deserializer as a
|
demonstrated. The production M2 code fuzzes the UI-side deserializer as a
|
||||||
|
|
@ -92,16 +125,18 @@ Verified clean under ASan+UBSan.
|
||||||
|
|
||||||
- **The process split is viable and cheap.** The §2.1 architecture (one
|
- **The process split is viable and cheap.** The §2.1 architecture (one
|
||||||
sandboxed document process per open document, IPC over a local socket) has
|
sandboxed document process per open document, IPC over a local socket) has
|
||||||
a measured cost that fits the performance budget with enormous headroom. The
|
a measured cost that fits the performance budget with enormous headroom.
|
||||||
"genuinely painful to retrofit" concern (§14 step 8) is de-risked: the
|
The "genuinely painful to retrofit" concern (§14 step 8) is de-risked: the
|
||||||
sandbox is installed after fork, before any untrusted data, and works.
|
sandbox is installed after fork, before any untrusted data, and works.
|
||||||
|
- **The Cap'n Proto RPC transport is proven over a socketpair.** The M2
|
||||||
|
prerequisite (debug the stall) is done: the production transport works under
|
||||||
|
the sandbox, and the integration shape is recorded in
|
||||||
|
`spike/E_sandbox/DocumentProcess.cpp`. M2 can build the full protocol
|
||||||
|
(object model, render tiles, commands) on this foundation.
|
||||||
- **The sandbox is the security foundation** (ADR-0004). macOS App Sandbox
|
- **The sandbox is the security foundation** (ADR-0004). macOS App Sandbox
|
||||||
and Windows AppContainer land in M1; the Linux seccomp-bpf filter here is
|
and Windows AppContainer land in M1; the Linux seccomp-bpf filter here is
|
||||||
the template. The §7.1 threat-model row "parser memory corruption →
|
the template. The §7.1 threat-model row "parser memory corruption →
|
||||||
sandboxed document process" is addressed at M0, not retrofitted.
|
sandboxed document process" is addressed at M0, not retrofitted.
|
||||||
- **Cap'n Proto remains the planned M2 transport** (§2.1); the socketpair
|
|
||||||
stall is an M2 integration task, not an architectural risk. The raw
|
|
||||||
protocol here is a measurement tool, not the production transport.
|
|
||||||
|
|
||||||
## Reproducing
|
## Reproducing
|
||||||
|
|
||||||
|
|
@ -114,4 +149,4 @@ FPE_SPIKE_E_NO_SANDBOX=1 build/manual/bin/spike_e_sandbox 1000 1024
|
||||||
```
|
```
|
||||||
|
|
||||||
Exit 0 if all requests round-trip under the sandbox, 1 otherwise. Also
|
Exit 0 if all requests round-trip under the sandbox, 1 otherwise. Also
|
||||||
verified clean under ASan+UBSan.
|
verified clean under ASan+UBSan (sandbox disabled).
|
||||||
|
|
@ -132,23 +132,51 @@ endif()
|
||||||
|
|
||||||
# --- Spike E: sandbox + IPC bring-up (§14 step 8) ---
|
# --- Spike E: sandbox + IPC bring-up (§14 step 8) ---
|
||||||
# Two-process model (ADR-0004): a sandboxed document process (seccomp-bpf) and
|
# Two-process model (ADR-0004): a sandboxed document process (seccomp-bpf) and
|
||||||
# a UI process, talking over a socketpair via a length-prefixed binary IPC.
|
# a UI process, talking over a socketpair via Cap'n Proto two-party RPC.
|
||||||
# This is the one thing the plan says is genuinely painful to retrofit, so it
|
# This is the one thing the plan says is genuinely painful to retrofit, so it
|
||||||
# is de-risked at M0. Linux-only at this stage (seccomp-bpf); macOS App Sandbox
|
# is de-risked at M0. Linux-only at this stage (seccomp-bpf); macOS App Sandbox
|
||||||
# and Windows AppContainer land in M1.
|
# and Windows AppContainer land in M1.
|
||||||
#
|
#
|
||||||
# An earlier version used Cap'n Proto two-party RPC over the socketpair; the
|
# Integration note: the server side uses the low-level TwoPartyVatNetwork +
|
||||||
# server received and processed requests but the responses never reached the
|
# makeRpcServer over wrapSocketFd (NOT EzRpcServer, which calls accept() on a
|
||||||
# client — a real integration issue recorded in the spike result doc for M2 to
|
# listening socket and fails on a connected socketpair end with EINVAL — the
|
||||||
# debug with the full event-loop integration. The raw length-prefixed protocol
|
# original Spike E stall, now resolved). The client uses EzRpcClient(fd),
|
||||||
# measures the channel cost without that blocker. The Cap'n Proto schema
|
# which is designed for an already-connected socket. See
|
||||||
# (ipc.capnp) is kept for the M2 RPC work.
|
# spike/E_sandbox/DocumentProcess.cpp for the full note.
|
||||||
find_package(PkgConfig QUIET)
|
find_package(PkgConfig QUIET)
|
||||||
if(PkgConfig_FOUND)
|
if(PkgConfig_FOUND)
|
||||||
pkg_check_modules(SECCOMP libseccomp IMPORTED_TARGET)
|
pkg_check_modules(SECCOMP libseccomp IMPORTED_TARGET)
|
||||||
|
pkg_check_modules(CAPNP capnp-rpc capnp IMPORTED_TARGET)
|
||||||
endif()
|
endif()
|
||||||
|
# The capnp compiler is needed to generate the C++ from the schema. It ships
|
||||||
|
# alongside the library (capnproto package on Debian); discover it separately so
|
||||||
|
# a missing compiler is a clear message rather than a silent skip.
|
||||||
|
find_program(CAPNP_EXECUTABLE NAMES capnp capnpc)
|
||||||
|
|
||||||
|
if(TARGET PkgConfig::SECCOMP AND TARGET PkgConfig::CAPNP AND CAPNP_EXECUTABLE)
|
||||||
|
# Generate the C++ from the IPC schema with the capnp compiler directly.
|
||||||
|
# (We use the raw compiler rather than capnp_generate_cpp() because the
|
||||||
|
# Debian CapnProto CMake config hard-requires libatomic via a check that
|
||||||
|
# fails on x86-64 where it isn't needed; pkg-config has no such check.)
|
||||||
|
set(_e_schema ${CMAKE_CURRENT_SOURCE_DIR}/E_sandbox/ipc.capnp)
|
||||||
|
set(_e_gen_dir ${CMAKE_CURRENT_BINARY_DIR}/E_sandbox_generated)
|
||||||
|
set(_e_gen_hdr ${_e_gen_dir}/ipc.capnp.h)
|
||||||
|
set(_e_gen_src ${_e_gen_dir}/ipc.capnp.c++)
|
||||||
|
file(MAKE_DIRECTORY ${_e_gen_dir})
|
||||||
|
# --src-prefix strips the given prefix from the input path so the output
|
||||||
|
# is written flat into the output dir (ipc.capnp.h / ipc.capnp.c++) rather
|
||||||
|
# than mirroring the spike/E_sandbox/ source tree.
|
||||||
|
add_custom_command(
|
||||||
|
OUTPUT ${_e_gen_hdr} ${_e_gen_src}
|
||||||
|
COMMAND ${CAPNP_EXECUTABLE} compile
|
||||||
|
--src-prefix=${CMAKE_CURRENT_SOURCE_DIR}/E_sandbox
|
||||||
|
-I${CAPNP_INCLUDE_DIRS}
|
||||||
|
-oc++:${_e_gen_dir}
|
||||||
|
${_e_schema}
|
||||||
|
DEPENDS ${_e_schema}
|
||||||
|
COMMENT "Generating Cap'n Proto C++ for Spike E IPC schema"
|
||||||
|
VERBATIM)
|
||||||
|
|
||||||
if(TARGET PkgConfig::SECCOMP)
|
|
||||||
add_executable(spike_e_sandbox
|
add_executable(spike_e_sandbox
|
||||||
common/SpikeRunner.cpp
|
common/SpikeRunner.cpp
|
||||||
common/SpikeRunner.h
|
common/SpikeRunner.h
|
||||||
|
|
@ -159,14 +187,26 @@ if(TARGET PkgConfig::SECCOMP)
|
||||||
E_sandbox/DocumentProcess.h
|
E_sandbox/DocumentProcess.h
|
||||||
E_sandbox/UIProcess.cpp
|
E_sandbox/UIProcess.cpp
|
||||||
E_sandbox/UIProcess.h
|
E_sandbox/UIProcess.h
|
||||||
|
${_e_gen_src}
|
||||||
)
|
)
|
||||||
target_include_directories(spike_e_sandbox PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(spike_e_sandbox PRIVATE
|
||||||
target_link_libraries(spike_e_sandbox PRIVATE PkgConfig::SECCOMP)
|
${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
${_e_gen_dir})
|
||||||
|
target_link_libraries(spike_e_sandbox PRIVATE
|
||||||
|
PkgConfig::SECCOMP
|
||||||
|
PkgConfig::CAPNP)
|
||||||
target_compile_features(spike_e_sandbox PRIVATE cxx_std_20)
|
target_compile_features(spike_e_sandbox PRIVATE cxx_std_20)
|
||||||
freepdfeditor_apply_warnings(spike_e_sandbox)
|
freepdfeditor_apply_warnings(spike_e_sandbox)
|
||||||
freepdfeditor_apply_hardening(spike_e_sandbox)
|
freepdfeditor_apply_hardening(spike_e_sandbox)
|
||||||
else()
|
else()
|
||||||
message(STATUS
|
message(STATUS
|
||||||
"libseccomp not found — Spike E (sandbox) will not be built. "
|
"libseccomp, Cap'n Proto, or the capnp compiler not found — Spike E "
|
||||||
"Install libseccomp-dev to enable it.")
|
"(sandbox) will not be built. Install libseccomp-dev, libcapnp-dev, "
|
||||||
|
"and capnproto to enable it.")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# --- Spike F: Rust-vs-C++ build-friction probe (ADR-0005 input, §15) ---
|
||||||
|
# Not a product spike; provides the empirical build-cost data for the
|
||||||
|
# Rust-vs-C++ leaf-decoder decision. Has its own CMakeLists so it can be
|
||||||
|
# excluded from builds without Rust.
|
||||||
|
include(${CMAKE_CURRENT_SOURCE_DIR}/F_rust_ffi_probe/CMakeLists.txt)
|
||||||
|
|
@ -2,59 +2,68 @@
|
||||||
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
|
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
|
||||||
//
|
//
|
||||||
// DocumentProcess.cpp — the sandboxed document process. Installs the seccomp
|
// DocumentProcess.cpp — the sandboxed document process. Installs the seccomp
|
||||||
// filter, then serves Parse requests over the socketpair fd using a trivial
|
// filter, then serves Parse requests over the socketpair fd using Cap'n Proto
|
||||||
// length-prefixed binary protocol.
|
// two-party RPC (the M2 transport, ADR-0004).
|
||||||
//
|
//
|
||||||
// Protocol (the "parse" operation counts the bytes it was handed):
|
// Integration note (the Spike E stall, now resolved): the server side CANNOT
|
||||||
// Request: [u64 request_id][u64 payload_len][payload_len bytes]
|
// use `EzRpcServer(int fd, ...)` — that constructor expects a LISTENING
|
||||||
// Response: [u64 request_id][u64 byte_count][u8 ok]
|
// socket and calls accept() on it. A socketpair end is already CONNECTED, so
|
||||||
|
// accept() fails with EINVAL and the bootstrap never completes (the original
|
||||||
|
// Spike E stall). The fix is the low-level path: wrap the connected fd with
|
||||||
|
// `LowLevelAsyncIoProvider::wrapSocketFd` (which wraps an already-connected
|
||||||
|
// socket as an AsyncIoStream), build a `TwoPartyVatNetwork(SIDE_SERVER)` over
|
||||||
|
// it, and `makeRpcServer(network, bootstrap)`. This is the shape the
|
||||||
|
// production M2 transport uses.
|
||||||
//
|
//
|
||||||
// The spike's goal (§14 step 8) is to measure the cost of the process split
|
// The sandbox is installed BEFORE the KJ event loop runs. The seccomp
|
||||||
// and prove the sandbox works, not to exercise Cap'n Proto. An earlier
|
// allow-list therefore includes the epoll/eventfd/timerfd syscalls the KJ
|
||||||
// version used Cap'n Proto two-party RPC over the socketpair; the server
|
// async I/O layer needs (Sandbox.cpp); the raw-protocol variant only needed
|
||||||
// received and processed requests (the parse handler ran) but the responses
|
// read/write.
|
||||||
// never reached the client — a real integration issue recorded in the spike
|
|
||||||
// result doc for M2 to debug with the full event-loop integration. The raw
|
|
||||||
// protocol measures the channel cost without that blocker.
|
|
||||||
|
|
||||||
#include "DocumentProcess.h"
|
#include "DocumentProcess.h"
|
||||||
#include "Sandbox.h"
|
#include "Sandbox.h"
|
||||||
|
#include "ipc.capnp.h"
|
||||||
|
|
||||||
|
#include <capnp/rpc-twoparty.h>
|
||||||
|
#include <capnp/rpc.h>
|
||||||
|
#include <kj/async-io.h>
|
||||||
|
#include <kj/memory.h>
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace proto = freepdfeditor::spike::e;
|
||||||
|
|
||||||
namespace freepdfeditor::spike::e {
|
namespace freepdfeditor::spike::e {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// Read exactly n bytes from fd (handles partial reads). Returns false on EOF/error.
|
// The document-process capability: a trivial "parser" that counts the bytes
|
||||||
bool read_exact(int fd, void* buf, std::size_t n)
|
// it was handed and returns the count. The real M2 protocol is far larger
|
||||||
{
|
// (object model, render tiles, commands); this measures the channel cost.
|
||||||
auto* p = static_cast<char*>(buf);
|
class DocumentProcessImpl final : public proto::DocumentProcess::Server {
|
||||||
while (n > 0) {
|
public:
|
||||||
ssize_t r = read(fd, p, n);
|
kj::Promise<void> parse(ParseContext ctx) override {
|
||||||
if (r <= 0) return false;
|
auto req = ctx.getParams().getRequest();
|
||||||
p += r; n -= std::size_t(r);
|
// Per ADR-0004 the document process is untrusted: bound-check the
|
||||||
|
// payload length it was handed (defence in depth; the UI also checks).
|
||||||
|
auto data = req.getData();
|
||||||
|
if (data.size() > 16 * 1024 * 1024) { // 16 MiB cap
|
||||||
|
auto r = ctx.getResults().initResult();
|
||||||
|
r.setRequestId(req.getRequestId());
|
||||||
|
r.setOk(false);
|
||||||
|
r.setError("payload exceeds 16 MiB cap");
|
||||||
|
return kj::READY_NOW;
|
||||||
}
|
}
|
||||||
return true;
|
auto r = ctx.getResults().initResult();
|
||||||
}
|
r.setRequestId(req.getRequestId());
|
||||||
|
r.setByteCount(data.size());
|
||||||
// Write exactly n bytes to fd (handles partial writes). Returns false on error.
|
r.setOk(true);
|
||||||
bool write_exact(int fd, const void* buf, std::size_t n)
|
return kj::READY_NOW;
|
||||||
{
|
|
||||||
const auto* p = static_cast<const char*>(buf);
|
|
||||||
while (n > 0) {
|
|
||||||
ssize_t w = write(fd, p, n);
|
|
||||||
if (w <= 0) return false;
|
|
||||||
p += w; n -= std::size_t(w);
|
|
||||||
}
|
}
|
||||||
return true;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
|
@ -74,23 +83,31 @@ int run_document_process(int fd)
|
||||||
std::fprintf(stderr, "[doc] sandbox DISABLED (FPE_SPIKE_E_NO_SANDBOX set)\n");
|
std::fprintf(stderr, "[doc] sandbox DISABLED (FPE_SPIKE_E_NO_SANDBOX set)\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve requests until the UI process closes the channel (EOF on read).
|
// Set up the KJ async I/O context. This creates the epoll/eventfd
|
||||||
for (;;) {
|
// machinery the event loop needs (all allowed by the seccomp filter).
|
||||||
std::uint64_t request_id = 0, payload_len = 0;
|
kj::AsyncIoContext io = kj::setupAsyncIo();
|
||||||
if (!read_exact(fd, &request_id, sizeof(request_id))) break; // EOF
|
|
||||||
if (!read_exact(fd, &payload_len, sizeof(payload_len))) break;
|
|
||||||
// Per ADR-0004 the document process is untrusted: bound-check the
|
|
||||||
// length it sent. The UI also checks, but defence in depth.
|
|
||||||
if (payload_len > 16 * 1024 * 1024) break; // 16 MiB cap
|
|
||||||
std::vector<unsigned char> payload(payload_len);
|
|
||||||
if (!read_exact(fd, payload.data(), payload_len)) break;
|
|
||||||
|
|
||||||
// The "parse": count the bytes.
|
// Wrap the already-connected socketpair end as an AsyncIoStream. This is
|
||||||
std::uint64_t byte_count = payload_len;
|
// the key difference from the stalled EzRpcServer path: wrapSocketFd does
|
||||||
std::uint8_t ok = 1;
|
// NOT call accept() — it uses the fd as-is for read/write.
|
||||||
if (!write_exact(fd, &request_id, sizeof(request_id))) break;
|
kj::Own<kj::AsyncIoStream> stream = io.lowLevelProvider->wrapSocketFd(fd);
|
||||||
if (!write_exact(fd, &byte_count, sizeof(byte_count))) break;
|
|
||||||
if (!write_exact(fd, &ok, sizeof(ok))) break;
|
// Two-party RPC: the document process is the SERVER side (it serves the
|
||||||
|
// bootstrap capability the UI imports via getMain()).
|
||||||
|
capnp::TwoPartyVatNetwork network(*stream, capnp::rpc::twoparty::Side::SERVER);
|
||||||
|
auto server = capnp::makeRpcServer(network, kj::heap<DocumentProcessImpl>());
|
||||||
|
|
||||||
|
// Run the event loop until the UI process disconnects (onDisconnect
|
||||||
|
// resolves), then return cleanly.
|
||||||
|
auto disconnect = network.onDisconnect().then([]() {
|
||||||
|
std::fprintf(stderr, "[doc] UI disconnected\n");
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
disconnect.wait(io.waitScope);
|
||||||
|
} catch (kj::Exception& e) {
|
||||||
|
std::fprintf(stderr, "[doc] event loop error: %s\n",
|
||||||
|
e.getDescription().cStr());
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,10 @@ int install_sandbox()
|
||||||
allow_syscall(ctx, SCMP_SYS(timerfd_create));
|
allow_syscall(ctx, SCMP_SYS(timerfd_create));
|
||||||
allow_syscall(ctx, SCMP_SYS(timerfd_settime));
|
allow_syscall(ctx, SCMP_SYS(timerfd_settime));
|
||||||
allow_syscall(ctx, SCMP_SYS(dup)); // Cap'n Proto may dup its socket
|
allow_syscall(ctx, SCMP_SYS(dup)); // Cap'n Proto may dup its socket
|
||||||
|
// ioctl(FIONBIO) is used by KJ's wrapSocketFd to set non-blocking mode on
|
||||||
|
// the socketpair end. Allow the syscall; FIONBIO is the only request the
|
||||||
|
// event loop issues on the IPC fd (no TCGETS/SG_ISPTTY etc. on a socket).
|
||||||
|
allow_syscall(ctx, SCMP_SYS(ioctl));
|
||||||
|
|
||||||
// Exit / signal.
|
// Exit / signal.
|
||||||
allow_syscall(ctx, SCMP_SYS(exit));
|
allow_syscall(ctx, SCMP_SYS(exit));
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
|
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
|
||||||
//
|
//
|
||||||
// UIProcess.cpp — the UI process side. Drives the length-prefixed binary IPC
|
// UIProcess.cpp — the UI process side. Drives the Cap'n Proto RPC channel,
|
||||||
// channel, sends Parse requests, and measures round-trip latency. The UI
|
// sends Parse requests, and measures round-trip latency. The UI does no
|
||||||
// does no untrusted parsing; it validates the document process's responses
|
// untrusted parsing; it validates the document process's responses (ADR-0004:
|
||||||
// (ADR-0004: bidirectional trust boundary).
|
// bidirectional trust boundary).
|
||||||
|
//
|
||||||
|
// The client uses `EzRpcClient(int fd)`, which is designed for an
|
||||||
|
// already-connected socket (unlike EzRpcServer, which expects a listening
|
||||||
|
// socket). See DocumentProcess.cpp for the server-side integration note.
|
||||||
|
|
||||||
#include "UIProcess.h"
|
#include "UIProcess.h"
|
||||||
|
#include "ipc.capnp.h"
|
||||||
|
|
||||||
|
#include <capnp/ez-rpc.h>
|
||||||
|
#include <capnp/message.h>
|
||||||
|
#include <kj/array.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
|
@ -17,90 +26,70 @@
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace proto = freepdfeditor::spike::e;
|
||||||
|
|
||||||
namespace freepdfeditor::spike::e {
|
namespace freepdfeditor::spike::e {
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
bool read_exact(int fd, void* buf, std::size_t n)
|
|
||||||
{
|
|
||||||
auto* p = static_cast<char*>(buf);
|
|
||||||
while (n > 0) {
|
|
||||||
ssize_t r = read(fd, p, n);
|
|
||||||
if (r <= 0) return false;
|
|
||||||
p += r; n -= std::size_t(r);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool write_exact(int fd, const void* buf, std::size_t n)
|
|
||||||
{
|
|
||||||
const auto* p = static_cast<const char*>(buf);
|
|
||||||
while (n > 0) {
|
|
||||||
ssize_t w = write(fd, p, n);
|
|
||||||
if (w <= 0) return false;
|
|
||||||
p += w; n -= std::size_t(w);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
UIResult run_ui_process(int parent_fd, int child_fd,
|
UIResult run_ui_process(int parent_fd, int child_fd,
|
||||||
std::size_t n_requests, std::size_t payload_bytes)
|
std::size_t n_requests, std::size_t payload_bytes)
|
||||||
{
|
{
|
||||||
(void)child_fd; // the child end is the document process's concern
|
(void)child_fd; // the child end is the document process's concern
|
||||||
UIResult r{};
|
UIResult r{};
|
||||||
|
|
||||||
|
// The payload the UI hands the document process. Sent as a Cap'n Proto
|
||||||
|
// Data field; the document process counts the bytes and returns the count.
|
||||||
std::vector<unsigned char> payload(payload_bytes, 0xAB);
|
std::vector<unsigned char> payload(payload_bytes, 0xAB);
|
||||||
|
|
||||||
|
// EzRpcClient(int fd) wraps an already-connected socket — exactly the
|
||||||
|
// socketpair-end shape. It sets up its own KJ EventLoop + WaitScope.
|
||||||
|
capnp::EzRpcClient client(parent_fd);
|
||||||
|
auto& waitScope = client.getWaitScope();
|
||||||
|
proto::DocumentProcess::Client doc =
|
||||||
|
client.getMain().castAs<proto::DocumentProcess>();
|
||||||
|
|
||||||
double min_us = 1e18, max_us = 0.0, sum_us = 0.0;
|
double min_us = 1e18, max_us = 0.0, sum_us = 0.0;
|
||||||
std::size_t acked = 0;
|
std::size_t acked = 0;
|
||||||
|
|
||||||
for (std::size_t i = 0; i < n_requests; ++i) {
|
for (std::size_t i = 0; i < n_requests; ++i) {
|
||||||
auto t0 = std::chrono::steady_clock::now();
|
auto t0 = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
// Request: [u64 request_id][u64 payload_len][payload]
|
auto req = doc.parseRequest();
|
||||||
std::uint64_t request_id = static_cast<std::uint64_t>(i);
|
req.getRequest().setRequestId(static_cast<std::uint64_t>(i));
|
||||||
std::uint64_t payload_len = static_cast<std::uint64_t>(payload_bytes);
|
req.getRequest().setData(kj::ArrayPtr<const kj::byte>(
|
||||||
if (!write_exact(parent_fd, &request_id, sizeof(request_id))) {
|
payload.data(), payload.size()));
|
||||||
r.error = "write request_id failed at " + std::to_string(i);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!write_exact(parent_fd, &payload_len, sizeof(payload_len))) {
|
|
||||||
r.error = "write payload_len failed at " + std::to_string(i);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!write_exact(parent_fd, payload.data(), payload_bytes)) {
|
|
||||||
r.error = "write payload failed at " + std::to_string(i);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response: [u64 request_id][u64 byte_count][u8 ok]
|
// The Response owns the message backing the result reader, so it must
|
||||||
std::uint64_t resp_id = 0, byte_count = 0;
|
// stay alive for as long as we read from `resp` (a dangling reader is
|
||||||
std::uint8_t ok = 0;
|
// a use-after-free — ASan catches it, validating §7.2). Validate the
|
||||||
if (!read_exact(parent_fd, &resp_id, sizeof(resp_id))) {
|
// response inside the scope that owns the Response.
|
||||||
r.error = "read resp_id failed at " + std::to_string(i);
|
bool ok = false;
|
||||||
break;
|
std::string err;
|
||||||
|
std::uint64_t resp_id = 0;
|
||||||
|
std::uint64_t byte_count = 0;
|
||||||
|
try {
|
||||||
|
auto response = req.send().wait(waitScope);
|
||||||
|
auto resp = response.getResult();
|
||||||
|
ok = resp.getOk();
|
||||||
|
if (!ok) {
|
||||||
|
err = "doc returned ok=false: " + std::string(resp.getError().cStr());
|
||||||
|
} else {
|
||||||
|
resp_id = resp.getRequestId();
|
||||||
|
byte_count = resp.getByteCount();
|
||||||
}
|
}
|
||||||
if (!read_exact(parent_fd, &byte_count, sizeof(byte_count))) {
|
} catch (kj::Exception& e) {
|
||||||
r.error = "read byte_count failed at " + std::to_string(i);
|
err = "RPC failed: " + std::string(e.getDescription().cStr());
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!read_exact(parent_fd, &ok, sizeof(ok))) {
|
|
||||||
r.error = "read ok failed at " + std::to_string(i);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auto t1 = std::chrono::steady_clock::now();
|
auto t1 = std::chrono::steady_clock::now();
|
||||||
double us = std::chrono::duration<double, std::micro>(t1 - t0).count();
|
double us = std::chrono::duration<double, std::micro>(t1 - t0).count();
|
||||||
sum_us += us;
|
|
||||||
min_us = std::min(min_us, us);
|
|
||||||
max_us = std::max(max_us, us);
|
|
||||||
|
|
||||||
// Validate the response (ADR-0004: the IPC is a trust boundary in both
|
// Validate the response (ADR-0004: the IPC is a trust boundary in both
|
||||||
// directions — the UI never trusts the document process blindly).
|
// directions — the UI never trusts the document process blindly).
|
||||||
if (!ok) { r.error = "doc returned ok=false at " + std::to_string(i); break; }
|
if (!ok) {
|
||||||
if (resp_id != request_id) {
|
r.error = err + " at " + std::to_string(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (resp_id != static_cast<std::uint64_t>(i)) {
|
||||||
r.error = "request id mismatch at " + std::to_string(i);
|
r.error = "request id mismatch at " + std::to_string(i);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +99,10 @@ UIResult run_ui_process(int parent_fd, int child_fd,
|
||||||
" expected " + std::to_string(payload_bytes);
|
" expected " + std::to_string(payload_bytes);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sum_us += us;
|
||||||
|
min_us = std::min(min_us, us);
|
||||||
|
max_us = std::max(max_us, us);
|
||||||
++acked;
|
++acked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ int main(int argc, char** argv)
|
||||||
|
|
||||||
freepdfeditor::spike::SpikeResult sr{};
|
freepdfeditor::spike::SpikeResult sr{};
|
||||||
sr.spike = "E";
|
sr.spike = "E";
|
||||||
sr.name = "sandbox + IPC bring-up (seccomp-bpf, length-prefixed socketpair)";
|
sr.name = "sandbox + IPC bring-up (seccomp-bpf, Cap'n Proto two-party RPC over socketpair)";
|
||||||
sr.total = n_requests;
|
sr.total = n_requests;
|
||||||
sr.passed = r.requests_acked;
|
sr.passed = r.requests_acked;
|
||||||
sr.failed = n_requests - r.requests_acked;
|
sr.failed = n_requests - r.requests_acked;
|
||||||
|
|
@ -96,7 +96,8 @@ int main(int argc, char** argv)
|
||||||
"requests=%zu acked=%zu payload=%zuB "
|
"requests=%zu acked=%zu payload=%zuB "
|
||||||
"avg_latency=%.1fus min=%.1fus max=%.1fus wall=%.1fus "
|
"avg_latency=%.1fus min=%.1fus max=%.1fus wall=%.1fus "
|
||||||
"child_exit=%d; seccomp-bpf deny=open/socket/connect/fork/exec, "
|
"child_exit=%d; seccomp-bpf deny=open/socket/connect/fork/exec, "
|
||||||
"allow=memory/read/write/poll/epoll/exit",
|
"allow=memory/read/write/poll/epoll/ioctl/exit; "
|
||||||
|
"transport=Cap'n Proto two-party RPC (wrapSocketFd + TwoPartyVatNetwork)",
|
||||||
n_requests, r.requests_acked, payload_bytes,
|
n_requests, r.requests_acked, payload_bytes,
|
||||||
r.avg_latency_us, r.min_latency_us, r.max_latency_us, wall_us,
|
r.avg_latency_us, r.min_latency_us, r.max_latency_us, wall_us,
|
||||||
WIFEXITED(status) ? WEXITSTATUS(status) : -1);
|
WIFEXITED(status) ? WEXITSTATUS(status) : -1);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
|
||||||
|
#
|
||||||
|
# Spike F — ADR-0005 Rust-vs-C++ build-friction probe (§15 decision data).
|
||||||
|
# Builds a tiny Rust leaf decoder (staticlib) behind a C ABI and links it into
|
||||||
|
# a C++ driver, to measure the actual integration cost of adding Rust to the
|
||||||
|
# tree. This is the empirical input to the Rust-vs-C++ decision; it is not a
|
||||||
|
# product spike.
|
||||||
|
|
||||||
|
# Find cargo. The probe only builds if Rust is available; the ADR records the
|
||||||
|
# decision either way, so a missing Rust toolchain doesn't block the decision.
|
||||||
|
find_program(CARGO_EXECUTABLE NAMES cargo)
|
||||||
|
|
||||||
|
if(CARGO_EXECUTABLE)
|
||||||
|
set(_rust_dir ${CMAKE_CURRENT_SOURCE_DIR}/F_rust_ffi_probe/rust)
|
||||||
|
set(_rust_lib ${_rust_dir}/target/release/libfpe_rust_leaf.a)
|
||||||
|
|
||||||
|
# Build the Rust staticlib with cargo. The staticlib is the link shape that
|
||||||
|
# avoids a runtime .so dependency — the standard Rust-into-C++ integration.
|
||||||
|
add_custom_command(
|
||||||
|
OUTPUT ${_rust_lib}
|
||||||
|
COMMAND ${CARGO_EXECUTABLE} build --release --manifest-path ${_rust_dir}/Cargo.toml
|
||||||
|
DEPENDS ${_rust_dir}/Cargo.toml ${_rust_dir}/src/lib.rs
|
||||||
|
COMMENT "Building Rust leaf decoder (staticlib) for FFI probe"
|
||||||
|
VERBATIM)
|
||||||
|
|
||||||
|
add_executable(spike_f_rust_ffi
|
||||||
|
common/SpikeRunner.cpp
|
||||||
|
common/SpikeRunner.h
|
||||||
|
F_rust_ffi_probe/cpp_driver.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(spike_f_rust_ffi PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
target_compile_features(spike_f_rust_ffi PRIVATE cxx_std_20)
|
||||||
|
freepdfeditor_apply_warnings(spike_f_rust_ffi)
|
||||||
|
freepdfeditor_apply_hardening(spike_f_rust_ffi)
|
||||||
|
|
||||||
|
# Link the Rust staticlib by absolute path (a .a is an archive, not a
|
||||||
|
# -l flag). Mark it as a dependency of the custom command output so the
|
||||||
|
# Rust build runs before the link.
|
||||||
|
add_dependencies(spike_f_rust_ffi fpe_rust_leaf_staticlib)
|
||||||
|
target_link_libraries(spike_f_rust_ffi PRIVATE
|
||||||
|
${_rust_lib} pthread dl)
|
||||||
|
target_link_options(spike_f_rust_ffi PRIVATE -static-libgcc)
|
||||||
|
|
||||||
|
# Make the staticlib a real CMake target with a custom command so the
|
||||||
|
# build graph knows it is produced, not pre-existing.
|
||||||
|
add_custom_target(fpe_rust_leaf_staticlib DEPENDS ${_rust_lib})
|
||||||
|
|
||||||
|
# Skip the rust spike from the default 'all' target if Rust isn't wanted in
|
||||||
|
# a given build (e.g. a CI image without cargo) — it's gated on CARGO_EXECUTABLE.
|
||||||
|
else()
|
||||||
|
message(STATUS "cargo not found — Spike F (Rust FFI probe) skipped; "
|
||||||
|
"ADR-0005 will record the decision without the empirical build data.")
|
||||||
|
endif()
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
|
||||||
|
//
|
||||||
|
// spike/F_rust_ffi_probe — ADR-0005 build-friction probe. Measures the cost of
|
||||||
|
// adding a Rust leaf decoder to the tree: a tiny `flate`-style decoder stub
|
||||||
|
// (echoes a byte buffer) compiled as a `cdylib`/`staticlib` behind a C ABI,
|
||||||
|
// linked into a C++ driver. The point is the build friction and FFI shape,
|
||||||
|
// not real decompression.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// The C ABI the Rust crate exposes. Declared here (not from a generated
|
||||||
|
// header) to show the minimal glue shape.
|
||||||
|
extern "C" {
|
||||||
|
// A bounded view, matching the §7.2 "std::span-like bounded views" rule.
|
||||||
|
// The Rust side gets a pointer + length and must not read past length.
|
||||||
|
int fpe_rust_decode(const unsigned char* in, std::size_t in_len,
|
||||||
|
unsigned char* out, std::size_t out_cap,
|
||||||
|
std::size_t* out_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char** argv)
|
||||||
|
{
|
||||||
|
const std::size_t n = (argc >= 2) ? std::size_t(std::atoll(argv[1])) : 1024;
|
||||||
|
std::vector<unsigned char> input(n, 0x5A);
|
||||||
|
std::vector<unsigned char> output(n * 2, 0);
|
||||||
|
|
||||||
|
std::size_t out_len = 0;
|
||||||
|
int rc = fpe_rust_decode(input.data(), input.size(),
|
||||||
|
output.data(), output.size(), &out_len);
|
||||||
|
if (rc != 0) {
|
||||||
|
std::fprintf(stderr, "decode failed: %d\n", rc);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// Verify the (stub) decoder echoed the input.
|
||||||
|
if (out_len != input.size() ||
|
||||||
|
std::memcmp(output.data(), input.data(), input.size()) != 0) {
|
||||||
|
std::fprintf(stderr, "decode output mismatch\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
std::printf("rust_ffi_probe: decoded %zu bytes via Rust C ABI, rc=%d\n",
|
||||||
|
out_len, rc);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fpe_rust_leaf"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
|
||||||
|
#
|
||||||
|
# ADR-0005 build-friction probe crate. A staticlib so the C++ driver links it
|
||||||
|
# without a runtime .so dependency. crate-type = ["staticlib"] is the standard
|
||||||
|
# Rust-into-C++ integration shape; "cdylib" would be for a .so.
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "fpe_rust_leaf"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "fpe_rust_leaf"
|
||||||
|
crate-type = ["staticlib"]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
# Release profile: the leaf decoder ships hardened. Mirrors the C++ side's
|
||||||
|
# -fstack-protector-strong / overflow checks via Rust's default release
|
||||||
|
# arithmetic checks and overflow guards.
|
||||||
|
[profile.release]
|
||||||
|
panic = "abort" # match the C++ side: no unwinding across the FFI
|
||||||
|
overflow-checks = true
|
||||||
|
lto = true
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
|
||||||
|
//
|
||||||
|
// ADR-0005 build-friction probe: a Rust leaf decoder behind a C ABI. The
|
||||||
|
// crate builds as a staticlib so it links into the C++ driver without a
|
||||||
|
// runtime .so dependency. The "decode" is a stub (echo the input) — the
|
||||||
|
// point is the FFI shape and build cost, not real decompression.
|
||||||
|
|
||||||
|
use std::slice;
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `in_buf` must be valid for `in_len` bytes; `out_buf` for `out_cap`.
|
||||||
|
/// Returns 0 on success and writes the decoded length to `*out_len`.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn fpe_rust_decode(
|
||||||
|
in_buf: *const u8,
|
||||||
|
in_len: usize,
|
||||||
|
out_buf: *mut u8,
|
||||||
|
out_cap: usize,
|
||||||
|
out_len: *mut usize,
|
||||||
|
) -> i32 {
|
||||||
|
// Bounded view — the §7.2 rule, enforced by the type system here.
|
||||||
|
if in_buf.is_null() || out_buf.is_null() || out_len.is_null() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if in_len > out_cap {
|
||||||
|
return 2; // output buffer too small
|
||||||
|
}
|
||||||
|
let input = unsafe { slice::from_raw_parts(in_buf, in_len) };
|
||||||
|
let output = unsafe { slice::from_raw_parts_mut(out_buf, out_cap) };
|
||||||
|
// The "decode": echo. Real Flate/LZW/CMap decode goes here in M2.
|
||||||
|
output[..in_len].copy_from_slice(input);
|
||||||
|
unsafe { ptr::write(out_len, in_len) };
|
||||||
|
0
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue