Skip to content

v2.3.0

Latest

Choose a tag to compare

@github-actions github-actions released this 01 Sep 04:05
· 4 commits to main since this release
15e191e

Breaking changes

  • A same-revision /XRefStm no 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
    /XRefStm defines it, PdfReader now resolves N to null, 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 /XRefStm forward 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 /Prev revision, is unaffected only in the two-revision case; a hybrid revision
    sitting between two others in a /Prev chain 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.Reader is 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 /DecodeParms half 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 /Pages node, 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 /Count and one walking /Kids now disagree. PreflightContext.WalkPages
      walks /Kids the 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 /Encrypt reference points at, the document
      now fails to open with InvalidDataException instead of decrypting, because /Encrypt can 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
      throws InvalidDataException: Malformed PDF: /Root does not resolve to a dictionary. where it
      previously opened.
    • A /Filter or /DecodeParms object resolving to null does not degrade to null output —
      it produces wrong bytes. PdfFilters.GetFilterList treats an unresolvable /Filter as no
      filter at all, so GetDecodedStreamData returns the raw, still-encoded body. Measured: a
      24-byte plaintext body, FlateDecode-compressed to 32 bytes (zlib header 78 9C), comes back as
      those 32 raw bytes instead of the 24-byte plaintext once its /Filter reference 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 /ObjStm container 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 and PdfDocumentReader throws
      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
      to null (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 /ObjStm container 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.EnumerateIndirectObjects and EnumerateStreams included. Measured: an
      ISO19005-2:6.1.13-name Error 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 /AcroForm this way makes reader.Signatures.Count go 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 ObjectNumbers shrinks, and — when
      /Size also understates the count — NextFreeObjectNumber can shrink with it, both feeding
      code outside this package (PreflightContext and ObjectLayoutRule; DssBuilder and
      ArchiveTimestampBuilder). The fixture measuring this shrink has its own /Size already
      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. The NextFreeObjectNumber
      shrink has no constructed consequence beyond itself; the ObjectNumbers shrink 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) is IsCompliant=False with rule
      ISO19005-2:6.1.7.1-external-stream when that object is live, and IsCompliant=True with 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 … 2b against ISO 19005-2:2011 clause 6.1.7.1 test 3 for the live
      one, PASS … 2b for the freed one. Two controls rule out the deflationary readings: a variant
      where the object is defined only by the /XRefStm and 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 type-2 (compressed) cross-reference entry whose container has no live entry anywhere in the
    merged table now resolves to null instead 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 in freed regardless, 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 to null rather than
    throwing InvalidDataException: Object stream container N not found in xref., matching qpdf. A
    member the sweep drops this way is absent from _xref itself — 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.Open takes a PdfReaderOptions instead of a string? password. The two
    Open(x, string?) overloads are gone and the password moves to PdfReaderOptions.Password. The
    two shapes could not coexist: adding an options overload beside the string? 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 throws ArgumentNullException instead
    of opening the document. Recompile that call as PdfReader.Open(bytes). Recorded here even though
    VellumPdf.Reader is 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: AssemblyVersion stays pinned at 2.0.0.0 across the 2.x line
    (Directory.Build.props), so the assembly identity is unchanged and the runtime binds it. It
    fails at the call instead, with MissingMethodException: Method not found: 'VellumPdf.Reader.PdfDocumentReader VellumPdf.Reader.PdfReader.Open(Byte[], System.String)'.
    Method resolution happens when the calling method is JITted, before any surrounding try is
    entered, so this cannot be caught at the call site; the only fix is to recompile against
    PdfReaderOptions. (#184)

Added

  • PdfReaderOptions.MaxDecodedStreamBytes and ReconstructionBudgetMultiplier — 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 of max(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), makes PdfReader.Open throw ArgumentOutOfRangeException naming 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 its PdfSaveDecryptedOptions/async twins re-serialise the whole
    object graph into a fresh single-revision PDF: /Encrypt and 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) — unlike AppendRevision, a full
    rewrite does not depend on the base file's own byte layout. Throws InvalidOperationException on
    a signed document unless PdfSaveDecryptedOptions.AllowInvalidatingSignatures is set — detected
    by unioning two sources, neither complete alone: the Signatures property's /AcroForm
    field-tree walk (fixed to thread an inherited /FT through 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 /V be an inline dictionary rather than requiring the indirect reference
    /Lock and /SV require, 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 /ByteRange names, 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
    /Crypt filter 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
    startxref is missing, unusable, or doesn't point at a recognisable xref table or stream,
    PdfReader.Open used to always throw. Setting AllowReconstruction instead rebuilds the table
    by walking the file once for N G obj headers (the recovery ISO 32000-2 Annex C.4, informative,
    describes), budgeted at max(1 MiB, 8 × file length) — the multiplier is a fixed constant here;
    #376 later makes it configurable via PdfReaderOptions.ReconstructionBudgetMultiplier. A
    document opened this way reports the fact through the new PdfDocumentReader.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 /Encrypt is carried through rather than
      refused. When nothing declares it (a trailer damaged past recovery), a confirmed object gets a
      synthesised /Encrypt N G R pointed 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. /Filter and /V are 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 /ID along with everything else now fails to
      authenticate with a PdfPasswordException naming 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 failed Open on 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.
  • 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 by eng/generate-pdf20-inventory.py from the
    PDF Association's PDF2NormRefs and Arlington PDF Model datasets, with a --check mode 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).
    ParserFuzzTests mutates the committed Fixtures/Encrypted and Fixtures/ThirdParty
    corpora at the byte level and throws the result at PdfLexer.NextToken,
    PdfObjectParser.ParseObject, and PdfReader.Open under both AllowReconstruction settings. It
    is a robustness oracle, not a conformance one: only InvalidDataException,
    UnsupportedPdfFeatureException, and PdfPasswordException may escape; a crash-class exception
    (IndexOutOfRangeException, NullReferenceException, OutOfMemoryException,
    OverflowException) is a finding, gated per-input by a wall-clock ceiling and, for Open, by
    PdfReaderOptions.MaxDecodedStreamBytes pinned to its floor. Runs at a fast, bounded default
    budget in every PR; .github/workflows/fuzz-nightly.yml runs 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 new Fixtures/Fuzz/ corpus, SHA-256-pinned like the other two — see
    Fixtures/Fuzz/README.md's capture rule. Separately, SaveDecryptedThirdPartyQpdfOracleTests
    extends #186's SaveDecrypted round-trip oracle (qpdf --check plus an object-graph comparison
    against the reopened output) to the eleven third-party fixtures that can produce one, now that
    SaveDecrypted accepts a reconstructed document — closing the half of #99 that was blocked on
    #186 not existing yet.

  • docs/reader-guide.md and docs/layout-guide.md. v2.3 added SaveDecrypted,
    AllowReconstruction, and the tighten-only limit options to VellumPdf.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 in docs/toc.yml and linked from the root README and each package
    README's roadmap section.

