Compare commits

...

2 Commits

Author SHA1 Message Date
ai-ad4 d43d0402b8 feat(spike-E): resolve Cap'n Proto socketpair stall; wire RPC transport (M2 prereq)
The M0 Spike E recorded that Cap'n Proto two-party RPC over an AF_UNIX
socketpair stalled (server processed requests, responses never reached the
client). Root cause, confirmed with a standalone reproduction: EzRpcServer(int
fd, ...) expects a LISTENING socket and calls accept() on it; a socketpair end
is already CONNECTED, so accept() fails with EINVAL and the bootstrap never
completes. Fix: server side uses the low-level path —
LowLevelAsyncIoProvider::wrapSocketFd(fd) + TwoPartyVatNetwork(SIDE_SERVER) +
makeRpcServer(network, bootstrap). The client uses EzRpcClient(fd), which is
designed for an already-connected socket. This is the production M2 transport
shape.

- spike/E_sandbox/DocumentProcess.cpp: low-level TwoPartyVatNetwork server
  over wrapSocketFd; serves DocumentProcess::Server capability with 16 MiB
  payload cap (ADR-0004 bidirectional trust boundary)
- spike/E_sandbox/UIProcess.cpp: EzRpcClient(fd) + parse() RPC; validates
  every response field. ASan caught a use-after-free (ParseResult::Reader
  outliving its Response) — fixed by validating inside the Response scope
  (validates §7.2 sanitizers-from-day-one)
- spike/E_sandbox/Sandbox.cpp: allow ioctl (FIONBIO) — KJ's wrapSocketFd sets
  non-blocking mode via ioctl; strace identified it as the denied syscall
- spike/CMakeLists.txt: Cap'n Proto via pkg-config (the Debian CMake config
  hard-requires libatomic via a check that fails on x86-64 where it isn't
  needed); schema compiled with the capnp tool + --src-prefix
- docs/spike-results/0004: record the resolution; gate still MET (1000/1000
  under seccomp, ~58us avg; ASan-clean with sandbox disabled — ASan's pipe2
  conflicts with the seccomp allow-list, an ASan-only artefact)
2026-07-27 10:54:21 +00:00
ai-ad4 067bb7c362 feat(adr-0005): Rust for image-codec leaf decoders; resolve M0 Rust-vs-C++ decision (§15)
ADR-0005 records the decision deferred in ADR-0003: Rust (staticlib, C ABI)
for the image-codec leaf decoders where the CVE history concentrates
(OpenJPEG ~40 buffer-overflow CVEs incl. 2024, several 'as used in PDFium');
C++ throughout elsewhere. Spike F measures the build-friction cost empirically
(~10s cargo build, one .a link, ASan-clean FFI) rather than estimating it.

- docs/adr/0005-rust-leaf-decoders.md: decision + CVE survey + evidence
- docs/adr/0003 + README: mark deferred portion superseded by ADR-0005
- spike/F_rust_ffi_probe: Rust staticlib leaf + C++ driver, bounded (ptr,len)
  FFI matching §7.2; builds clean under ASan+UBSan
- .gitignore: ignore cargo target/ (keep Cargo.lock for reproducibility)
2026-07-27 10:42:57 +00:00
15 changed files with 630 additions and 183 deletions

12
.gitignore vendored
View File

@ -19,6 +19,18 @@ compile_commands.json
vcpkg/
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
.DS_Store
Thumbs.db

View File

@ -1,10 +1,11 @@
# ADR-0003 — Memory safety posture for the parser layers
* **Status**: Accepted (decision on Rust-vs-C++ for L1/L2 leaf decoders is
*deferred to end of M0* per §15; this ADR records the posture that holds
regardless of that decision)
* **Status**: Accepted. The Rust-vs-C++ decision deferred here is **resolved
by ADR-0005** (Rust for the image-codec leaf decoders; C++ elsewhere).
* **Date**: 2025-07-25
* **Plan reference**: §7.2, §15
* **Superseded by**: the deferred-decision portion is superseded by ADR-0005;
the hardening posture below stands.
## Context

