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)
This commit is contained in:
parent
067bb7c362
commit
d43d0402b8
|
|
@ -25,6 +25,12 @@ vcpkg_installed/
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
Cargo.lock.bak
|
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
|
||||||
|
|
|
||||||
|
|
@ -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,16 +187,22 @@ 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) ---
|
# --- Spike F: Rust-vs-C++ build-friction probe (ADR-0005 input, §15) ---
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue