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:
parent
91ae2077fa
commit
067bb7c362
|
|
@ -19,6 +19,12 @@ compile_commands.json
|
|||
vcpkg/
|
||||
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
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
# ADR-0003 — Memory safety posture for the parser layers
|
||||
|
||||
* **Status**: Accepted (decision on Rust-vs-C++ for L1/L2 leaf decoders is
|
||||
*deferred to end of M0* per §15; this ADR records the posture that holds
|
||||
regardless of that decision)
|
||||
* **Status**: Accepted. The Rust-vs-C++ decision deferred here is **resolved
|
||||
by ADR-0005** (Rust for the image-codec leaf decoders; C++ elsewhere).
|
||||
* **Date**: 2025-07-25
|
||||
* **Plan reference**: §7.2, §15
|
||||
* **Superseded by**: the deferred-decision portion is superseded by ADR-0005;
|
||||
the hardening posture below stands.
|
||||
|
||||
## Context
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
```
|
||||
|
|
@ -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)
|
||||
(§7.2)
|
||||
* [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.
|
||||
|
|
@ -169,4 +169,10 @@ else()
|
|||
message(STATUS
|
||||
"libseccomp not found — Spike E (sandbox) will not be built. "
|
||||
"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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue