feat(spike-E): sandbox + IPC bring-up; seccomp-bpf, 18us round-trip (§14 step 8)

Implement Spike E: the two-process model from ADR-0004 (§2.1), the one thing
the plan says is genuinely painful to retrofit, de-risked at M0.

Sandbox.cpp: seccomp-bpf allow-list filter for the document process.
Default-deny (SCMP_ACT_KILL_PROCESS), permit only memory/thread-sync/the
already-open IPC socket fds/exit. DENIED: open, socket, connect, fork,
exec — the document process cannot reach the network or filesystem and
cannot spawn children. A forbidden syscall kills the process loudly.

DocumentProcess.cpp + UIProcess.cpp: the sandboxed child and the UI process
over a socketpair, with a trivial length-prefixed binary IPC protocol
([u64 id][u64 len][payload] -> [u64 id][u64 count][u8 ok]). The UI
validates every response field (ADR-0004: bidirectional trust boundary);
the child bounds-checks every request length. main.cpp forks, sets up the
socketpair, measures round-trip latency.

Gate MET: 1000/1000 requests round-trip under the sandbox, avg 18us
(sandbox OFF: 20us — no measurable overhead). Verified clean under
ASan+UBSan. Result and findings in docs/spike-results/0004-spike-e-sandbox.md.

Key finding: Cap'n Proto two-party RPC over a socketpair stalled in this
environment (server processed requests but responses never reached the
client; reproduced with the sandbox disabled, so it's an RPC integration
issue not a sandbox issue). Recorded for M2 to debug with the full event-
loop integration. The spike uses a raw protocol to measure the channel cost
without that blocker; the ipc.capnp schema is kept for M2. The process
split cost is ~tens of us/request, far under the §5 budget.

CI: add Spike E to the spike-gates job (libseccomp-dev); fails the build on
regression, sandbox must be ON (no env override in CI).

Signed-off-by: ai-ad4 <ai-ad4@users.noreply.gitea.lm.je>
This commit is contained in:
ai-ad4 2026-07-25 20:52:39 +00:00
parent 17512022e9
commit 3f563b42c5
11 changed files with 720 additions and 3 deletions

View File

@ -113,7 +113,7 @@ jobs:
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends build-essential cmake \
ninja-build libqpdf-dev libharfbuzz-dev libfreetype-dev \
fonts-dejavu-core
fonts-dejavu-core libseccomp-dev
- name: Configure
run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
@ -121,7 +121,7 @@ jobs:
- name: Build spikes
run: cmake --build build --target spike_a_verbatim_roundtrip \
spike_b_reconstruction spike_c_subset_growth
spike_b_reconstruction spike_c_subset_growth spike_e_sandbox
- name: Spike A — QPDF verbatim round-trip
# §14 step 4: gate is ≥99% byte-identical. Spike A's result doc records
@ -141,4 +141,9 @@ jobs:
- name: Spike C — hb-subset font growth
# §14 step 6: gate is the grown subset renders the new glyph. Fails the
# build on regression. Uses DejaVu Sans (fonts-dejavu-core).
run: build/bin/spike_c_subset_growth
run: build/bin/spike_c_subset_growth
- name: Spike E — sandbox + IPC bring-up
# §14 step 8: gate is all requests round-trip under the seccomp sandbox.
# Fails the build on regression. The sandbox must be ON (no env override).
run: build/bin/spike_e_sandbox 500 1024

View File

@ -0,0 +1,117 @@
<!--
SPDX-License-Identifier: GPL-3.0-or-later
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
* **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
works under the sandbox).
## What was built
`spike/E_sandbox/` implements the two-process model from ADR-0004 (§2.1):
- `Sandbox.cpp` — a seccomp-bpf allow-list filter for the document process.
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),
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).
- `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
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.
## Result
| Metric | Value | Target |
|---|---|---|
| round-trip success rate | **1.0** (1000/1000) | 1.0 |
| 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 |
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.
## 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.
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`.
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.
4. **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
first-class harness (§8.2); this spike is the foundation for that.
## What this means for the project
- **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
sandbox is installed after fork, before any untrusted data, and works.
- **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
```bash
cmake -S . -B build/manual -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build/manual --target spike_e_sandbox
build/manual/bin/spike_e_sandbox 1000 1024 # <n_requests> <payload_bytes>
# To diagnose without the sandbox (debug aid; the gate requires it ON):
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.

View File

@ -101,4 +101,45 @@ else()
message(STATUS
"HarfBuzz/FreeType not found Spike C (subset growth) will not be "
"built. Install libharfbuzz-dev and libfreetype-dev to enable it.")
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.
# 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.
find_package(PkgConfig QUIET)
if(PkgConfig_FOUND)
pkg_check_modules(SECCOMP libseccomp IMPORTED_TARGET)
endif()
if(TARGET PkgConfig::SECCOMP)
add_executable(spike_e_sandbox
common/SpikeRunner.cpp
common/SpikeRunner.h
E_sandbox/main.cpp
E_sandbox/Sandbox.cpp
E_sandbox/Sandbox.h
E_sandbox/DocumentProcess.cpp
E_sandbox/DocumentProcess.h
E_sandbox/UIProcess.cpp
E_sandbox/UIProcess.h
)
target_include_directories(spike_e_sandbox PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(spike_e_sandbox PRIVATE PkgConfig::SECCOMP)
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()

View File

@ -0,0 +1,98 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// 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.
//
// 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]
//
// 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.
#include "DocumentProcess.h"
#include "Sandbox.h"
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include <unistd.h>
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);
}
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
int run_document_process(int fd)
{
// Allow disabling the sandbox via an env var for diagnosing RPC issues
// (the spike's gate requires the sandbox ON; this is a debug aid only).
const char* skip = std::getenv("FPE_SPIKE_E_NO_SANDBOX");
if (skip == nullptr || skip[0] == '\0') {
// Install the sandbox BEFORE any untrusted data arrives. After this,
// the process cannot open files, create sockets, fork, or exec.
if (install_sandbox() != 0) {
std::fprintf(stderr, "[doc] sandbox install failed\n");
return 1;
}
} else {
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;
// 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;
}
return 0;
}
} // namespace freepdfeditor::spike::e

View File

@ -0,0 +1,24 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// DocumentProcess.h — the sandboxed document process side of Spike E. It
// installs the seccomp filter, then runs a Cap'n Proto RPC loop answering
// ParseRequest messages from the UI process. This is the trivial parser the
// §14 step 8 spike uses to measure the process-split cost: the "parse"
// operation counts the bytes it was handed and returns the count.
#ifndef FREEPDFEDITOR_SPIKE_E_DOCUMENTPROCESS_H
#define FREEPDFEDITOR_SPIKE_E_DOCUMENTPROCESS_H
#include <string>
namespace freepdfeditor::spike::e {
// Run the document process on the given fd (one end of a socketpair). Installs
// the sandbox, then serves Parse requests until the UI process closes the
// channel. Returns 0 on clean exit, non-zero on error.
int run_document_process(int fd);
} // namespace freepdfeditor::spike::e
#endif // FREEPDFEDITOR_SPIKE_E_DOCUMENTPROCESS_H

View File

@ -0,0 +1,92 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// Sandbox.cpp — seccomp-bpf filter for the sandboxed document process.
// Allow-list: default-deny, permit only memory/thread-sync/exit and the
// already-open IPC socket fds. DENIED: socket, connect, open/openat, unlink,
// fork, exec.
#include "Sandbox.h"
#include <seccomp.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <string>
namespace freepdfeditor::spike::e {
namespace {
// Add a syscall to the allow-list with the given argument restrictions (or
// none for a full allow). Returns 0 on success, negative on failure.
int allow_syscall(scmp_filter_ctx ctx, int nr)
{
if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, nr, 0) < 0) return -1;
return 0;
}
} // namespace
int install_sandbox()
{
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL_PROCESS);
if (!ctx) {
std::fprintf(stderr, "seccomp_init failed\n");
return -ENOMEM;
}
// Memory management.
allow_syscall(ctx, SCMP_SYS(mmap));
allow_syscall(ctx, SCMP_SYS(munmap));
allow_syscall(ctx, SCMP_SYS(mprotect));
allow_syscall(ctx, SCMP_SYS(mremap));
allow_syscall(ctx, SCMP_SYS(brk));
allow_syscall(ctx, SCMP_SYS(madvise));
// Thread synchronisation and futex.
allow_syscall(ctx, SCMP_SYS(futex));
allow_syscall(ctx, SCMP_SYS(set_robust_list));
// The IPC channel: read/write/close on already-open fds. We do NOT allow
// open/openat/socket/connect — the document process works only with fds
// the UI process handed it.
allow_syscall(ctx, SCMP_SYS(read));
allow_syscall(ctx, SCMP_SYS(write));
allow_syscall(ctx, SCMP_SYS(close));
allow_syscall(ctx, SCMP_SYS(readv));
allow_syscall(ctx, SCMP_SYS(writev));
// poll/ppoll/epoll are needed by Cap'n Proto's event loop.
allow_syscall(ctx, SCMP_SYS(poll));
allow_syscall(ctx, SCMP_SYS(ppoll));
allow_syscall(ctx, SCMP_SYS(epoll_create1));
allow_syscall(ctx, SCMP_SYS(epoll_ctl));
allow_syscall(ctx, SCMP_SYS(epoll_wait));
allow_syscall(ctx, SCMP_SYS(eventfd2));
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
// Exit / signal.
allow_syscall(ctx, SCMP_SYS(exit));
allow_syscall(ctx, SCMP_SYS(exit_group));
allow_syscall(ctx, SCMP_SYS(rt_sigprocmask));
allow_syscall(ctx, SCMP_SYS(rt_sigaction));
allow_syscall(ctx, SCMP_SYS(rt_sigreturn));
allow_syscall(ctx, SCMP_SYS(sigaltstack));
// Getters for things Cap'n Proto's event loop may query.
allow_syscall(ctx, SCMP_SYS(getpid));
allow_syscall(ctx, SCMP_SYS(gettid));
int rc = seccomp_load(ctx);
seccomp_release(ctx);
if (rc < 0) {
std::fprintf(stderr, "seccomp_load failed: %s\n", std::strerror(errno));
return -errno;
}
return 0;
}
} // namespace freepdfeditor::spike::e

30
spike/E_sandbox/Sandbox.h Normal file
View File

@ -0,0 +1,30 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// Sandbox.h — the seccomp-bpf filter for the sandboxed document process
// (engineering plan §2.1, ADR-0004). The document process gets no network
// access and no filesystem access beyond the fds handed to it by the UI
// process. This is the Linux part of the sandbox; macOS (App Sandbox) and
// Windows (AppContainer) land in M1.
//
// The filter is allow-list: default-deny, then permit the syscalls the
// document process needs (memory, thread-sync, the IPC socket fds it already
// holds, exit). Notably DENIED: socket, connect, open/openat, unlink, fork,
// exec — the document process cannot reach the network or the filesystem, and
// cannot spawn children.
#ifndef FREEPDFEDITOR_SPIKE_E_SANDBOX_H
#define FREEPDFEDITOR_SPIKE_E_SANDBOX_H
namespace freepdfeditor::spike::e {
// Install the seccomp-bpf filter in the calling process. Returns 0 on
// success, a negative errno on failure. After this returns, the process can
// only call the allow-listed syscalls; any other syscall kills the process
// (SECCOMP_RET_KILL_PROCESS) so a sandbox escape attempt crashes loudly
// rather than silently falling back.
int install_sandbox();
} // namespace freepdfeditor::spike::e
#endif // FREEPDFEDITOR_SPIKE_E_SANDBOX_H

View File

@ -0,0 +1,126 @@
// 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).
#include "UIProcess.h"
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include <unistd.h>
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{};
std::vector<unsigned char> payload(payload_bytes, 0xAB);
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;
}
// 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;
}
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) {
r.error = "request id mismatch at " + std::to_string(i);
break;
}
if (byte_count != payload_bytes) {
r.error = "byte count mismatch at " + std::to_string(i) +
": got " + std::to_string(byte_count) +
" expected " + std::to_string(payload_bytes);
break;
}
++acked;
}
r.requests_sent = n_requests;
r.requests_acked = acked;
r.min_latency_us = acked ? min_us : 0.0;
r.max_latency_us = acked ? max_us : 0.0;
r.avg_latency_us = acked ? sum_us / double(acked) : 0.0;
r.total_time_us = sum_us;
r.ok = (acked == n_requests);
return r;
}
} // namespace freepdfeditor::spike::e

View File

@ -0,0 +1,38 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// UIProcess.h — the UI process side of Spike E. Spawns the sandboxed document
// process, opens the Cap'n Proto RPC channel, sends N Parse requests, and
// measures the round-trip latency. The UI process does NO untrusted parsing;
// it hands byte ranges to the document process and validates the responses.
#ifndef FREEPDFEDITOR_SPIKE_E_UIPROCESS_H
#define FREEPDFEDITOR_SPIKE_E_UIPROCESS_H
#include <cstddef>
#include <cstdint>
#include <string>
namespace freepdfeditor::spike::e {
struct UIResult {
bool ok = false;
std::string error;
std::size_t requests_sent = 0;
std::size_t requests_acked = 0;
double avg_latency_us = 0.0; // average round-trip per request
double min_latency_us = 0.0;
double max_latency_us = 0.0;
double total_time_us = 0.0;
};
// Run the UI process: spawn the sandboxed child on `child_fd`, talk to it on
// `parent_fd`, send `n_requests` Parse requests of `payload_bytes` each, and
// measure the round-trip latency. The child must already be exec'd and
// configured to use child_fd; this function just drives the channel.
UIResult run_ui_process(int parent_fd, int child_fd,
std::size_t n_requests, std::size_t payload_bytes);
} // namespace freepdfeditor::spike::e
#endif // FREEPDFEDITOR_SPIKE_E_UIPROCESS_H

40
spike/E_sandbox/ipc.capnp Normal file
View File

@ -0,0 +1,40 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
#
# Cap'n Proto schema for the Spike E IPC (engineering plan §2.1, ADR-0004).
# This is the trivial two-message protocol the spike uses to measure the
# round-trip cost of the process split: a Parse request carrying a byte range,
# and a ParseResult carrying back a fixed response (the count of bytes that
# would be "parsed"). The real M2 protocol is far larger (object model, render
# tiles, commands) but the spike only needs to measure the channel cost.
@0x96b8a3f0c1d72e6f;
using Cxx = import "/capnp/c++.capnp";
$Cxx.namespace("freepdfeditor::spike::e");
# A request from the UI process to the sandboxed document process. The byte
# range is the (offset, length) of a region of a file the UI process has
# already opened and mapped; the document process gets no filesystem access and
# works only with what the UI hands it via shared memory or this message.
struct ParseRequest {
requestId @0 :UInt64;
data @1 :Data; # the bytes to "parse" (trivial: the doc counts them)
}
# The response. The document process validates the request (bounds, counts) —
# per ADR-0004 the IPC is a trust boundary in *both* directions, so the UI
# never indexes or size-computes from a document-supplied number without
# checking it first.
struct ParseResult {
requestId @0 :UInt64;
byteCount @1 :UInt64; # number of bytes the document process saw
ok @2 :Bool;
error @3 :Text; # populated when ok == false
}
# The UI process initiates; the document process replies. Cap'n Proto's
# request/response over a TwoPartyPipe is the transport.
interface DocumentProcess {
parse @0 (request :ParseRequest) -> (result :ParseResult);
}

106
spike/E_sandbox/main.cpp Normal file
View File

@ -0,0 +1,106 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// Spike E main (§14 step 8): fork into a UI process and a sandboxed document
// process, connect them over a socketpair, send N Parse requests, and measure
// the round-trip latency. This is the M0 spike that measures the cost of the
// §2.1 process split — the one thing the plan says is "genuinely painful to
// retrofit", so it must be de-risked at M0.
//
// The sandbox is installed in the child AFTER fork, before any untrusted data
// is processed. The parent (UI) does no untrusted parsing.
//
// Usage:
// spike_e_sandbox [n_requests=1000] [payload_bytes=1024]
//
// Exit 0 if all requests round-trip correctly (the channel works under the
// sandbox); 1 otherwise. The latency numbers go in the JSON report's notes.
#include "DocumentProcess.h"
#include "UIProcess.h"
#include "../common/SpikeRunner.h"
#include <cerrno>
#include <chrono>
#include <csignal>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
int main(int argc, char** argv)
{
const std::size_t n_requests = (argc >= 2) ? std::size_t(std::atoll(argv[1])) : 1000;
const std::size_t payload_bytes = (argc >= 3) ? std::size_t(std::atoll(argv[2])) : 1024;
// Create a socketpair for the two processes to talk over. Set it
// non-blocking up front — Cap'n Proto's event loop needs non-blocking fds.
// We use a socketpair (not TCP) so the document process has no network
// address to reach and the sandbox can deny socket() outright; a TCP
// connection would let the sandboxed process talk to the network stack.
int fds[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, fds) != 0) {
std::fprintf(stderr, "socketpair failed: %s\n", std::strerror(errno));
return 2;
}
// Fork: child becomes the sandboxed document process; parent is the UI.
// The child inherits the socketpair fd; the sandbox (installed in the
// child after fork) denies socket/connect/open/fork/exec but allows the
// already-open fd the UI handed it.
pid_t pid = fork();
if (pid < 0) {
std::fprintf(stderr, "fork failed: %s\n", std::strerror(errno));
return 2;
}
if (pid == 0) {
// Child — document process. Close the parent's end, run on our end.
close(fds[0]);
int rc = freepdfeditor::spike::e::run_document_process(fds[1]);
close(fds[1]);
_exit(rc);
}
// Parent — UI process. Close the child's end, drive the channel, measure.
close(fds[1]);
auto t0 = std::chrono::steady_clock::now();
auto r = freepdfeditor::spike::e::run_ui_process(
fds[0], fds[1], n_requests, payload_bytes);
auto t1 = std::chrono::steady_clock::now();
close(fds[0]);
// Reap the child.
int status = 0;
waitpid(pid, &status, 0);
double wall_us = std::chrono::duration<double, std::micro>(t1 - t0).count();
freepdfeditor::spike::SpikeResult sr{};
sr.spike = "E";
sr.name = "sandbox + IPC bring-up (seccomp-bpf, length-prefixed socketpair)";
sr.total = n_requests;
sr.passed = r.requests_acked;
sr.failed = n_requests - r.requests_acked;
sr.errored = r.ok ? 0 : 1;
sr.metric_name = "round_trip_success_rate";
sr.metric_value = n_requests ? double(r.requests_acked) / double(n_requests) : 0.0;
sr.target = 1.0;
sr.gate_met = r.ok;
char buf[512];
std::snprintf(buf, sizeof(buf),
"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",
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);
sr.notes = buf;
if (!r.ok) sr.notes += "; " + r.error;
return freepdfeditor::spike::emit_json_report(sr);
}