Breaking changes
-
A same-revision
/XRefStmno longer overrides a classic cross-reference table's free entry
for the same object. Given one revision whose classic table marks object N free and whose
/XRefStmdefines it,PdfReadernow resolves N tonull, matching qpdf; it previously
resolved N from the stream. The construct is describable beyond a hand-freed/Contents: an
incremental update from a writer that copies/XRefStmforward without understanding it, and
also carries the previous revision's free entries along unchanged, produces exactly this shape —
the case MatthiasValvekens describes in
pdf-association/pdf-issues#237, by
his own account without having checked it against a real processor ("I'm not aware of any
processors that do either of this, so maybe my intuition is completely wrong"). The
cross-section arrangement ISO 32000-2 §7.5.8.4 actually describes, where the free entry sits in
an earlier/Prevrevision, is unaffected only in the two-revision case; a hybrid revision
sitting between two others in a/Prevchain is not exempt, and loses its own copy the same way
a same-revision one does while also suppressing whatever a still-older revision defined. What
survives a chain like that is the definition in whichever revision is newest among the ones that
mention the object at all, not simply whichever one sits outside a same-revision pairing. This
aligns with the reading in issue #237, open at the time of writing; if it or errata
#523 resolves the other way, this is
revisited.VellumPdf.Readeris still Preview, where a behaviour change would ordinarily stay
under Changed, but silently dropping rendered content is closer to what this section otherwise
covers than a changed exception type is, so it's recorded here instead. (#206)Consequences, measured rather than assumed except where a bullet says otherwise — the
/Encrypt
shape has no test, and the/DecodeParmshalf rests on a code reading rather than a fixture:- A page's content stream can disappear: if N is a page's
/Contents, the page now has no
content stream where it had one. - A page can disappear outright, not just its content. If N is the page-tree root, the page
count drops from 1 to 0; if N is an intermediate/Pagesnode, freeing it drops only its own
subtree — a three-page document with a two-page branch under that node loses those two pages
and keeps the third, 3 becoming 1. The surviving root still declares its old/Count, so a
caller trusting/Countand one walking/Kidsnow disagree.PreflightContext.WalkPages
walks/Kidsthe same way, so page-scoped PDF/A rules silently stop covering the lost subtree
too. - A rarer variant costs more: if N is the object an
/Encryptreference points at, the document
now fails to open withInvalidDataExceptioninstead of decrypting, because/Encryptcan no
longer resolve to a dictionary at all. This shape has no test yet. - Worse still, if N is the catalog itself, the document does not open at all:
PdfReader.Open
throwsInvalidDataException: Malformed PDF: /Root does not resolve to a dictionary.where it
previously opened. - A
/Filteror/DecodeParmsobject resolving tonulldoes not degrade tonulloutput —
it produces wrong bytes.PdfFilters.GetFilterListtreats an unresolvable/Filteras no
filter at all, soGetDecodedStreamDatareturns the raw, still-encoded body. Measured: a
24-byte plaintext body, FlateDecode-compressed to 32 bytes (zlib header78 9C), comes back as
those 32 raw bytes instead of the 24-byte plaintext once its/Filterreference is freed this
way; a byte-identical control with the filter object live decodes correctly./DecodeParms
degrades the same way — a PNG predictor's rows are never undone. qpdf degrades identically
here, so this is not a divergence from the oracle, only from what this entry previously implied
the general case is. - When N is an
/ObjStmcontainer rather than an ordinary object, its compressed members have
to drop out of the merged table along with it, or a member nobody asked to free resolves
through a container that no longer exists andPdfDocumentReaderthrows
InvalidDataException: Object stream container N not found in xref.— a more surprising
failure than most other consequences here, and harsher than qpdf, which resolves such a member
tonull(with a warning) and keeps the document open. Fixed: such a member now resolves to
null, where it previously resolved to its live compressed value. §7.5.8.4's own EXAMPLE frees
a hidden object's/ObjStmcontainer alongside its members in the same table, and the reader's
existing member-level free tracking already handled that case before this change; what the fix
actually needed is narrower — a writer that frees the container without also freeing its
members, leaving their compressed rows pointing at nothing. - That container-cascade removal reaches further than "a freed object drops out": members never
themselves named by any free entry, only orphaned when their container was, also drop out of
the merged table, and therefore out of anything built from it —
PreflightContext.EnumerateIndirectObjectsandEnumerateStreamsincluded. Measured: an
ISO19005-2:6.1.13-nameError for an over-long name inside such a member disappears from a
PDF/A validation, even though nothing in the file ever freed that member's own object number. - Freeing the
/AcroFormthis way makesreader.Signatures.Countgo from 1 to 0 with no
exception and no warning, so code that reads an empty signature list as "unsigned" now
misreads a signed document. - A freed object drops out of the merged table entirely, so
ObjectNumbersshrinks, and — when
/Sizealso understates the count —NextFreeObjectNumbercan shrink with it, both feeding
code outside this package (PreflightContextandObjectLayoutRule;DssBuilderand
ArchiveTimestampBuilder). The fixture measuring this shrink has its own/Sizealready
understating the file's real object count, independent of anything freed — ISO 32000-2 Table 15
already calls that non-conforming ("any object in a cross-reference section whose number is
greater than this value shall be ignored and defined to be missing by a PDF reader") — so the
shrink measured there is not attributable to this change alone. TheNextFreeObjectNumber
shrink has no constructed consequence beyond itself; theObjectNumbersshrink has two, both
below — a disappearing name-limit Error and a flipped PDF/A verdict, each reached through
PreflightContext, the consumer this bullet names. - This reading can flip a PDF/A conformance verdict, not only drop content — and the verdict it
flips to is the one veraPDF gives. A PDF/A-2B file whose only violation is an external-stream
object (/F//FFilter//FDecodeParms) isIsCompliant=Falsewith rule
ISO19005-2:6.1.7.1-external-streamwhen that object is live, andIsCompliant=Truewith zero
assertions once it is freed this way. veraPDF 1.30.2 — the validator this repository gates on,
and the closest thing PDF/A has to a reference implementation — reaches those same two verdicts
on those same two files:FAIL … 2bagainst ISO 19005-2:2011 clause 6.1.7.1 test 3 for the live
one,PASS … 2bfor the freed one. Two controls rule out the deflationary readings: a variant
where the object is defined only by the/XRefStmand not freed still fails, so veraPDF is
not simply ignoring/XRefStm; and a variant violating a different rule flips the same way on
clause 6.1.7.2, so the agreement is about the cross-reference reading rather than one clause.
So the flip lands on veraPDF's side of a question the specification leaves open — which makes it
a correction under this reading rather than a straightforward cost, with the same caveat as
everything else here: if #237 or #523 resolves the other way, so does this. The mechanism:
PdfPreflight's file-structure rules walk the cross-reference keyspace directly
(PreflightContext.EnumerateStreams), specifically so an object the file never draws still gets
checked, so an object this reading removes from the merged table drops out of that enumeration
the same way a freed page does. The control differs from the freed file only in that object's
xref row, and is non-compliant on both builds.
- A page's content stream can disappear: if N is a page's
-
A type-2 (compressed) cross-reference entry whose container has no live entry anywhere in the
merged table now resolves tonullinstead of sometimes throwing. The container-cascade sweep
above used to also require the container to have actually been freed by some revision
(freed.Contains(container)) before dropping its orphaned members. That pairing looked like it
distinguished "genuinely freed" from "never mentioned by anything", but it does not: object 0
and any object an ordinary incremental update deletes are already infreedregardless, so the
pairing told the two cases apart from neither in practice. Dropped: the sweep now runs whenever
the container is absent from the merged table, freed or not. The one behaviour change this
reaches beyond the rest of this entry: a dangling type-2 reference to a container no revision
ever mentions, in a file with no free entry anywhere near it, now resolves tonullrather than
throwingInvalidDataException: Object stream container N not found in xref., matching qpdf. A
member the sweep drops this way is absent from_xrefitself — the table a future full
re-serialisation (tracked in #186) would walk to decide what to emit — so it is not carried into
a rewritten copy either. (#206) -
PdfReader.Opentakes aPdfReaderOptionsinstead of astring?password. The two
Open(x, string?)overloads are gone and the password moves toPdfReaderOptions.Password. The
two shapes could not coexist: adding an options overload beside thestring?one makes
Open(bytes, null)a CS0121 ambiguity, because nullable annotations do not participate in overload
resolution and nothing else separates the candidates. The reader also needs one place for later
settings to go — the cross-reference reconstruction switch in #184 is the next one. Migration is
mechanical:PdfReader.Open(bytes, "secret")becomes
PdfReader.Open(bytes, new PdfReaderOptions { Password = "secret" });PdfReader.Open(bytes)is
unchanged. One recompiled call is not mechanical, though:PdfReader.Open(bytes, null)used to
mean no password, and now binds to the options overload and throwsArgumentNullExceptioninstead
of opening the document. Recompile that call asPdfReader.Open(bytes). Recorded here even though
VellumPdf.Readeris still Preview and its surface is expected to move, because the removed
overloads shipped in 2.1.0 and 2.2.0. A consumer compiled against 2.1.0 or 2.2.0 that is not
recompiled does not fail to load:AssemblyVersionstays pinned at2.0.0.0across the 2.x line
(Directory.Build.props), so the assembly identity is unchanged and the runtime binds it. It
fails at the call instead, withMissingMethodException: Method not found: 'VellumPdf.Reader.PdfDocumentReader VellumPdf.Reader.PdfReader.Open(Byte[], System.String)'.
Method resolution happens when the calling method is JITted, before any surroundingtryis
entered, so this cannot be caught at the call site; the only fix is to recompile against
PdfReaderOptions. (#184)
Added
-
PdfReaderOptions.MaxDecodedStreamBytesandReconstructionBudgetMultiplier— configurable
resource ceilings (#376). Reconstruction's cost budget and the per-decode ceiling on decompressed
stream output were fixed constants sized for a desktop: a 512 MiB decode cap and a reconstruction
work budget ofmax(1 MiB, 8 × file length). ISO 32000-2 Annex C.1 (informative) states that "a
particular PDF processor running on a particular device and in a particular operating environment
will always have practical limits", and Annex C.3 (informative) adds that available memory is
"often much less in mobile devices than desktop computers" — the ceiling was always this library's
own choice, not a spec requirement, so a caller on a constrained device, or one hardening against a
decompression bomb or a reconstruction budget attack, can now lower either value through
PdfReaderOptions. Both remain tighten-only: a value above the existing default, or below a floor
an ordinary document needs to open at all (1 MiB for the decode cap, a multiplier of 1 for the
budget), makesPdfReader.OpenthrowArgumentOutOfRangeExceptionnaming the property and its
allowed range. The defaults are unchanged, so this adds no behaviour for a caller who does not set
either property. A nested read this library opens on a caller's behalf — recursive PDF/A
validation of an embedded-file attachment is the one case today — now inherits the outer read's
resolved ceiling too, rather than falling back to the 512 MiB / 8× defaults for attacker-supplied
bytes found inside an already-open document. -
PdfDocumentReader.SaveDecrypted— write a decrypted copy of an encrypted document (#186).
SaveDecrypted(Stream)and itsPdfSaveDecryptedOptions/async twins re-serialise the whole
object graph into a fresh single-revision PDF:/Encryptand its object are removed entirely
(not just the trailer entry — the object still carries/O,/U,/OE,/UE, offline-cracking
material against the original passwords), every string and stream comes out plaintext, and object
numbers and generations are preserved. Object streams, cross-reference streams, and the
linearization parameter dictionary are all dissolved into the one classic table the output writes.
Unencrypted input is accepted (the output degenerates to a normalised rewrite), and so is a
reconstructed document (PdfDocumentReader.WasReconstructed) — unlikeAppendRevision, a full
rewrite does not depend on the base file's own byte layout. ThrowsInvalidOperationExceptionon
a signed document unlessPdfSaveDecryptedOptions.AllowInvalidatingSignaturesis set — detected
by unioning two sources, neither complete alone: theSignaturesproperty's/AcroForm
field-tree walk (fixed to thread an inherited/FTthrough the walk, ISO 32000-2 §12.7.4.1), and
a direct recursive scan of the emitted object graph for a signature dictionary's own shape, since
Table 226 lets a field's/Vbe an inline dictionary rather than requiring the indirect reference
/Lockand/SVrequire, so a signature can sit nested where neither the field-tree walk alone
nor a scan of top-level objects alone would find it. Rewriting the
object graph moves every byte a signature's/ByteRangenames, so re-serialising invalidates every
signature by construction. A stream's own filter chain and body pass through untouched — a
passthrough image (DCTDecode, JPXDecode, and so on) is never re-encoded — except a leading
/Cryptfilter entry (ISO 32000-2 §7.4.10), which is stripped along with its paired
/DecodeParms, since the crypt filter no longer applies to plaintext output. A linearized input's
hint stream is left behind as unreferenced dead weight once the linearization parameter dictionary
pointing at it is dropped; serialisation failures leave the destination stream untouched, but a
failure or cancellation during the final copy from the internal buffer can leave a plaintext,
truncated prefix already written. -
PdfReaderOptions.AllowReconstruction— opt-in cross-reference reconstruction (#184). When
startxrefis missing, unusable, or doesn't point at a recognisable xref table or stream,
PdfReader.Openused to always throw. SettingAllowReconstructioninstead rebuilds the table
by walking the file once forN G objheaders (the recovery ISO 32000-2 Annex C.4, informative,
describes), budgeted atmax(1 MiB, 8 × file length)— the multiplier is a fixed constant here;
#376 later makes it configurable viaPdfReaderOptions.ReconstructionBudgetMultiplier. A
document opened this way reports the fact through the newPdfDocumentReader.WasReconstructed.
Appending a further incremental revision to a document opened this way, or to one repaired by
dropping orphaned object-stream members, now refuses: neither's object graph is what the file's
own cross-reference table actually declared, so building a signature revision on top of either
would hand back an artifact this library cannot reliably reopen.- Reconstruction now covers encrypted documents too, without ever handing back ciphertext as
plaintext. A recovered trailer candidate that declares/Encryptis carried through rather than
refused. When nothing declares it (a trailer damaged past recovery), a confirmed object gets a
synthesised/Encrypt N G Rpointed at it — the trailer-destroyed last resort — but only when
its structure disambiguates SPECIFICALLY as the Standard handler (ISO 32000-2 §7.6.5.2); a
public-key dictionary, one this pass cannot classify at all, or evidence a whole-file sweep
finds only in bytes the walk never tokenized, all still refuse with
UnsupportedPdfFeatureException./Filterand/Vare the only two entries Table 20 requires
of any encryption dictionary, so a dictionary this pass cannot further classify is still a
legitimate one it does not recognise, not proof the document is safe to open — refusing it is
the same asymmetry the pre-PR3 refusal always took, now scoped to the cases that actually need
it. Opening what IS carried still authenticates through the existing password path, which is
where a public-key handler is refused (this library only implements the Standard handler). A
recovered cross-reference stream keeps its §7.5.8.2 encryption exemption, computed the same way
the ordinary path computes it — by where a stream was actually read as one, never by its
/Type. At R≤4, a trailer that lost its/IDalong with everything else now fails to
authenticate with aPdfPasswordExceptionnaming the missing/ID, since Algorithm 2 step (e)
needs it to derive the key at that revision; R6 still recovers, since Algorithm 2.A never reads
/ID. A failedOpenon an encrypted document now zeroes the file key before returning,
closing a gap where a constructor throw used to leave it in memory with no live reader instance
for a caller to dispose.
- Reconstruction now covers encrypted documents too, without ever handing back ciphertext as
-
docs/pdf20-conformance.md— a reference-by-reference inventory of what this library implements
of ISO 32000-2. "Supports PDF 2.0" is a claim nobody can check; this is one anybody can. It covers
all 79 documents the standard cites normatively, every feature and deprecation listed in the
specification's own clause 0.3, and the ISO/TS 32001–32005 extension series, each with a status and a
pointer to the code or the issue tracking it. Generated byeng/generate-pdf20-inventory.pyfrom the
PDF Association's PDF2NormRefs and Arlington PDF Model datasets, with a--checkmode so it cannot
drift silently. Note ISO 32000-2 Annex I is normative but carries no feature table — the standard
dropped the one ISO 32000-1 had — which is why this has to be generated rather than transcribed.
(#225) -
CsCheck-driven lexer/parser fuzzing, and a CI round-trip oracle for the third-party corpus
(#99).ParserFuzzTestsmutates the committedFixtures/EncryptedandFixtures/ThirdParty
corpora at the byte level and throws the result atPdfLexer.NextToken,
PdfObjectParser.ParseObject, andPdfReader.Openunder bothAllowReconstructionsettings. It
is a robustness oracle, not a conformance one: onlyInvalidDataException,
UnsupportedPdfFeatureException, andPdfPasswordExceptionmay escape; a crash-class exception
(IndexOutOfRangeException,NullReferenceException,OutOfMemoryException,
OverflowException) is a finding, gated per-input by a wall-clock ceiling and, forOpen, by
PdfReaderOptions.MaxDecodedStreamBytespinned to its floor. Runs at a fast, bounded default
budget in every PR;.github/workflows/fuzz-nightly.ymlruns the same tests on a schedule at a
budget roughly two orders of magnitude larger. A finding's fix must commit its minimized input to
the newFixtures/Fuzz/corpus, SHA-256-pinned like the other two — see
Fixtures/Fuzz/README.md's capture rule. Separately,SaveDecryptedThirdPartyQpdfOracleTests
extends #186'sSaveDecryptedround-trip oracle (qpdf --checkplus an object-graph comparison
against the reopened output) to the eleven third-party fixtures that can produce one, now that
SaveDecryptedaccepts a reconstructed document — closing the half of #99 that was blocked on
#186 not existing yet. -
docs/reader-guide.mdanddocs/layout-guide.md. v2.3 addedSaveDecrypted,
AllowReconstruction, and the tighten-only limit options toVellumPdf.Reader, and none of it
had a narrative guide — only XML docs and CHANGELOG entries. Each guide ends in a capability
table (Supported / Planned / Not yet, each row citing its milestone or ISO reference) built
from the actual public API and test suite rather than from memory, so a row is only marked
Supported where a test proves it; two Layout rows are marked for reviewer verification instead
of guessed. Registered indocs/toc.ymland linked from the root README and each package
README's roadmap section.
Changed
-
docs/architecture.mdand the family of package READMEs now describe the reader as it exists
after v2.3, not before it. The architecture doc still framed serialization as Kernel-only,
which stopped being true oncePdfDocumentReader.SaveDecryptedshipped its own full-rewrite
serializer inside the Reader package; it also still roadmapped the general reader's structural
parser and content extraction at v2.1/v2.2, which have long since shipped and moved (content
extraction is now v2.4, #98). The root README and all eight package READMEs get the same
correction to their shared roadmap section, marking v2.3.0 as the latest published release. -
CI's external oracles are now pinned instead of floating on whatever the runner image ships.
The build job itself moves off the floatingubuntu-latestlabel ontoubuntu-24.04, since
every apt pin below is noble-only and would all break at once the day GitHub retargets the
label. qpdf was the worst offender: apt'sqpdfon the runner is 11.9.0, two majors behind
upstream 12.4.1, and--check's output has changed across qpdf majors while #186's acceptance
criterion is exactly that output. CI now installs qpdf 12.4.1 from the official release artifact
(checksum-verified) instead of apt;poppler-utils,fonts-dejavu-coreandfonts-texgyre
stay on apt, pinned to the exact versionsubuntu-24.04's noble archive serves today
(24.02.0-1ubuntu9.9, 2.37-8, 20180621-6 respectively), so a font update can no longer silently
shift fixture rendering out from under every downstream oracle. Since noble's apt pockets keep
only the newest revision of a package, the install step also pins apt to the Ubuntu snapshot
service for the date those versions were measured, so the exact revisions keep resolving after
the live archive rotates past them instead of 404ing with no code change involved — verified
against an already-rotated-off revision, not merely today's current one. Since apt reports
success even when that snapshot fetch itself silently fails, the step also asserts the
snapshot's own index files actually landed, so a silent fallback to the live archive fails the
build instead of quietly un-pinning the install. The veraPDF
Docker tag, already pinned, was duplicated across the image pull and the shim that backs it;
both now read one job-levelVERAPDF_TAG. zxing-cpp moves from 3.0.0 to 3.1.1 (withpillow
newly pinned to 12.3.0) — the barcode oracle suite passes unchanged, including the EAN add-on
case the 3.0.0 pin was recorded against, since that test only asserts the main 13 digits and
treats the add-on's presentation as version-dependent;setup-python's interpreter is pinned
to 3.14 rather than floating too, for the same reproducibility reason as the pip versions
alongside it. A new version-assert step checks each pinned tool's own version report after
install — the apt packages' full Ubuntu revision viadpkg-query, sincepdftotext -vreports
only poppler's upstream version and fonts cannot self-report a version at all, alongside a
pdftotext -videntity check proving which binary the tests actually invoke — so drift between
the workflow and what actually landed fails the build instead of changing what CI validates
against with no commit to blame.
actions/setup-dotnetandactions/setup-pythonalso move to their current majors (v6 and v7)
across all four workflow files. (#230) -
The roadmap now describes the scope past 2.5 as two parallel tracks, Kernel and conformance
alongside Layout, and adds the milestones covering the ISO/TS extension series, embedded files,
graphics, fonts, tagged PDF, PDF/UA-2 and signature verification. The previous table had drifted:
it named milestones that had been renamed and omitted one entirely. -
NOTICErecords the two PDF Association datasets the inventory is generated from. Both are
dual-licensed by their own NOTICE files, Apache-2.0 for software and CC-BY-4.0 for other
documentation; these are data files, so the CC-BY-4.0 terms are the ones followed. -
docs/architecture.mdrecords that the specifications are now held and read locally, so clause
citations in this codebase point at text that was actually consulted. That is a provenance
statement, not a conformance claim — see the inventory for what is actually implemented. -
docs/toc.ymlregisters the barcodes roadmap and the new inventory, taking the published site from
three of six files to five of seven. (#220) -
Dropped the
Microsoft.SourceLink.GitHubpackage reference. The .NET SDK has imported
SourceLink implicitly since .NET 8, and steps aside whenSuppressImplicitGitSourceLinkis set —
which NuGet does automatically for this reference, by emittingPkgMicrosoft_SourceLink_Common.
So the reference was not layered on top of the SDK's import; it replaced it, and removing it
hands the job back. Packing all eight packages both ways underContinuousIntegrationBuild=true
produces archives that differ only in the random GUID NuGet stamps into every pack — every nuspec,
every assembly, and all eleven symbol PDBs with their SourceLink document maps are byte-identical,
so nothing that ships changes. The package's own MSBuild logic is byte-identical to the SDK's copy;
only the compiled task assembly now floats with the SDK band instead of being pinned. (#202) -
global.jsonnow pins the SDK feature band the workflows resolve, instead of trailing it.
It named10.0.204withlatestFeature, while everyactions/setup-dotnetstep asked for
10.0.xand landed on whatever band was newest at the time —10.0.400as of this change. With
TreatWarningsAsErrorsandAnalysisLevel latest, a diagnostic new to that band is a CI failure
a developer on the older SDK cannot reproduce.global.jsonnow reads10.0.400with
latestPatch, and all fivesetup-dotnetsteps (ci.ymlbuild and AOT smoke jobs,release.yml
library and tool jobs,docs.yml) point atglobal-json-file: global.jsonrather than repeating
the version inline, so the two can no longer drift apart. (#231) -
Verify.XunitV3moves to 32.0.0. The dependency floor onxunit.v3.extensibility.coreis
[3.2.2, ), open-ended and already satisfied by the xunit.v3 3.2.2 pin in
Directory.Packages.props, from whichextensibility.corecomes transitively, so the bump is
independent of #200's xunit v4 migration. The measured transitive delta against the base
commit's restore:Verify31.28.0 → 32.0.0, moving in lockstep withVerify.XunitV3itself,
DiffEngine19.3.3 → 20.0.0 (a major), andMicrosoft.Bcl.AsyncInterfaces10.0.10 → 10.0.11;
ArgonandSimpleInfoNamewere already in 31.28.0's closure and did not move. DiffEngine
20.0.0 reads its own disable flag lazily rather than capturing it once at type-init
(VerifyTests/DiffEngine#825) and adds a bundled viewer as an always-available last-resort tool,
so a detection miss that was a harmless no-op in 19.x could now launch a GUI.ci.yml's test
step setsDiffEngine_Disabled: trueagainst that, andtests/Directory.Build.propssets
<DiffEngineBundledViewer>false</DiffEngineBundledViewer>, which drops the bundled viewer
binaries and theDiffEngine.ViewerDirectorypath DiffEngine's build targets otherwise stamp
into each test project'sruntimeconfig.json— a username-bearing absolute path this keeps out
of build artifacts. Neither setting reaches a developer's own installed diff tool (VS, Rider, VS
Code, WinMerge); onlyDiffEngine_Disableddoes that. None of the five packages above appear in
dotnet list package --vulnerable. No.verified.*file undertests/changed. (#221) -
Added
.github/dependabot.yml: weeklynuget,github-actions, anddotnet-sdkchecks. The
nugetgroup batches minor/patch bumps into one PR and excludes
System.Security.Cryptography.Pkcs— the one third-party runtime dependency this repository
ships, whose patches change emitted CMS/PAdES bytes — from that batch; majors stay individual, so
a snapshot-risk major like this one stays attributable to a single PR.xunit.v3*(matching
xunit.v3andxunit.v3.assert) andxunit.runner.visualstudiomajors are ignored pending
#200's deliberate hold on the v3-to-v4 migration.dotnet-sdkcovers theglobal.json
feature-band pin from #379: that updater does not readrollForward, so it proposes SDKs
outside the pinned 10.0.4xx band as readily as ones inside it — by design, since each such PR is
the deliberate band-move signal #379's CONTRIBUTING rule calls for, not noise.github-actions
carries no group: every action here is currently pinned to a floating major tag, so only major
bumps ever surface. (#221) -
CI now fails if an oracle tool stops actually being invoked, not just if it goes missing.
#227 made a missing tool fail loudly instead of the suite passing vacuously, but a tool that
resolves correctly and is simply never called again — a disabled filter, a stale gate condition,
a refactor that drops the call — left the same pass/skip counts an engaged oracle would; only
wall-clock time gave it away.ExternalTool.RunProcessnow appends onetool<TAB>first-argument
line per successful, non-probe launch to a file named byORACLE_INVOCATION_LOGplus the current
process id, when that variable is set; identity probes and failed launches are excluded, so
neither can pad a tool's count without the tool having actually validated anything, and
ORACLE_INVOCATION_LOGis unset on every local run, so this is a no-op outside CI.ci.yml's
Test step sets it and clears any stale log from a prior run first, and a new step after Test
concatenates the per-process files (each test project runs as its own process, so counting
cannot happen in-process without an ordering guarantee no runner gives it), sums a count per
tool — foldingpython3intopython's total, since the barcode oracle's fallback logs
whichever name actually launched — and fails the build if any falls below a floor pinned beside
it: measured at 90% of a full local run with every oracle enabled, verapdf 268, pdftoppm 121,
python 121, qpdf 26, pdftotext 7, pdfsig 1. A misconfiguredORACLE_INVOCATION_LOG(an
unwritable path) now fails once, at type-init, with a message naming the variable, rather than
as a bareDirectoryNotFoundExceptionout of whichever unrelated oracle test happened to run
first. (#228) -
Tests now run on Microsoft.Testing.Platform instead of VSTest.
xunit.v3and
xunit.v3.assertmove 3.2.2 → 4.0.0, which shipxunit.v3.core.mtp-v2; on the .NET 10 SDK,
Microsoft.Testing.Platform refuses the VSTest target that 4.0.0's predecessors ran under, so
this isn't an ordinary bump.Microsoft.NET.Test.Sdkandxunit.runner.visualstudio— both
VSTest-only, and unneeded once xUnit self-hosts under MTP — are removed rather than bumped;
global.jsongains"test": { "runner": "Microsoft.Testing.Platform" }, the documented .NET 10
opt-in (not theTestingPlatformDotnetTestSupportMSBuild property, which is the .NET 8/9-era
mechanism).coverlet.collectoris replaced bycoverlet.MTP10.0.1, chosen on licence grounds
overMicrosoft.Testing.Extensions.CodeCoverage(MIT with no acceptance requirement, versus a
package that setsrequireLicenseAcceptanceand ships no SPDX expression). Test counts came back
unchanged against the same commit, measured in Git Bash with neitherQPDF_HOMEnor
POPPLER_HOMEset: 5,574 total, 5,222 passed, 352 skipped, 0 failed, identical under VSTest and
under MTP (both figures are specific to the commit and the oracle tools available on the
measuring machine, not numbers this migration pins going forward). This is a runner and
coverage-tooling swap, not a test change.coverlet.MTPreads its instrumentation scope fromcoverlet.testconfig.json(copied to
every test host's own output directory bytests/Directory.Build.props) rather than a
--settingsrunsettings file, socoverlet.runsettingsis gone. That has to be a config file,
not command-line--coverlet-include/--coverlet-excludeoptions: with no config file present,
coverlet.MTP's command-line mode merges in its own default exclude-by-attribute list
(GeneratedCodeAttribute,CompilerGeneratedAttribute), which silently drops every compiler-
and source-generator-emitted member from instrumentation. That was tried first and measured as
a real regression before landing on the config file: it costVellumPdf.Cliits entire
System.Text.Json source-generatedCliJsonContextpartial, and every other assembly some of
their auto-property backing fields. A config file is authoritative and does not get that default
list injected, so scoping throughtestconfig.jsonis what keeps generated code counted the way
coverlet.collectorcounted it under VSTest, confirmed by measurement: every one of the eight
shipping assemblies' valid-line counts, and the combined total (31,401 unique lines), matched
what this gate already carried before the migration, so the threshold and every per-assembly
floor carry over unchanged rather than being re-baselined lower. Parity here is about the
migration dropping nothing it used to instrument, not about the two figures staying pinned
together forever: both move independently as unrelated PRs add code. The coverage gate's glob still moves from a fixed
coverage.cobertura.xmltocoverage.cobertura.*.xml, since coverlet.MTP stamps a timestamp
into every report's own filename rather than writing one fixed name into a per-run guid folder;
measured across a full solution run, that timestamp resolution kept all 7 reports distinct with
none landing within even a second of another.Eleven
[Fact(Timeout = 10_000)]tests raisexUnit1069under 4.0.0's analyzers, and
TreatWarningsAsErrorsturns that into a build break — the decision is made per #200's own
analysis, treating groups separately by what their assertions actually prove. Two in
tests/VellumPdf.Kernel.Tests/MalformedInputTests.cs
(TiffLzw_hugeDimensions_throwsWithoutAllocating,
Tiff_hugeStripOffsetsCount_throwsInvalidDataException) are redundant guards whose functional
assertions already prove the point without a wall-clock budget, so theirTimeoutis dropped.
The other nine keepTimeoutunder a targeted#pragma warning disable xUnit1069, each with a
comment naming why: four are#208/#193regression pins where the timeout is the assertion
(PdfDictionaryIndexTests.cs×2 andEncryptDictionaryDenialOfServiceTests.csfor #208,
PdfObjectParserTests.cs:672for #193's endstream-scan hardening), where the functional checks
pass equally against the quadratic code they exist to catch. The remaining three, also in
MalformedInputTests.cs(CmapFormat4_overlappingWideSegments_throwsAndDoesNotHang,
Png_zlibBomb_throwsAndDoesNotExhaustMemory,
Png_interlaced_zlibBomb_throwsAndDoesNotExhaustMemory), have names that promise a bound their
Assert.Throwsalone doesn't provide: the assertion proves the guard rejects the hostile input,
not that it does so before running the unbounded path it exists to cut off, so they get the same
pragma-kept treatment rather than joining the two that dropTimeout. Verified against a
scratch[Fact(Timeout = 500)]sleeping 3 seconds: it failed at 504ms under MTP with this
repository's parallelization settings, confirming the pin still works before any of this was
decided. The build job also gainstimeout-minutes: 30, so a test that really does hang fails
the job within the hour instead of running to GitHub's 360-minute default. (#200)
Fixed
-
A reader test called correct behaviour a defect awaiting a fix.
Test-only; nothing ships.FreedFilterObject_streamDecodesToRawCompressedBytes_notNull, added in
#372 to characterise an unresolvable/Filterdecoding as raw compressed bytes, called that
outcome "wrong content that looks right" and expected itself to go red once #373 fixed it.
ISO 32000-2 §7.3.10 treats an indirect reference to an undefined object as the null object, not
an error, and §7.3.9 treats a null-valued dictionary entry as equivalent to the entry being
absent — chained, a stream whose/Filtercannot be resolved has no/Filterat all, and
returning it unfiltered is what the spec requires, not a bug. Renamed to
FreedFilterObject_streamTreatedAsUnfiltered_perIso7310with the doc comment rewritten to cite
the clause chain; assertions unchanged.GetFilterListinFilters.csgets a matching
why-comment. This closure covers only the scalar/Filterentry; an unresolvable element inside
a/Filterarray is a separate, unlabelled path, tracked in #385 since Table 5 sanctions a
null there for/DecodeParmsbut not for/Filter. The residual concern from #373's review, a
reader diagnostics channel for this and other notify-and-continue conditions, is split out to
#385. (#373) -
The CI coverage gate could pass with less real coverage than the run before it, and couldn't
say which report went missing. Merging per(assembly, file, line)across cobertura reports
and counting a line once, covered if any report covered it, meant a report's exclusive lines left
both the numerator and the denominator when that report disappeared — dropping one whose
exclusive lines ran below the average made the merged percentage rise, not fall. The only guard
was a zero-file check, so six reports going to five passed silently. The gate now checks four
things. The assembly names surviving the merge must match an explicit set of the eight shipping
assemblies (vellum-preflightforVellumPdf.Cli), so a report that never got written, or one
written empty by a crashed run, usually fails by naming the missing assembly rather than by
moving an average — "usually" because three of the seven test projects (Kernel.Tests,
Layout.Tests,Reader.Tests) instrument an identical assembly set, so losing one of them costs
no assembly its name; the report count must be exactly seven to catch that case instead. At
most one report may be empty, sinceVellumPdf.TestSupport.Testslegitimately produces zero
packages once its own target,VellumPdf.TestSupport, is excluded from instrumentation — a
second empty report means a crash, not that. And each assembly's valid-line count must clear a
floor seeded at roughly 75% of what it measured here, so an assembly whose instrumentation
collapses to a single covered line can no longer report 100% and pass: the #229 defect one level
down, where the denominator vanishes from a single assembly instead of the whole run. A separate
per-assembly coverage floor (40%) stops a new subsystem landing at ~0% from hiding behind the
other seven's average. The merge key also now strips a leadingsrc//tests//eng/path
segment before the existing package-relative normalization, closing a case where two spellings of
one physical file's path doubled four assemblies' denominators in a local run. A new
coverlet.runsettings, wired into thedotnet teststep via--settings, excludes[*.Tests]*
and[VellumPdf.TestSupport]*from instrumentation, so the denominator means shipping code rather
than padding itself with near-100%-covered test helpers — CI-measured, excluding them raised the
merged figure from 76.4% (44,702 unique lines, test assemblies instrumented) to 88.8% (31,368
lines), becauseVellumPdf.Conformance.Testshad itself been running at only 46.5%. The global
threshold moves from 68% to 84% to match. (#229) -
The AOT smoke never covered
VellumPdf.Fonts.Standard14, and never ran on Windows.
CI-only; nothing ships. This closes two holes in what the gate proves. The package embeds its 12
Liberation TTFs asEmbeddedResourcelooked up by manifest string — exactly what trimming breaks
silently — and nothing in the smoke orVellumPdf.Clireferenced it; the smoke's own comment
claimed "Standard-14 fonts" coverage that was actually Kernel's built-in AFM metrics path, reached
through Layout, not this package. The smoke now embeds a Liberation substitute via
EmbedStandard14Font, inflates the resulting/FontFile2stream, and checks its sfnt version —
proof the manifest lookup returned the real font under AOT, not just that a PDF was produced.
Separately, theaot-smokeCI matrix ran onlyubuntu-latestandmacos-26, so a Windows-only
Native AOT regression was discoverable only at release time, by which point the NuGet push had
already happened.windows-latestis now in the matrix. (#219) -
Eight
qpdforacle tests passed whether or notqpdfactually recognized their fixture as
linearized. Test-only; nothing ships.qpdf --show-linearizationexits 0 and prints no
WARNINGfor a linearized and a non-linearized file alike (executed directly against qpdf
12.3.2 and 12.4.1; byte-identical in qpdf's source from 10.6.3 through 12.4.1, including CI's
11.9.0, per review), so the eightLinearizationQpdfTestscases that stopped
atexit == 0plusDoesNotContain("WARNING", ...)would have stayed green hadVellumPdf
silently stopped linearizing altogether. They now also assert
stdout.Contains("linearization data:"), the header qpdf prints only once it accepts a file's
hint tables — this is the one load-bearing addition here. A further elevenQpdfCheck_Passes
cases (seven inPdfValidatorOracleTests, four inImageCodecOracleTests) got the same
treatment for symmetry, asserting
stdout.Contains("No syntax or stream encoding errors found")alongside the existing exit-code
check; measured directly, that line prints if and only ifexit == 0(a warning forces exit 3),
so for these eleven it is a redundancy guard against a change to that contract, not an
independent discriminator — the exit check already covered the case that matters. All eleven
also now capture thetimedOutoutputExternalTool.TryRunhad been
discarding viaout _, as does the oneLinearizationQpdfTestscase that still discarded it.
Found in review of #198 (PR #227). (#234) -
Oracle tests across three test projects reported a pass, not a skip, when their external tool
was missing. Test-only; nothing ships.GateOnCi— duplicated five times, next to five
near-identical process runners, across the Barcodes, Kernel and Layout test projects (one file in
Barcodes; two in Kernel,LinearizationQpdfTestsandPadesLevelTests; two in Layout,
ImageCodecOracleTestsandPdfValidatorOracleTests) — was a no-op off CI, except in the
Barcodes copy, which already honoredREQUIRE_BARCODE_ORACLE == "1"there too. All 73 of
its call sites onmain(counted directly: 2 in Barcodes, 18 in Kernel, 53 in Layout) let the
calling method return normally instead of running its assertion, so xUnit recorded a pass; all
but the 2 insideZxingDecodeOracleTests'bool-returning helper did that via a bare
{ GateOnCi(tool); return; }, the other two viareturn false;. 43 of the 73 gated on a missing
CLI tool or interpreter (qpdf, poppler-utils, veraPDF, or python/zxing-cpp); the other 30 gated on
a missing platform font or OTF font instead, an unrelated local-machine condition. A new
VellumPdf.TestSupportproject — with its ownVellumPdf.TestSupport.Tests, so the gate and
the runner have direct coverage rather than only the oracle tests built on top of them —
consolidates all six oracle process runners in the tree — five near-identical copies, plus the
conformance suite's own veraPDF runner, a different shape that never shared the defect below —
into oneExternalTool, and the fiveGateOnCicopies into oneOracleGate, which calls
Assert.Skipinstead of falling through a barereturn. All five
copies already drained both pipes concurrently beforeWaitForExit; their real defect was
reading that drain with an unboundedGetAwaiter().GetResult(), unconditionally and ahead of the
branch that kills a timed-out process, so a child that hung without closing its pipes hung the
test host indefinitely.ExternalToolbounds that drain at 5 seconds — shared between both
streams, not applied to each in turn — and reports a timeout as its own outcome rather than
folding it into an empty string, which a caller checking for the absence of something — an
error, a warning — could otherwise accept as if the tool had produced none. (#198) -
ExternalToolcould resolve to the wrong tool and hand its output to the caller anyway.
Test-only; nothing ships.qpdf,pdftotext,pdftoppmandpdfsignow resolve through an
explicitQPDF_HOME/POPPLER_HOMEenvironment variable before falling back to PATH, and a
variable that is set but does not resolve is reported rather than silently falling back, because
a bare name is not deterministic even on one machine: resolvingpdftotextfrom a PowerShell
session finds poppler, but from a Git Bash session finds Xpdf, a different codebase with no
-tsvflag; the version banner alone does not tell the two apart, sopdftoppmneeds the same
check via-png. veraPDF gets that sameVERAPDF_HOME-first treatment only on Windows, where it
needs the variable to find its.batlauncher at all; on every other platform, including CI's
ubuntu-24.04 runner,VERAPDF_HOMEis not read here and veraPDF resolves by bare name, same as
before this fix. The barcode decode oracle'spythonleg is unchanged too: it has no*_HOME
and no identity check of its own, resolving by bare name everywhere, the ambiguity this fix
removes for the other four. A hand-check in the "wrong" shell would never catch the swap, and the
first version of this fix didn't either: a gated theory resolved and checked each tool's
identity, but nothing stoppedExternalTool.TryRunitself from resolving the same wrong tool on
every other call and handing its output to whichever test asked for it. Reproduced directly: with
Xpdf shadowing poppler and no environment override, the identity theory correctly skipped, while
eightPdfValidatorOracleTeststext-extraction tests validated VellumPdf's own output against
Xpdf and reported green. The identity check now lives onExternalToolitself, gating every
caller through the same skip-locally/fail-on-CI outcome a missing tool gets, rather than only the
one test that used to check it. (#198) -
VeraPdfOracleTests, the largest oracle gate in the tree at 273 cases, readREQUIRE_VERAPDF
directly and never consulted the shared gate at all. Test-only; nothing ships. Its two call
sites (the 273-case cross-validation theory and a dedicated encrypted-file regression) compared
the variable to the literal"1"and fell through to a bareAssert.Skipotherwise, so neither
CI,GITHUB_ACTIONS, norREQUIRE_ORACLEScould turn a missing veraPDF into a build failure
there. This repository's own CI was not exposed by that specific gap —ci.ymlsets
REQUIRE_VERAPDFto the same literal"1"the old check compared against — but a CI environment
that instead relies onCI/GITHUB_ACTIONS/REQUIRE_ORACLES, or that setsREQUIRE_VERAPDFto
truerather than1, would have silently skipped the one gate meant to catch a missing
veraPDF. Both call sites now route throughOracleGate.Unavailable, the same shared gate almost
every other oracle test in the tree uses. (#198) -
The conformance suite's own veraPDF wrapper carried a second, uncoordinated probe that the
widenedExternalToolbudget above never reached. Test-only; nothing ships.VeraPdf.IsAvailable
ran its own hardcoded 10-secondverapdf --versioncheck, independent ofExternalTool's, and
cached the result forever in astatic readonlyfield initializer, so a single slow JVM or
container cold start decided every later call in the same test run. Once its two call sites
started routing that verdict throughOracleGate.Unavailable, a merely slow probe escalated
under CI and could fail all 273InProcessVerdict_EqualsVeraPdfcases off that one sample — the
exact flake the widened budget exists to remove, reopened through a second code path.VeraPdf
now gates throughExternalTool.CheckIdentitydirectly, so it shares the same 30-second veraPDF
budget and the same cache. That cache itself no longer keeps a timed-out or unstartable probe's
verdict for the rest of the process — only a definitive one (a wrong banner, a non-zero version
exit, an unresolvable*_HOME, or a missing poppler-only flag) is kept; a merely slow probe is
retried on the next call, and routes through a newOracleGate.Transient, which skips rather
than ever failing the build on a single timeout (see below for what a probe that keeps timing
out now does instead). (#198) -
40 tool-availability checks were dead code, and one oracle's
timedOutoutcome went nowhere.
Test-only; nothing ships. OnceExternalTool.TryRunstarted routing an unusable resolution
throughOracleGateitself (see above), theif (!ExternalTool.TryRun(...)) OracleGate.Unavailable(...)
guard at each of its call sites for one of the five known tools could never seeTryRunreturn
false— the negatedifwas unreachable — so those 40 sites acrossPdfValidatorOracleTests
(20),LinearizationQpdfTests(15) andImageCodecOracleTests(5) are now a plain call. The same
guard was dead at 2 more sites for a known tool elsewhere in the tree:PadesLevelTests' single
pdfsigcall andZxingDecodeOracleTests'pdftoppmcall, for 42 tree-wide. Two moreTryRun
call sites in the tree are not part of that count:ZxingDecodeOracleTests' own python leg,
since python has no identity probe (see the entry above), so its call could, and still can,
genuinely returnfalse; andVeraPdf.Validate's call inOracleTests.cs, never built on the
dead-guard pattern at all — 44TryRuncall sites tree-wide in total. Separately, the barcode
decode oracle's python leg discardedExternalTool.TryRun'stimedOutoutput entirely (out _); a hungeng/barcode-decode.pyrun still reported exit code 0 with empty output, so the
test failed on an empty decode-result collection instead of naming the timeout, the same
contract pdftoppm's own leg already honored. (#198) -
A few smaller oracle-tooling robustness fixes. Test-only; nothing ships.
verapdf.bat
(veraPDF's Windows launcher, invoked throughcmd.exe) is now run through the standard
cmd /c ""bat" "arg1" "arg2""quoting form instead of .NET's own argument escaping, which cmd
does not parse the same way — an argument containing&started a second command, and a
VERAPDF_HOMEcontaining a space broke the line entirely; both reproduced.cmd.exeis also
resolved fromEnvironment.SystemDirectoryrather than by bare name, so a PATH without System32
on it can no longer make a present veraPDF report as unavailable.OracleGate's five environment
variables (CI,GITHUB_ACTIONS, the newREQUIRE_ORACLES,REQUIRE_VERAPDF,
REQUIRE_BARCODE_ORACLE) all accept1ortruecase-insensitively; onmain,CIand
GITHUB_ACTIONSwere compared only against"true"and the twoREQUIRE_*switches only
against literal"1", which would have left a CI system exportingCI=1(common outside GitHub
Actions) with every oracle reporting a phantom pass rather than even a skip — the same defect the
first entry above describes, reached through a different variable. And the veraPDF compliance
checks inPdfValidatorOracleTestsandImageCodecOracleTestsnow assert veraPDF's
own exit code is 0 or 1 before reading its report, so a broken JRE or a staleVERAPDF_HOMEis
reported as the environment problem it is, not as a conformance defect in the library. (#198) -
The PDF/A-2b oracle in
ImageCodecOracleTestsmatched acompliant="true"disjunct that
veraPDF never emits. Test-only; nothing ships.compliant/nonCompliantin its
machine-readable report are counts (compliant="N"), not theisCompliant="true"/"false"
boolean the overall verdict actually uses, so the disjunct was dead code, not a live defect. Both
oracles' predicates now check onlyisCompliant="true", matching whatPdfValidatorOracleTests
already did. (#198) -
LinearizationQpdfTestshad a hardcodedC:\Users\Timothy\tools\qpdf\...fallback path that
let its ten tests run on this machine withoutQPDF_HOMEset. Test-only; nothing ships.
Dropping that fallback in favor ofQPDF_HOMEalone means those ten tests now skip locally
unlessQPDF_HOMEis set, the same as every other oracle test. (#198) -
Two embedded-font checks in
PdfValidatorOracleTestshad no gate at all, so they phantom-passed
even on CI. Test-only; nothing ships. Both tests readif (fontPath is null) return;with no
GateOnCicall, unlike the seventeen other platform-font sites in the same file, which all had
one; a CI image without a platform font produced a silent green for both, and no environment
variable could catch it. Every other case this change fixes was already visible to a CI that
checked the right thing; these two were invisible to CI outright. Both now read
OracleGate.Unavailable("platform font for embedded-font qpdf oracle")and
OracleGate.Unavailable("platform font for embedded-font pdftotext oracle"), which fail the
build the same way every other missing-dependency gate does. (#198 review, round 5) -
A timed-out identity probe could still escalate to a build failure, from the one call site
round 4's fix didn't reach. Test-only; nothing ships.ExternalTool.TryRun,
VeraPdf.EnsureAvailableandExternalToolResolutionTests.Resolves_ToTheClaimedTooleach
re-derived the same branch overIdentityStatusandIsTimeout; the third read onlyStatus,
so a probe that had merely run out of time was sent to the escalatingOracleGate.Unavailable
instead of the always-skippingOracleGate.Transient, failing the build on one slow sample. That
is exactly the defect round 4's own fix toTryRunexists to prevent, surviving in the one
consumer that fix never touched. All three now route through a single new
ExternalTool.EnsureUsable. Verified directly with
ExternalToolTests.TryRun_ForATimedOutProbe_SkipsRatherThanFailing_UnderAnEscalationSwitch: a
fixture that is the correct tool but answers past its probe budget (averapdf.batbehind
VERAPDF_HOMEon Windows, aPATH-shadowingshscript elsewhere) skips underCI=truerather
than failing;OracleGate.Unavailable's own escalation logic is untouched, so a genuinely missing
qpdf or pdfsig still fails the build exactly as before. (#198 review, round 5) -
A tool whose identity probe keeps timing out skipped forever instead of ever escalating.
Test-only; nothing ships. Round 4 made a single timeout always skip and never cache, correct for
one slow sample. ButVeraPdf.EnsureAvailableis called once per test case, in a non-parallel
collection, across the 273VeraPdfOracleTestscases (confirmed by test discovery), so a
persistently slow veraPDF would re-probe, and skip, all 273 times at the full 30-second budget
each: about two and a quarter hours spent to skip the largest gate in the tree and report the
run green.ExternalTool.ProbeIdentitynow counts consecutive timeouts per tool and converts the
third into a definitive, cached verdict thatEnsureUsableroutes to the escalating
OracleGate.Unavailableinstead; any non-timeout answer resets the count to zero. Verified
directly withExternalToolTests.CheckIdentity_ForVerapdf_EscalatesAfterThreeConsecutiveTimeouts.
(#198 review, round 5) -
veraPDF validation shared
TryRun's 30-second default budget, sized for a version-flag
probe, not a full validation run. Test-only; nothing ships. The two Layout call sites
(PdfValidatorOracleTestsandImageCodecOracleTests) now pass the same 120-second budget
VeraPdf.Validatein the conformance suite already used, and theirtimedOutoutcome is
asserted rather than discarded, so a validation run against CI's Docker-shimmed veraPDF that
overruns is reported as a timeout instead of an unexplained exit code. Their exit-code guard
(0 or 1 expected) also stopped calling exit 7 or 8 an "environment problem": veraPDF returns 7
for a file it cannot parse and 8 for one it refuses as encrypted, and both name a defect in the
PDF VellumPdf itself emitted, not the harness. The guard's condition (exit is 0 or 1) is
unchanged. (#198 review, round 5) -
33 dead
return;statements after a gate that never returns have been removed. Test-only;
nothing ships.[DoesNotReturn]feeds nullable flow analysis, not reachability, so the compiler
never flagged the unreachablereturn;left behind by the mechanical migration offGateOnCi's
{ GateOnCi(tool); return; }idiom: 32 single-line{ OracleGate.Unavailable(...); return; }
blocks, plus oneAssert.Skip(...); return;inConformanceCatalogTests. None of the 33 was
itself a phantom-pass defect; the gate before each already skipped or failed correctly. But it
was the same bare-return-after-a-gate idiom this whole change exists to remove. (#198 review,
round 5) -
ExternalToolResolutionTestscould itself report a passed test that ran no assertion, the
exact #198 failure mode, inside the test built to catch it. Test-only; nothing ships.
Resolves_ToTheClaimedToolcalledCheckIdentityonce to read a verdict, then, on the branch
where that verdict was notOk, called the single-argumentEnsureUsable(tool), which probed
the same tool a second time. Against veraPDF's slow JVM cold start the two probes could disagree:
a first attempt that timed out against a second, immediate one landing on an already-warm JVM and
answeringOk. When they did, that branch's ownreturn;ran before the assertion below it ever
did, so xUnit recorded a passed test that had executed no assertion.EnsureUsablenow has a
second overload that takes the verdict already in hand instead of re-probing, and the test uses
that one, so a single probe decides both the gate and the assertion (reproduced directly: a
verapdf.batthat times out on its first call and answers instantly afterward now reports the
testSkipped, and the fixture is invoked exactly once). The same double probe also meant a
timed-outResolves_ToTheClaimedToolcase could advanceConsecutiveTimeoutsby two rather than
one. (#198 review, round 6) -
Four
OracleGateTestscases assertingAssert.Throws<FailException>would report the test
Skippedrather thanFailedif the escalation switch they exist to guard ever stopped
escalating. Test-only; nothing ships. xUnit v3 rethrows a caughtSkipExceptionout of
Assert.Throwsby design (this file's own class doc already covers why), so a regressed
OracleGate.IsRequiredthat madeOracleGate.UnavailablecallAssert.Skipinstead of
Assert.Failwould report the whole test as a skip rather than failing it on the resulting type
mismatch. All four now use the same rawtry/catchthe round-5 join test already used for
SkipException, asserting both the caught exception's type and that its message names the
dependency. Reproduced directly against the fix: neutering theCIdisjunct inIsRequiredand
rerunningUnavailable_WithCiTrue_FailsNamingTheDependencyreports itFailed, on
Assert.IsType<FailException>catching the resultingSkipExceptioninstead, rather than
letting the regression pass as a skip. (#198 review, round 6) -
A few smaller round-6 fixes to the round-5 tests themselves. Test-only; nothing ships. The
30-second timeout canary now assertsVeraPdfProbeTimeoutMsOverrideForTestsis at its default
before relying on the literal budget it pins, so a run against a leaked override fails on that
assertion, naming the actual cause, rather than on the literal-30000ms one further down silently
checking the wrong number. The two tests that shrink that
override for a faster fixture now set it only after their own temp directory is safely created,
not before, so a throw fromDirectory.CreateTempSubdirectorycan no longer leave the shortened
budget in effect process-wide without reaching thefinallyblock that resets it. The join
test'sSkipExceptionassertion now also checks that the message names "verapdf", so it can no
longer accept aTransientskip for the wrong dependency. Three comments described guarantees
the code did not have:ConsecutiveTimeouts' thread-safety comment credited per-key atomicity of
AddOrUpdateandTryRemoveindividually, which does not make the two calls an atomic pair
together; the actual guarantee isCheckIdentity'sLazy<IdentityResult>, which serialises
every probe for one tool so those two calls can never race for the same key. TheTryRemove
after an escalated verdict claimed it let "a later recovery start counting from zero," but the
escalated verdict is cacheable, soCheckIdentitynever callsProbeIdentityfor that tool
again; there is no later recovery for the streak to resume from.
ConsecutiveTimeoutEscalationBound's own comment read as though it bounded test cases; it bounds
probes, and the sameLazycollapses many concurrent callers racing for one tool into a single
probe. Finally, the veraPDF exit-code guard inPdfValidatorOracleTestsand
ImageCodecOracleTestsfires for any exit outside 0 or 1, but its message named only exit 7 and
8; it now names only whichever code actually fired. (#198 review, round 6) -
Round-7 review: a design gap in
EnsureUsable's two-argument overload, plus prose the round-6
pass got wrong. Test-only; nothing ships.EnsureUsabletook a tool name and an
already-probed verdict as separate parameters, with nothing tying the two together: a caller
could gate on a verdict probed for one tool under a different tool's name, misrouting
REQUIRE_VERAPDF/REQUIRE_BARCODE_ORACLE's per-dependency scoping along with it (reproduced).
IdentityResultnow carries its ownTool, stamped byCheckIdentity, and the overload reads
the name from there instead of taking one, making the mismatch unrepresentable rather than
merely undocumented. The 30-second timeout canary's own comment overstated what it caught: given
this class's actual test order, it runs before either of the two tests that touch
VeraPdfProbeTimeoutMsOverrideForTests, so it could not have been catching a leak from an
earlier one.ExternalToolTestsnow asserts the same default fromIDisposable.Dispose, run
after every test regardless of order, and the canary's own comment says only what it actually
pins. FiveAssert.Containscalls — the round-5/6 join test and the four cases converted from
Assert.Throws<FailException>— checked only for a bare dependency name that the gate's own
detail text can independently contain, so each would still pass against a message naming the
wrong dependency; all five now check for"oracle '<name>'"instead. The veraPDF exit-code
switch, byte-identical betweenPdfValidatorOracleTestsandImageCodecOracleTests, moved to
a newVellumPdf.TestSupport.VeraPdfExitCode— the shared home this PR's own thesis argues for.
On the prose side: a CHANGELOG sentence above assertedZxingDecodeOracleTests' python leg was
both counted among the 42 dead-guard sites and excluded from them in the same clause; it now
names both call sites the 44 tree-wideTryRuncalls exclude — that python leg, and
VeraPdf.Validate's own call inOracleTests.cs— instead of one. A doc comment on
EnsureUsable(string)dated its "three call sites re-derived the same branch" milestone to the
two-argument overload, which came a full round later; restored to the one it actually describes,
round 5. And three comments a round-6 em-dash sweep turned into misparsed lists or run-on
sentences — onIdentityResult,ProbeIdentity, and theKill(entireProcessTree: true)branch
— are recast with a colon, a full stop or a parenthesis instead of the dashes or the added
commas that broke them. (#198 review, round 7) -
Info.Keywordswas never mirrored into the XMP packet.XmpMetadataWriteralready mirrored
Title, Author, Subject, and Creator intodc:title,dc:creator,dc:description, and
xmp:CreatorTool; Keywords was the one descriptive field that reached only/Info. It now emits
pdf:Keywordsas scalar text per the XMP Spec §8.7 Adobe PDF schema, not anrdf:Bag, which is
howdc:languageis typed instead. This is symmetry, not a conformance fix: ISO 32000-2 §14.3.4's
Info/XMP equivalence rule scopes only to the two date fields, and absence from XMP is not itself
a violation under ISO 19005-2. It's user-visible withEncryptMetadata = false(#182): the XMP
packet stays cleartext while/Infostays encrypted, and Keywords was the one descriptive field
a consumer honouring that flag could never read in cleartext. (#199)