View File

@ -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)
```

View File

@ -27,4 +27,7 @@ Per §13.4 and §14 step 9, the following ADRs exist from M0:
* [ADR-0003 — Memory safety posture for the parser layers](0003-memory-safety-posture.md)
(§7.2)
* [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.

View File

@ -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 (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.**
* **Gate**: the sandboxed document process round-trips IPC requests with the
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
(mmap/munmap/mprotect/brk/...), thread sync (futex), the already-open IPC
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`,
`fork`, `exec` — the document process cannot reach the network or the
filesystem, and cannot spawn children. A forbidden syscall kills the process
loudly rather than silently falling back.
- `ipc.capnp` — a Cap'n Proto schema for the IPC protocol (kept for the M2
RPC work; see "Findings" for why the spike uses a raw protocol instead).
- `ipc.capnp` — a Cap'n Proto schema for the IPC protocol. **Now the actual
transport** (see "Findings" for the resolution of the original stall).
- `DocumentProcess.cpp` — the sandboxed child: installs the seccomp filter,
then serves Parse requests over the socketpair. Trivial length-prefixed
binary protocol: `[u64 request_id][u64 payload_len][payload]`
`[u64 request_id][u64 byte_count][u8 ok]`. Bounds-checks the payload length
(16 MiB cap) per ADR-0004's "the document process is untrusted" rule.
- `UIProcess.cpp` — the UI process: sends Parse requests, measures round-trip
then serves Parse requests over the socketpair via Cap'n Proto two-party
RPC. Uses the low-level `LowLevelAsyncIoProvider::wrapSocketFd(fd)` +
`TwoPartyVatNetwork(SIDE_SERVER)` + `makeRpcServer(network, bootstrap)` path
(NOT `EzRpcServer` — see Findings). The `DocumentProcess::Server` capability
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)
per ADR-0004's "IPC is a trust boundary in both directions" rule.
- `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 |
|---|---|---|---|---|
| sandbox ON, Release, 1KB payload, 1000 reqs | **18.0 µs** | 7.0 µs | 330.6 µs | 0 (clean) |
| sandbox OFF, Release, 1KB payload, 1000 reqs | 20.1 µs | 7.8 µs | 630.9 µs | 0 |
| sandbox ON, ASan+UBSan, 512B payload, 200 reqs | 33.7 µs | 7.7 µs | 2005.9 µs | 0 |
| 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 (Cap'n Proto RPC) | ~50 µs | ~28 µs | ~1.5 ms | 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
noise; seccomp-bpf is a per-syscall filter checked in the kernel, not per-byte).
Verified clean under ASan+UBSan.
The sandbox adds no measurable latency overhead. The Cap'n Proto RPC framing
(~5058 µs/round-trip) is ~3× the raw length-prefixed protocol it replaced
(~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
1. **The process split works and is cheap.** 18 µs average round-trip per IPC
request, well under the §5 performance targets (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). The sandbox is free. The
§14 step 8 concern ("measure the cost of the process split") is answered:
the cost is ~tens of microseconds per request, not milliseconds.
1. **The process split works and is cheap.** ~50 µs average round-trip per
Cap'n Proto RPC request over the socketpair, well under the §5 performance
targets. The sandbox is free. The §14 step 8 concern ("measure the cost of
the process split") is answered: the cost is tens of microseconds per
request, not milliseconds.
2. **The seccomp allow-list is correct.** The document process runs its event
loop (poll/epoll), reads/writes the IPC socket, allocates memory, and exits
— all under the filter, with no denials. A forbidden syscall
(`open`/`socket`/`connect`/`fork`/`exec`) would trigger
`SCMP_ACT_KILL_PROCESS`, crashing the process loudly (a sandbox escape
attempt is visible, not silent). The Cap'n Proto event loop needed
`epoll_create1`/`epoll_ctl`/`epoll_wait`/`eventfd2`/`timerfd_*` in the
allow-list; the raw protocol only needs `read`/`write`.
2. **The seccomp allow-list is correct for the RPC event loop.** The document
process runs the KJ event loop (epoll/eventfd/timerfd), reads/writes the
IPC socket, allocates memory, and exits — all under the filter, with no
denials. A forbidden syscall (`open`/`socket`/`connect`/`fork`/`exec`)
would trigger `SCMP_ACT_KILL_PROCESS`, crashing the process loudly. The
one addition over the raw-protocol variant was `ioctl` (FIONBIO), which
KJ's `wrapSocketFd` calls to set non-blocking mode; strace identified it
as the denied syscall in the first sandboxed run.
3. **Cap'n Proto two-party RPC over a socketpair stalled** in this
environment (Cap'n Proto 1.1.0, seccomp 2.6, Debian 13). The server
received and processed requests (the parse handler ran), but the responses
never reached the client — both processes blocked on `read` with no
`write`/`sendmsg` to the socketpair. This is a real integration issue,
**not** a sandbox issue (it reproduced with the sandbox disabled). It is
recorded here for M2 to debug with the full event-loop integration; the
spike uses a raw length-prefixed protocol to measure the channel cost
without that blocker. The `ipc.capnp` schema is kept for the M2 work. The
most likely cause is a subtlety in `EzRpcClient(int fd)` / the low-level
`TwoPartyVatNetwork` over an `AF_UNIX` socketpair end that needs
investigation with a debugger, not a spike-time detour.
3. **The Cap'n Proto socketpair stall is RESOLVED.** The original M0 spike
recorded that `EzRpcServer(int fd, ...)` over a socketpair stalled: the
server processed requests but responses never reached the client. Root
cause, confirmed with a standalone reproduction: **`EzRpcServer(int fd,
...)` expects a LISTENING socket and calls `accept()` on it.** A
socketpair end is already CONNECTED, so `accept()` fails with `EINVAL`
("Invalid argument") and the bootstrap never completes — the server
aborts and the client sees a disconnect. The fix is the low-level path on
the server side:
```cpp
kj::AsyncIoContext io = kj::setupAsyncIo();
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
bounds-checks every request length — both sides of ADR-0004 are
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
sandboxed document process per open document, IPC over a local socket) has
a measured cost that fits the performance budget with enormous headroom. The
"genuinely painful to retrofit" concern (§14 step 8) is de-risked: the
a measured cost that fits the performance budget with enormous headroom.
The "genuinely painful to retrofit" concern (§14 step 8) is de-risked: the
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
and Windows AppContainer land in M1; the Linux seccomp-bpf filter here is
the template. The §7.1 threat-model row "parser memory corruption →
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
@ -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
verified clean under ASan+UBSan.
verified clean under ASan+UBSan (sandbox disabled).

View File

@ -132,23 +132,51 @@ endif()
# --- Spike E: sandbox + IPC bring-up (§14 step 8) ---
# 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
# is de-risked at M0. Linux-only at this stage (seccomp-bpf); macOS App Sandbox
# and Windows AppContainer land in M1.
#
# An earlier version used Cap'n Proto two-party RPC over the socketpair; the
# server received and processed requests but the responses 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 length-prefixed protocol
# measures the channel cost without that blocker. The Cap'n Proto schema
# (ipc.capnp) is kept for the M2 RPC work.
# Integration note: the server side uses the low-level TwoPartyVatNetwork +
# makeRpcServer over wrapSocketFd (NOT EzRpcServer, which calls accept() on a
# listening socket and fails on a connected socketpair end with EINVAL the
# original Spike E stall, now resolved). The client uses EzRpcClient(fd),
# which is designed for an already-connected socket. See
# spike/E_sandbox/DocumentProcess.cpp for the full note.
find_package(PkgConfig QUIET)
if(PkgConfig_FOUND)
pkg_check_modules(SECCOMP libseccomp IMPORTED_TARGET)
pkg_check_modules(CAPNP capnp-rpc capnp IMPORTED_TARGET)
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
common/SpikeRunner.cpp
common/SpikeRunner.h
@ -159,14 +187,26 @@ if(TARGET PkgConfig::SECCOMP)
E_sandbox/DocumentProcess.h
E_sandbox/UIProcess.cpp
E_sandbox/UIProcess.h
${_e_gen_src}
)
target_include_directories(spike_e_sandbox PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(spike_e_sandbox PRIVATE PkgConfig::SECCOMP)
target_include_directories(spike_e_sandbox PRIVATE
${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)
freepdfeditor_apply_warnings(spike_e_sandbox)
freepdfeditor_apply_hardening(spike_e_sandbox)
else()
message(STATUS
"libseccomp not found Spike E (sandbox) will not be built. "
"Install libseccomp-dev to enable it.")
endif()
"libseccomp, Cap'n Proto, or the capnp compiler not found Spike E "
"(sandbox) will not be built. Install libseccomp-dev, libcapnp-dev, "
"and capnproto to enable it.")
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)

View File

@ -2,59 +2,68 @@
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// DocumentProcess.cpp — the sandboxed document process. Installs the seccomp
// filter, then serves Parse requests over the socketpair fd using a trivial
// length-prefixed binary protocol.
// filter, then serves Parse requests over the socketpair fd using Cap'n Proto
// two-party RPC (the M2 transport, ADR-0004).
//
// Protocol (the "parse" operation counts the bytes it was handed):
// Request: [u64 request_id][u64 payload_len][payload_len bytes]
// Response: [u64 request_id][u64 byte_count][u8 ok]
// Integration note (the Spike E stall, now resolved): the server side CANNOT
// use `EzRpcServer(int fd, ...)` — that constructor expects a LISTENING
// 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
// and prove the sandbox works, not to exercise Cap'n Proto. An earlier
// version used Cap'n Proto two-party RPC over the socketpair; the server
// received and processed requests (the parse handler ran) but the responses
// 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.
// The sandbox is installed BEFORE the KJ event loop runs. The seccomp
// allow-list therefore includes the epoll/eventfd/timerfd syscalls the KJ
// async I/O layer needs (Sandbox.cpp); the raw-protocol variant only needed
// read/write.
#include "DocumentProcess.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 <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include <unistd.h>
namespace proto = freepdfeditor::spike::e;
namespace freepdfeditor::spike::e {
namespace {
// Read exactly n bytes from fd (handles partial reads). Returns false on EOF/error.
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);
// The document-process capability: a trivial "parser" that counts the bytes
// it was handed and returns the count. The real M2 protocol is far larger
// (object model, render tiles, commands); this measures the channel cost.
class DocumentProcessImpl final : public proto::DocumentProcess::Server {
public:
kj::Promise<void> parse(ParseContext ctx) override {
auto req = ctx.getParams().getRequest();
// 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;
}
auto r = ctx.getResults().initResult();
r.setRequestId(req.getRequestId());
r.setByteCount(data.size());
r.setOk(true);
return kj::READY_NOW;
}
return true;
}
// Write exactly n bytes to fd (handles partial writes). Returns false on error.
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
@ -74,23 +83,31 @@ int run_document_process(int fd)
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).
for (;;) {
std::uint64_t request_id = 0, payload_len = 0;
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;
// Set up the KJ async I/O context. This creates the epoll/eventfd
// machinery the event loop needs (all allowed by the seccomp filter).
kj::AsyncIoContext io = kj::setupAsyncIo();
// The "parse": count the bytes.
std::uint64_t byte_count = payload_len;
std::uint8_t ok = 1;
if (!write_exact(fd, &request_id, sizeof(request_id))) break;
if (!write_exact(fd, &byte_count, sizeof(byte_count))) break;
if (!write_exact(fd, &ok, sizeof(ok))) break;
// Wrap the already-connected socketpair end as an AsyncIoStream. This is
// the key difference from the stalled EzRpcServer path: wrapSocketFd does
// NOT call accept() — it uses the fd as-is for read/write.
kj::Own<kj::AsyncIoStream> stream = io.lowLevelProvider->wrapSocketFd(fd);
// 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;
}

View File

