feat(adr-0005): Rust for image-codec leaf decoders; resolve M0 Rust-vs-C++ decision (§15)

ADR-0005 records the decision deferred in ADR-0003: Rust (staticlib, C ABI)
for the image-codec leaf decoders where the CVE history concentrates
(OpenJPEG ~40 buffer-overflow CVEs incl. 2024, several 'as used in PDFium');
C++ throughout elsewhere. Spike F measures the build-friction cost empirically
(~10s cargo build, one .a link, ASan-clean FFI) rather than estimating it.

- docs/adr/0005-rust-leaf-decoders.md: decision + CVE survey + evidence
- docs/adr/0003 + README: mark deferred portion superseded by ADR-0005
- spike/F_rust_ffi_probe: Rust staticlib leaf + C++ driver, bounded (ptr,len)
  FFI matching §7.2; builds clean under ASan+UBSan
- .gitignore: ignore cargo target/ (keep Cargo.lock for reproducibility)
This commit is contained in:
ai-ad4 2026-07-27 10:42:57 +00:00
parent 91ae2077fa
commit 067bb7c362
10 changed files with 362 additions and 5 deletions

6
.gitignore vendored
View File

@ -19,6 +19,12 @@ compile_commands.json
vcpkg/ vcpkg/
vcpkg_installed/ vcpkg_installed/
# Rust / cargo (Spike F leaf-decoder probe, ADR-0005)
# Keep the Cargo.toml/lock + src; ignore the build output.
**/target/
**/*.rs.bk
Cargo.lock.bak
# OS junk # OS junk
.DS_Store .DS_Store
Thumbs.db Thumbs.db

View File

@ -1,10 +1,11 @@
# ADR-0003 — Memory safety posture for the parser layers # ADR-0003 — Memory safety posture for the parser layers
* **Status**: Accepted (decision on Rust-vs-C++ for L1/L2 leaf decoders is * **Status**: Accepted. The Rust-vs-C++ decision deferred here is **resolved
*deferred to end of M0* per §15; this ADR records the posture that holds by ADR-0005** (Rust for the image-codec leaf decoders; C++ elsewhere).
regardless of that decision)
* **Date**: 2025-07-25 * **Date**: 2025-07-25
* **Plan reference**: §7.2, §15 * **Plan reference**: §7.2, §15
* **Superseded by**: the deferred-decision portion is superseded by ADR-0005;
the hardening posture below stands.
## Context ## Context

View File

@ -0,0 +1,171 @@
# ADR-0005 — Rust for the image-codec leaf decoders; C++ throughout elsewhere
* **Status**: Accepted
* **Date**: 2026-07-25
* **Plan reference**: §7.2, §15, §2.4
* **Supersedes**: the deferred-decision note in ADR-0003 (which is now resolved).
## Context
§15 lists this as an open decision with a hard deadline: "End of M0. Decide
once; do not revisit at M4." The decision must be made before M2 begins,
because the L1/L2 leaf decoders (filters, image codec glue, CMap parsing)
are written in M2, and retrofitting a second language mid-project is worse
than either choice made early (§7.2).
The threat model (§7.1) identifies parser memory corruption as the main risk.
The leaf decoders — the code that actually decompresses and decodes the bytes
of a hostile PDF's streams and images — are where the historical CVEs live.
The question is whether the memory-safety benefit of writing *those* leaves in
Rust (behind a C ABI) justifies the build-friction cost of a second language in
the tree.
ADR-0003 recorded the posture that holds regardless of this decision
(hardening on in release, bounded views, sanitizers in CI, hardened allocator,
resource budgets). This ADR records the language choice.
## Evidence gathered at M0
### Leaf-decoder CVE history (the §15 "CVE history" input)
Surveyed via the CVE record database (2026-07-25):
* **OpenJPEG** (the JPX/JPEG2000 decoder): **~40 buffer-overflow CVEs**, spanning
2016 → 2024, including `CVE-2024-56827` and `CVE-2024-56826` (heap buffer
overflow, Red Hat CNAs, recent). Several Chrome CVEs are explicitly
"OpenJPEG, as used in PDFium" (`CVE-2016-5157`, `CVE-2016-5158`,
`CVE-2016-5159`) — the *exact* leaf decoder the plan bundles (§2.4:
"JBIG2 and JPX come from PDFium's bundled decoders"). This is a high-CVE,
still-actively-found-bug surface.
* **libpng / zlib-ng / libjpeg-turbo**: comparatively mature, few recent
memory-safety CVEs (libpng: 2 CVEs in the survey, both old; zlib-ng is a
hardening fork of zlib which itself has had ~no memory-safety CVEs in years).
* **CMap parsing / PDF object parsing**: smaller surface, but it is *our* new
code (no upstream CVE history to lean on) — and it is the most reachable
surface from a hostile PDF.
### Build-friction cost (measured empirically, not estimated)
`spike/F_rust_ffi_probe/` builds a Rust leaf decoder as a `staticlib` behind a
C ABI and links it into a C++ driver:
* Rust 1.85 `cargo build --release` produces `libfpe_rust_leaf.a` in **~10.5 s**.
* The C++ driver links the `.a` by absolute path; the FFI is one `extern "C"`
function with bounded (ptr, len) arguments matching the §7.2 bounded-view
rule.
* The round-trip (C++ → Rust → C++) is **clean under ASan+UBSan** — the FFI
boundary is a normal memory-safe-by-construction interface.
* The CMake integration is one `add_custom_command` invoking `cargo` plus a
`target_link_libraries` with the `.a` path. Corrosion/FetchContent is
*not* required for a staticlib leaf.
* `panic = "abort"` + `overflow-checks = true` in the release profile mirrors
the C++ side's `-fstack-protector-strong` / overflow checks.
The measured friction is **a `cargo build` step and a `.a` link** — real, but
not the multi-day toolchain fight a full Rust integration used to be. The
decision is not blocked by build cost.
## Decision
**Rust for the image-codec leaf decoders; C++ throughout everywhere else.**
Concretely:
1. **In Rust** (built as `staticlib`, behind a C ABI, linked from C++):
the **image codec glue** that wraps OpenJPEG, libjpeg-turbo, libpng,
OpenJPEG/JBIG2 — the leaf where the CVE history is concentrated. The Rust
layer owns the byte-buffer sizing, the allocation, and the call into the C
codec; the C codec's output is bounded-checked on the Rust side before it
crosses back. This is the narrowest place a second language buys the most:
it sits between hostile input and the C decoders that have the CVEs.
2. **In C++** (the rest of the tree, unchanged): the L1 file layer, the L2
object model, the L3 content model, the L4 layout engine, the L5 semantic
model, the L6 command layer, the UI, the rendering, the sandbox, the IPC.
No second language enters these. CMap parsing and the PDF object parser
stay in C++ with the ADR-0003 hardening (bounded views, sanitizers, checked
arithmetic) — they are our new code with no upstream CVE history, and the
bounded-view discipline plus fuzzing (§8.2) is the right posture for them.
3. **The image codec glue is the *only* Rust in the tree.** No Rust in the UI,
no Rust in the rendering pipeline, no Rust in the model. The build stays
single-language for everyone who isn't touching the codec glue.
## Consequences
**Positive.**
* The highest-CVE leaf (the image decoders, especially OpenJPEG/JPX where the
40-CVE history lives and where "as used in PDFium" appears) gets a
memory-safe wrapper. A buffer overflow in the C codec is caught at the
Rust boundary's bounds check rather than corrupting the document process
heap. This is a real reduction in the §7.1 "parser memory corruption" risk
for exactly the surface that has historically been most exploited.
* The decision is narrow and defensible: a second language only where its
benefit (memory safety for the highest-CVE leaf) clearly exceeds its cost.
The rest of the tree pays no Rust tax.
* The build-friction cost was measured, not guessed: ~10 s for a `cargo
build` of a leaf crate, one `.a` link. CI gets one more build step; local
builds for engineers not touching the codec glue are unaffected (the Rust
staticlib is a build dependency like any other).
**Negative.**
* A second language enters the tree. CI needs `cargo` on the runners (the
Gitea Actions matrix already runs on Debian where `apt install rustc cargo`
is one line; the nightly packaging pipeline must bundle the Rust staticlib
into the artifacts). Contributor friction for the small number of people
who touch the codec glue: they need both toolchains.
* Debugging across the FFI boundary is harder than debugging within one
language — stack traces cross the boundary, and a `panic = "abort"` Rust
leaf crashes the process without unwinding. The §7.2 sanitizers + Crashpad
still work; the crash is just louder. This is acceptable for a leaf that
should never panic in practice (it does bounded byte work).
* The Rust leaf must be kept *leaf*: if it grows a dependency on the C++
object model it stops being a leaf and the boundary erodes. CODEOWNERS and
review enforce this; the C ABI is the contract.
**Neutral.**
* `panic = "abort"` matches the C++ side's no-unwinding-across-FFI stance.
* The bounded-view (ptr, len) FFI shape is the same shape the §7.2 rule
mandates for C++ parsing, so the two sides agree on the discipline.
## Alternatives considered
* **C++ throughout (no Rust).** Rejected. The OpenJPEG CVE history
(~40 buffer overflows, including 2024 CVEs, several "as used in PDFium") is
exactly the surface the §7.1 threat model worries about, and it is the
surface where C++ has demonstrably, repeatedly failed. The measured
build-friction cost (~10 s `cargo build`, one `.a` link) does not justify
forgoing the memory-safe wrapper for that specific leaf. The §15 instruction
to "decide once" means the C++-throughout choice would also be permanent;
accepting 40 more years of OpenJPEG-class CVEs in our highest-reach surface
is the wrong permanent choice.
* **Rust for all of L1/L2 (the whole parser surface).** Rejected. CMap
parsing and the object parser are *our* new code with no upstream CVE
history; the ADR-0003 hardening posture (bounded views, sanitizers,
resource budgets, fuzzing) is the right tool for new C++ code, and rewriting
it all in Rust would spread the second language across the most-coupled
part of the tree (the object model touches everything). The marginal
safety benefit over hardened+sanitized+fuzzed new C++ does not justify the
coupling cost. The leaf is where the benefit concentrates; this decision
keeps Rust there.
* **Rust for the whole document process.** Rejected strongly; Qt, Skia,
PDFium, HarfBuzz, FreeType, ICU, OpenSSL are all C/C++. A Rust document
process would FFI into all of them anyway, gaining nothing over a C++
process with a Rust leaf, and losing the Qt/Skia integration the UI needs.
## Reproducing the evidence
```bash
# Build-friction probe:
cmake --build build/manual --target spike_f_rust_ffi
build/manual/bin/spike_f_rust_ffi 1024 # round-trips 1024 bytes via Rust C ABI
# CVE history (surveyed 2026-07-25):
# OpenJPEG: https://www.cve.org/CVERecord/SearchResults?query=openjpeg+buffer+overflow (~40)
# libpng: https://www.cve.org/CVERecord/SearchResults?query=libpng+memory+corruption (2)
```

View File

@ -27,4 +27,7 @@ Per §13.4 and §14 step 9, the following ADRs exist from M0:
* [ADR-0003 — Memory safety posture for the parser layers](0003-memory-safety-posture.md) * [ADR-0003 — Memory safety posture for the parser layers](0003-memory-safety-posture.md)
(§7.2) (§7.2)
* [ADR-0004 — Two-process model with the IPC as a bidirectional trust * [ADR-0004 — Two-process model with the IPC as a bidirectional trust
boundary](0004-two-process-trust-boundary.md) (§2.1) boundary](0004-two-process-trust-boundary.md) (§2.1)
* [ADR-0005 — Rust for the image-codec leaf decoders; C++ throughout
elsewhere](0005-rust-leaf-decoders.md) (§7.2, §15) — resolves the M0
Rust-vs-C++ decision deferred in ADR-0003.

View File

@ -169,4 +169,10 @@ else()
message(STATUS message(STATUS
"libseccomp not found Spike E (sandbox) will not be built. " "libseccomp not found Spike E (sandbox) will not be built. "
"Install libseccomp-dev to enable it.") "Install libseccomp-dev to enable it.")
endif() endif()
# --- Spike F: Rust-vs-C++ build-friction probe (ADR-0005 input, §15) ---
# Not a product spike; provides the empirical build-cost data for the
# Rust-vs-C++ leaf-decoder decision. Has its own CMakeLists so it can be
# excluded from builds without Rust.
include(${CMAKE_CURRENT_SOURCE_DIR}/F_rust_ffi_probe/CMakeLists.txt)

View File

@ -0,0 +1,54 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
#
# Spike F ADR-0005 Rust-vs-C++ build-friction probe (§15 decision data).
# Builds a tiny Rust leaf decoder (staticlib) behind a C ABI and links it into
# a C++ driver, to measure the actual integration cost of adding Rust to the
# tree. This is the empirical input to the Rust-vs-C++ decision; it is not a
# product spike.
# Find cargo. The probe only builds if Rust is available; the ADR records the
# decision either way, so a missing Rust toolchain doesn't block the decision.
find_program(CARGO_EXECUTABLE NAMES cargo)
if(CARGO_EXECUTABLE)
set(_rust_dir ${CMAKE_CURRENT_SOURCE_DIR}/F_rust_ffi_probe/rust)
set(_rust_lib ${_rust_dir}/target/release/libfpe_rust_leaf.a)
# Build the Rust staticlib with cargo. The staticlib is the link shape that
# avoids a runtime .so dependency the standard Rust-into-C++ integration.
add_custom_command(
OUTPUT ${_rust_lib}
COMMAND ${CARGO_EXECUTABLE} build --release --manifest-path ${_rust_dir}/Cargo.toml
DEPENDS ${_rust_dir}/Cargo.toml ${_rust_dir}/src/lib.rs
COMMENT "Building Rust leaf decoder (staticlib) for FFI probe"
VERBATIM)
add_executable(spike_f_rust_ffi
common/SpikeRunner.cpp
common/SpikeRunner.h
F_rust_ffi_probe/cpp_driver.cpp
)
target_include_directories(spike_f_rust_ffi PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(spike_f_rust_ffi PRIVATE cxx_std_20)
freepdfeditor_apply_warnings(spike_f_rust_ffi)
freepdfeditor_apply_hardening(spike_f_rust_ffi)
# Link the Rust staticlib by absolute path (a .a is an archive, not a
# -l flag). Mark it as a dependency of the custom command output so the
# Rust build runs before the link.
add_dependencies(spike_f_rust_ffi fpe_rust_leaf_staticlib)
target_link_libraries(spike_f_rust_ffi PRIVATE
${_rust_lib} pthread dl)
target_link_options(spike_f_rust_ffi PRIVATE -static-libgcc)
# Make the staticlib a real CMake target with a custom command so the
# build graph knows it is produced, not pre-existing.
add_custom_target(fpe_rust_leaf_staticlib DEPENDS ${_rust_lib})
# Skip the rust spike from the default 'all' target if Rust isn't wanted in
# a given build (e.g. a CI image without cargo) it's gated on CARGO_EXECUTABLE.
else()
message(STATUS "cargo not found — Spike F (Rust FFI probe) skipped; "
"ADR-0005 will record the decision without the empirical build data.")
endif()

View File

@ -0,0 +1,48 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// spike/F_rust_ffi_probe — ADR-0005 build-friction probe. Measures the cost of
// adding a Rust leaf decoder to the tree: a tiny `flate`-style decoder stub
// (echoes a byte buffer) compiled as a `cdylib`/`staticlib` behind a C ABI,
// linked into a C++ driver. The point is the build friction and FFI shape,
// not real decompression.
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>
// The C ABI the Rust crate exposes. Declared here (not from a generated
// header) to show the minimal glue shape.
extern "C" {
// A bounded view, matching the §7.2 "std::span-like bounded views" rule.
// The Rust side gets a pointer + length and must not read past length.
int fpe_rust_decode(const unsigned char* in, std::size_t in_len,
unsigned char* out, std::size_t out_cap,
std::size_t* out_len);
}
int main(int argc, char** argv)
{
const std::size_t n = (argc >= 2) ? std::size_t(std::atoll(argv[1])) : 1024;
std::vector<unsigned char> input(n, 0x5A);
std::vector<unsigned char> output(n * 2, 0);
std::size_t out_len = 0;
int rc = fpe_rust_decode(input.data(), input.size(),
output.data(), output.size(), &out_len);
if (rc != 0) {
std::fprintf(stderr, "decode failed: %d\n", rc);
return 1;
}
// Verify the (stub) decoder echoed the input.
if (out_len != input.size() ||
std::memcmp(output.data(), input.data(), input.size()) != 0) {
std::fprintf(stderr, "decode output mismatch\n");
return 1;
}
std::printf("rust_ffi_probe: decoded %zu bytes via Rust C ABI, rc=%d\n",
out_len, rc);
return 0;
}

7
spike/F_rust_ffi_probe/rust/Cargo.lock generated Normal file
View File

@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "fpe_rust_leaf"
version = "0.1.0"

View File

@ -0,0 +1,25 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
#
# ADR-0005 build-friction probe crate. A staticlib so the C++ driver links it
# without a runtime .so dependency. crate-type = ["staticlib"] is the standard
# Rust-into-C++ integration shape; "cdylib" would be for a .so.
[package]
name = "fpe_rust_leaf"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0-or-later"
[lib]
name = "fpe_rust_leaf"
crate-type = ["staticlib"]
path = "src/lib.rs"
# Release profile: the leaf decoder ships hardened. Mirrors the C++ side's
# -fstack-protector-strong / overflow checks via Rust's default release
# arithmetic checks and overflow guards.
[profile.release]
panic = "abort" # match the C++ side: no unwinding across the FFI
overflow-checks = true
lto = true

View File

@ -0,0 +1,36 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// ADR-0005 build-friction probe: a Rust leaf decoder behind a C ABI. The
// crate builds as a staticlib so it links into the C++ driver without a
// runtime .so dependency. The "decode" is a stub (echo the input) — the
// point is the FFI shape and build cost, not real decompression.
use std::slice;
use std::ptr;
/// # Safety
/// `in_buf` must be valid for `in_len` bytes; `out_buf` for `out_cap`.
/// Returns 0 on success and writes the decoded length to `*out_len`.
#[no_mangle]
pub extern "C" fn fpe_rust_decode(
in_buf: *const u8,
in_len: usize,
out_buf: *mut u8,
out_cap: usize,
out_len: *mut usize,
) -> i32 {
// Bounded view — the §7.2 rule, enforced by the type system here.
if in_buf.is_null() || out_buf.is_null() || out_len.is_null() {
return 1;
}
if in_len > out_cap {
return 2; // output buffer too small
}
let input = unsafe { slice::from_raw_parts(in_buf, in_len) };
let output = unsafe { slice::from_raw_parts_mut(out_buf, out_cap) };
// The "decode": echo. Real Flate/LZW/CMap decode goes here in M2.
output[..in_len].copy_from_slice(input);
unsafe { ptr::write(out_len, in_len) };
0
}