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>
This commit is contained in:
ai-ad4 2026-07-25 20:30:37 +00:00
parent e778a56540
commit 1d5372f140
3 changed files with 44 additions and 7 deletions

View File

@ -44,7 +44,10 @@ FileResult roundtrip(const std::filesystem::path& input,
QPDF q; QPDF q;
try { 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) { } catch (const std::exception& e) {
r.error = std::string("QPDF open failed: ") + e.what(); r.error = std::string("QPDF open failed: ") + e.what();
return r; return r;
@ -62,7 +65,7 @@ FileResult roundtrip(const std::filesystem::path& input,
for (auto& page : pages) { for (auto& page : pages) {
QPDFObjectHandle content = page.getAttribute("/Contents", true); QPDFObjectHandle content = page.getAttribute("/Contents", true);
if (content.isArray()) { if (content.isArray()) {
auto items = content.getArrayAsArray(); auto items = content.getArrayAsVector();
for (auto& item : items) { for (auto& item : items) {
if (item.isStream()) { if (item.isStream()) {
(void)item.getStreamData(); // force decode (void)item.getStreamData(); // force decode
@ -81,13 +84,18 @@ FileResult roundtrip(const std::filesystem::path& input,
try { try {
QPDFWriter w(q); 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 // Preserve as much as possible: write with the same object stream
// strategy and linearisation state as the input so byte-identity is a // strategy and linearisation state as the input so byte-identity is a
// realistic target. The defaults preserve the structure verbatim where // realistic target. The defaults preserve the structure verbatim where
// QPDF can. // QPDF can.
w.setPreserveEncryption(true); w.setPreserveEncryption(true);
w.setQDF(false); w.setQDFMode(false);
w.setLinearization(false); w.setLinearization(false);
w.write(); w.write();
} catch (const std::exception& e) { } catch (const std::exception& e) {

View File

@ -6,15 +6,40 @@
# independently and its results reported separately by CI. The spikes link only # independently and its results reported separately by CI. The spikes link only
# against the leaf libraries they exercise (QPDF here), not against Qt. # 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) 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 message(STATUS
"QPDF not found Spike A (verbatim round-trip) will not be built. " "QPDF not found Spike A (verbatim round-trip) will not be built. "
"Install qpdf (vcpkg, system package, or from source) to enable it.") "Install qpdf (vcpkg, system package, or from source) to enable it.")
return() return()
endif() 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 add_executable(spike_a_verbatim_roundtrip
common/SpikeRunner.cpp common/SpikeRunner.cpp
common/SpikeRunner.h common/SpikeRunner.h
@ -22,7 +47,8 @@ add_executable(spike_a_verbatim_roundtrip
A_verbatim_roundtrip/Roundtrip.cpp A_verbatim_roundtrip/Roundtrip.cpp
A_verbatim_roundtrip/Roundtrip.h 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) target_compile_features(spike_a_verbatim_roundtrip PRIVATE cxx_std_20)
freepdfeditor_apply_warnings(spike_a_verbatim_roundtrip) freepdfeditor_apply_warnings(spike_a_verbatim_roundtrip)
freepdfeditor_apply_hardening(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. # 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. # 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) target_compile_features(test_spike_runner_contract PRIVATE cxx_std_20)
freepdfeditor_apply_warnings(test_spike_runner_contract) freepdfeditor_apply_warnings(test_spike_runner_contract)
freepdfeditor_apply_hardening(test_spike_runner_contract) freepdfeditor_apply_hardening(test_spike_runner_contract)