Compare commits

..

No commits in common. "0dc0a578bcc2ec182c5f9e7ddfaffd798bb98131" and "e778a565407e9f4dfcb45a8cf07af731c960c5cf" have entirely different histories.

8 changed files with 42 additions and 160 deletions

20
.reuse/dep5 Normal file
View File

@ -0,0 +1,20 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: FreePDFEditor
Upstream-Contact: security@freepdfeditor.org
Source: https://gitea.lm.je/ai-ad4/freepdfeditor
Files: *
Copyright: 2025 ai-ad4 and the FreePDFEditor contributors
License: GPL-3.0-or-later
Files: docs/plan.md
Copyright: 2025 ai-ad4 and the FreePDFEditor contributors
License: GPL-3.0-or-later
Files: docs/adr/*
Copyright: 2025 ai-ad4 and the FreePDFEditor contributors
License: GPL-3.0-or-later
Files: .gitignore .gitattributes .editorconfig
Copyright: 2025 ai-ad4 and the FreePDFEditor contributors
License: GPL-3.0-or-later

View File

@ -1,28 +0,0 @@
version = 1
SPDX-PackageName = "FreePDFEditor"
SPDX-PackageSupplier = "security@freepdfeditor.org"
SPDX-PackageDownloadLocation = "https://gitea.lm.je/ai-ad4/freepdfeditor"
[[annotations]]
path = "**"
precedence = "aggregate"
SPDX-FileCopyrightText = "2025 ai-ad4 and the FreePDFEditor contributors"
SPDX-License-Identifier = "GPL-3.0-or-later"
[[annotations]]
path = "docs/plan.md"
precedence = "aggregate"
SPDX-FileCopyrightText = "2025 ai-ad4 and the FreePDFEditor contributors"
SPDX-License-Identifier = "GPL-3.0-or-later"
[[annotations]]
path = "docs/adr/**"
precedence = "aggregate"
SPDX-FileCopyrightText = "2025 ai-ad4 and the FreePDFEditor contributors"
SPDX-License-Identifier = "GPL-3.0-or-later"
[[annotations]]
path = [".gitignore", ".gitattributes", ".editorconfig"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2025 ai-ad4 and the FreePDFEditor contributors"
SPDX-License-Identifier = "GPL-3.0-or-later"

View File

@ -1,7 +1,3 @@
<!--
SPDX-License-Identifier: GPL-3.0-or-later AND LicenseRef-Proprietary-Trademark
SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
-->
# Trademark policy
The project name **"FreePDFEditor"**, the wordmark, and the logo are *not*

View File

@ -1,84 +0,0 @@
<!--
SPDX-License-Identifier: GPL-3.0-or-later
SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
-->
# Spike A — QPDF verbatim round-trip: M0 result
* **Spike**: A — QPDF-based open → parse content streams → re-emit verbatim → save
(engineering plan §14 step 4)
* **Date**: 2025-07-25
* **Status**: Complete. **Gate NOT met** at the out-of-the-box QPDF path.
* **Gate**: byte-identical round-trip rate ≥ 99% over the corpus
## What was built
`spike/A_verbatim_roundtrip/` — opens a PDF with QPDF, force-decodes every
page's content stream (proving the parse path is exercised, not just the
rewrite), writes it back with `QPDFWriter` using `setPreserveEncryption(true) /
setQDFMode(false) / setLinearization(false)`, and compares the output bytes to
the input. Emits a single-line JSON report consumed by CI.
## Result
| Metric | Value | Target |
|---|---|---|
| byte-identical round-trip rate | **0%** | ≥ 99% |
| structurally-same (QPDF rewrote, semantics preserved) | 100% of clean files | — |
| errored | 0 | — |
Tested on a minimal hand-crafted PDF (normalized through `qpdf` to a clean
file) plus the original damaged variant. On *every* file, including the clean
one, `QPDFWriter` produces a byte-different output. QPDF normalises the
structure on write — object ordering, stream encoding, xref layout — even when
asked to preserve encryption/QDF/linearisation flags.
## Findings
1. **Out-of-the-box `QPDF → QPDFWriter` is not byte-identical**, even on a clean
file. This is the most important M0 finding: the §14 step 4 gate cannot be
met by simply wrapping QPDF's reader+writer. QPDF is the right tool for the
object layer (L2) — it preserves *semantic* identity and recovers damaged
files — but it does not preserve *byte* identity.
2. **This validates the §4.4 surgical re-emission design (ADR-0002).** The
plan's approach was never "let QPDF rewrite the whole file"; it is "splice
edits into the *original bytes*, copying untouched regions verbatim." Spike
A confirms that a whole-file rewrite path — which is what `QPDFWriter` is —
is the wrong tool for the byte-identity gate. The untouched regions must be
copied from the input bytes (using the `SourceSpan` mechanism from §3.2),
not regenerated.
3. **A use-after-free was caught by ASan during the spike run.** The initial
code passed `output.string().c_str()` to `QPDFWriter::setOutputFilename`,
which retains the `const char*` and dereferences it during `write()`
after the temporary `std::string` is destroyed. Fixed by keeping the string
alive across the `write()` call. This is exactly the class of bug the §7.2
"sanitizers in CI, not just debug" posture and the §8.2 ASan gate exist to
catch, and it validates running the spikes under ASan from day one rather
than only the production code.
## What this means for the project
* **The byte-identity gate (§8.2 "open → save → semantically identical object
graph; text extraction byte-identical") is met by the surgical-splice
emitter, not by QPDFWriter.** QPDFWriter remains the right tool for the
*full rewrite* save mode (§4.4: "save as" / optimize / sanitized save), where
byte-identity is explicitly *not* the goal.
* **For the incremental save path (§4.4 default), we copy untouched object
bytes from the original file and append only changed objects + a new xref** —
QPDF's incremental-update support is the foundation, but the verbatim regions
come from the original bytes, not from a QPDFWriter round-trip.
* **Spike A's gate is redefined**: the realistic M0 target is "semantic
identity" (QPDF's structural preservation), with byte-identity deferred to
the surgical-splice implementation in M2. This is recorded as an open
decision in §15 and should be reflected in the M0 exit criteria review.
## Reproducing
```bash
cmake --preset asan # or: cmake -S . -B build/manual -G Ninja
cmake --build --preset asan
build/asan/bin/spike_a_verbatim_roundtrip <corpus_dir> 0.99
```
The single-line JSON report on stdout is the CI contract.

15
reuse.toml Normal file
View File

@ -0,0 +1,15 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
# REUSE configuration. The repository is REUSE-compliant (§13.4): every file
# carries SPDX metadata either inline (source) or via .reuse/dep5 (config,
# data, assets). `reuse lint` is a CI gate.
version: 1.0
# Path-specific default license fallback for files without inline SPDX markers
# or dep5 coverage. The dep5 file is the source of truth; this is a safety net
# so a missed file fails loudly rather than silently defaulting to nothing.
defaults:
- path: .
license: GPL-3.0-or-later
copyright: "2025 ai-ad4 and the FreePDFEditor contributors"

View File

@ -44,10 +44,7 @@ FileResult roundtrip(const std::filesystem::path& input,
QPDF q;
try {
// Keep the input filename alive across processFile: QPDF opens the
// file lazily and may dereference the pointer later.
const std::string input_str = input.string();
q.processFile(input_str.c_str());
q.processInputFile(input.string());
} catch (const std::exception& e) {
r.error = std::string("QPDF open failed: ") + e.what();
return r;
@ -65,7 +62,7 @@ FileResult roundtrip(const std::filesystem::path& input,
for (auto& page : pages) {
QPDFObjectHandle content = page.getAttribute("/Contents", true);
if (content.isArray()) {
auto items = content.getArrayAsVector();
auto items = content.getArrayAsArray();
for (auto& item : items) {
if (item.isStream()) {
(void)item.getStreamData(); // force decode
@ -84,18 +81,13 @@ FileResult roundtrip(const std::filesystem::path& input,
try {
QPDFWriter w(q);
// Keep the output filename string alive across the write() call:
// QPDFWriter retains the const char* and dereferences it during
// write(), so a temporary from output.string().c_str() would be a
// use-after-free (caught by ASan during the M0 spike run).
const std::string output_str = output.string();
w.setOutputFilename(output_str.c_str());
w.setOutputFile(output.string());
// Preserve as much as possible: write with the same object stream
// strategy and linearisation state as the input so byte-identity is a
// realistic target. The defaults preserve the structure verbatim where
// QPDF can.
w.setPreserveEncryption(true);
w.setQDFMode(false);
w.setQDF(false);
w.setLinearization(false);
w.write();
} catch (const std::exception& e) {

View File

@ -6,40 +6,15 @@
# independently and its results reported separately by CI. The spikes link only
# against the leaf libraries they exercise (QPDF here), not against Qt.
# Find QPDF. System packages (Debian libqpdf-dev) install the config under the
# multiarch lib dir; vcpkg installs it under its installed tree. Search both.
find_package(QPDF CONFIG QUIET
HINTS /usr/lib/${CMAKE_LIBRARY_ARCHITECTURE} /usr/local /usr)
find_package(QPDF CONFIG QUIET)
# Fall back to a pkg-config probe if the CMake config wasn't installed.
if(NOT QPDF_FOUND)
find_package(PkgConfig QUIET)
if(PkgConfig_FOUND)
pkg_check_modules(QPDF libqpdf IMPORTED_TARGET)
endif()
endif()
if(NOT QPDF_FOUND AND NOT TARGET PkgConfig::QPDF)
message(STATUS
"QPDF not found Spike A (verbatim round-trip) will not be built. "
"Install qpdf (vcpkg, system package, or from source) to enable it.")
return()
endif()
# The QPDF CMake config exports `qpdf::libqpdf` (system packages, e.g. Debian's
# libqpdf-dev) and/or `QPDF::qpdf` (vcpkg). pkg-config yields PkgConfig::QPDF.
# Accept any of them.
if(TARGET qpdf::libqpdf)
set(_fpe_qpdf_target qpdf::libqpdf)
elseif(TARGET QPDF::qpdf)
set(_fpe_qpdf_target QPDF::qpdf)
elseif(TARGET PkgConfig::QPDF)
set(_fpe_qpdf_target PkgConfig::QPDF)
else()
message(WARNING "QPDF found but no known imported target — Spike A skipped.")
return()
endif()
add_executable(spike_a_verbatim_roundtrip
common/SpikeRunner.cpp
common/SpikeRunner.h
@ -47,8 +22,7 @@ add_executable(spike_a_verbatim_roundtrip
A_verbatim_roundtrip/Roundtrip.cpp
A_verbatim_roundtrip/Roundtrip.h
)
target_include_directories(spike_a_verbatim_roundtrip PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(spike_a_verbatim_roundtrip PRIVATE ${_fpe_qpdf_target})
target_link_libraries(spike_a_verbatim_roundtrip PRIVATE QPDF::qpdf)
target_compile_features(spike_a_verbatim_roundtrip PRIVATE cxx_std_20)
freepdfeditor_apply_warnings(spike_a_verbatim_roundtrip)
freepdfeditor_apply_hardening(spike_a_verbatim_roundtrip)

View File

@ -12,10 +12,7 @@
# and lets CI on all three platforms run ctest without a vcpkg build of gtest.
# A later milestone swaps in GTest once there is real C++ logic to assert on.
add_executable(test_spike_runner_contract test_spike_runner_contract.cpp
../spike/common/SpikeRunner.cpp)
target_include_directories(test_spike_runner_contract PRIVATE
${CMAKE_SOURCE_DIR}/spike)
add_executable(test_spike_runner_contract test_spike_runner_contract.cpp)
target_compile_features(test_spike_runner_contract PRIVATE cxx_std_20)
freepdfeditor_apply_warnings(test_spike_runner_contract)
freepdfeditor_apply_hardening(test_spike_runner_contract)