feat(m1-ipc): production IPC layer — sandboxed document process + Cap'n Proto RPC

The first M1 (Viewer) increment: lift the Spike E RPC shape into src/ipc as a
Qt-free, unit-testable library implementing the §2.1 process split. The UI
process forks a sandboxed document process, hands it the document fd via
SCM_RIGHTS (§2.1: no FS access beyond handed fds), and talks Cap'n Proto
two-party RPC over a socketpair. The document process installs the seccomp
sandbox, receives the fd, and serves the DocumentProcess interface (open,
getPageCount, ping) backed by QPDF reading from the handed fd.

src/ipc/ (new, Qt-free so testable without QApplication):
- ipc.capnp: M1 protocol — DocumentProcess{open(fd via SCM_RIGHTS, pathHint)
  -> OpenResult{pageCount, pdfVersion}; getPageCount; ping}. ADR-0004 trust
  boundary: the UI validates every response; the doc bounds-checks requests.
- DocumentProcessServer: low-level wrapSocketFd + TwoPartyVatNetwork(SERVER)
  + makeRpcServer (the Spike E proven shape, NOT EzRpcServer which calls
  accept() on a connected socketpair end). QPDF reads from the handed fd.
- DocumentProcessClient: the Qt<->KJ event-loop integration (§2.3) — runs the
  KJ event loop on a dedicated worker thread (low-level makeRpcClient, not
  EzRpcClient which uses a thread-local context that breaks cross-thread
  destruction). Synchronous methods block the caller via std::promise.
- ProcessLauncher: fork + socketpair + send_fd + construct client.
- FdPassing: raw SCM_RIGHTS send/recv, orthogonal to the RPC transport.
- Sandbox: seccomp allow-list lifted from Spike E, +sendmsg/recvmsg (fd
  passing), +fcntl/fstat/lseek/pread64 (QPDF/stdio on the handed fd).

src/app: wire File>Open and the command-line positional arg to launch the
document process off the GUI thread (QtConcurrent) and show the page count in
the status bar (QMetaObject::invokeMethod back to the GUI thread).

test/test_ipc: end-to-end test — launches the sandboxed document process,
opens a real PDF, verifies ping + pageCount + getPageCount. PASSES under the
seccomp sandbox; ASan-clean with sandbox disabled (ASan's pipe2 conflicts
with the allow-list, an ASan-only artefact). Registered as ctest ipc_end_to_end.

Both Spike E (raw protocol) and this (real RPC) now pass under the sandbox.
This commit is contained in:
ai-ad4 2026-07-27 11:26:51 +00:00
parent d43d0402b8
commit ea69535812
18 changed files with 1211 additions and 10 deletions

View File