Changed

  • docs/architecture.md and 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 once PdfDocumentReader.SaveDecrypted shipped 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 floating ubuntu-latest label onto ubuntu-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's qpdf on 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-core and fonts-texgyre
    stay on apt, pinned to the exact versions ubuntu-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-level VERAPDF_TAG. zxing-cpp moves from 3.0.0 to 3.1.1 (with pillow
    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 via dpkg-query, since pdftotext -v reports
    only poppler's upstream version and fonts cannot self-report a version at all, alongside a
    pdftotext -v identity 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-dotnet and actions/setup-python also 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.

  • NOTICE records 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.md records 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.yml registers 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.GitHub package reference. The .NET SDK has imported
    SourceLink implicitly since .NET 8, and steps aside when SuppressImplicitGitSourceLink is set —
    which NuGet does automatically for this reference, by emitting PkgMicrosoft_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 under ContinuousIntegrationBuild=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.json now pins the SDK feature band the workflows resolve, instead of trailing it.
    It named 10.0.204 with latestFeature, while every actions/setup-dotnet step asked for
    10.0.x and landed on whatever band was newest at the time — 10.0.400 as of this change. With
    TreatWarningsAsErrors and AnalysisLevel latest, a diagnostic new to that band is a CI failure
    a developer on the older SDK cannot reproduce. global.json now reads 10.0.400 with
    latestPatch, and all five setup-dotnet steps (ci.yml build and AOT smoke jobs, release.yml
    library and tool jobs, docs.yml) point at global-json-file: global.json rather than repeating
    the version inline, so the two can no longer drift apart. (#231)

  • Verify.XunitV3 moves to 32.0.0. The dependency floor on xunit.v3.extensibility.core is
    [3.2.2, ), open-ended and already satisfied by the xunit.v3 3.2.2 pin in
    Directory.Packages.props, from which extensibility.core comes transitively, so the bump is
    independent of #200's xunit v4 migration. The measured transitive delta against the base
    commit's restore: Verify 31.28.0 → 32.0.0, moving in lockstep with Verify.XunitV3 itself,
    DiffEngine 19.3.3 → 20.0.0 (a major), and Microsoft.Bcl.AsyncInterfaces 10.0.10 → 10.0.11;
    Argon and SimpleInfoName were 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 sets DiffEngine_Disabled: true against that, and tests/Directory.Build.props sets
    <DiffEngineBundledViewer>false</DiffEngineBundledViewer>, which drops the bundled viewer
    binaries and the DiffEngine.ViewerDirectory path DiffEngine's build targets otherwise stamp
    into each test project's runtimeconfig.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); only DiffEngine_Disabled does that. None of the five packages above appear in
    dotnet list package --vulnerable. No .verified.* file under tests/ changed. (#221)

  • Added .github/dependabot.yml: weekly nuget, github-actions, and dotnet-sdk checks. The
    nuget group 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.v3 and xunit.v3.assert) and xunit.runner.visualstudio majors are ignored pending
    #200's deliberate hold on the v3-to-v4 migration. dotnet-sdk covers the global.json
    feature-band pin from #379: that updater does not read rollForward, 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.RunProcess now appends one tool<TAB>first-argument
    line per successful, non-probe launch to a file named by ORACLE_INVOCATION_LOG plus 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_LOG is 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 — folding python3 into python'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 misconfigured ORACLE_INVOCATION_LOG (an
    unwritable path) now fails once, at type-init, with a message naming the variable, rather than
    as a bare DirectoryNotFoundException out of whichever unrelated oracle test happened to run
    first. (#228)

  • Tests now run on Microsoft.Testing.Platform instead of VSTest. xunit.v3 and
    xunit.v3.assert move 3.2.2 → 4.0.0, which ship xunit.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.Sdk and xunit.runner.visualstudio — both
    VSTest-only, and unneeded once xUnit self-hosts under MTP — are removed rather than bumped;
    global.json gains "test": { "runner": "Microsoft.Testing.Platform" }, the documented .NET 10
    opt-in (not the TestingPlatformDotnetTestSupport MSBuild property, which is the .NET 8/9-era
    mechanism). coverlet.collector is replaced by coverlet.MTP 10.0.1, chosen on licence grounds
    over Microsoft.Testing.Extensions.CodeCoverage (MIT with no acceptance requirement, versus a
    package that sets requireLicenseAcceptance and ships no SPDX expression). Test counts came back
    unchanged against the same commit, measured in Git Bash with neither QPDF_HOME nor
    POPPLER_HOME set: 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.MTP reads its instrumentation scope from coverlet.testconfig.json (copied to
    every test host's own output directory by tests/Directory.Build.props) rather than a
    --settings runsettings file, so coverlet.runsettings is gone. That has to be a config file,
    not command-line --coverlet-include/--coverlet-exclude options: 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 cost VellumPdf.Cli its entire
    System.Text.Json source-generated CliJsonContext partial, 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 through testconfig.json is what keeps generated code counted the way
    coverlet.collector counted 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.xml to coverage.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 raise xUnit1069 under 4.0.0's analyzers, and
    TreatWarningsAsErrors turns 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 their Timeout is dropped.
    The other nine keep Timeout under a targeted #pragma warning disable xUnit1069, each with a
    comment naming why: four are #208/#193 regression pins where the timeout is the assertion
    (PdfDictionaryIndexTests.cs ×2 and EncryptDictionaryDenialOfServiceTests.cs for #208,
    PdfObjectParserTests.cs:672 for #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.Throws alone 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 drop Timeout. 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 gains timeout-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 /Filter decoding 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 /Filter cannot be resolved has no /Filter at all, and
    returning it unfiltered is what the spec requires, not a bug. Renamed to
    FreedFilterObject_streamTreatedAsUnfiltered_perIso7310 with the doc comment rewritten to cite
    the clause chain; assertions unchanged. GetFilterList in Filters.cs gets a matching
    why-comment. This closure covers only the scalar /Filter entry; an unresolvable element inside
    a /Filter array is a separate, unlabelled path, tracked in #385 since Table 5 sanctions a
    null there for /DecodeParms but 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-preflight for VellumPdf.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, since VellumPdf.TestSupport.Tests legitimately 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 leading src//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 the dotnet test step 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), because VellumPdf.Conformance.Tests had 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 as EmbeddedResource looked up by manifest string — exactly what trimming breaks
    silently — and nothing in the smoke or VellumPdf.Cli referenced 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 /FontFile2 stream, and checks its sfnt version —
    proof the manifest lookup returned the real font under AOT, not just that a PDF was produced.
    Separately, the aot-smoke CI matrix ran only ubuntu-latest and macos-26, so a Windows-only
    Native AOT regression was discoverable only at release time, by which point the NuGet push had
    already happened. windows-latest is now in the matrix. (#219)

  • Eight qpdf oracle tests passed whether or not qpdf actually recognized their fixture as
    linearized.
    Test-only; nothing ships. qpdf --show-linearization exits 0 and prints no
    WARNING for 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 eight LinearizationQpdfTests cases that stopped
    at exit == 0 plus DoesNotContain("WARNING", ...) would have stayed green had VellumPdf
    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 eleven QpdfCheck_Passes
    cases (seven in PdfValidatorOracleTests, four in ImageCodecOracleTests) 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 if exit == 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 the timedOut output ExternalTool.TryRun had been
    discarding via out _, as does the one LinearizationQpdfTests case 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, LinearizationQpdfTests and PadesLevelTests; two in Layout,
    ImageCodecOracleTests and PdfValidatorOracleTests) — was a no-op off CI, except in the
    Barcodes copy, which already honored REQUIRE_BARCODE_ORACLE == "1" there too. All 73 of
    its call sites on main (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 inside ZxingDecodeOracleTests' bool-returning helper did that via a bare
    { GateOnCi(tool); return; }, the other two via return 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.TestSupport project — with its own VellumPdf.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 one ExternalTool, and the five GateOnCi copies into one OracleGate, which calls
    Assert.Skip instead of falling through a bare return. All five
    copies already drained both pipes concurrently before WaitForExit; their real defect was
    reading that drain with an unbounded GetAwaiter().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. ExternalTool bounds 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)

  • ExternalTool could resolve to the wrong tool and hand its output to the caller anyway.
    Test-only; nothing ships. qpdf, pdftotext, pdftoppm and pdfsig now resolve through an
    explicit QPDF_HOME/POPPLER_HOME environment 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: resolving pdftotext from a PowerShell
    session finds poppler, but from a Git Bash session finds Xpdf, a different codebase with no
    -tsv flag; the version banner alone does not tell the two apart, so pdftoppm needs the same
    check via -png. veraPDF gets that same VERAPDF_HOME-first treatment only on Windows, where it
    needs the variable to find its .bat launcher at all; on every other platform, including CI's
    ubuntu-24.04 runner, VERAPDF_HOME is not read here and veraPDF resolves by bare name, same as
    before this fix. The barcode decode oracle's python leg 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 stopped ExternalTool.TryRun itself 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
    eight PdfValidatorOracleTests text-extraction tests validated VellumPdf's own output against
    Xpdf and reported green. The identity check now lives on ExternalTool itself, 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, read REQUIRE_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 bare Assert.Skip otherwise, so neither
    CI, GITHUB_ACTIONS, nor REQUIRE_ORACLES could turn a missing veraPDF into a build failure
    there. This repository's own CI was not exposed by that specific gap — ci.yml sets
    REQUIRE_VERAPDF to the same literal "1" the old check compared against — but a CI environment
    that instead relies on CI/GITHUB_ACTIONS/REQUIRE_ORACLES, or that sets REQUIRE_VERAPDF to
    true rather than 1, would have silently skipped the one gate meant to catch a missing
    veraPDF. Both call sites now route through OracleGate.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
    widened ExternalTool budget above never reached.
    Test-only; nothing ships. VeraPdf.IsAvailable
    ran its own hardcoded 10-second verapdf --version check, independent of ExternalTool's, and
    cached the result forever in a static readonly field 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 through OracleGate.Unavailable, a merely slow probe escalated
    under CI and could fail all 273 InProcessVerdict_EqualsVeraPdf cases off that one sample — the
    exact flake the widened budget exists to remove, reopened through a second code path. VeraPdf
    now gates through ExternalTool.CheckIdentity directly, 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 new OracleGate.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 timedOut outcome went nowhere.
    Test-only; nothing ships. Once ExternalTool.TryRun started routing an unusable resolution
    through OracleGate itself (see above), the if (!ExternalTool.TryRun(...)) OracleGate.Unavailable(...)
    guard at each of its call sites for one of the five known tools could never see TryRun return
    false — the negated if was unreachable — so those 40 sites across PdfValidatorOracleTests
    (20), LinearizationQpdfTests (15) and ImageCodecOracleTests (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
    pdfsig call and ZxingDecodeOracleTests' pdftoppm call, for 42 tree-wide. Two more TryRun
    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 return false; and VeraPdf.Validate's call in OracleTests.cs, never built on the
    dead-guard pattern at all — 44 TryRun call sites tree-wide in total. Separately, the barcode
    decode oracle's python leg discarded ExternalTool.TryRun's timedOut output entirely (out _); a hung eng/barcode-decode.py run 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 through cmd.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_HOME containing a space broke the line entirely; both reproduced. cmd.exe is also
    resolved from Environment.SystemDirectory rather 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 new REQUIRE_ORACLES, REQUIRE_VERAPDF,
    REQUIRE_BARCODE_ORACLE) all accept 1 or true case-insensitively; on main, CI and
    GITHUB_ACTIONS were compared only against "true" and the two REQUIRE_* switches only
    against literal "1", which would have left a CI system exporting CI=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 in PdfValidatorOracleTests and ImageCodecOracleTests now assert veraPDF's
    own exit code is 0 or 1 before reading its report, so a broken JRE or a stale VERAPDF_HOME is
    reported as the environment problem it is, not as a conformance defect in the library. (#198)

  • The PDF/A-2b oracle in ImageCodecOracleTests matched a compliant="true" disjunct that
    veraPDF never emits.
    Test-only; nothing ships. compliant/nonCompliant in its
    machine-readable report are counts (compliant="N"), not the isCompliant="true"/"false"
    boolean the overall verdict actually uses, so the disjunct was dead code, not a live defect. Both
    oracles' predicates now check only isCompliant="true", matching what PdfValidatorOracleTests
    already did. (#198)

  • LinearizationQpdfTests had a hardcoded C:\Users\Timothy\tools\qpdf\... fallback path that
    let its ten tests run on this machine without QPDF_HOME set.
    Test-only; nothing ships.
    Dropping that fallback in favor of QPDF_HOME alone means those ten tests now skip locally
    unless QPDF_HOME is set, the same as every other oracle test. (#198)

  • Two embedded-font checks in PdfValidatorOracleTests had no gate at all, so they phantom-passed
    even on CI.
    Test-only; nothing ships. Both tests read if (fontPath is null) return; with no
    GateOnCi call, 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.EnsureAvailable and ExternalToolResolutionTests.Resolves_ToTheClaimedTool each
    re-derived the same branch over IdentityStatus and IsTimeout; the third read only Status,
    so a probe that had merely run out of time was sent to the escalating OracleGate.Unavailable
    instead of the always-skipping OracleGate.Transient, failing the build on one slow sample. That
    is exactly the defect round 4's own fix to TryRun exists 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 (a verapdf.bat behind
    VERAPDF_HOME on Windows, a PATH-shadowing sh script elsewhere) skips under CI=true rather
    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. But VeraPdf.EnsureAvailable is called once per test case, in a non-parallel
    collection, across the 273 VeraPdfOracleTests cases (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.ProbeIdentity now counts consecutive timeouts per tool and converts the
    third into a definitive, cached verdict that EnsureUsable routes to the escalating
    OracleGate.Unavailable instead; any non-timeout answer resets the count to zero. Verified
    directly with ExternalToolTests.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
    (PdfValidatorOracleTests and ImageCodecOracleTests) now pass the same 120-second budget
    VeraPdf.Validate in the conformance suite already used, and their timedOut outcome 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 unreachable return; left behind by the mechanical migration off GateOnCi's
    { GateOnCi(tool); return; } idiom: 32 single-line { OracleGate.Unavailable(...); return; }
    blocks, plus one Assert.Skip(...); return; in ConformanceCatalogTests. 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)

  • ExternalToolResolutionTests could 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_ToTheClaimedTool called CheckIdentity once to read a verdict, then, on the branch
    where that verdict was not Ok, called the single-argument EnsureUsable(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
    answering Ok. When they did, that branch's own return; ran before the assertion below it ever
    did, so xUnit recorded a passed test that had executed no assertion. EnsureUsable now 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.bat that times out on its first call and answers instantly afterward now reports the
    test Skipped, and the fixture is invoked exactly once). The same double probe also meant a
    timed-out Resolves_ToTheClaimedTool case could advance ConsecutiveTimeouts by two rather than
    one. (#198 review, round 6)

  • Four OracleGateTests cases asserting Assert.Throws<FailException> would report the test
    Skipped rather than Failed if the escalation switch they exist to guard ever stopped
    escalating.
    Test-only; nothing ships. xUnit v3 rethrows a caught SkipException out of
    Assert.Throws by design (this file's own class doc already covers why), so a regressed
    OracleGate.IsRequired that made OracleGate.Unavailable call Assert.Skip instead of
    Assert.Fail would report the whole test as a skip rather than failing it on the resulting type
    mismatch. All four now use the same raw try/catch the 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 the CI disjunct in IsRequired and
    rerunning Unavailable_WithCiTrue_FailsNamingTheDependency reports it Failed, on
    Assert.IsType<FailException> catching the resulting SkipException instead, 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 asserts VeraPdfProbeTimeoutMsOverrideForTests is 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 from Directory.CreateTempSubdirectory can no longer leave the shortened
    budget in effect process-wide without reaching the finally block that resets it. The join
    test's SkipException assertion now also checks that the message names "verapdf", so it can no
    longer accept a Transient skip for the wrong dependency. Three comments described guarantees
    the code did not have: ConsecutiveTimeouts' thread-safety comment credited per-key atomicity of
    AddOrUpdate and TryRemove individually, which does not make the two calls an atomic pair
    together; the actual guarantee is CheckIdentity's Lazy<IdentityResult>, which serialises
    every probe for one tool so those two calls can never race for the same key. The TryRemove
    after an escalated verdict claimed it let "a later recovery start counting from zero," but the
    escalated verdict is cacheable, so CheckIdentity never calls ProbeIdentity for 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 same Lazy collapses many concurrent callers racing for one tool into a single
    probe. Finally, the veraPDF exit-code guard in PdfValidatorOracleTests and
    ImageCodecOracleTests fires 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. EnsureUsable took 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).
    IdentityResult now carries its own Tool, stamped by CheckIdentity, 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. ExternalToolTests now asserts the same default from IDisposable.Dispose, run
    after every test regardless of order, and the canary's own comment says only what it actually
    pins. Five Assert.Contains calls — 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 between PdfValidatorOracleTests and ImageCodecOracleTests, moved to
    a new VellumPdf.TestSupport.VeraPdfExitCode — the shared home this PR's own thesis argues for.
    On the prose side: a CHANGELOG sentence above asserted ZxingDecodeOracleTests' 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-wide TryRun calls exclude — that python leg, and
    VeraPdf.Validate's own call in OracleTests.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 — on IdentityResult, ProbeIdentity, and the Kill(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.Keywords was never mirrored into the XMP packet. XmpMetadataWriter already mirrored
    Title, Author, Subject, and Creator into dc:title, dc:creator, dc:description, and
    xmp:CreatorTool; Keywords was the one descriptive field that reached only /Info. It now emits
    pdf:Keywords as scalar text per the XMP Spec §8.7 Adobe PDF schema, not an rdf:Bag, which is
    how dc:language is 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 with EncryptMetadata = false (#182): the XMP
    packet stays cleartext while /Info stays encrypted, and Keywords was the one descriptive field
    a consumer honouring that flag could never read in cleartext. (#199)