Compare commits

...

2 Commits

Author SHA1 Message Date
ai-ad4 0dc0a578bc build(license): convert dep5 to REUSE.toml; record Spike A result
Convert the deprecated .reuse/dep5 copyright file to the modern REUSE.toml
format (REUSE 3.3). The old reuse.toml (non-standard schema) is removed;
REUSE.toml is now the single source of path-level SPDX annotations, with
inline headers remaining on source files.

Add the LicenseRef-Proprietary-Trademark SPDX identifier to TRADEMARK.md so
the trademark license file in LICENSES/ is referenced and no longer flagged
as unused. # SUMMARY

* Bad licenses: 0
* Deprecated licenses: 0
* Licenses without file extension: 0
* Missing licenses: 0
* Unused licenses: 0
* Used licenses: GPL-3.0-or-later, LicenseRef-Proprietary-Trademark
* Read errors: 0
* Invalid SPDX License Expressions: 0
* Files with copyright information: 53 / 53
* Files with license information: 53 / 53

Congratulations! Your project is compliant with version 3.3 of the REUSE Specification :-) now passes: 52/52 files compliant, zero issues.

Record the Spike A M0 result under docs/spike-results/: the out-of-the-box
QPDF read→write path produces 0% byte-identical output (QPDFWriter
normalises structure on every write), so the §14 step 4 gate of ≥99% is not
met by wrapping QPDF. This validates the §4.4 surgical-re-emission design
(ADR-0002): untouched regions must be copied from the original bytes via
SourceSpan, not regenerated. The finding and its implications for the M0
exit criteria are documented in the result file.

Signed-off-by: ai-ad4 <ai-ad4@users.noreply.gitea.lm.je>
2026-07-25 20:30:43 +00:00
ai-ad4 1d5372f140 fix(spike-A): adapt to installed QPDF 12.2 API; fix use-after-free
The QPDF 12.2 API differs from the initially-written calls:
- processInputFile → processFile
- getArrayAsArray → getArrayAsVector
- setOutputFile(string) → setOutputFilename(const char*)
- setQDF → setQDFMode

The spike CMakeLists now finds QPDF via either a CMake config target
(qpdf::libqpdf for system packages, QPDF::qpdf for vcpkg) or a pkg-config
fallback (PkgConfig::QPDF), so it builds against the Debian libqpdf-dev
package as well as a vcpkg manifest install.

The contract test target was missing SpikeRunner.cpp from its sources,
causing a link error; added it with the spike/ include directory.

ASan caught a heap-use-after-free in the initial write path: QPDFWriter
retains the const char* passed to setOutputFilename and dereferences it
during write(), after the temporary std::string from output.string() is
destroyed. Both the input and output filename strings are now kept alive
across the QPDF calls that retain them. This is exactly the bug class the
§7.2 sanitizers-in-CI posture exists to catch.

Builds and passes under GCC 14.2 + Qt 6.8.2 + QPDF 12.2, both Release and
ASan+UBSan configurations.

Signed-off-by: ai-ad4 <ai-ad4@users.noreply.gitea.lm.je>
2026-07-25 20:30:37 +00:00
8 changed files with 160 additions and 42 deletions

View File

@ -1,20 +0,0 @@
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

28
REUSE.toml Normal file
View File

@ -0,0 +1,28 @@
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,3 +1,7 @@
<!--
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

@ -0,0 +1,84 @@
<!--
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.

View File

@ -1,15 +0,0 @@
# 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,7 +44,10 @@ FileResult roundtrip(const std::filesystem::path& input,
QPDF q;
try {
q.processInputFile(input.string());
// 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());
} catch (const std::exception& e) {
r.error = std::string("QPDF open failed: ") + e.what();
return r;
@ -62,7 +65,7 @@ FileResult roundtrip(const std::filesystem::path& input,
for (auto& page : pages) {
QPDFObjectHandle content = page.getAttribute("/Contents", true);
if (content.isArray()) {
auto items = content.getArrayAsArray();
auto items = content.getArrayAsVector();
for (auto& item : items) {
if (item.isStream()) {
(void)item.getStreamData(); // force decode
@ -81,13 +84,18 @@ FileResult roundtrip(const std::filesystem::path& input,
try {
QPDFWriter w(q);
w.setOutputFile(output.string());
// 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());
// 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.setQDF(false);
w.setQDFMode(false);
w.setLinearization(false);
w.write();
} catch (const std::exception& e) {

View File

@ -6,15 +6,40 @@
# independently and its results reported separately by CI. The spikes link only
# against the leaf libraries they exercise (QPDF here), not against Qt.
find_package(QPDF CONFIG QUIET)
# 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)
# 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
@ -22,7 +47,8 @@ add_executable(spike_a_verbatim_roundtrip
A_verbatim_roundtrip/Roundtrip.cpp
A_verbatim_roundtrip/Roundtrip.h
)
target_link_libraries(spike_a_verbatim_roundtrip PRIVATE QPDF::qpdf)
target_include_directories(spike_a_verbatim_roundtrip PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(spike_a_verbatim_roundtrip PRIVATE ${_fpe_qpdf_target})
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,7 +12,10 @@
# 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)
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)
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)