@ -11,7 +11,7 @@
# Locate Qt. The vcpkg manifest does not currently include Qt (it is huge and
# often built system-wide); we use find_package so a system Qt or a vcpkg-built
# Qt both work.
find_package(Qt6 6.7 COMPONENTS Core Gui Widgets Network QUIET)
find_package(Qt6 6.7 COMPONENTS Core Gui Widgets Network Concurrent QUIET)
if(NOT Qt6_FOUND)
message(WARNING
@ -23,6 +23,11 @@ endif()
qt_standard_project_setup()
# The M1 IPC layer (sandboxed document process + Cap'n Proto RPC). Qt-free so
# it is unit-testable; built before the app so the app can link it. Gated on
# the IPC deps (QPDF/Cap'n Proto/libseccomp) being available.
add_subdirectory(ipc)
add_executable(freepdfeditor
app/main.cpp
app/MainWindow.cpp
@ -30,7 +35,14 @@ add_executable(freepdfeditor
)
target_compile_features(freepdfeditor PRIVATE cxx_std_20)
target_link_libraries(freepdfeditor PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Network)
target_link_libraries(freepdfeditor PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Network Qt6::Concurrent)
# Link the IPC layer when it built (the app spawns the sandboxed document
# process and talks Cap'n Proto RPC over a socketpair §2.1).
if(TARGET freepdfeditor_ipc)
target_link_libraries(freepdfeditor PRIVATE freepdfeditor_ipc)
target_compile_definitions(freepdfeditor PRIVATE FREEPDFEDITOR_HAVE_IPC=1)
endif()
freepdfeditor_apply_warnings(freepdfeditor)
freepdfeditor_apply_hardening(freepdfeditor)

View File

@ -3,11 +3,19 @@
#include "MainWindow.h"
#include "../ipc/ProcessLauncher.h"
#include "../ipc/DocumentProcessClient.h"
#include <QAction>
#include <QFileDialog>
#include <QFileInfo>
#include <QFuture>
#include <QIcon>
#include <QKeySequence>
#include <QLabel>
#include <QMenuBar>
#include <QObject>
#include <QtConcurrent>
#include <QStatusBar>
#include <QToolBar>
#include <QStackedWidget>
@ -15,6 +23,9 @@
#include <QWidget>
#include <QStandardPaths>
#include <atomic>
#include <memory>
namespace FreePDFEditor {
MainWindow::MainWindow(QWidget* parent)
@ -39,9 +50,11 @@ void MainWindow::buildCentralChrome()
auto* placeholder = new QLabel(host);
placeholder->setAlignment(Qt::AlignCenter);
placeholder->setText(
tr("<h3>FreePDFEditor — pre-M0 scaffold</h3>"
"<p>The viewer, sandbox, canvas and editing tools land in M1M6.</p>"
"<p>See <code>docs/plan.md</code> for the roadmap.</p>"));
tr("<h3>FreePDFEditor — M1 viewer</h3>"
"<p>Open a PDF (File > Open) to launch the sandboxed document "
"process and view its page count.</p>"
"<p>Canvas, tiling, search and print land as M1 progresses; "
"see <code>docs/plan.md</code> for the roadmap.</p>"));
placeholder->setStyleSheet("color: palette(window-text);");
layout->addWidget(placeholder, 1);
@ -56,7 +69,7 @@ void MainWindow::buildMenus()
auto* openAct = fileMenu->addAction(
QIcon::fromTheme(QStringLiteral("document-open")), tr("&Open…"));
openAct->setShortcut(QKeySequence::Open);
openAct->setEnabled(false); // M1 viewer
connect(openAct, &QAction::triggered, this, &MainWindow::onOpen);
fileMenu->addAction(tr("Open Recent"))->setEnabled(false);
fileMenu->addSeparator();
@ -126,6 +139,59 @@ void MainWindow::showStatus(const QString& text)
}
}
void MainWindow::onOpen()
{
const QString startDir = QStandardPaths::writableLocation(
QStandardPaths::DocumentsLocation);
const QString path = QFileDialog::getOpenFileName(
this, tr("Open PDF"), startDir, tr("PDF files (*.pdf)"));
if (path.isEmpty()) return;
openFile(path);
}
void MainWindow::openFile(const QString& path)
{
showStatus(tr("Opening %1…").arg(QFileInfo(path).fileName()));
// Launch the sandboxed document process and open the file off the GUI
// thread (§2.3: nothing blocks the GUI thread for more than one frame).
// The IPC client runs its own worker thread internally; we just need to
// avoid doing the fork + open on the GUI thread.
const QByteArray pathBytes = path.toUtf8();
auto* self = this;
// Capture the path as a shared C-string so the worker thread can use it.
auto pathPtr = std::make_shared<std::string>(pathBytes.constData());
(void)QtConcurrent::run([self, pathPtr]() {
auto proc = freepdfeditor::ipc::launch_document_process(pathPtr->c_str());
if (!proc.client) {
QMetaObject::invokeMethod(self, [self, pathPtr]() {
self->showStatus(QObject::tr("Failed to open %1 (document "
"process could not start).").arg(
QString::fromStdString(*pathPtr)));
}, Qt::QueuedConnection);
return;
}
auto* client = proc.client.get();
// open() blocks the calling (worker) thread until the reply arrives.
auto reply = client->open(*pathPtr);
QMetaObject::invokeMethod(self, [self, pathPtr, reply, proc = std::move(proc)]()
mutable {
self->m_docProcess = std::make_unique<
freepdfeditor::ipc::LaunchedProcess>(std::move(proc));
if (reply.ok) {
self->showStatus(QObject::tr("%1 — %2 pages (PDF %3)")
.arg(QString::fromStdString(*pathPtr))
.arg(reply.pageCount)
.arg(QString::fromStdString(reply.pdfVersion)));
} else {
self->showStatus(QObject::tr("Open failed: %1")
.arg(QString::fromStdString(reply.error)));
}
}, Qt::QueuedConnection);
});
}
void MainWindow::closeEvent(QCloseEvent* event)
{
// M2 will gate this on unsaved changes + journal flush.

View File

@ -10,6 +10,9 @@
#define FREEPDFEDITOR_APP_MAINWINDOW_H
#include <QMainWindow>
#include <memory>
#include "../ipc/ProcessLauncher.h"
class QLabel;
class QStackedWidget;
@ -26,15 +29,28 @@ public:
// phase before the real status bar logic from §9 lands).
void showStatus(const QString& text);
// Open a PDF file: spawns the sandboxed document process, opens the file
// via the IPC client, and updates the chrome. Called from the File>Open
// action and from the command-line positional argument. Runs the IPC call
// off the GUI thread so the UI stays responsive (§2.3: nothing blocks the
// GUI thread for more than one frame).
void openFile(const QString& path);
protected:
void closeEvent(QCloseEvent* event) override;
private slots:
void onOpen();
private:
void buildMenus();
void buildCentralChrome();
void buildStatusBar();
QLabel* m_statusLabel = nullptr;
// The currently-open document's sandboxed process. One document process
// per open document (§2.1); held alive for the document's lifetime.
std::unique_ptr<freepdfeditor::ipc::LaunchedProcess> m_docProcess;
};
} // namespace FreePDFEditor

View File

@ -36,11 +36,9 @@ int main(int argc, char* argv[])
const QStringList args = parser.positionalArguments();
if (!args.isEmpty()) {
// No document process yet; the M1 viewer will wire this up.
window.showStatus(QApplication::translate(
"main", "Opening documents is not implemented until M1."));
// M1: spawn the sandboxed document process and open the file.
window.openFile(args.first());
}
Q_UNUSED(args);
return app.exec();
}

77
src/ipc/CMakeLists.txt Normal file
View File

@ -0,0 +1,77 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
# src/ipc/CMakeLists.txt the M1 IPC layer (§2.1, ADR-0004).
#
# Qt-free so it is unit-testable without a QApplication. Links QPDF (the
# document process reads PDFs from the handed fd), Cap'n Proto (the RPC
# transport), and libseccomp (the sandbox). The Qt adapter that marshals
# results back to the GUI thread lives in src/app.
find_package(PkgConfig QUIET)
if(PkgConfig_FOUND)
pkg_check_modules(QPDF libqpdf IMPORTED_TARGET)
pkg_check_modules(SECCOMP libseccomp IMPORTED_TARGET)
pkg_check_modules(CAPNP capnp-rpc capnp IMPORTED_TARGET)
endif()
find_program(CAPNP_EXECUTABLE NAMES capnp capnpc)
set(_fpe_ipc_deps_ok ON)
if(NOT TARGET PkgConfig::QPDF)
message(STATUS "QPDF not found — src/ipc will not be built.")
set(_fpe_ipc_deps_ok OFF)
endif()
if(NOT TARGET PkgConfig::SECCOMP)
message(STATUS "libseccomp not found — src/ipc will not be built.")
set(_fpe_ipc_deps_ok OFF)
endif()
if(NOT TARGET PkgConfig::CAPNP OR NOT CAPNP_EXECUTABLE)
message(STATUS "Cap'n Proto or the capnp compiler not found — src/ipc will not be built.")
set(_fpe_ipc_deps_ok OFF)
endif()
if(_fpe_ipc_deps_ok)
# Generate the C++ from the IPC schema with the capnp compiler directly
# (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 see ~/memories/gitea-freepdfeditor.md).
set(_schema ${CMAKE_CURRENT_SOURCE_DIR}/ipc.capnp)
set(_gen_dir ${CMAKE_CURRENT_BINARY_DIR}/generated)
set(_gen_hdr ${_gen_dir}/ipc.capnp.h)
set(_gen_src ${_gen_dir}/ipc.capnp.c++)
file(MAKE_DIRECTORY ${_gen_dir})
add_custom_command(
OUTPUT ${_gen_hdr} ${_gen_src}
COMMAND ${CAPNP_EXECUTABLE} compile
--src-prefix=${CMAKE_CURRENT_SOURCE_DIR}
-I${CAPNP_INCLUDE_DIRS}
-oc++:${_gen_dir}
${_schema}
DEPENDS ${_schema}
COMMENT "Generating Cap'n Proto C++ for the M1 IPC schema"
VERBATIM)
add_library(freepdfeditor_ipc STATIC
FdPassing.cpp
FdPassing.h
Sandbox.cpp
Sandbox.h
DocumentProcessServer.cpp
DocumentProcessServer.h
DocumentProcessClient.cpp
DocumentProcessClient.h
ProcessLauncher.cpp
ProcessLauncher.h
${_gen_src}
)
target_include_directories(freepdfeditor_ipc PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
${_gen_dir})
target_link_libraries(freepdfeditor_ipc PUBLIC
PkgConfig::QPDF
PkgConfig::SECCOMP
PkgConfig::CAPNP
Threads::Threads)
target_compile_features(freepdfeditor_ipc PUBLIC cxx_std_20)
freepdfeditor_apply_warnings(freepdfeditor_ipc)
freepdfeditor_apply_hardening(freepdfeditor_ipc)
endif()

View File

@ -0,0 +1,234 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// DocumentProcessClient.cpp — the UI-side RPC client. Runs the KJ event loop
// on a dedicated worker thread (the Qt<->KJ integration, §2.3) and exposes
// synchronous methods that block the caller until the reply arrives.
//
// Design: EzRpcClient(int fd) creates its own kj::EventLoop and makes it
// current on the constructing thread. We construct it ON the worker thread,
// so its event loop is current there. The worker thread pumps a thread-safe
// task queue; each task performs a .wait() on the client's WaitScope, which
// pumps the KJ event loop and drives the RPC to completion. Between tasks
// the loop is not pumped, which is fine — RPC progress only matters while a
// call is in flight. This is the standard "run KJ on a dedicated thread"
// integration shape.
//
// fd handoff: the document fd is sent via SCM_RIGHTS by the ProcessLauncher
// BEFORE this client is constructed (the server receives it before RPC). So
// this client only does RPC; it never touches the document fd.
#include "DocumentProcessClient.h"
#include "ipc.capnp.h"
#include <capnp/rpc-twoparty.h>
#include <capnp/rpc.h>
#include <capnp/message.h>
#include <kj/async-io.h>
#include <kj/memory.h>
#include <atomic>
#include <condition_variable>
#include <cstring>
#include <functional>
#include <future>
#include <mutex>
#include <queue>
#include <stdexcept>
#include <thread>
namespace proto = freepdfeditor::ipc;
namespace freepdfeditor::ipc {
namespace {
// Thread-safe function queue. The worker thread blocks on runOne() until a
// task is pushed; callers block on the returned std::future.
class WorkQueue {
public:
void push(std::function<void()> task) {
{
std::lock_guard<std::mutex> lock(mutex_);
queue_.push(std::move(task));
}
cv_.notify_one();
}
bool runOne() {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] { return stopped_ || !queue_.empty(); });
if (stopped_ && queue_.empty()) return false;
task = std::move(queue_.front());
queue_.pop();
}
task();
return true;
}
void stop() {
std::lock_guard<std::mutex> lock(mutex_);
stopped_ = true;
cv_.notify_all();
}
private:
std::mutex mutex_;
std::condition_variable cv_;
std::queue<std::function<void()>> queue_;
bool stopped_ = false;
};
} // namespace
struct DocumentProcessClient::Impl {
int fd = -1;
WorkQueue queue;
std::thread worker;
std::atomic<bool> ready{false};
std::atomic<bool> failed{false};
std::string failError;
// All owned by the worker thread after ready=true. We use the low-level
// path (NOT EzRpcClient) because EzRpcClient uses a thread-local
// EzRpcContext that must be created AND destroyed on the same thread —
// which breaks when the client is constructed on a worker thread but
// destroyed from the main thread. setupAsyncIo() + wrapSocketFd +
// TwoPartyVatNetwork(SIDE_CLIENT) + makeRpcClient gives us an EventLoop
// owned by the worker thread with no thread-local context constraint.
std::unique_ptr<kj::AsyncIoContext> io;
kj::Own<kj::AsyncIoStream> stream;
std::unique_ptr<capnp::TwoPartyVatNetwork> network;
std::unique_ptr<capnp::RpcSystem<capnp::rpc::twoparty::VatId>> rpc;
proto::DocumentProcess::Client doc{nullptr};
void workerMain();
};
void DocumentProcessClient::Impl::workerMain()
{
try {
// setupAsyncIo() creates an EventLoop and makes it current on THIS
// thread. All subsequent KJ objects belong to that loop.
io = std::make_unique<kj::AsyncIoContext>(kj::setupAsyncIo());
stream = io->lowLevelProvider->wrapSocketFd(fd);
network = std::make_unique<capnp::TwoPartyVatNetwork>(
*stream, capnp::rpc::twoparty::Side::CLIENT);
rpc = std::make_unique<capnp::RpcSystem<capnp::rpc::twoparty::VatId>>(
capnp::makeRpcClient<capnp::rpc::twoparty::VatId>(*network));
// The bootstrap: a two-party client gets the server's main interface
// by bootstrapping the peer vat id (VatId with side=SERVER).
capnp::MallocMessageBuilder vatMsg;
auto vatBuilder = vatMsg.initRoot<capnp::rpc::twoparty::VatId>();
vatBuilder.setSide(capnp::rpc::twoparty::Side::SERVER);
doc = rpc->bootstrap(vatBuilder.asReader())
.castAs<proto::DocumentProcess>();
ready = true;
} catch (const kj::Exception& e) {
failError = std::string(e.getDescription().cStr());
failed = true; ready = true; return;
} catch (const std::exception& e) {
failError = e.what(); failed = true; ready = true; return;
}
while (queue.runOne()) {}
// Destroy the KJ objects on THIS (worker) thread — they were created here
// and KJ requires destruction on the same thread that ran the event loop
// (the thread-local current-loop check fires on destruction otherwise).
// Reset in reverse order of construction.
doc = nullptr;
rpc = nullptr;
network = nullptr;
stream = nullptr;
io = nullptr;
}
DocumentProcessClient::DocumentProcessClient(int fd) : impl_(new Impl)
{
impl_->fd = fd;
impl_->worker = std::thread([this] { impl_->workerMain(); });
while (!impl_->ready.load()) {}
if (impl_->failed.load()) {
impl_->queue.stop();
if (impl_->worker.joinable()) impl_->worker.join();
throw std::runtime_error("IPC client start failed: " + impl_->failError);
}
}
DocumentProcessClient::~DocumentProcessClient()
{
// Stop the queue (the worker's runOne() returns false once stopped and
// the KJ objects above are destroyed), then join. The worker destroys
// the KJ objects before exiting workerMain() — see above.
impl_->queue.stop();
if (impl_->worker.joinable()) impl_->worker.join();
if (impl_->fd >= 0) ::close(impl_->fd);
}
OpenReply DocumentProcessClient::open(const std::string& pathHint)
{
OpenReply out;
auto p = std::make_shared<std::promise<OpenReply>>();
auto f = p->get_future();
impl_->queue.push([this, &pathHint, p, &out]() {
try {
auto req = impl_->doc.openRequest();
req.setPathHint(pathHint);
auto resp = req.send().wait(impl_->io->waitScope);
auto r = resp.getResult();
out.ok = r.getOk();
out.pageCount = r.getPageCount();
out.pdfVersion = std::string(r.getPdfVersion().cStr());
out.error = r.getOk() ? std::string() : std::string(r.getError().cStr());
p->set_value(out);
} catch (const kj::Exception& e) {
try { p->set_exception(std::make_exception_ptr(
std::runtime_error(std::string(e.getDescription().cStr())))); }
catch (...) {}
}
});
return f.get();
}
std::uint32_t DocumentProcessClient::getPageCount()
{
auto p = std::make_shared<std::promise<std::uint32_t>>();
auto f = p->get_future();
impl_->queue.push([this, p]() {
try {
auto req = impl_->doc.getPageCountRequest();
auto resp = req.send().wait(impl_->io->waitScope);
p->set_value(resp.getCount());
} catch (const kj::Exception& e) {
try { p->set_exception(std::make_exception_ptr(
std::runtime_error(std::string(e.getDescription().cStr())))); }
catch (...) {}
}
});
return f.get();
}
bool DocumentProcessClient::ping(const std::string& payload)
{
auto p = std::make_shared<std::promise<bool>>();
auto f = p->get_future();
impl_->queue.push([this, &payload, p]() {
try {
auto req = impl_->doc.pingRequest();
req.setPayload(kj::ArrayPtr<const kj::byte>(
reinterpret_cast<const kj::byte*>(payload.data()),
payload.size()));
auto resp = req.send().wait(impl_->io->waitScope);
auto echo = resp.getEcho();
bool match = echo.size() == payload.size() &&
std::memcmp(echo.begin(), payload.data(), payload.size()) == 0;
p->set_value(match);
} catch (const kj::Exception& e) {
try { p->set_exception(std::make_exception_ptr(
std::runtime_error(std::string(e.getDescription().cStr())))); }
catch (...) {}
}
});
return f.get();
}
} // namespace freepdfeditor::ipc

View File

@ -0,0 +1,64 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// DocumentProcessClient.h — the UI-process side of the IPC. Owns a dedicated
// worker thread that runs the KJ event loop and the Cap'n Proto RPC client;
// exposes synchronous methods that the Qt thread (or a test) can call.
//
// This is the Qt<->KJ event-loop integration (§2.3): KJ and Qt both want to
// own the thread, so the KJ client runs on its own worker thread. The Qt
// adapter (src/app/DocumentProcessQtAdapter) wraps these calls and marshals
// results back to the GUI thread via QMetaObject::invokeMethod. The core
// here is Qt-free so it is unit-testable without a QApplication.
#ifndef FREEPDFEDITOR_IPC_DOCUMENTPROCESSCLIENT_H
#define FREEPDFEDITOR_IPC_DOCUMENTPROCESSCLIENT_H
#include <cstdint>
#include <memory>
#include <string>
namespace freepdfeditor::ipc {
struct OpenReply {
bool ok = false;
std::uint32_t pageCount = 0;
std::string pdfVersion;
std::string error; // populated when ok == false
};
// The UI-side RPC client. One instance per open document (§2.1: one document
// process per open document). Constructed AFTER the UI has forked the
// sandboxed child and holds the parent end of the socketpair; the child must
// already be running run_document_process() on the other end.
class DocumentProcessClient {
public:
// Takes ownership of `fd` (the parent end of the socketpair). Spawns the
// worker thread that runs the KJ event loop + EzRpcClient.
explicit DocumentProcessClient(int fd);
~DocumentProcessClient();
DocumentProcessClient(const DocumentProcessClient&) = delete;
DocumentProcessClient& operator=(const DocumentProcessClient&) = delete;
// Call open() after the launcher has handed the document fd to the
// document process via SCM_RIGHTS. The document process reads the PDF
// from that fd and returns the page count + version. `pathHint` is for
// diagnostics only (the document process never opens a path — §2.1).
// Blocks the calling thread until the reply arrives (the KJ wait happens
// on the worker thread). Throws std::runtime_error on transport error.
OpenReply open(const std::string& pathHint);
std::uint32_t getPageCount(); // throws std::runtime_error on transport error
// Liveness probe. Returns true if the echo matches the payload.
bool ping(const std::string& payload);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace freepdfeditor::ipc
#endif // FREEPDFEDITOR_IPC_DOCUMENTPROCESSCLIENT_H

View File

@ -0,0 +1,149 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// DocumentProcessServer.cpp — the sandboxed document process. Installs the
// seccomp filter, receives the document fd the UI process hands it via
// SCM_RIGHTS (§2.1: no filesystem access beyond fds the UI hands it), then
// serves the DocumentProcess interface over Cap'n Proto two-party RPC.
//
// The server uses the low-level TwoPartyVatNetwork + makeRpcServer path proven
// in Spike E (NOT EzRpcServer). See docs/spike-results/0004 and the Spike E
// integration note in spike/E_sandbox/DocumentProcess.cpp.
#include "DocumentProcessServer.h"
#include "Sandbox.h"
#include "FdPassing.h"
#include "ipc.capnp.h"
#include <capnp/rpc-twoparty.h>
#include <capnp/rpc.h>
#include <kj/async-io.h>
#include <kj/memory.h>
#include <qpdf/QPDF.hh>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unistd.h>
namespace proto = freepdfeditor::ipc;
namespace freepdfeditor::ipc {
namespace {
// The document-process capability. The UI obtains this via getMain() and
// calls open()/getPageCount()/ping(). All responses are bounds-checked and
// validated on the UI side (ADR-0004: bidirectional trust boundary).
class DocumentProcessImpl final : public proto::DocumentProcess::Server {
public:
// The fd the UI handed us via SCM_RIGHTS before the RPC session. We dup
// per call so QPDF's FILE* lifecycle doesn't close our held fd; the
// production M2 code holds one QPDF handle in the model thread (§2.3).
explicit DocumentProcessImpl(int docFd) : docFd_(docFd) {}
kj::Promise<void> open(OpenContext ctx) override {
auto r = ctx.getResults().initResult();
int dupFd = ::dup(docFd_);
if (dupFd < 0) {
r.setOk(false);
r.setError(std::string("dup failed: ") + std::strerror(errno));
return kj::READY_NOW;
}
FILE* fp = ::fdopen(dupFd, "rb");
if (!fp) { ::close(dupFd); r.setOk(false); r.setError("fdopen failed"); return kj::READY_NOW; }
try {
QPDF q;
q.processFile("", fp, false); // owns fp; closes it on destroy
auto pages = q.getAllPages();
r.setOk(true);
r.setPageCount(static_cast<std::uint32_t>(pages.size()));
r.setPdfVersion(std::string(q.getPDFVersion()));
} catch (const std::exception& e) {
::fclose(fp);
r.setOk(false);
r.setError(std::string("QPDF open failed: ") + e.what());
}
return kj::READY_NOW;
}
kj::Promise<void> getPageCount(GetPageCountContext ctx) override {
int dupFd = ::dup(docFd_);
if (dupFd < 0) { ctx.getResults().setCount(0); return kj::READY_NOW; }
FILE* fp = ::fdopen(dupFd, "rb");
if (!fp) { ::close(dupFd); ctx.getResults().setCount(0); return kj::READY_NOW; }
std::uint32_t n = 0;
try {
QPDF q;
q.processFile("", fp, false);
n = static_cast<std::uint32_t>(q.getAllPages().size());
} catch (...) {
::fclose(fp);
}
ctx.getResults().setCount(n);
return kj::READY_NOW;
}
kj::Promise<void> ping(PingContext ctx) override {
auto req = ctx.getParams().getPayload();
ctx.getResults().setEcho(req);
return kj::READY_NOW;
}
private:
int docFd_ = -1;
};
} // namespace
int run_document_process(int fd)
{
// Allow disabling the sandbox via an env var for diagnosing RPC issues
// (the production path always installs it; this is a debug aid only).
const char* skip = std::getenv("FPE_IPC_NO_SANDBOX");
if (skip == nullptr || skip[0] == '\0') {
if (install_document_sandbox() != 0) {
std::fprintf(stderr, "[doc] sandbox install failed\n");
return 1;
}
} else {
std::fprintf(stderr, "[doc] sandbox DISABLED (FPE_IPC_NO_SANDBOX set)\n");
}
// Receive the document fd the UI hands us via SCM_RIGHTS BEFORE starting
// RPC. §2.1: the document process gets no filesystem access beyond fds
// the UI hands it. This raw handoff keeps fd-passing orthogonal to the
// Cap'n Proto transport. This raw handoff keeps fd-passing orthogonal to the
// RPC transport.
int docFd = recv_fd(fd);
if (docFd < 0) {
std::fprintf(stderr, "[doc] recv_fd failed: %s\n", std::strerror(-docFd));
return 1;
}
kj::AsyncIoContext io = kj::setupAsyncIo();
// wrapSocketFd (plain AsyncIoStream) for RPC — the fd handoff is already
// done, so we don't need capability passing on the stream. This is the
// Spike E proven shape.
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>(docFd));
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;
}
} // namespace freepdfeditor::ipc

View File

@ -0,0 +1,26 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// DocumentProcessServer.h — the sandboxed document process's RPC capability
// and run loop. The UI process forks this child, hands it one end of a
// socketpair, and calls the DocumentProcess interface over Cap'n Proto
// two-party RPC (§2.1, ADR-0004).
//
// The server uses the low-level TwoPartyVatNetwork + makeRpcServer path proven
// in Spike E (NOT EzRpcServer, which calls accept() on a listening socket and
// fails on a connected socketpair end). See docs/spike-results/0004.
#ifndef FREEPDFEDITOR_IPC_DOCUMENTPROCESSSERVER_H
#define FREEPDFEDITOR_IPC_DOCUMENTPROCESSSERVER_H
namespace freepdfeditor::ipc {
// Run the sandboxed document process on the given fd (one end of a
// socketpair). Installs the seccomp sandbox, then serves the DocumentProcess
// interface until the UI process disconnects. Returns 0 on clean exit,
// non-zero on error.
int run_document_process(int fd);
} // namespace freepdfeditor::ipc
#endif // FREEPDFEDITOR_IPC_DOCUMENTPROCESSSERVER_H

66
src/ipc/FdPassing.cpp Normal file
View File

@ -0,0 +1,66 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// FdPassing.cpp — raw SCM_RIGHTS fd passing over an AF_UNIX socket.
#include "FdPassing.h"
#include <cerrno>
#include <cstring>
#include <sys/socket.h>
namespace freepdfeditor::ipc {
int send_fd(int sock, int fd)
{
// A zero-byte payload carrying one ancillary fd. The data is irrelevant;
// the fd travels in the ancillary data.
char buf[1] = {0};
struct iovec iov{.iov_base = buf, .iov_len = 1 };
alignas(struct cmsghdr) char cmsgbuf[CMSG_SPACE(sizeof(int))];
struct msghdr msg{};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsgbuf;
msg.msg_controllen = sizeof(cmsgbuf);
struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
std::memcpy(CMSG_DATA(cmsg), &fd, sizeof(int));
ssize_t n = ::sendmsg(sock, &msg, 0);
if (n < 0) return -errno;
return 0;
}
int recv_fd(int sock)
{
char buf[1];
struct iovec iov{.iov_base = buf, .iov_len = sizeof(buf) };
alignas(struct cmsghdr) char cmsgbuf[CMSG_SPACE(sizeof(int))];
struct msghdr msg{};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsgbuf;
msg.msg_controllen = sizeof(cmsgbuf);
ssize_t n = ::recvmsg(sock, &msg, 0);
if (n < 0) return -errno;
if (msg.msg_controllen < CMSG_LEN(sizeof(int))) return -EBADMSG;
for (struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); cmsg;
cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) {
int fd = -1;
std::memcpy(&fd, CMSG_DATA(cmsg), sizeof(int));
return fd;
}
}
return -EBADMSG;
}
} // namespace freepdfeditor::ipc

31
src/ipc/FdPassing.h Normal file
View File

@ -0,0 +1,31 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// FdPassing.h — raw SCM_RIGHTS fd passing over an AF_UNIX socket. Used to
// hand the document fd from the UI process to the sandboxed document process
// BEFORE the Cap'n Proto RPC session starts (§2.1: the document process gets
// no filesystem access beyond fds the UI hands it). Doing the handoff as a
// one-shot message before RPC keeps the fd-passing orthogonal to the RPC
// transport and avoids the in-band-capability complexity.
//
// Protocol: the UI sends a single zero-byte message carrying one ancillary
// fd via SCM_RIGHTS; the document process receives it. Both sides then proceed
// to set up Cap'n Proto two-party RPC over the same socket.
#ifndef FREEPDFEDITOR_IPC_FDPASSING_H
#define FREEPDFEDITOR_IPC_FDPASSING_H
namespace freepdfeditor::ipc {
// Send `fd` over the connected AF_UNIX socket `sock` via SCM_RIGHTS. Returns
// 0 on success, -errno on failure. The receiver gets a duplicate fd; the
// sender's fd is NOT consumed (the caller closes it after).
int send_fd(int sock, int fd);
// Receive a single fd sent via SCM_RIGHTS on the connected AF_UNIX socket
// `sock`. Returns the received fd (>=0) on success, -errno on failure.
int recv_fd(int sock);
} // namespace freepdfeditor::ipc
#endif // FREEPDFEDITOR_IPC_FDPASSING_H

View File

@ -0,0 +1,85 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// ProcessLauncher.cpp — spawns the sandboxed document process.
#include "ProcessLauncher.h"
#include "DocumentProcessClient.h"
#include "DocumentProcessServer.h"
#include "FdPassing.h"
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <stdexcept>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
namespace freepdfeditor::ipc {
// Defined here so the unique_ptr<DocumentProcessClient> destructor sees the
// complete type (DocumentProcessClient is only forward-declared in the header).
LaunchedProcess::~LaunchedProcess() = default;
LaunchedProcess launch_document_process(const char* path)
{
LaunchedProcess result;
// The UI process opens the file (it has FS access; the document process
// does not — §2.1).
int docFd = ::open(path, O_RDONLY | O_CLOEXEC);
if (docFd < 0) {
std::fprintf(stderr, "[ui] open(%s) failed: %s\n", path, std::strerror(errno));
return result;
}
int fds[2];
if (::socketpair(AF_UNIX, SOCK_STREAM, 0, fds) != 0) {
std::fprintf(stderr, "[ui] socketpair failed: %s\n", std::strerror(errno));
::close(docFd);
return result;
}
pid_t pid = ::fork();
if (pid < 0) {
std::fprintf(stderr, "[ui] fork failed: %s\n", std::strerror(errno));
::close(docFd); ::close(fds[0]); ::close(fds[1]);
return result;
}
if (pid == 0) {
// Child — sandboxed document process.
::close(fds[0]); // parent's end
::close(docFd); // the child gets its own dup via SCM_RIGHTS
int rc = run_document_process(fds[1]);
::close(fds[1]);
_exit(rc);
}
// Parent — UI process.
::close(fds[1]); // child's end
// Hand the document fd to the child via SCM_RIGHTS BEFORE constructing the
// RPC client. The child receives it before starting RPC.
if (send_fd(fds[0], docFd) != 0) {
std::fprintf(stderr, "[ui] send_fd failed: %s\n", std::strerror(errno));
::close(docFd); ::close(fds[0]);
int status = 0; ::waitpid(pid, &status, 0);
return result;
}
::close(docFd); // the child has its own dup now
try {
result.client = std::make_unique<DocumentProcessClient>(fds[0]);
result.pid = pid;
} catch (const std::exception& e) {
std::fprintf(stderr, "[ui] client start failed: %s\n", e.what());
::close(fds[0]);
int status = 0; ::waitpid(pid, &status, 0);
}
return result;
}
} // namespace freepdfeditor::ipc

45
src/ipc/ProcessLauncher.h Normal file
View File

@ -0,0 +1,45 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// ProcessLauncher.h — spawns the sandboxed document process and returns a
// connected IPC client (§2.1, ADR-0004). Linux path: fork, give the child one
// end of a socketpair, hand the document fd to the child via SCM_RIGHTS, then
// construct a DocumentProcessClient on the parent's end. The child installs
// the seccomp sandbox and runs run_document_process().
//
// The UI process opens the file (it has filesystem access); the document
// process receives only the fd (§2.1: no FS access beyond handed fds).
#ifndef FREEPDFEDITOR_IPC_PROCESSLAUNCHER_H
#define FREEPDFEDITOR_IPC_PROCESSLAUNCHER_H
#include <memory>
namespace freepdfeditor::ipc {
class DocumentProcessClient;
struct LaunchedProcess {
std::unique_ptr<DocumentProcessClient> client;
pid_t pid = -1;
// Defined out-of-line in ProcessLauncher.cpp so the unique_ptr<DocumentProcessClient>
// destructor sees the complete type (DocumentProcessClient is forward-declared here).
~LaunchedProcess();
LaunchedProcess() = default;
LaunchedProcess(LaunchedProcess&&) noexcept = default;
LaunchedProcess& operator=(LaunchedProcess&&) noexcept = default;
};
// Open `path` in the UI process, fork a sandboxed document process, hand it
// the fd via SCM_RIGHTS, and return a connected client. Returns nullptr (with
// `client` null and pid set) on failure. Throws std::runtime_error if the
// client cannot be started after a successful fork.
//
// The caller owns the returned LaunchedProcess and must keep it alive for the
// document's lifetime; destroying the client disconnects and reaps the child.
LaunchedProcess launch_document_process(const char* path);
} // namespace freepdfeditor::ipc
#endif // FREEPDFEDITOR_IPC_PROCESSLAUNCHER_H

105
src/ipc/Sandbox.cpp Normal file
View File

