Compare commits
No commits in common. "0dc0a578bcc2ec182c5f9e7ddfaffd798bb98131" and "e778a565407e9f4dfcb45a8cf07af731c960c5cf" have entirely different histories.
0dc0a578bc
...
e778a56540
|
|
@ -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
|
||||
28
REUSE.toml
28
REUSE.toml
|
|
@ -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"
|
||||
|
|
@ -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*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue