fix: thread safety, lifetime and bounds-checking follow-ups - #659
Merged
Conversation
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
DynamicUnit's registry is a function-local static whose map is mutated on every DynamicUnit construction, with no lock. Every Measure built while rendering goes through it and HttpServer serves from a thread pool, so two concurrent translates were a data race. Now a shared_mutex with a read-locked fast path and a transparent hash so the lookup does not allocate a key. std::localtime returns a pointer to a shared static tm; print_head now uses localtime_r / localtime_s. Output is unchanged. Also: File's shared_ptr constructor rejects null like DecodedFile's already did, bring_offline dedups non-adjacent duplicates rather than only adjacent ones, and to_row_num throws instead of mapping "0" to UINT32_MAX. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D8GkXFfKmN7KaCoeYcAayS
string::split with an empty delimiter never advanced, so the callback fired forever; it now throws. TemporaryDiskFile was copyable, so two objects owned one path and the first destructor deleted the file under the other; it is now move-only. VirtualFileWalker::equals compared iterators from two different maps and dynamic_cast'd by reference, so a foreign walker threw bad_cast. list_file_types constructed ZipFile/CfbFile outside the try, so a truncated container threw out of what is only a probe and killed the graceful fallback in open_file and magic::mimetype. The primary open paths still throw by design and are left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D8GkXFfKmN7KaCoeYcAayS
invoke_x_object and show_type3 ran a form's content against the caller's graphics-state stack, so an unbalanced form corrupted its caller. A ContentScope now pins a restore floor and pops back to the entry depth. The type 4 PostScript operators cast operands with a bare static_cast to int32, which is undefined for an out-of-range double; they now saturate, and idiv/mod no longer divide by zero for a divisor below 1 or overflow on INT32_MIN / -1. Object streams get the same cycle guard the page tree has, and /Font, /XObject and /ToUnicode tolerate a direct value the way /Pattern already did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D8GkXFfKmN7KaCoeYcAayS
The EncryptionInfo path advanced its offset by an attacker-controlled encryption_header_size and decrypt read the first 8 bytes through an unaligned reinterpret_cast, both without checking the buffer. derive_key also built a string from a char[16] salt using a uint32 length from the file. All now go through a bounds-checked cursor. Also: SVM read_primitive throws on a short read and no longer sizes buffers from an unvalidated length prefix, CFB derives its sector size from sector_shift and validates it against [MS-CFB] 2.2, and sfnt search_hints guards count == 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D8GkXFfKmN7KaCoeYcAayS
py::exception creates the type but installs no translator, so every unregistered odr::Exception subclass surfaced as a bare RuntimeError and `except pyodr.Error` was a misleading net. Registered properly, and based on RuntimeError so code catching that keeps working. The GIL was held across translate, warmup, write, bring_offline, save, decrypt and open, blocking every other Python thread for the duration. JNI: Logger.createFromSink returned a live handle with a pending exception, and wrap_views / wrap_elements / make_content leaked native handles when the Java allocation failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D8GkXFfKmN7KaCoeYcAayS
andiwand
force-pushed
the
review/followup-findings
branch
2 times, most recently
from
August 7, 2026 23:03
29b57c4 to
a8b3e21
Compare
andiwand
marked this pull request as draft
August 8, 2026 06:17
andiwand
marked this pull request as ready for review
August 8, 2026 06:18
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/odr/table_position.cpp:39
to_row_numcan silently wrapresulton very long inputs:std::uint64_toverflow wraps modulo 2^64, and the subsequent> uint32_t::max()check may miss it. This can yield an incorrect row index instead of rejecting out-of-range input.
src/odr/table_position.cpp:28- The exception message
"s is empty"is unclear (it doesn’t say what input is empty) and looks like a leftover from a different parameter name. A more specific message improves diagnosability without changing behavior.
src/odr/internal/ooxml/ooxml_crypto.cpp:33 ByteReader::seekthrows"truncated ooxml crypto stream"both when seeking past the end and when seeking backwards. The backward-seek case indicates an invalid offset / inconsistent length field rather than truncation, so the message is misleading.
/// Jumps to @p offset, which must lie ahead of the cursor and inside the
/// stream.
void seek(const std::uint64_t offset) {
if (offset < m_offset || offset > m_data.size()) {
throw std::runtime_error("truncated ooxml crypto stream");
}
m_offset = static_cast<std::size_t>(offset);
src/odr/html.cpp:131
- The dedup loop builds an
unordered_setwithout reserving, which can cause repeated rehashes for largeresourcesvectors. Reserving upfront makes the O(n) dedup path more predictable.
std::unordered_set<std::string> seen;
const auto removed =
std::ranges::remove_if(resources, [&seen](const auto &resource) {
return !seen.insert(resource.first.path()).second;
});
`File::location()` is `noexcept`, so a default-constructed handle had to answer something, and `memory` invited a `memory_data()` call that throws. `FileLocation` gains `unknown` as its first enumerator, matching every other odr enum; the JNI, Objective-C and Python mirrors move with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHdTUo9Md3a7sVJCxnaUGo
`NewObjectArray` returning null left the loops calling `SetObjectArrayElement` on it, which is undefined. The neighbouring JNI calls were already checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHdTUo9Md3a7sVJCxnaUGo
`ooxml_crypto` and `ppt_style` had each grown the same bounds-checked cursor over a byte range; both now use `util::byte_string::Reader`. `svm_format` had likewise reimplemented `byte_stream::read_u8s`, which had no callers at all — the incremental growth that keeps a bogus length prefix from allocating ahead of the stream moves there, and the three `byte_stream` read failures now report one message. `crypto::util` and the ooxml crypto API take `string_view` over `const std::string &`, which drops the copies the boundary forced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHdTUo9Md3a7sVJCxnaUGo
andiwand
enabled auto-merge (squash)
August 8, 2026 07:04
andiwand
disabled auto-merge
August 8, 2026 07:11
andiwand
enabled auto-merge (squash)
August 8, 2026 07:12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 Generated with Claude Code
The findings the review turned up that needed more than a surgical edit, so
they were held back out of #658. Mostly concurrency, lifetime and
bounds-checking. 32 files, +660 / −172.
Verification
Same sweep as #658, plus a check that this round changes no output at all.
ctest)ODR_APPLE=ON)What is fixed
DynamicUnit's registry was a data race. A function-localstaticwhoseunordered_mapis mutated on everyDynamicUnitconstruction, unlocked.Every
Measurebuilt while rendering goes through it, andHttpServerservesfrom a thread pool — so two concurrent translates raced. Now a
shared_mutexwith a read-locked fast path and a transparent hash so the lookup does not
allocate a key.
std::localtimein the logger had the same shape (a sharedstatic
tm) and is nowlocaltime_r/localtime_s, with output unchanged.Form XObjects could corrupt their caller's graphics state.
invoke_x_objectand
show_type3ran a form's content stream against the caller'sq/Qstack, so an unbalanced form destroyed saved states or leaked a CTM. A
ContentScopenow pins a restore floor and pops back to exactly the entrydepth.
OOXML encryption headers were read without bounds checks. The
EncryptionInfopath advanced its offset by an attacker-controlledencryption_header_size,decryptread the first 8 bytes through an unalignedreinterpret_cast, andderive_keybuilt a string from achar[16]saltusing a
uint32length taken from the file — a 4 GB read past a 16-byte field.All go through a bounds-checked cursor now.
pyodr.Errorcaught almost nothing.py::exceptioncreates the type butinstalls no translator, so every unregistered
odr::Exceptionsubclass —NoZipFile,InvalidPath,ServerBindFailed, … — surfaced as a bareRuntimeError. Registered properly, and deliberately based onRuntimeErrorso existingexcept RuntimeErrorcode keeps working whileexcept pyodr.Errorstarts working. Verified both catch paths at runtime.The GIL was held across every long-running call —
translate,warmup,write,bring_offline,save,decrypt,open— blocking all other Pythonthreads for the duration. Released, scoped narrowly where the binding builds a
py::bytesinside the lambda. Both re-entry paths (thePyLoggertrampolineand a Python
resource_locatorthroughpybind11/functional.h) were read andconfirmed to re-acquire the GIL themselves.
Also:
string::splitwith an empty delimiter never advanced and loopedforever;
TemporaryDiskFilewas copyable so two objects owned one path and thefirst destructor deleted the file under the other;
VirtualFileWalker::equalscompared iterators from two different maps;
list_file_typesbuiltZipFile/CfbFileoutside itstry, so a truncated container threw out ofwhat is only a probe and killed the graceful fallback in
open_fileandmagic::mimetype; the PDF type 4 operators cast operands toint32with abare
static_cast(undefined out of range) andidiv/moddivided by zerofor any divisor below 1; object streams got the cycle guard the page tree
already had; CFB now derives its sector size from
sector_shiftand validatesit against [MS-CFB] 2.2; SVM
read_primitivethrows on a short read instead ofleaving the destination uninitialized and sizing buffers from an unvalidated
length prefix.
The SVM change is the one that could plausibly reject a file that used to
parse, so it was checked rather than assumed: a byte-exact re-implementation of
the header and action loop was run over all four SVM fixtures plus the one
embedded in
image-2.odp. Every one consumes to the exact last byte with thedeclared action count and never reads short. Likewise all 30 CFB fixtures in
the corpus are major version 3 /
sector_shift9, so they derive 512 exactlyas before.
Deliberately still not fixed
Each of these is a product decision rather than a bug fix, so it wants an
owner's call rather than a drive-by:
.pptxstyle readers look forWordprocessingML attribute names that do not exist in DrawingML —
rFonts/@asciiinstead ofa:latin/@typeface,@color/@highlightinsteadof
a:solidFill/a:highlight,a:pPr/@jcinstead of@algn. So fontfamily, colour, background and paragraph alignment are silently never applied
for pptx, while
presentation/README.mdticks them as supported. Fixing itchanges every pptx reference output.
nonnullthat can benil. ~40 properties behindguarded_value(..., nil)are declared non-nullable, so a genuine engineexception puts
nilinto a slot Swift imports as non-optional. The honest fixis
nullable(an API change) or returning a default-constructed value (abehaviour change).
ODRElementhas noisEqual:/hash, so two wrappers for the same nodecompare unequal in Swift and the type is unusable as a
Setkey — while JNIexposes the same thing as
Element.isSame. AndODRElement.existsisdocumented as reachable but is always
YES.table:number-rows-repeatedmaterialises one registry element per repeat.Needs a clamp policy, not a local edit.
File::location()isnoexceptand so still cannot report a null impl;it returns
FileLocation::memory. Making it throw means droppingnoexceptfrom a public signature.
TemporaryDiskFile/TemporaryDiskFileFactoryturn out to be dead code —nothing outside their own
.cppuses either. Worth deleting, separately.