diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6aad96d..ba17fd3 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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) diff --git a/src/app/MainWindow.cpp b/src/app/MainWindow.cpp index b9530eb..7bf1692 100644 --- a/src/app/MainWindow.cpp +++ b/src/app/MainWindow.cpp @@ -3,11 +3,19 @@ #include "MainWindow.h" +#include "../ipc/ProcessLauncher.h" +#include "../ipc/DocumentProcessClient.h" + #include +#include +#include +#include #include #include #include #include +#include +#include #include #include #include @@ -15,6 +23,9 @@ #include #include +#include +#include + 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("

FreePDFEditor — pre-M0 scaffold

" - "

The viewer, sandbox, canvas and editing tools land in M1–M6.

" - "

See docs/plan.md for the roadmap.

")); + tr("

FreePDFEditor — M1 viewer

" + "

Open a PDF (File > Open) to launch the sandboxed document " + "process and view its page count.

" + "

Canvas, tiling, search and print land as M1 progresses; " + "see docs/plan.md for the roadmap.

")); 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(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. diff --git a/src/app/MainWindow.h b/src/app/MainWindow.h index 5922390..c7d73bf 100644 --- a/src/app/MainWindow.h +++ b/src/app/MainWindow.h @@ -10,6 +10,9 @@ #define FREEPDFEDITOR_APP_MAINWINDOW_H #include +#include + +#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 m_docProcess; }; } // namespace FreePDFEditor diff --git a/src/app/main.cpp b/src/app/main.cpp index d2ab174..18560e8 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -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(); } \ No newline at end of file diff --git a/src/ipc/CMakeLists.txt b/src/ipc/CMakeLists.txt new file mode 100644 index 0000000..23c0d48 --- /dev/null +++ b/src/ipc/CMakeLists.txt @@ -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() \ No newline at end of file diff --git a/src/ipc/DocumentProcessClient.cpp b/src/ipc/DocumentProcessClient.cpp new file mode 100644 index 0000000..b7789a3 --- /dev/null +++ b/src/ipc/DocumentProcessClient.cpp @@ -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 +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 task) { + { + std::lock_guard lock(mutex_); + queue_.push(std::move(task)); + } + cv_.notify_one(); + } + bool runOne() { + std::function task; + { + std::unique_lock 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 lock(mutex_); + stopped_ = true; + cv_.notify_all(); + } +private: + std::mutex mutex_; + std::condition_variable cv_; + std::queue> queue_; + bool stopped_ = false; +}; + +} // namespace + +struct DocumentProcessClient::Impl { + int fd = -1; + WorkQueue queue; + std::thread worker; + std::atomic ready{false}; + std::atomic 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 io; + kj::Own stream; + std::unique_ptr network; + std::unique_ptr> 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::setupAsyncIo()); + stream = io->lowLevelProvider->wrapSocketFd(fd); + network = std::make_unique( + *stream, capnp::rpc::twoparty::Side::CLIENT); + rpc = std::make_unique>( + capnp::makeRpcClient(*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(); + vatBuilder.setSide(capnp::rpc::twoparty::Side::SERVER); + doc = rpc->bootstrap(vatBuilder.asReader()) + .castAs(); + 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>(); + 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>(); + 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>(); + auto f = p->get_future(); + impl_->queue.push([this, &payload, p]() { + try { + auto req = impl_->doc.pingRequest(); + req.setPayload(kj::ArrayPtr( + reinterpret_cast(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 \ No newline at end of file diff --git a/src/ipc/DocumentProcessClient.h b/src/ipc/DocumentProcessClient.h new file mode 100644 index 0000000..d5a2dce --- /dev/null +++ b/src/ipc/DocumentProcessClient.h @@ -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 +#include +#include + +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_; +}; + +} // namespace freepdfeditor::ipc + +#endif // FREEPDFEDITOR_IPC_DOCUMENTPROCESSCLIENT_H \ No newline at end of file diff --git a/src/ipc/DocumentProcessServer.cpp b/src/ipc/DocumentProcessServer.cpp new file mode 100644 index 0000000..075205c --- /dev/null +++ b/src/ipc/DocumentProcessServer.cpp @@ -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 +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +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 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(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 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(q.getAllPages().size()); + } catch (...) { + ::fclose(fp); + } + ctx.getResults().setCount(n); + return kj::READY_NOW; + } + + kj::Promise 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 stream = io.lowLevelProvider->wrapSocketFd(fd); + + capnp::TwoPartyVatNetwork network(*stream, capnp::rpc::twoparty::Side::SERVER); + auto server = capnp::makeRpcServer( + network, kj::heap(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 \ No newline at end of file diff --git a/src/ipc/DocumentProcessServer.h b/src/ipc/DocumentProcessServer.h new file mode 100644 index 0000000..b2585c4 --- /dev/null +++ b/src/ipc/DocumentProcessServer.h @@ -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 \ No newline at end of file diff --git a/src/ipc/FdPassing.cpp b/src/ipc/FdPassing.cpp new file mode 100644 index 0000000..cb5be04 --- /dev/null +++ b/src/ipc/FdPassing.cpp @@ -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 +#include +#include + +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 \ No newline at end of file diff --git a/src/ipc/FdPassing.h b/src/ipc/FdPassing.h new file mode 100644 index 0000000..ecf206b --- /dev/null +++ b/src/ipc/FdPassing.h @@ -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 \ No newline at end of file diff --git a/src/ipc/ProcessLauncher.cpp b/src/ipc/ProcessLauncher.cpp new file mode 100644 index 0000000..9b1f506 --- /dev/null +++ b/src/ipc/ProcessLauncher.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include + +namespace freepdfeditor::ipc { + +// Defined here so the unique_ptr 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(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 \ No newline at end of file diff --git a/src/ipc/ProcessLauncher.h b/src/ipc/ProcessLauncher.h new file mode 100644 index 0000000..0a8b0c0 --- /dev/null +++ b/src/ipc/ProcessLauncher.h @@ -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 + +namespace freepdfeditor::ipc { + +class DocumentProcessClient; + +struct LaunchedProcess { + std::unique_ptr client; + pid_t pid = -1; + + // Defined out-of-line in ProcessLauncher.cpp so the unique_ptr + // 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 \ No newline at end of file diff --git a/src/ipc/Sandbox.cpp b/src/ipc/Sandbox.cpp new file mode 100644 index 0000000..1aea328 --- /dev/null +++ b/src/ipc/Sandbox.cpp @@ -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 +#include +#include +#include + +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 \ No newline at end of file diff --git a/src/ipc/Sandbox.h b/src/ipc/Sandbox.h new file mode 100644 index 0000000..78f679f --- /dev/null +++ b/src/ipc/Sandbox.h @@ -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 \ No newline at end of file diff --git a/src/ipc/ipc.capnp b/src/ipc/ipc.capnp new file mode 100644 index 0000000..f649953 --- /dev/null +++ b/src/ipc/ipc.capnp @@ -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; +} \ No newline at end of file diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index ca67eb9..e174bcb 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -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 diff --git a/test/test_ipc.cpp b/test/test_ipc.cpp new file mode 100644 index 0000000..7c5af23 --- /dev/null +++ b/test/test_ipc.cpp @@ -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 +#include +#include +#include +#include +#include + +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<>endobj\n" + "2 0 obj<>endobj\n" + "3 0 obj<>endobj\n" + "xref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n" + "0000000052 00000 n \n0000000101 00000 n \n" + "trailer<>\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; +} \ No newline at end of file