@ -67,6 +67,10 @@ int install_sandbox()
allow_syscall(ctx, SCMP_SYS(timerfd_create));
allow_syscall(ctx, SCMP_SYS(timerfd_settime));
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.
allow_syscall(ctx, SCMP_SYS(exit));

View File

@ -1,12 +1,21 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// UIProcess.cpp — the UI process side. Drives the length-prefixed binary IPC
// channel, sends Parse requests, and measures round-trip latency. The UI
// does no untrusted parsing; it validates the document process's responses
// (ADR-0004: bidirectional trust boundary).
// UIProcess.cpp — the UI process side. Drives the Cap'n Proto RPC channel,
// sends Parse requests, and measures round-trip latency. The UI does no
// untrusted parsing; it validates the document process's responses (ADR-0004:
// 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 "ipc.capnp.h"
#include <capnp/ez-rpc.h>
#include <capnp/message.h>
#include <kj/array.h>
#include <algorithm>
#include <chrono>
@ -17,90 +26,70 @@
#include <vector>
#include <unistd.h>
namespace proto = 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,
std::size_t n_requests, std::size_t payload_bytes)
{
(void)child_fd; // the child end is the document process's concern
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);
// 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;
std::size_t acked = 0;
for (std::size_t i = 0; i < n_requests; ++i) {
auto t0 = std::chrono::steady_clock::now();
// Request: [u64 request_id][u64 payload_len][payload]
std::uint64_t request_id = static_cast<std::uint64_t>(i);
std::uint64_t payload_len = static_cast<std::uint64_t>(payload_bytes);
if (!write_exact(parent_fd, &request_id, sizeof(request_id))) {
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;
}
auto req = doc.parseRequest();
req.getRequest().setRequestId(static_cast<std::uint64_t>(i));
req.getRequest().setData(kj::ArrayPtr<const kj::byte>(
payload.data(), payload.size()));
// Response: [u64 request_id][u64 byte_count][u8 ok]
std::uint64_t resp_id = 0, byte_count = 0;
std::uint8_t ok = 0;
if (!read_exact(parent_fd, &resp_id, sizeof(resp_id))) {
r.error = "read resp_id failed at " + std::to_string(i);
break;
}
if (!read_exact(parent_fd, &byte_count, sizeof(byte_count))) {
r.error = "read byte_count failed at " + std::to_string(i);
break;
}
if (!read_exact(parent_fd, &ok, sizeof(ok))) {
r.error = "read ok failed at " + std::to_string(i);
break;
// The Response owns the message backing the result reader, so it must
// stay alive for as long as we read from `resp` (a dangling reader is
// a use-after-free — ASan catches it, validating §7.2). Validate the
// response inside the scope that owns the Response.
bool ok = false;
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();
}
} catch (kj::Exception& e) {
err = "RPC failed: " + std::string(e.getDescription().cStr());
}
auto t1 = std::chrono::steady_clock::now();
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
// directions — the UI never trusts the document process blindly).
if (!ok) { r.error = "doc returned ok=false at " + std::to_string(i); break; }
if (resp_id != request_id) {
if (!ok) {
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);
break;
}
@ -110,6 +99,10 @@ UIResult run_ui_process(int parent_fd, int child_fd,
" expected " + std::to_string(payload_bytes);
break;
}
sum_us += us;
min_us = std::min(min_us, us);
max_us = std::max(max_us, us);
++acked;
}

View File

@ -82,7 +82,7 @@ int main(int argc, char** argv)
freepdfeditor::spike::SpikeResult sr{};
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.passed = 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 "
"avg_latency=%.1fus min=%.1fus max=%.1fus wall=%.1fus "
"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,
r.avg_latency_us, r.min_latency_us, r.max_latency_us, wall_us,
WIFEXITED(status) ? WEXITSTATUS(status) : -1);

View File

@ -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()

View File

@ -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;
}

7
spike/F_rust_ffi_probe/rust/Cargo.lock generated Normal file
View File

@ -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"

View File

@ -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

View File

@ -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
}