@ -0,0 +1,105 @@
// 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.
// Lifted from the proven Spike E allow-list (docs/spike-results/0004). The
// allow-list covers the KJ async event loop (epoll/eventfd/timerfd/ioctl) and
// read/write on the already-open IPC fd; it denies open/openat/socket/
// connect/unlink/fork/exec.
#include "Sandbox.h"
#include <seccomp.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
namespace freepdfeditor::ipc {
namespace {
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_document_sandbox()
{
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL_PROCESS);
if (!ctx) {
std::fprintf(stderr, "[doc] 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));
// sendmsg/recvmsg carry the document fd via SCM_RIGHTS (the UI hands the
// fd to the document process before the RPC session — §2.1).
allow_syscall(ctx, SCMP_SYS(sendmsg));
allow_syscall(ctx, SCMP_SYS(recvmsg));
// 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
// fcntl(F_GETFL) is used by stdio (fdopen) and QPDF on the handed fd.
allow_syscall(ctx, SCMP_SYS(fcntl));
// fstat/lseek are used by stdio and QPDF when reading the handed fd
// (FILE* buffering needs the fd's seekability and size). The document
// process reads only from fds the UI handed it — fstat on those is safe.
allow_syscall(ctx, SCMP_SYS(fstat));
allow_syscall(ctx, SCMP_SYS(lseek));
allow_syscall(ctx, SCMP_SYS(pread64));
allow_syscall(ctx, SCMP_SYS(pwrite64));
// ioctl(FIONBIO) is used by KJ's wrapSocketFd to set non-blocking mode on
// the socketpair end (Spike E finding).
allow_syscall(ctx, SCMP_SYS(ioctl));
// 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, "[doc] seccomp_load failed: %s\n", std::strerror(errno));
return -errno;
}
return 0;
}
} // namespace freepdfeditor::ipc

28
src/ipc/Sandbox.h Normal file
View File

@ -0,0 +1,28 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// Sandbox.h — seccomp-bpf filter for the sandboxed document process (§2.1,
// ADR-0004). Lifted from the proven Spike E allow-list (docs/spike-results/
// 0004): default-deny, permit only memory/thread-sync/exit and the
// already-open IPC socket fds the UI process handed the child. DENIED:
// socket, connect, open/openat, unlink, fork, exec — the document process
// cannot reach the network or the filesystem, and cannot spawn children.
//
// Linux-only at this stage (seccomp-bpf). macOS App Sandbox and Windows
// AppContainer land later in M1.
#ifndef FREEPDFEDITOR_IPC_SANDBOX_H
#define FREEPDFEDITOR_IPC_SANDBOX_H
namespace freepdfeditor::ipc {
// Install the seccomp-bpf allow-list on the calling process. Must be called
// in the document process AFTER fork, BEFORE any untrusted data is parsed.
// Returns 0 on success, negative errno on failure. A forbidden syscall kills
// the process loudly (SCMP_ACT_KILL_PROCESS) — a sandbox escape attempt is
// visible, not silent.
int install_document_sandbox();
} // namespace freepdfeditor::ipc
#endif // FREEPDFEDITOR_IPC_SANDBOX_H

56
src/ipc/ipc.capnp Normal file
View File

@ -0,0 +1,56 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
#
# M1 IPC protocol between the UI process (Qt, unsandboxed) and the sandboxed
# document process (§2.1, ADR-0004). Transport: Cap'n Proto two-party RPC over
# an AF_UNIX socketpair (see src/ipc/DocumentProcessClient.cpp for the
# Qt<->KJ event-loop integration and the server-side wrapSocketFd shape proven
# in Spike E).
#
# Trust boundary (ADR-0004): the IPC is a trust boundary in BOTH directions.
# The document process handles hostile input, so anything it sends is itself
# untrusted — the UI validates every message (bounds, counts, string
# encodings). Conversely the document process bounds-checks every request.
# Cap'n Proto arena reader limits are set explicitly, not left at defaults.
#
# Filesystem rule (§2.1): the document process gets NO filesystem access. The
# UI process opens the file and passes the fd to the document process via
# SCM_RIGHTS (capability passing). The document process reads from the fd it
# was handed, never from a path.
@0x8a1f5c4e2b7d6093;
using Cxx = import "/capnp/c++.capnp";
$Cxx.namespace("freepdfeditor::ipc");
# The document process's bootstrap interface. The UI obtains this via
# EzRpcClient(fd).getMain() and calls these methods. One document process per
# open document (§2.1); a crash loses one tab, not the app.
interface DocumentProcess {
# Open the document handed via `fd`. The UI has already opened and
# stat'd the file; the document process takes ownership of the fd and
# parses it with QPDF. Returns the page count and basic metadata. This
# is the M1 increment; richer metadata (outline, fonts, permissions)
# lands as the viewer grows.
open @0 (fd :Data, pathHint :Text) -> (result :OpenResult);
# Cheap re-query of the page count after the open (e.g. after a page
# assembly op in M2). Kept separate from open() so the UI can refresh
# without re-passing the fd.
getPageCount @1 () -> (count :UInt32);
# Liveness/latency probe used by the IPC round-trip test and by the UI
# to detect a dead document process before showing a "document process
# crashed" banner (§2.1: one document process per open document; the UI
# restarts it and replays the journal).
ping @2 (payload :Data) -> (echo :Data);
}
struct OpenResult {
ok @0 :Bool;
pageCount @1 :UInt32;
error @2 :Text; # populated when ok == false
# A versioned format hint the UI can show without trusting (it re-checks).
# M1: the PDF version string from the QPDF handle, e.g. "1.7".
pdfVersion @3 :Text;
}

View File

@ -21,6 +21,20 @@ freepdfeditor_apply_warnings(test_spike_runner_contract)
freepdfeditor_apply_hardening(test_spike_runner_contract)
add_test(NAME spike_runner_contract COMMAND test_spike_runner_contract)
# --- M1 IPC end-to-end test (§2.1, ADR-0004) --------------------------------
# Launches the sandboxed document process, opens a real PDF via the RPC client,
# and verifies the page count + ping round-trip. Proves the process split, the
# Cap'n Proto RPC over a socketpair, the seccomp sandbox, the SCM_RIGHTS fd
# handoff, and the QPDF read-from-handed-fd path all work together.
if(TARGET freepdfeditor_ipc)
add_executable(test_ipc test_ipc.cpp)
target_link_libraries(test_ipc PRIVATE freepdfeditor_ipc)
target_compile_features(test_ipc PRIVATE cxx_std_20)
freepdfeditor_apply_warnings(test_ipc)
freepdfeditor_apply_hardening(test_ipc)
add_test(NAME ipc_end_to_end COMMAND test_ipc)
endif()
# Pixel-diff harness scaffolding (§8.2 gate: render vs Ghostscript+PDFium
# reference). The actual comparison runs against a corpus that lives in the
# separate freepdfeditor-corpus repo (§13.2 rule 5); this scaffold is a

129
test/test_ipc.cpp Normal file
View File

@ -0,0 +1,129 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// test/test_ipc.cpp — end-to-end test for the M1 IPC layer (§2.1, ADR-0004).
// Launches the sandboxed document process, opens a real PDF via the RPC
// client, and verifies the page count + ping round-trip. This is the M1
// foundation test: it proves the process split, the Cap'n Proto RPC over a
// socketpair, the seccomp sandbox, the SCM_RIGHTS fd handoff, and the QPDF
// read-from-handed-fd path all work together.
//
// Plain freestanding main (no GTest yet — matches the M0 test convention in
// test/CMakeLists.txt). Returns non-zero on failure.
#include "DocumentProcessClient.h"
#include "ProcessLauncher.h"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <string>
#include <unistd.h>
namespace {
// A minimal valid one-page PDF with a correct xref (QPDF --check clean).
// Written to a temp file by the test so the UI process can open() it and hand
// the fd to the sandboxed document process.
const char* kMinimalPdf =
"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
"xref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n"
"0000000052 00000 n \n0000000101 00000 n \n"
"trailer<</Size 4/Root 1 0 R>>\nstartxref\n164\n%%EOF\n";
std::string write_temp_pdf()
{
char tmpl[] = "/tmp/fpe_ipc_test_XXXXXX";
int fd = ::mkstemp(tmpl);
if (fd < 0) { std::perror("mkstemp"); std::exit(2); }
// Append the .pdf extension to the mkstemp-generated name.
std::string path = std::string(tmpl) + ".pdf";
::close(fd);
::unlink(tmpl); // mkstemp created the placeholder; we want the .pdf name
{
std::ofstream f;
f.open(path, std::ios::binary | std::ios::trunc);
f << kMinimalPdf;
}
return path;
}
int fail(const char* msg)
{
std::fprintf(stderr, "FAIL: %s\n", msg);
return 1;
}
} // namespace
int main()
{
std::string path = write_temp_pdf();
auto proc = freepdfeditor::ipc::launch_document_process(path.c_str());
if (!proc.client) {
::unlink(path.c_str());
return fail("launch_document_process returned null client");
}
// ping first — cheapest liveness check.
try {
if (!proc.client->ping("hello-ipc")) {
::unlink(path.c_str());
return fail("ping echo mismatch");
}
} catch (const std::exception& e) {
::unlink(path.c_str());
std::fprintf(stderr, "FAIL: ping threw: %s\n", e.what());
return 1;
}
// open — should report 1 page (the minimal PDF has one page).
freepdfeditor::ipc::OpenReply rep;
try {
rep = proc.client->open(path);
} catch (const std::exception& e) {
::unlink(path.c_str());
std::fprintf(stderr, "FAIL: open threw: %s\n", e.what());
return 1;
}
if (!rep.ok) {
::unlink(path.c_str());
std::fprintf(stderr, "FAIL: open returned ok=false: %s\n", rep.error.c_str());
return 1;
}
if (rep.pageCount != 1) {
::unlink(path.c_str());
std::fprintf(stderr, "FAIL: expected 1 page, got %u\n", rep.pageCount);
return 1;
}
if (rep.pdfVersion.empty()) {
::unlink(path.c_str());
return fail("empty pdfVersion");
}
// getPageCount — should agree with open.
try {
std::uint32_t n = proc.client->getPageCount();
if (n != 1) {
::unlink(path.c_str());
std::fprintf(stderr, "FAIL: getPageCount expected 1, got %u\n", n);
return 1;
}
} catch (const std::exception& e) {
::unlink(path.c_str());
std::fprintf(stderr, "FAIL: getPageCount threw: %s\n", e.what());
return 1;
}
// Destroying the client disconnects and reaps the child.
proc.client.reset();
std::printf("PASS: ipc end-to-end — ping ok, open ok (pageCount=%u, "
"pdfVersion=%s), getPageCount ok\n", rep.pageCount, rep.pdfVersion.c_str());
::unlink(path.c_str());
return 0;
}