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:
parent
17512022e9
commit
3f563b42c5
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue