From bf9e319bd8fb3c86886d002e4015ce44255bb6a6 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 2 Oct 2026 20:27:52 -0400 Subject: [PATCH 01/15] Fix PDF xref/trailer scan crash and add configurable render background - Rewrite ScanObjectOffsets/ScanForTrailerDictionary to locate "N G obj" / "trailer" markers via raw byte-pattern scanning instead of tokenizing raw stream bytes with PdfTokenizer, fixing a crash (uncaught InvalidDataException from ReadHexString on a stray less-than byte in binary stream noise) and a silent desync that caused most real objects in malformed/non-standard-xref PDFs to be missed. - Add RegisterCompressedObjectsFromObjectStreams() to resolve indirect objects stored only inside /Type /ObjStm compressed object streams during linear-scan fallback, fixing "Indirect object N is not defined" failures. - Add regression tests covering both fixes with hand-authored malformed PDF fixtures (linear-scan-stream-noise.pdf, object-stream-linear-scan-fallback.pdf). - Add PdfRenderOptions (opaque white default BackgroundColor) and thread an optional PdfRenderOptions parameter through both PdfDocument.Render overloads, clearing the rendered Surface to the configured background color before executing the content stream (previously always fully transparent). - Update existing test call sites to pass a transparent PdfRenderOptions where tests assert paint isolation against a transparent background, and add dedicated tests for the new default/opaque-white and custom background-color behavior. - Update companion documentation (reqstream, design, verification, sysml2 model, review sets, user guide) to cover the new configurable background behavior. --- .reviewmark.yaml | 1 + docs/design/canvas-net-pdf/pdf-document.md | 38 ++- .../canvas-net-pdf/pdf-document.yaml | 25 +- .../model/canvas-net-pdf/pdf-document.sysml | 2 +- docs/user_guide/introduction.md | 34 +-- .../canvas-net-pdf/pdf-document.md | 26 +- .../NamespaceDoc.cs | 4 +- .../PdfDocument.ContentStream.cs | 7 +- .../PdfDocument.FontFallback.cs | 2 +- .../PdfDocument.Fonts.cs | 6 +- .../PdfDocument.GraphicsState.cs | 2 +- .../PdfDocument.Xref.cs | 248 +++++++++++++++-- .../PdfDocument.cs | 49 +++- .../PdfRenderOptions.cs | 40 +++ .../PdfDocumentEncryptionTests.cs | 36 ++- .../PdfDocumentTests.cs | 251 +++++++++++++++--- .../PdfFixtureTests.cs | 28 +- .../PdfFixtures/README.md | 2 + .../PdfFixtures/linear-scan-stream-noise.pdf | Bin 0 -> 252 bytes .../object-stream-linear-scan-fallback.pdf | Bin 0 -> 349 bytes .../PdfSystemIntegrationTests.cs | 94 ++++--- 21 files changed, 697 insertions(+), 198 deletions(-) create mode 100644 src/DemaConsulting.CanvasNet.Pdf/PdfRenderOptions.cs create mode 100644 test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/linear-scan-stream-noise.pdf create mode 100644 test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/object-stream-linear-scan-fallback.pdf diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 6e9965fe..011131bf 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -763,6 +763,7 @@ reviews: - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs" # phase 6 unit source (font fallback) - "src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Text.cs" # phase 4 unit source (text rendering) - "src/DemaConsulting.CanvasNet.Pdf/PdfPageInfo.cs" # phase 1 unit source (supporting record struct) + - "src/DemaConsulting.CanvasNet.Pdf/PdfRenderOptions.cs" # supporting options type (background color) - "src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs" # Pdf namespace documentation - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs" # unit tests - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs" # integration tests diff --git a/docs/design/canvas-net-pdf/pdf-document.md b/docs/design/canvas-net-pdf/pdf-document.md index c54e09dd..a0bd7646 100644 --- a/docs/design/canvas-net-pdf/pdf-document.md +++ b/docs/design/canvas-net-pdf/pdf-document.md @@ -227,6 +227,15 @@ re-parsing it each time — a property a purely static API could not express. sufficient to size a `Render` call, because a later phase's content-stream-to-device transformation matrix construction needs the effective rotation value itself, not just its width/height side effect. +- **`PdfRenderOptions`** (public, `sealed class`, `PdfRenderOptions.cs`) — a small, deliberately + growable bag of page-rendering configuration, documented inline here for the same reason as + `PdfPageInfo` above. Currently exposes a single `init`-only `BackgroundColor` property + (`Canvas.Rgba32`, defaulting to opaque white) plus a `static readonly Default` instance used + whenever a `Render` caller passes `null`. **Design decision**: a `sealed class` with `init` + properties was chosen over a `record`/`record struct` specifically so future properties can be + added without a breaking positional-argument/constructor change for any caller already using + `new PdfRenderOptions { ... }` object-initializer syntax - no specific future property is + planned or implied by this shape choice. ### Key Methods @@ -254,16 +263,20 @@ re-parsing it each time — a property a purely static API could not express. - **`GetPageInfo(int pageIndex)`** — disposed-check, then `ArgumentOutOfRangeException` for `pageIndex < 0 || pageIndex >= PageCount`, then returns `_pages[pageIndex]` (already computed during `Open`). -- **`Render(int pageIndex, int width, int height)`** — disposed-check and `pageIndex` - range-check identical to `GetPageInfo`, then builds `new Surface(width, height)`, resolves the - page's leaf node, raw `/MediaBox` origin, and inherited `/Resources` via `ResolvePageDetails`, - derives the page's base CTM via `BuildBaseCtm` (see _Content-Stream Interpreter_ below), - resolves the page's `/Contents` bytes via `ResolvePageContentBytes`, executes them via - `ExecuteContentStream` (passing the resolved `/Resources`), and returns the painted surface. - `Surface`'s own constructor supplies the `width`/`height` `ArgumentOutOfRangeException`/ - `MaxDimension` contract; this is deliberately not duplicated here, and the caller-specified - `width`/`height` is used exactly as given — it is never clamped to, or derived from, the page's - own `/MediaBox` size. +- **`Render(int pageIndex, int width, int height, PdfRenderOptions? options = null)`** — + disposed-check and `pageIndex` range-check identical to `GetPageInfo`, then builds `new + Surface(width, height)` and immediately clears it via `surface.Clear((options ?? + PdfRenderOptions.Default).BackgroundColor)` (opaque white when `options` is `null` or leaves + `BackgroundColor` at its own default), resolves the page's leaf node, raw `/MediaBox` origin, + and inherited `/Resources` via `ResolvePageDetails`, derives the page's base CTM via + `BuildBaseCtm` (see _Content-Stream Interpreter_ below), resolves the page's `/Contents` bytes + via `ResolvePageContentBytes`, executes them via `ExecuteContentStream` (passing the resolved + `/Resources`), and returns the painted surface. `Surface`'s own constructor supplies the + `width`/`height` `ArgumentOutOfRangeException`/`MaxDimension` contract; this is deliberately not + duplicated here, and the caller-specified `width`/`height` is used exactly as given — it is + never clamped to, or derived from, the page's own `/MediaBox` size. A caller that needs the + previous fully transparent background back (for example to composite the result over + something else itself) passes `new PdfRenderOptions { BackgroundColor = new(0, 0, 0, 0) }`. - **`Dispose()`** — idempotent (mirrors `Surface`'s exact pattern): `if (_disposed) return;` then `_disposed = true;`. No finalizer (only managed memory — the buffered bytes and parsed object model — is held). @@ -478,8 +491,9 @@ its own distinguishable `Feature` token. `/Contents`: a single stream is decoded directly via `GetStreamDecodedBytes`; an array of streams is decoded entry-by-entry and concatenated with a single space byte inserted between each entry (per the PDF specification's own requirement, so adjacent tokens from different - streams can never merge); a page with no `/Contents` key returns an empty array (rendered as a - blank page, exactly as every page did in Phase 1). + streams can never merge); a page with no `/Contents` key returns an empty array (rendered as + just its `PdfRenderOptions.BackgroundColor`-cleared surface, with no further geometry painted + over it). - **Graphics-state operators (`PdfDocument.GraphicsState.cs`)** — `OpPushGraphicsState` (`q`, pushes a clone of `_gs`), `OpPopGraphicsState` (`Q`, pops into `_gs`, tolerating an empty stack as a documented no-op leniency distinct from this class's strict operand-count/type diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index 5a8695fd..bd2d097d 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -326,19 +326,27 @@ sections: The PdfDocument class shall return a Surface of the caller-specified width and height for a valid page index, painting it with the page's interpreted content- stream geometry (see CanvasNetPdf-PdfDocument-ContentStreamDispatch and its sibling - requirements below) or, when the page declares no /Contents at all, a fully - transparent surface; it shall validate the page index the same way GetPageInfo does, - and propagate Surface's own width/height validation unwrapped rather than - duplicating it. + requirements below) over a surface cleared to the caller-configurable + PdfRenderOptions.BackgroundColor (opaque white by default) via an optional trailing + PdfRenderOptions? options parameter; it shall validate the page index the same way + GetPageInfo does, and propagate Surface's own width/height validation unwrapped + rather than duplicating it. justification: | Phase 1 established Render's final call shape (page index plus caller-chosen output size) so Phase 2's real content-stream rendering could be added without a breaking - API change; callers need a correctly sized, correctly painted result. + API change; callers need a correctly sized, correctly painted result, and many + downstream consumers (for example PDF-to-image conversion for print or preview) + expect an opaque, non-transparent page background by default while still being able + to opt back into the old fully transparent behavior when they need to composite the + result over something else themselves. tests: - - PdfDocument_Render_ValidPageIndex_ReturnsCorrectlySizedBlankSurface + - PdfDocument_Render_ValidPageIndex_ReturnsCorrectlySizedOpaqueWhiteSurface - PdfDocument_Render_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException - PdfDocument_Render_InvalidWidth_PropagatesSurfaceArgumentOutOfRangeException - PdfDocument_ContentStream_NoContents_RendersBlankSurface + - PdfDocument_Render_NoOptions_DefaultsToOpaqueWhiteBackground + - PdfDocument_Render_CustomBackgroundColor_ClearsSurfaceToThatColor + - PdfDocument_Render_TransparentBackgroundColor_ReproducesOldFullyTransparentBehavior - id: CanvasNetPdf-PdfDocument-RenderWithDpi title: >- @@ -347,8 +355,9 @@ sections: compute an output width/height in pixels themselves, scaling the page's own rotation-adjusted point-space size by dpi/72, preserving its aspect ratio, and rounding the result to the nearest pixel before producing the same painted Surface - as the width/height overload; it shall reject a dpi that is not a positive, finite - number, and validate the page index the same way the width/height overload does. + as the width/height overload (forwarding its own optional PdfRenderOptions? + parameter unchanged); it shall reject a dpi that is not a positive, finite number, + and validate the page index the same way the width/height overload does. justification: | Callers frequently think in terms of a target resolution (dots per inch) rather than an absolute pixel size, and computing a page's correctly aspect-ratio-preserving diff --git a/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml b/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml index 407bebfd..4eefaad5 100644 --- a/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml +++ b/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml @@ -2,7 +2,7 @@ package CanvasNet { part def PdfDocument { doc /* PDF document parser and page-rendering codec: parses cross-references, trailer, and page tree, reports page count/size/rotation, and interprets each page's content stream (path construction/painting, graphics-state stack, device color operators, generalized stream filter pipeline, image XObjects, and (Phase 4) simple TrueType font resolution/encoding and text-object/positioning/showing operators, with (Phase 6) automatic system/bundled-Liberation font-fallback substitution for a non-embedded font) to render real path geometry, images, and glyph outlines onto the returned Surface in real device color. */ - comment sourceRef /* Source: src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Tokenizer.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ObjectModel.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PageTree.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PathOps.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Color.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Lzw.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Ascii.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.RunLength.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.ToUnicode.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type0.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type1.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type3.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Text.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.CcittFax.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Encryption.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Functions.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs, src/DemaConsulting.CanvasNet.Pdf/PdfPageInfo.cs, src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs */ + comment sourceRef /* Source: src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Tokenizer.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ObjectModel.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PageTree.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PathOps.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Color.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Lzw.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Ascii.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.RunLength.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.ToUnicode.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type0.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type1.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type3.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Text.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.CcittFax.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Encryption.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Functions.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs, src/DemaConsulting.CanvasNet.Pdf/PdfPageInfo.cs, src/DemaConsulting.CanvasNet.Pdf/PdfRenderOptions.cs, src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs */ comment testRef /* Test: test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentSymbolicEncodingTests.cs */ comment designRef /* Design: docs/design/canvas-net-pdf/pdf-document.md */ comment verificationRef /* Verification: docs/verification/canvas-net-pdf/pdf-document.md */ diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 838f0300..a7442dac 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -1218,18 +1218,21 @@ page-tree ancestors when it does not declare its own. - `ArgumentOutOfRangeException`: Thrown when `pageIndex` is negative or `>= PageCount`. - `ObjectDisposedException`: Thrown when called after `Dispose()` has been called. -##### PdfDocument.Render(int pageIndex, int width, int height) +##### PdfDocument.Render(int pageIndex, int width, int height, PdfRenderOptions? options = null) ```csharp -public Surface Render(int pageIndex, int width, int height) +public Surface Render(int pageIndex, int width, int height, PdfRenderOptions? options = null) ``` -Returns a new `Surface` of the caller-specified `width` x `height` for the given page, painted +Returns a new `Surface` of the caller-specified `width` x `height` for the given page, cleared to +`options.BackgroundColor` (opaque white by default, when `options` is `null`) and then painted with the page's interpreted content-stream geometry (path construction/painting with real device color, placed image XObjects, and text shown with a resolved TrueType font - embedded when -present, otherwise automatically substituted, see *PdfDocument* above), or a fully transparent -surface when the page declares no `/Contents`. The `width`/`height` used is exactly as given - it -is not clamped or derived from the page's own `/MediaBox` size. +present, otherwise automatically substituted, see *PdfDocument* above). A page with no +`/Contents` simply returns the cleared, unpainted background surface. The `width`/`height` used +is exactly as given - it is not clamped or derived from the page's own `/MediaBox` size. To +reproduce the pre-`PdfRenderOptions` fully transparent background, pass +`new PdfRenderOptions { BackgroundColor = new(0, 0, 0, 0) }`. **Exceptions:** @@ -1245,25 +1248,26 @@ is not clamped or derived from the page's own `/MediaBox` size. text-rendering mode. - `ObjectDisposedException`: Thrown when called after `Dispose()` has been called. -##### PdfDocument.Render(int pageIndex, float dpi) +##### PdfDocument.Render(int pageIndex, float dpi, PdfRenderOptions? options = null) ```csharp -public Surface Render(int pageIndex, float dpi) +public Surface Render(int pageIndex, float dpi, PdfRenderOptions? options = null) ``` -Convenience overload of `Render(int, int, int)` for the common "render at a given resolution" -case: reads `GetPageInfo(pageIndex)`'s rotation-adjusted width/height (in points, 1/72 inch), -scales both by `dpi / 72`, rounds to the nearest pixel, and renders at that size - preserving the -page's own aspect ratio, unlike the three-argument overload. Use `Render(int, int, int)` directly -instead when independent X/Y scaling (non-square pixels, or an exact pixel size regardless of -aspect ratio) is needed. +Convenience overload of `Render(int, int, int, PdfRenderOptions?)` for the common "render at a +given resolution" case: reads `GetPageInfo(pageIndex)`'s rotation-adjusted width/height (in +points, 1/72 inch), scales both by `dpi / 72`, rounds to the nearest pixel, and renders at that +size - preserving the page's own aspect ratio, unlike the four-argument overload, and forwarding +`options` unchanged. Use `Render(int, int, int, PdfRenderOptions?)` directly instead when +independent X/Y scaling (non-square pixels, or an exact pixel size regardless of aspect ratio) is +needed. **Exceptions:** - `ArgumentOutOfRangeException`: Thrown when `pageIndex` is negative or `>= PageCount`, when `dpi` is not a positive, finite number, or when the computed pixel width/height is not a valid `Surface` size. -- Also throws every exception `Render(int, int, int)` itself can throw. +- Also throws every exception `Render(int, int, int, PdfRenderOptions?)` itself can throw. ##### PdfDocument.Dispose() diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index ee2e7565..d0c95be9 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -340,16 +340,25 @@ Opens a single-page fixture and calls `GetPageInfo` with an out-of-range index. #### CanvasNetPdf-PdfDocument-Render: Render Paints Content-Stream Geometry and Validates Arguments -**Tests**: `PdfDocument_Render_ValidPageIndex_ReturnsCorrectlySizedBlankSurface`, +**Tests**: `PdfDocument_Render_ValidPageIndex_ReturnsCorrectlySizedOpaqueWhiteSurface`, `PdfDocument_Render_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException`, `PdfDocument_Render_InvalidWidth_PropagatesSurfaceArgumentOutOfRangeException`, -`PdfDocument_ContentStream_NoContents_RendersBlankSurface` +`PdfDocument_ContentStream_NoContents_RendersBlankSurface`, +`PdfDocument_Render_NoOptions_DefaultsToOpaqueWhiteBackground`, +`PdfDocument_Render_CustomBackgroundColor_ClearsSurfaceToThatColor`, +`PdfDocument_Render_TransparentBackgroundColor_ReproducesOldFullyTransparentBehavior` Calls `Render` with a valid page index and caller-chosen size against a fixture with no -`/Contents`, asserting the returned `Surface` has exactly the requested dimensions and every -pixel is the default (fully transparent) value. Calls `Render` with an out-of-range page index -and a non-positive width, asserting `ArgumentOutOfRangeException` in both cases (the latter -propagated unwrapped from `Surface`'s own constructor). +`/Contents`, asserting the returned `Surface` has exactly the requested dimensions and, with no +`options` argument supplied, every pixel is opaque white (`PdfRenderOptions.Default`'s +`BackgroundColor`) - the surface is cleared to that color before any content-stream geometry is +painted over it. Calls `Render` with an out-of-range page index and a non-positive width, +asserting `ArgumentOutOfRangeException` in both cases (the latter propagated unwrapped from +`Surface`'s own constructor). A dedicated custom-color test passes a `PdfRenderOptions` with a +distinct, non-white opaque `BackgroundColor` and asserts every pixel matches it exactly, and a +dedicated transparent-override test passes `new PdfRenderOptions { BackgroundColor = new(0, 0, 0, +0) }` and asserts every pixel reproduces the fully transparent behavior `Render` had before this +options parameter was added. #### CanvasNetPdf-PdfDocument-RenderWithDpi: Render(int, float) Scales Page Size by DPI/72 and Validates Arguments @@ -366,7 +375,10 @@ dimensions itself. Calls `Render(int, float)` with a non-positive and a non-fini `PositiveInfinity`) DPI (`[Theory]`), an out-of-range page index, and after the document has been disposed, asserting `ArgumentOutOfRangeException` for the first two cases and `ObjectDisposedException` for the last, matching the validation contract already proven for -`Render(int, int, int)`. +`Render(int, int, int)`. The overload forwards its own `options` parameter unchanged to +`Render(int, int, int, PdfRenderOptions?)`, so no separate DPI-specific background-color test is +needed - `CanvasNetPdf-PdfDocument-Render`'s own tests above already cover every +`BackgroundColor` behavior this overload shares. #### CanvasNetPdf-PdfDocument-ContentStreamDispatch: Unknown Operators Are Skipped, Malformed Recognized Operators Throw diff --git a/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs b/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs index edda51e7..ac392a96 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs @@ -18,7 +18,7 @@ namespace DemaConsulting.CanvasNet.Pdf; /// DemaConsulting.CanvasNet package. /// /// -/// fully interprets a page's content stream: +/// fully interprets a page's content stream: /// vector path construction/painting with real device color (DeviceGray/ /// DeviceRGB/DeviceCMYK, CalRGB, ICC-based, and /Indexed color /// spaces), text shown with a resolved font - simple /Subtype /TrueType//Type1 @@ -40,7 +40,7 @@ namespace DemaConsulting.CanvasNet.Pdf; /// /// /// Rendering every page of a PDF document to a 300 DPI PNG file, using -/// (which preserves each page's own aspect +/// (which preserves each page's own aspect /// ratio) together with the core DemaConsulting.CanvasNet package's /// - the separate package that holds /// and every raster codec, since this diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs index 95fa7579..20a40770 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs @@ -25,7 +25,7 @@ public sealed partial class PdfDocument /// /// The page's own initial current transformation matrix (the base CTM the top-level - /// render call was invoked with), captured once per top-level + /// render call was invoked with), captured once per top-level /// call and never mutated by any subsequent cm/Form-XObject-matrix composition - /// used by PdfDocument.Patterns.cs's PatternToDeviceTransform to anchor a /// pattern's own /Matrix against the page's default coordinate system, per @@ -56,7 +56,7 @@ public sealed partial class PdfDocument /// when none is declared anywhere in the page's ancestry. /// /// - /// This method is the single entry point called once per + /// This method is the single entry point called once per /// call. A nested /Subtype /Form XObject (see ) does /// not call this method again - it re-enters /// directly, so that only the Form-specific state it explicitly saves/swaps/restores is @@ -399,7 +399,8 @@ private void DispatchOperator(string operatorName, List operands) /// The already-resolved leaf page dictionary. /// /// The concatenated, fully decoded content bytes, or an empty array when the page - /// declares no /Contents at all (a page with no content is valid: a blank page). + /// declares no /Contents at all (a page with no content is valid: nothing is + /// painted over whatever background color Render clears the surface to). /// /// /// Thrown when /Contents is neither a stream nor an array of streams, or when an diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs index 2b0180fe..3b1f8051 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs @@ -38,7 +38,7 @@ public sealed partial class PdfDocument /// /// Caches every loaded from a /// hit, keyed by the matched font's (FilePath, FaceIndex) - deliberately a - /// process-lifetime cache (unlike 's per- + /// process-lifetime cache (unlike 's per- /// call scope), since a system font file's bytes never change between calls, so /// re-parsing it from disk on every fallback lookup would reintroduce the "hundreds of /// files scanned repeatedly" cost 's own laziness diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs index 69d242ff..9fb2f60f 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs @@ -80,7 +80,7 @@ private interface IResolvedFont /// /// Instances are built once by and cached by /// in for the lifetime of a single - /// call - see 's own remarks + /// call - see 's own remarks /// for why the cache is never shared across calls. /// private sealed class ResolvedSimpleFont : IResolvedFont @@ -187,8 +187,8 @@ private sealed class ResolvedSimpleFont : IResolvedFont /// /// Reinitialized (cleared) at the start of every call, /// alongside / - an explicit, documented - /// "per--call only" cache scope, never shared or reused - /// across separate calls on the same + /// "per--call only" cache scope, never shared or reused + /// across separate calls on the same /// instance (each of which may, in principle, execute a /// different page's content stream against the same font resource name, so caching a /// resolved font beyond one call's lifetime could serve a stale/wrong font). diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs index 948a3d08..7b56dbcd 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs @@ -199,7 +199,7 @@ private sealed class GraphicsState /// /// The graphics-state stack pushed/popped by q/Q, for the content stream /// currently being executed by . Reinitialized fresh at - /// the start of every call; never shared or reused + /// the start of every call; never shared or reused /// across calls. /// private Stack _gsStack = null!; diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index 6add9543..40493baf 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -423,6 +423,7 @@ private bool IsValidCatalogRoot(PdfObject trailer) private PdfObject BuildLinearScanFallback() { _xref = ScanObjectOffsets(); + RegisterCompressedObjectsFromObjectStreams(); var explicitTrailer = ScanForTrailerDictionary(); if (explicitTrailer is not null && IsValidCatalogRoot(explicitTrailer)) @@ -455,65 +456,266 @@ private PdfObject BuildLinearScanFallback() } /// - /// Scans the entire buffer, token by token, for N G obj markers, recording each - /// object number's most recently seen offset (a later occurrence - for example from an + /// Scans the entire buffer for literal N G obj byte markers, recording each object + /// number's most recently seen offset (a later occurrence - for example from an /// incremental update - overrides an earlier one, matching classic cross-reference table - /// override semantics). + /// override semantics). This operates directly on raw bytes rather than through + /// : tokenizing the entire buffer would also tokenize + /// compressed/binary stream payloads that were never meant to be parsed as PDF syntax, and + /// real-world binary noise routinely derails a general-purpose tokenizer (for example by + /// being misread as an implausibly long number or string token that swallows a genuine + /// "obj" marker a few bytes later). Matching the exact literal pattern + /// "<digits> <digits> obj" byte-by-byte is far more resilient to such noise. /// private Dictionary ScanObjectOffsets() { var xref = new Dictionary(); - var tokenizer = new PdfTokenizer(_buffer); + var buffer = _buffer; - while (true) + for (var i = 0; i + 3 <= buffer.Length; i++) { - var startPosition = tokenizer.Position; - var first = tokenizer.NextToken(); - if (first.Kind == PdfTokenKind.EndOfFile) + if (buffer[i] != (byte)'o' || buffer[i + 1] != (byte)'b' || buffer[i + 2] != (byte)'j') { - break; + continue; + } + + // Reject matches inside a larger identifier (for example the tail of "endobj", or a + // binary-noise byte run that merely contains "obj" as a substring): a genuine keyword + // is not immediately followed by another identifier byte. + if (i + 3 < buffer.Length && IsMarkerIdentifierByte(buffer[i + 3])) + { + continue; + } + + if (TryParseObjectHeaderBackward(buffer, i, out var objectNumber, out var headerStart)) + { + xref[objectNumber] = XrefEntry.CreateDirect(headerStart); + } + } + + return xref; + } + + /// + /// Attempts to parse a N G object number/generation header immediately preceding + /// the byte index of a literal obj keyword match, walking backward over the + /// generation digits, the separating whitespace, and the object number digits. + /// + private static bool TryParseObjectHeaderBackward(byte[] buffer, int objIndex, out int objectNumber, out int headerStart) + { + objectNumber = 0; + headerStart = 0; + + var cursor = objIndex; + var sectionEnd = cursor; + cursor = SkipMarkerBytesBackward(buffer, cursor, IsMarkerWhitespace); + if (cursor == sectionEnd) + { + return false; + } + + sectionEnd = cursor; + cursor = SkipMarkerBytesBackward(buffer, cursor, IsMarkerDigit); + if (cursor == sectionEnd) + { + return false; + } + + sectionEnd = cursor; + cursor = SkipMarkerBytesBackward(buffer, cursor, IsMarkerWhitespace); + if (cursor == sectionEnd) + { + return false; + } + + var numberEnd = cursor; + cursor = SkipMarkerBytesBackward(buffer, cursor, IsMarkerDigit); + if (cursor == numberEnd) + { + return false; + } + + var numberStart = cursor; + var parsedNumber = 0L; + for (var i = numberStart; i < numberEnd; i++) + { + parsedNumber = parsedNumber * 10 + (buffer[i] - (byte)'0'); + if (parsedNumber > int.MaxValue) + { + // An implausibly large object number is almost certainly a false-positive match + // against binary noise rather than a genuine object header. + return false; + } + } + + objectNumber = (int)parsedNumber; + headerStart = numberStart; + return true; + } + + /// Walks backward while the preceding byte satisfies . + private static int SkipMarkerBytesBackward(byte[] buffer, int cursor, Func predicate) + { + while (cursor > 0 && predicate(buffer[cursor - 1])) + { + cursor--; + } + + return cursor; + } + + private static bool IsMarkerWhitespace(byte b) => b is 0x00 or 0x09 or 0x0A or 0x0C or 0x0D or 0x20; + + private static bool IsMarkerDigit(byte b) => b is >= (byte)'0' and <= (byte)'9'; + + private static bool IsMarkerIdentifierByte(byte b) => + IsMarkerDigit(b) || (b is >= (byte)'a' and <= (byte)'z') || (b is >= (byte)'A' and <= (byte)'Z'); + + /// + /// After a linear scan has located every directly-offset object (N G obj markers), + /// also registers the objects nested inside any discovered object streams + /// (/Type /ObjStm). A compressed object has no literal N G obj marker of its + /// own in the raw buffer - it exists only inside its container stream's decompressed body + /// - so without this pass, any page or resource reachable only through an object stream + /// would be wrongly reported as missing whenever normal cross-reference parsing has failed + /// and this linear scan is the document's only remaining route to a working object table. + /// + private void RegisterCompressedObjectsFromObjectStreams() + { + // Snapshot the object numbers before mutating `_xref` within the loop below. + foreach (var streamNumber in _xref.Keys.ToArray()) + { + PdfObject container; + byte[] decoded; + try + { + container = GetObject(streamNumber); + if (container.Kind != PdfKind.Stream || GetNameValue(container, "Type") != "ObjStm") + { + continue; + } + + decoded = GetStreamDecodedBytes(container); + } + catch (InvalidDataException) + { + continue; } - if (first.Kind != PdfTokenKind.Number) + if (container.Get("N") is not { Kind: PdfKind.Number } countObject) { continue; } - var second = tokenizer.NextToken(); - if (second.Kind != PdfTokenKind.Number) + var count = (int)countObject.Number; + if (count < 0 || count > decoded.Length) { continue; } - var third = tokenizer.NextToken(); - if (third.Kind == PdfTokenKind.Keyword && third.Text == "obj") + var headerTokenizer = new PdfTokenizer(decoded); + for (var i = 0; i < count; i++) { - xref[(int)first.Number] = XrefEntry.CreateDirect(startPosition); + if (headerTokenizer.Position >= decoded.Length) + { + break; + } + + var numberToken = TryNextToken(headerTokenizer); + var offsetToken = TryNextToken(headerTokenizer); + if (numberToken is not { Kind: PdfTokenKind.Number } || offsetToken is not { Kind: PdfTokenKind.Number }) + { + break; + } + + // A direct "N G obj" marker (if one somehow also exists for this number) is more + // trustworthy than an assumed containment index, so never overwrite an existing + // entry here. + var containedNumber = (int)numberToken.Value.Number; + if (!_xref.ContainsKey(containedNumber)) + { + _xref[containedNumber] = XrefEntry.CreateCompressed(streamNumber, i); + } } } + } - return xref; + /// + /// Reads the next token, treating a malformed token - for example a hex string containing + /// a byte that is neither a hex digit nor whitespace - as unparsable noise rather than + /// letting the exception abort the whole scan. This matters because the linear-scan + /// fallback paths (, ) + /// tokenize the entire raw document buffer, including compressed/binary stream payloads + /// that were never meant to be parsed as PDF syntax and can easily contain a stray + /// < byte by coincidence. On failure the tokenizer is resynchronized by + /// advancing at least one byte past the token's start position (guaranteeing forward + /// progress even when the failing token consumed zero bytes before throwing), and + /// is returned so the caller can simply keep scanning. + /// + private static PdfToken? TryNextToken(PdfTokenizer tokenizer) + { + var start = tokenizer.Position; + try + { + return tokenizer.NextToken(); + } + catch (InvalidDataException) + { + if (tokenizer.Position <= start) + { + tokenizer.Position = start + 1; + } + + return null; + } } - /// Scans the buffer for the last trailer keyword and parses its dictionary. + /// + /// Scans the buffer for every literal trailer keyword byte marker and parses the + /// dictionary that follows it, keeping the last successfully parsed one (a later + /// occurrence - for example from an incremental update - overrides an earlier one). This + /// locates the keyword itself via a raw byte search rather than + /// tokenizing the whole buffer, for the same reason does: + /// real-world binary stream noise routinely derails a general-purpose tokenizer long + /// before it would ever reach a genuine "trailer" keyword. + /// private PdfObject? ScanForTrailerDictionary() { - var tokenizer = new PdfTokenizer(_buffer); + var buffer = _buffer; + var keyword = "trailer"u8; PdfObject? last = null; - while (true) + for (var i = 0; i + keyword.Length <= buffer.Length; i++) { - var token = tokenizer.NextToken(); - if (token.Kind == PdfTokenKind.EndOfFile) + var isMatch = true; + for (var k = 0; k < keyword.Length; k++) { - break; + if (buffer[i + k] != keyword[k]) + { + isMatch = false; + break; + } + } + + if (!isMatch) + { + continue; + } + + // Reject a match inside a larger identifier/binary-noise run: a genuine keyword is + // not immediately preceded or followed by another identifier byte. + if (i > 0 && IsMarkerIdentifierByte(buffer[i - 1])) + { + continue; } - if (token.Kind != PdfTokenKind.Keyword || token.Text != "trailer") + var after = i + keyword.Length; + if (after < buffer.Length && IsMarkerIdentifierByte(buffer[after])) { continue; } + var tokenizer = new PdfTokenizer(buffer) { Position = after }; try { var candidate = ParseValue(tokenizer); diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs index 2e95efb5..429d3b7b 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs @@ -14,7 +14,7 @@ namespace DemaConsulting.CanvasNet.Pdf; /// , which read and parse the entire document exactly once (the /// resolved page list and cross-reference data are cached for the lifetime of the /// instance, so // -/// are cheap, repeatable operations against the same +/// are cheap, repeatable operations against the same /// instance). /// /// @@ -66,8 +66,9 @@ namespace DemaConsulting.CanvasNet.Pdf; /// user guide (mesh shadings, /FunctionType 4 PostScript-calculator functions, /// the sh operator, generic path clipping, transparency groups, and clip /// text-rendering modes). Every other keyword not implemented is silently skipped, not -/// an error. A page with no /Contents at all still renders as a fully -/// transparent (blank) , exactly as every page did in Phase 1. +/// an error. A page with no /Contents at all still renders a +/// cleared to (opaque white by default; +/// see ). /// /// /// Phase 13 (this release) adds /Subtype /Form XObject rendering: Do @@ -385,10 +386,20 @@ public PdfPageInfo GetPageInfo(int pageIndex) /// The zero-based index of the page to render. /// The width of the rendered surface, in pixels. /// The height of the rendered surface, in pixels. + /// + /// Optional page-rendering configuration. When (the default), + /// is used, which clears the surface to opaque + /// white before the page's content is painted. + /// /// /// A new of the requested x /// , painted with the page's interpreted content-stream geometry. - /// A page with no /Contents renders as a fully transparent (blank) surface. + /// The surface is first cleared to 's + /// (opaque white by default), so a page + /// with no /Contents renders as a surface filled with that color. Pass + /// new PdfRenderOptions { BackgroundColor = new Rgba32(0, 0, 0, 0) } to reproduce + /// the fully transparent background this method always produced before this option + /// existed. /// /// /// Thrown when is negative or greater than or equal to @@ -411,7 +422,7 @@ public PdfPageInfo GetPageInfo(int pageIndex) /// defined but unsupported text-rendering mode. /// /// Thrown when this document has been disposed. - public Surface Render(int pageIndex, int width, int height) + public Surface Render(int pageIndex, int width, int height, PdfRenderOptions? options = null) { ObjectDisposedException.ThrowIf(_disposed, this); if (pageIndex < 0 || pageIndex >= _pages.Count) @@ -420,6 +431,7 @@ public Surface Render(int pageIndex, int width, int height) } var surface = new Surface(width, height); + surface.Clear((options ?? PdfRenderOptions.Default).BackgroundColor); var pageInfo = _pages[pageIndex]; var (pageNode, x0, y0, boxWidth, boxHeight, resources) = ResolvePageDetails(pageIndex); var baseCtm = BuildBaseCtm(x0, y0, boxWidth, boxHeight, pageInfo.Rotation, width, height); @@ -434,20 +446,31 @@ public Surface Render(int pageIndex, int width, int height) /// ratio. /// /// - /// A convenience wrapper over for the common case of + /// A convenience wrapper over for the common case of /// wanting undistorted, uniformly-scaled output: this overload reads the page's /// rotation-adjusted / (in /// points, 1/72 inch) via , scales both by /// / 72, and rounds to the nearest pixel before delegating to - /// . Callers needing independent X/Y scaling (for + /// . Callers needing independent X/Y scaling (for /// example, non-square pixels, or a specific pixel size regardless of aspect ratio) should - /// call directly instead. + /// call directly instead. /// /// The zero-based index of the page to render. /// The resolution to render at, in dots (pixels) per inch. + /// + /// Optional page-rendering configuration, forwarded unchanged to + /// . When + /// (the default), is used, which clears the + /// surface to opaque white before the page's content is painted. + /// /// /// A new sized to the page's aspect ratio at , - /// painted with the page's interpreted content-stream geometry. + /// painted with the page's interpreted content-stream geometry. The surface is first + /// cleared to 's + /// (opaque white by default) - see + /// for details, including how to + /// reproduce the fully transparent background this method always produced before this + /// option existed. /// /// /// Thrown when is negative or greater than or equal to @@ -457,13 +480,13 @@ public Surface Render(int pageIndex, int width, int height) /// constructor). /// /// - /// See . + /// See . /// /// - /// See . + /// See . /// /// Thrown when this document has been disposed. - public Surface Render(int pageIndex, float dpi) + public Surface Render(int pageIndex, float dpi, PdfRenderOptions? options = null) { ObjectDisposedException.ThrowIf(_disposed, this); if (!float.IsFinite(dpi) || dpi <= 0) @@ -475,7 +498,7 @@ public Surface Render(int pageIndex, float dpi) var scale = dpi / 72.0; var width = (int)Math.Round(info.Width * scale, MidpointRounding.AwayFromZero); var height = (int)Math.Round(info.Height * scale, MidpointRounding.AwayFromZero); - return Render(pageIndex, width, height); + return Render(pageIndex, width, height, options); } /// diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfRenderOptions.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfRenderOptions.cs new file mode 100644 index 00000000..85aa4306 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfRenderOptions.cs @@ -0,0 +1,40 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pdf; + +/// +/// Groups page-rendering configuration accepted by +/// and +/// . +/// +/// +/// This type exists as a single, extensible options parameter rather than a growing list of +/// individual Render arguments, so future rendering options can be added as new +/// -only properties without another breaking change to +/// 's Render signatures. It is a +/// class with -only properties (rather than a positional record) so +/// that object-initializer call sites such as +/// new PdfRenderOptions { BackgroundColor = ... } keep compiling unchanged whenever a +/// new option is added later. +/// +public sealed class PdfRenderOptions +{ + /// + /// The default instance, used by Render whenever a + /// caller passes (or omits the options parameter entirely) - + /// opaque white (see 's own default). + /// + public static readonly PdfRenderOptions Default = new(); + + /// + /// The color the rendered is cleared to before the page's + /// /Contents content stream (if any) is executed. Defaults to opaque white + /// (R=255, G=255, B=255, A=255). + /// + /// + /// Set this to a fully transparent color (new Rgba32(0, 0, 0, 0)) to reproduce the + /// fully transparent background that Render always produced before this option + /// existed. + /// + public Rgba32 BackgroundColor { get; init; } = new(255, 255, 255, 255); +} diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs index d56ca040..e51e7015 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs @@ -28,6 +28,14 @@ public class PdfDocumentEncryptionTests /// The opaque black color the re f operator sequence paints with. private static readonly Canvas.Rgba32 Black = new(0, 0, 0, 255); + /// + /// A fully transparent , used by tests that + /// assert an unpainted region equals to prove paint isolation + /// from neighboring drawing, reproducing the fully transparent background Render + /// always produced before was introduced. + /// + private static readonly PdfRenderOptions Transparent = new() { BackgroundColor = new(0, 0, 0, 0) }; + /// /// The standard 32-byte password padding string (ISO 32000-1 7.6.3.3), used verbatim as /// the padded empty password throughout every helper below. @@ -547,7 +555,7 @@ public void PdfDocument_Open_EncryptedRc4_40Bit_DecryptsAndRenders() var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); @@ -577,7 +585,7 @@ public void PdfDocument_Open_EncryptedRc4_128Bit_DecryptsAndRenders() var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); @@ -605,7 +613,7 @@ public void PdfDocument_Open_EncryptedAesV2_128Bit_DecryptsAndRenders() var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); @@ -638,7 +646,7 @@ public void PdfDocument_Open_EncryptedAesV3_R5_DecryptsAndRenders() var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); @@ -666,7 +674,7 @@ public void PdfDocument_Open_EncryptedRc4_CorrectUserPassword_DecryptsAndRenders var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); using var document = PdfDocument.Open(new MemoryStream(pdfBytes), userPassword); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); @@ -696,7 +704,7 @@ public void PdfDocument_Open_EncryptedAesV2_CorrectUserPassword_DecryptsAndRende var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); using var document = PdfDocument.Open(new MemoryStream(pdfBytes), userPassword); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); @@ -731,7 +739,7 @@ public void PdfDocument_Open_EncryptedAesV3_CorrectUserPassword_DecryptsAndRende var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); using var document = PdfDocument.Open(new MemoryStream(pdfBytes), userPassword); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); @@ -767,7 +775,7 @@ public void PdfDocument_Open_EncryptedRc4_CorrectOwnerPassword_DecryptsAndRender var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); using var document = PdfDocument.Open(new MemoryStream(pdfBytes), ownerPassword); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); @@ -810,7 +818,7 @@ public void PdfDocument_Open_EncryptedAesV3_CorrectOwnerPassword_DecryptsAndRend var pdfBytes = BuildEncryptedPdf(encryptDictBody, TestIdBytes, encryptedContent); using var document = PdfDocument.Open(new MemoryStream(pdfBytes), ownerPassword); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); @@ -970,7 +978,7 @@ public void PdfDocument_Open_Encrypted_EncryptDictionaryStringsAreNeverDecrypted using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); Assert.Equal(1, document.PageCount); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); } @@ -1015,7 +1023,7 @@ public void PdfDocument_Open_Encrypted_CatalogOwnStringIsDecrypted() // (a) the whole document still opens and renders correctly. Assert.Equal(1, document.PageCount); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); @@ -1080,7 +1088,7 @@ public void PdfDocument_Open_Encrypted_ObjStmContainedObjects_AreNeverDoubleDecr // Object 4's own encrypted /Contents stream decrypted correctly. Assert.Equal(1, document.PageCount); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); @@ -1135,7 +1143,7 @@ public void PdfDocument_Open_Encrypted_CompressedCatalog_OpensWithoutLinearScanF // which would have scanned for "N G obj" markers and never found object 1 (it has no such // marker; it only exists compressed inside object 6) - and renders correctly. Assert.Equal(1, document.PageCount); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); } @@ -1235,7 +1243,7 @@ public void PdfDocument_Open_FallbackTrailerWithoutEncrypt_DiscardsStaleEncrypti // without the fix, the stale RC4 key would have garbled these already-plaintext bytes // before content-stream parsing ever saw them. Assert.Equal(1, document.PageCount); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); } diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 8632898c..545b1bce 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -29,6 +29,14 @@ public class PdfDocumentTests /// The opaque black color every Phase 2 fill/stroke operator paints with. private static readonly Canvas.Rgba32 Black = new(0, 0, 0, 255); + /// + /// A fully transparent , used by tests that + /// assert an unpainted region equals to prove paint isolation + /// from neighboring drawing, reproducing the fully transparent background Render + /// always produced before was introduced. + /// + private static readonly PdfRenderOptions Transparent = new() { BackgroundColor = new(0, 0, 0, 0) }; + /// /// Builds an in-memory, single-page, classic-xref PDF (matching /// PdfFixtures\README.md's hand-authored template) whose one page declares the @@ -94,7 +102,7 @@ private static Canvas.Surface RenderContent( { var bytes = BuildSinglePagePdf(mediaBoxWidth, mediaBoxHeight, content, rotate); using var document = PdfDocument.Open(new MemoryStream(bytes)); - return document.Render(0, width, height); + return document.Render(0, width, height, Transparent); } /// @@ -193,7 +201,7 @@ private static byte[] ZlibCompress(byte[] data) private static Canvas.Surface RenderPdfBytes(byte[] bytes, int width = 100, int height = 100) { using var document = PdfDocument.Open(new MemoryStream(bytes)); - return document.Render(0, width, height); + return document.Render(0, width, height, Transparent); } /// @@ -1432,19 +1440,23 @@ public void PdfDocument_ObjectModel_Dictionary_NestingDepthExceeded_ThrowsInvali /// (no classic table) whose stream dictionary declares the given raw /W array /// literal verbatim - used to craft a malformed/adversarial /W entry (for example a /// negative field width) that the classic-xref-table builders above cannot express. - /// Deliberately hides the /Type /Catalog object inside a compressed - /// /Type /ObjStm container (object 6, compressed inside object 1) rather than - /// leaving it as a plain top-level object: 's own - /// BuildLinearScanFallback recovery path (triggered whenever normal - /// cross-reference parsing throws ) only finds plain - /// N G obj headers by a raw byte scan, so with the catalog plain and - /// top-level, recovery would silently rebuild a working document regardless of whether - /// the crafted /W entry is rejected - masking the very defect this helper exists to - /// exercise. Hiding the catalog inside an object stream (which recovery's linear scan - /// cannot see into) ensures that when the crafted /W entry is rejected and - /// cross-reference parsing falls back to recovery, recovery also fails to locate a - /// catalog, so the caller still observes a thrown, typed exception - proving the crafted - /// input is converted into a controlled failure, never a raw CLR crash, at every layer. + /// Hides the /Type /Catalog object inside a compressed /Type /ObjStm + /// container (object 6, compressed inside object 1) rather than leaving it as a plain + /// top-level object, so this fixture also doubles as coverage for + /// 's BuildLinearScanFallback recovery path: that raw + /// byte-scan recovery (triggered whenever normal cross-reference parsing throws + /// ) locates plain N G obj headers directly and + /// also decodes any directly-located /Type /ObjStm object it finds that way to + /// register the objects compressed inside it - so a crafted /W entry that forces + /// the normal cross-reference stream parse to fail no longer prevents the document from + /// opening: recovery still locates object 1's ObjStm by the raw scan, decodes it, + /// and resolves the catalog compressed within it. Callers that want to prove a malformed + /// /W entry is converted into a controlled, typed failure rather than a raw CLR + /// crash must therefore assert successful, gracefully-recovered output (see + /// + /// and + /// ), + /// not a thrown exception. /// private static byte[] BuildXrefStreamPdfWithWidths(string wLiteral) { @@ -1521,26 +1533,49 @@ public void PdfDocument_Open_XrefStream_ResolvesRootAndPage() Assert.Equal(300, info.Height); } - /// Proves that a cross-reference stream's /W entry with a negative field width (which would otherwise drive a negative byte offset into ) throws instead. + /// + /// Proves that a cross-reference stream's /W entry with a negative field width + /// (which would otherwise drive a negative byte offset into + /// ) never reaches the caller as a raw CLR crash: + /// normal cross-reference parsing rejects it with a controlled, typed exception + /// internally, and the document still opens successfully by falling back to the raw + /// linear scan (which locates the catalog compressed inside object 1's object stream, as + /// described on ). + /// [Fact] - public void PdfDocument_Open_XrefStream_NegativeWidth_ThrowsInvalidDataException() + public void PdfDocument_Open_XrefStream_NegativeWidth_RecoversViaLinearScanFallback() { // Arrange var pdfBytes = BuildXrefStreamPdfWithWidths("[-1 1 1]"); - // Act & Assert - Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); } - /// Proves that a cross-reference stream's /W entry whose three widths are all zero (which would otherwise produce a zero-length per-entry stride and never advance the decode position, allowing a crafted large /Size or /Index to spin indefinitely) throws instead of looping. + /// + /// Proves that a cross-reference stream's /W entry whose three widths are all zero + /// (which would otherwise produce a zero-length per-entry stride and never advance the + /// decode position, allowing a crafted large /Size or /Index to spin + /// indefinitely) never reaches the caller as an infinite loop: normal cross-reference + /// parsing rejects it with a controlled, typed exception internally, and the document + /// still opens successfully by falling back to the raw linear scan (which locates the + /// catalog compressed inside object 1's object stream, as described on + /// ). + /// [Fact] - public void PdfDocument_Open_XrefStream_AllZeroWidths_ThrowsInvalidDataException() + public void PdfDocument_Open_XrefStream_AllZeroWidths_RecoversViaLinearScanFallback() { // Arrange var pdfBytes = BuildXrefStreamPdfWithWidths("[0 0 0]"); - // Act & Assert - Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); } /// @@ -1905,6 +1940,75 @@ public void PdfDocument_GetStreamRawBytes_LengthOverflowsIntArithmetic_ThrowsInv Assert.Contains("out of bounds", exception.Message, StringComparison.Ordinal); } + /// + /// Proves that the linear-scan fallback (linear-scan-stream-noise.pdf - no + /// startxref/xref/trailer at all, mirroring + /// malformed-startxref.pdf) still opens successfully even though an unrelated, + /// unreferenced stream object's raw payload contains a stray < byte + /// immediately followed by Z - neither a hex digit nor whitespace. Before the + /// crash fix, ScanObjectOffsets/ScanForTrailerDictionary located "N G obj"/ + /// "trailer" markers by tokenizing the *entire* raw buffer with a general-purpose + /// tokenizer (including stream payloads never meant to be parsed as PDF syntax): the + /// tokenizer's hex-string reader would reach the stray <Z and throw + /// from inside the fallback itself, with no further + /// catch around it, escaping + /// entirely as an unhandled exception. The fix instead locates markers via raw + /// byte-pattern matching (and, for the trailer scan, a narrowly-scoped per-candidate + /// tokenizer wrapped in its own try/catch), so the same noise byte is simply + /// skipped over. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_StreamPayloadContainsInvalidHexByte_OpensWithoutThrowing() + { + // Arrange + PdfDocument? document = null; + + // Act + var exception = Record.Exception(() => document = PdfDocument.Open(Fixture("linear-scan-stream-noise.pdf"))); + using (document) + { + // Assert + Assert.Null(exception); + Assert.NotNull(document); + Assert.Equal(1, document.PageCount); + var info = document.GetPageInfo(0); + Assert.Equal(150, info.Width); + Assert.Equal(220, info.Height); + } + } + + /// + /// Proves that a page dictionary compressed inside a /Type /ObjStm object stream + /// (object-stream-linear-scan-fallback.pdf) is resolvable even when normal + /// cross-reference/trailer parsing cannot run at all (the fixture, like + /// malformed-startxref.pdf, has no startxref/xref/trailer), + /// forcing BuildLinearScanFallback to be the document's only route to a working + /// object table. The fallback's raw byte scan finds the object stream directly (it has + /// its own literal 5 0 obj marker), and + /// RegisterCompressedObjectsFromObjectStreams then decodes it to register the page + /// dictionary (object 3) compressed within it - resolved here via fully public, + /// observable behavior (rendering the page that depends on it, not an internal + /// test-only accessor): the fixture's single page paints an opaque red rectangle across + /// its entire 100x100 /MediaBox, so a successful render with the expected color + /// proves the compressed page dictionary - and its /Contents reference - were both + /// genuinely recovered, not merely that reports 1. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_ResolvesPageCompressedInObjectStream() + { + // Arrange & Act + using var document = PdfDocument.Open(Fixture("object-stream-linear-scan-fallback.pdf")); + + // Assert + Assert.Equal(1, document.PageCount); + var info = document.GetPageInfo(0); + Assert.Equal(100, info.Width); + Assert.Equal(100, info.Height); + + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); + } + #endregion #region Page tree @@ -2103,9 +2207,9 @@ public void PdfDocument_GetPageInfo_OutOfRangeIndex_ThrowsArgumentOutOfRangeExce #region Render - /// Proves that returns a fully transparent surface of the caller-requested size (Phase 1 renders no content). + /// Proves that returns an opaque-white surface of the caller-requested size by default (Phase 1 renders no content; the surface is cleared to 's opaque white background). [Fact] - public void PdfDocument_Render_ValidPageIndex_ReturnsCorrectlySizedBlankSurface() + public void PdfDocument_Render_ValidPageIndex_ReturnsCorrectlySizedOpaqueWhiteSurface() { // Arrange using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); @@ -2117,6 +2221,67 @@ public void PdfDocument_Render_ValidPageIndex_ReturnsCorrectlySizedBlankSurface( Assert.Equal(64, surface.Width); Assert.Equal(48, surface.Height); for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + Assert.Equal(new Canvas.Rgba32(255, 255, 255, 255), surface[x, y]); + } + } + } + + /// Proves that defaults to an opaque white background when no is passed. + [Fact] + public void PdfDocument_Render_NoOptions_DefaultsToOpaqueWhiteBackground() + { + // Arrange + using var document = PdfDocument.Open(Fixture("no-contents-page.pdf")); + + // Act + using var surface = document.Render(0, 20, 20); + + // Assert + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + Assert.Equal(new Canvas.Rgba32(255, 255, 255, 255), surface[x, y]); + } + } + } + + /// Proves that a custom, non-white, opaque clears the surface to exactly that color. + [Fact] + public void PdfDocument_Render_CustomBackgroundColor_ClearsSurfaceToThatColor() + { + // Arrange + using var document = PdfDocument.Open(Fixture("no-contents-page.pdf")); + var options = new PdfRenderOptions { BackgroundColor = new Canvas.Rgba32(10, 20, 30, 255) }; + + // Act + using var surface = document.Render(0, 20, 20, options); + + // Assert + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + Assert.Equal(new Canvas.Rgba32(10, 20, 30, 255), surface[x, y]); + } + } + } + + /// Proves that passing a fully transparent reproduces the fully transparent background Render always produced before was introduced. + [Fact] + public void PdfDocument_Render_TransparentBackgroundColor_ReproducesOldFullyTransparentBehavior() + { + // Arrange + using var document = PdfDocument.Open(Fixture("no-contents-page.pdf")); + + // Act + using var surface = document.Render(0, 20, 20, Transparent); + + // Assert + for (var y = 0; y < surface.Height; y++) { for (var x = 0; x < surface.Width; x++) { @@ -2125,7 +2290,15 @@ public void PdfDocument_Render_ValidPageIndex_ReturnsCorrectlySizedBlankSurface( } } - /// Proves that rejects an out-of-range page index the same way does. + /// Proves that 's is opaque white. + [Fact] + public void PdfRenderOptions_Default_BackgroundColorIsOpaqueWhite() + { + // Assert + Assert.Equal(new Canvas.Rgba32(255, 255, 255, 255), PdfRenderOptions.Default.BackgroundColor); + } + + /// Proves that rejects an out-of-range page index the same way does. [Fact] public void PdfDocument_Render_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException() { @@ -2136,7 +2309,7 @@ public void PdfDocument_Render_OutOfRangePageIndex_ThrowsArgumentOutOfRangeExcep Assert.Throws(() => document.Render(5, 10, 10)); } - /// Proves that lets 's own constructor validate width/height rather than duplicating that check. + /// Proves that lets 's own constructor validate width/height rather than duplicating that check. [Fact] public void PdfDocument_Render_InvalidWidth_PropagatesSurfaceArgumentOutOfRangeException() { @@ -2147,7 +2320,7 @@ public void PdfDocument_Render_InvalidWidth_PropagatesSurfaceArgumentOutOfRangeE Assert.Throws(() => document.Render(0, 0, 10)); } - /// Proves that scales the page's own point-space size by DPI/72, preserving aspect ratio. + /// Proves that scales the page's own point-space size by DPI/72, preserving aspect ratio. [Theory] [InlineData(72f, 200, 300)] [InlineData(36f, 100, 150)] @@ -2165,7 +2338,7 @@ public void PdfDocument_RenderWithDpi_ScalesPageSizeByDpiOver72(float dpi, int e Assert.Equal(expectedHeight, surface.Height); } - /// Proves that rejects a non-positive or non-finite DPI. + /// Proves that rejects a non-positive or non-finite DPI. /// The invalid DPI value under test. [Theory] [InlineData(0f)] @@ -2181,7 +2354,7 @@ public void PdfDocument_RenderWithDpi_InvalidDpi_ThrowsArgumentOutOfRangeExcepti Assert.Throws(() => document.Render(0, dpi)); } - /// Proves that rejects an out-of-range page index the same way does. + /// Proves that rejects an out-of-range page index the same way does. [Fact] public void PdfDocument_RenderWithDpi_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException() { @@ -2192,7 +2365,7 @@ public void PdfDocument_RenderWithDpi_OutOfRangePageIndex_ThrowsArgumentOutOfRan Assert.Throws(() => document.Render(5, 72f)); } - /// Proves that throws once the document is disposed. + /// Proves that throws once the document is disposed. [Fact] public void PdfDocument_RenderWithDpi_AfterDispose_ThrowsObjectDisposedException() { @@ -2250,7 +2423,7 @@ public void PdfDocument_GetPageInfo_AfterDispose_ThrowsObjectDisposedException() Assert.Throws(() => document.GetPageInfo(0)); } - /// Proves that throws once the document is disposed. + /// Proves that throws once the document is disposed. [Fact] public void PdfDocument_Render_AfterDispose_ThrowsObjectDisposedException() { @@ -2289,7 +2462,7 @@ public void PdfDocument_ContentStream_ContentsArray_ConcatenatesStreamsWithSpace // Act: stream 4 holds "10 10 40" and stream 5 holds "40 re f"; correct concatenation // with a space separator reconstructs "10 10 40 40 re f" (a filled 40x40 square). - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert Assert.Equal(Black, surface[30, 70]); @@ -2317,7 +2490,7 @@ public void PdfDocument_ContentStream_BdcWithInlinePropertiesDictionary_IsParsed Assert.Equal(Black, surface[30, 65]); } - /// Proves that a page with no /Contents key at all renders as a fully blank surface, without throwing. + /// Proves that a page with no /Contents key at all renders without throwing. [Fact] public void PdfDocument_ContentStream_NoContents_RendersBlankSurface() { @@ -2325,16 +2498,10 @@ public void PdfDocument_ContentStream_NoContents_RendersBlankSurface() using var document = PdfDocument.Open(Fixture("no-contents-page.pdf")); // Act - using var surface = document.Render(0, 20, 20); + var exception = Record.Exception(() => document.Render(0, 20, 20, Transparent)); // Assert - for (var y = 0; y < surface.Height; y++) - { - for (var x = 0; x < surface.Width; x++) - { - Assert.Equal(default, surface[x, y]); - } - } + Assert.Null(exception); } #endregion diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs index 4b3f041d..45b92b14 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs @@ -25,6 +25,14 @@ public class PdfFixtureTests /// Resolves a fixture file within . private static string Fixture(string name) => Path.Join(FixturesPath, name); + /// + /// A fully transparent , used so + /// 's "any painted pixel" check keeps proving real + /// content was rendered rather than becoming vacuously true against an opaque-white + /// default background. + /// + private static readonly PdfRenderOptions Transparent = new() { BackgroundColor = new(0, 0, 0, 0) }; + /// /// The path to the real "Open Sans" TrueType font, copied to the test output directory by /// this project's FontFixtures\** content-link item (see @@ -65,7 +73,7 @@ public void PdfDocument_Load_ClassicXrefSinglePageFixture_OpensAndRenders() // Arrange & Act using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); var info = document.GetPageInfo(0); - using var surface = document.Render(0, info.Width, info.Height); + using var surface = document.Render(0, info.Width, info.Height, Transparent); // Assert Assert.Equal(1, document.PageCount); @@ -89,7 +97,7 @@ public void PdfDocument_Load_MultiPageMixedMediaBoxRotateFixture_RendersEveryPag for (var pageIndex = 0; pageIndex < document.PageCount; pageIndex++) { var info = document.GetPageInfo(pageIndex); - using var surface = document.Render(pageIndex, info.Width, info.Height); + using var surface = document.Render(pageIndex, info.Width, info.Height, Transparent); Assert.Equal(info.Width, surface.Width); Assert.Equal(info.Height, surface.Height); } @@ -104,7 +112,7 @@ public void PdfDocument_Load_ColorRgbRectangleFillFixture_PaintsVisibleContent() { // Arrange & Act using var document = PdfDocument.Open(Fixture("color-rgb-rectangle-fill.pdf")); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert AssertPaintedSomePixel(surface); @@ -119,7 +127,7 @@ public void PdfDocument_Load_ImageXObjectDeviceRgbFlateFixture_PaintsVisibleCont { // Arrange & Act using var document = PdfDocument.Open(Fixture("image-xobject-devicergb-flate.pdf")); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert AssertPaintedSomePixel(surface); @@ -134,7 +142,7 @@ public void PdfDocument_Load_TextEmbeddedTrueTypeFontFixture_PaintsVisibleGlyphI { // Arrange & Act using var document = PdfDocument.Open(Fixture("text-embedded-truetype-font.pdf")); - using var surface = document.Render(0, 200, 100); + using var surface = document.Render(0, 200, 100, Transparent); // Assert AssertPaintedSomePixel(surface); @@ -154,7 +162,7 @@ public void PdfDocument_Load_ContentStreamFilterFixtures_PaintsVisibleContent(st { // Arrange & Act using var document = PdfDocument.Open(Fixture(fixtureName)); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert AssertPaintedSomePixel(surface); @@ -175,7 +183,7 @@ public void PdfDocument_Load_CombinedVectorTextImageFixture_RendersAllThreeConst using var document = PdfDocument.Open(Fixture("combined-vector-text-image.pdf")); // Act - using var surface = document.Render(0, 200, 200); + using var surface = document.Render(0, 200, 200, Transparent); // Assert: the filled vector rectangle painted opaque black. Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[30, 170]); @@ -204,7 +212,7 @@ public void PdfDocument_Load_Standard14FontFallbackFixture_PaintsVisibleSubstitu { // Arrange & Act using var document = PdfDocument.Open(Fixture("standard14-font-fallback.pdf")); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert AssertPaintedSomePixel(surface); @@ -221,7 +229,7 @@ public void PdfDocument_Load_TextEmbeddedType1FontFixture_PaintsVisibleGlyphInk( { // Arrange & Act using var document = PdfDocument.Open(Fixture("text-embedded-type1-font.pdf")); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert AssertPaintedSomePixel(surface); @@ -240,7 +248,7 @@ public void PdfDocument_Load_TextStandard14Type1NoFontFileFixture_PaintsVisibleS { // Arrange & Act using var document = PdfDocument.Open(Fixture("text-standard14-type1-no-fontfile.pdf")); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert AssertPaintedSomePixel(surface); diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md index 9e37be97..9156edf2 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md @@ -36,6 +36,8 @@ same MIT license as the rest of this repository. | `hybrid-xref.pdf` | Classic trailer + `/XRefStm` hybrid link to a supplementary xref stream | | `multi-page-mixed-mediabox-rotate.pdf` | Three pages, differing/inherited `/MediaBox` and `/Rotate` | | `malformed-startxref.pdf` | No `startxref`/`xref`/`trailer` at all - exercises the linear-scan fallback | +| `linear-scan-stream-noise.pdf` | Stray `wzGa#+#cfL8PX{7NP(gAdVT@fkqHe~ahKdlOrmczodvAeg9Xj0f+;hKk!gP`J zcz+ylL{OJ9m`uQq?}r4N3oGs_2TMgR4F)9GV>%6FojIYK{});vTNRnXY3SrV0|OI#gMr|H;Y&#Dcx yyOal8fo&a1QfOl_0_)Di+&e?yUI{LfYd}j%(N;i_YD=RbhhHWC0AaY?CBX;LCSZF2 literal 0 HcmV?d00001 diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs index 5b3dc930..c9c54a49 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs @@ -19,6 +19,14 @@ public class PdfSystemIntegrationTests private static string Fixture(string name) => Path.Join(FixturesPath, name); + /// + /// A fully transparent , used by tests that + /// assert an unpainted region equals to prove paint isolation + /// from neighboring drawing, reproducing the fully transparent background Render + /// always produced before was introduced. + /// + private static readonly PdfRenderOptions Transparent = new() { BackgroundColor = new(0, 0, 0, 0) }; + /// /// The path to the real "Open Sans" TrueType font, copied to the test output directory by /// this project's FontFixtures\** content-link item (mirroring @@ -30,7 +38,7 @@ public class PdfSystemIntegrationTests /// /// Opens a multi-page fixture once, asserts , then calls /// for two different pages and - /// for two different pages against the same + /// for two different pages against the same /// opened instance - demonstrating the "parse once, reused across calls" property that no /// stateless design could express. This is also the shared platform-proof test referenced /// by every CanvasNetPdf-Platform-* requirement. @@ -45,8 +53,8 @@ public void CanvasNetPdf_SystemIntegration_PdfOpen_ReturnsExpectedPageCount() var pageCount = document.PageCount; var firstInfo = document.GetPageInfo(0); var secondInfo = document.GetPageInfo(2); - using var firstSurface = document.Render(0, 32, 32); - using var secondSurface = document.Render(2, 16, 16); + using var firstSurface = document.Render(0, 32, 32, Transparent); + using var secondSurface = document.Render(2, 16, 16, Transparent); // Assert Assert.Equal(3, pageCount); @@ -74,9 +82,9 @@ public void CanvasNetPdf_SystemIntegration_PdfGetPageInfo_ReportsRotatedDimensio Assert.Equal(90, info.Rotation); } - /// Proves returns a blank, correctly sized surface (Phase 1). + /// Proves returns an opaque-white, correctly sized surface by default (Phase 1 renders no content). [Fact] - public void CanvasNetPdf_SystemIntegration_PdfRender_ReturnsBlankSizedSurface() + public void CanvasNetPdf_SystemIntegration_PdfRender_ReturnsOpaqueWhiteSizedSurface() { // Arrange using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); @@ -87,12 +95,12 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_ReturnsBlankSizedSurface() // Assert Assert.Equal(40, surface.Width); Assert.Equal(20, surface.Height); - Assert.Equal(default, surface[0, 0]); - Assert.Equal(default, surface[39, 19]); + Assert.Equal(new Canvas.Rgba32(255, 255, 255, 255), surface[0, 0]); + Assert.Equal(new Canvas.Rgba32(255, 255, 255, 255), surface[39, 19]); } /// - /// Proves rasterizes real path geometry end-to-end: a + /// Proves rasterizes real path geometry end-to-end: a /// hand-authored fixture containing a filled rectangle and a stroked vertical line, /// asserting specific opaque-black/transparent pixels at specific coordinates (not merely /// "the surface is not blank"). @@ -106,7 +114,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_FilledRectangleAndStrokedLi using var document = PdfDocument.Open(Fixture("path-construction-rect-and-line.pdf")); // Act - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: interior of the filled rectangle is opaque black; a point outside it is not. Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[30, 70]); @@ -131,7 +139,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_RotatedPage_MapsGeometryToC using var document = PdfDocument.Open(Fixture("path-construction-rotated-page.pdf")); // Act: displayed size swaps to 100x200 under /Rotate 90 - render at that exact size. - using var surface = document.Render(0, 100, 200); + using var surface = document.Render(0, 100, 200, Transparent); // Assert: correctly rotated, the rectangle lands at device px in [10,30), py in [10,40). Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[20, 25]); @@ -161,7 +169,7 @@ public void CanvasNetPdf_SystemIntegration_PdfValidationEmptyPath_EmptyPathThrow /// Proves a well-formed document/xref/page-tree whose /Contents stream is itself /// lexically malformed (a re operator given only 2 of its 4 required operands) is /// rejected with end-to-end through - /// , distinct from the structural malformations + /// , distinct from the structural malformations /// (malformed-startxref.pdf/cyclic-page-tree.pdf) already covered elsewhere. /// [Fact] @@ -246,7 +254,7 @@ public void CanvasNetPdf_SystemIntegration_PdfOpen_EncryptedRc4_CorrectUserPassw // Act using var document = PdfDocument.Open(new MemoryStream(pdfBytes), userPassword); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: the rectangle's device footprint is painted; outside it is left blank. Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[30, 70]); @@ -254,7 +262,7 @@ public void CanvasNetPdf_SystemIntegration_PdfOpen_EncryptedRc4_CorrectUserPassw } /// - /// Proves paints real device color end-to-end (Phase 3): + /// Proves paints real device color end-to-end (Phase 3): /// a hand-authored fixture using rg to fill a rectangle red, asserting a specific /// interior pixel is opaque red and an exterior pixel remains transparent. /// @@ -266,7 +274,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_ColoredRectangleFill_Paints using var document = PdfDocument.Open(Fixture("color-rgb-rectangle-fill.pdf")); // Act - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: interior of the filled rectangle is opaque red; a point outside it is not. Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); @@ -274,7 +282,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_ColoredRectangleFill_Paints } /// - /// Proves decodes and composites an image XObject + /// Proves decodes and composites an image XObject /// end-to-end (Phase 3): a hand-authored fixture placing a 2x2 DeviceRGB /// FlateDecode image via cm/Do, asserting specific composited pixel /// colors matching the fixture's known source pixels, and a pixel outside the placed @@ -290,7 +298,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_ImageXObjectPlacement_Compo using var document = PdfDocument.Open(Fixture("image-xobject-devicergb-flate.pdf")); // Act - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: the four source quadrants land in their mathematically correct device pixels. Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[25, 40]); @@ -303,7 +311,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_ImageXObjectPlacement_Compo } /// - /// Proves executes a /Subtype /Form XObject + /// Proves executes a /Subtype /Form XObject /// placed via the Do operator end-to-end: a synthetic, in-memory single-page PDF /// (no binary fixture) whose page content stream invokes /Fm0 Do, where /Fm0 /// is a genuine Form XObject (/Type /XObject /Subtype /Form) with its own nested @@ -326,7 +334,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_FormXObject_PaintsNestedCon // Act using var document = PdfDocument.Open(new MemoryStream(bytes)); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: the rectangle's device footprint is painted; outside it is left blank. Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[50, 50]); @@ -334,7 +342,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_FormXObject_PaintsNestedCon } /// - /// Proves decodes an LZWDecode-compressed page + /// Proves decodes an LZWDecode-compressed page /// content stream end-to-end (Phase 7): a hand-authored fixture whose /Contents /// stream is the PDF-variant-LZW-compressed bytes of "1 0 0 rg 10 10 80 80 re f" /// (an 80x80 rectangle filled opaque red), proving the decoded operator text is parsed @@ -347,7 +355,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_LzwDecodeContentStream_Pain using var document = PdfDocument.Open(Fixture("content-stream-lzw.pdf")); // Act - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: interior of the filled rectangle is opaque red; a point outside it is not. Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); @@ -355,7 +363,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_LzwDecodeContentStream_Pain } /// - /// Proves decodes an ASCII85Decode-armored page + /// Proves decodes an ASCII85Decode-armored page /// content stream end-to-end (Phase 7): a hand-authored fixture whose /Contents /// stream is the base-85 encoding (terminated by ~>) of the same /// "1 0 0 rg 10 10 80 80 re f" content-stream text. @@ -367,7 +375,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_Ascii85DecodeContentStream_ using var document = PdfDocument.Open(Fixture("content-stream-ascii85.pdf")); // Act - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: interior of the filled rectangle is opaque red; a point outside it is not. Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); @@ -375,7 +383,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_Ascii85DecodeContentStream_ } /// - /// Proves decodes an ASCIIHexDecode-armored page + /// Proves decodes an ASCIIHexDecode-armored page /// content stream end-to-end (Phase 7): a hand-authored fixture whose /Contents /// stream is the hex-digit-pair encoding (terminated by >) of the same /// "1 0 0 rg 10 10 80 80 re f" content-stream text. @@ -387,7 +395,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_AsciiHexDecodeContentStream using var document = PdfDocument.Open(Fixture("content-stream-asciihex.pdf")); // Act - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: interior of the filled rectangle is opaque red; a point outside it is not. Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); @@ -395,7 +403,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_AsciiHexDecodeContentStream } /// - /// Proves decodes a RunLengthDecode-compressed + /// Proves decodes a RunLengthDecode-compressed /// page content stream end-to-end (Phase 7): a hand-authored fixture whose /// /Contents stream is a single PackBits-style literal run (length byte, the /// literal bytes, then the 128 EOD marker) wrapping the same @@ -408,7 +416,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_RunLengthDecodeContentStrea using var document = PdfDocument.Open(Fixture("content-stream-runlength.pdf")); // Act - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: interior of the filled rectangle is opaque red; a point outside it is not. Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); @@ -416,7 +424,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_RunLengthDecodeContentStrea } /// - /// Proves resolves an embedded simple TrueType font end + /// Proves resolves an embedded simple TrueType font end /// to end (Phase 4): a hand-authored fixture with a real, embedded (via /// /FontDescriptor/FontFile2) copy of the shared OpenSans-Regular.ttf /// production font (see PdfFixtures\README.md for provenance and the @@ -446,7 +454,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_EmbeddedTrueTypeFontText_Pa // Act: render at the MediaBox's own pixel dimensions (a 1:1 user-space-to-device-pixel // mapping, since width/height exactly match the MediaBox), then load the same real font // independently to re-derive expected glyph-ink pixel positions from its own metrics. - using var surface = document.Render(0, 200, 100); + using var surface = document.Render(0, 200, 100, Transparent); var font = TrueTypeFont.Load(FontPath); // Maps a font-design-space point (in the glyph currently being measured, whose text-space @@ -494,7 +502,7 @@ public void CanvasNetPdf_SystemIntegration_PdfRender_EmbeddedTrueTypeFontText_Pa } /// - /// Proves resolves a /Type0//Identity-H + /// Proves resolves a /Type0//Identity-H /// CIDFontType2 composite font end to end (Phase 9): a hand-authored fixture with a /// real, embedded (via the descendant font's /FontDescriptor/FontFile2) copy of the /// shared OpenSans-Regular.ttf production font (see PdfFixtures\README.md for @@ -531,7 +539,7 @@ public void CanvasNetPdf_SystemIntegration_RenderType0CompositeFont_PaintsExpect // Act: render at the MediaBox's own pixel dimensions (a 1:1 user-space-to-device-pixel // mapping), then load the same real font independently to re-derive expected glyph-ink // pixel positions from its own outline, looked up directly by glyph index. - using var surface = document.Render(0, 200, 100); + using var surface = document.Render(0, 200, 100, Transparent); var font = TrueTypeFont.Load(FontPath); // Maps a font-design-space point (in the glyph currently being measured, whose text-space @@ -582,7 +590,7 @@ public void CanvasNetPdf_SystemIntegration_RenderType0CompositeFont_PaintsExpect } /// - /// Proves resolves a /Type0//Identity-H + /// Proves resolves a /Type0//Identity-H /// CIDFontType0 composite font end to end (Phase 12): a hand-authored, entirely /// synthetic fixture (see PdfFixtures\README.md) whose descendant font's /// /FontDescriptor/FontFile3 is a synthetic, non-CID-keyed, /OpenType- @@ -603,7 +611,7 @@ public void CanvasNetPdf_SystemIntegration_RenderCidFontType0CompositeFont_Paint using var document = PdfDocument.Open(Fixture("text-composite-cff-cidfonttype0-identity-h.pdf")); // Act - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: text x [7, 15) holds the painted square glyph (design x [100, 500) of 1000, // scaled by fontSize 20, offset by originX 5, flipped against the MediaBox height for @@ -618,7 +626,7 @@ public void CanvasNetPdf_SystemIntegration_RenderCidFontType0CompositeFont_Paint } /// - /// Proves resolves a /Subtype /Type1 simple font + /// Proves resolves a /Subtype /Type1 simple font /// with an embedded classic PostScript /FontDescriptor/FontFile program end to end /// (Phase B): a hand-authored, entirely synthetic fixture (see /// PdfFixtures\README.md) built via SyntheticFontBuilder.Type1 (no @@ -635,7 +643,7 @@ public void CanvasNetPdf_SystemIntegration_RenderEmbeddedType1Font_PaintsExpecte using var document = PdfDocument.Open(Fixture("text-embedded-type1-font.pdf")); // Act - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: text x [7, 15) holds the painted square glyph (design x [100, 500) of 1000, // scaled by fontSize 20, offset by originX 5, flipped against the MediaBox height for @@ -650,7 +658,7 @@ public void CanvasNetPdf_SystemIntegration_RenderEmbeddedType1Font_PaintsExpecte } /// - /// Proves resolves a Standard-14 simple TrueType font + /// Proves resolves a Standard-14 simple TrueType font /// (/BaseFont /Helvetica) with no embedded /FontFile2 end to end (Phase 6): /// a synthetic, in-memory single-page document (no new binary fixture needed) drawing a /// single glyph. Since the actual substitute font (a matching system font, or the bundled @@ -670,7 +678,7 @@ public void CanvasNetPdf_SystemIntegration_RenderStandard14HelveticaWithoutEmbed // Act using var document = PdfDocument.Open(new MemoryStream(bytes)); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: at least one visibly-painted (non-transparent) pixel proves a real substitute // glyph was actually rendered, not merely that no exception was thrown. @@ -691,7 +699,7 @@ public void CanvasNetPdf_SystemIntegration_RenderStandard14HelveticaWithoutEmbed } /// - /// Proves resolves a /BaseFont /Symbol font with + /// Proves resolves a /BaseFont /Symbol font with /// no embedded /FontFile2 and no /FontDescriptor entries at all (PDF /// 32000-1 §9.6.2.2 permits an entirely absent/empty descriptor) end to end (Phase 6): /// Symbol/ZapfDingbats no longer fail closed, instead resolving via the bundled Noto @@ -711,7 +719,7 @@ public void CanvasNetPdf_SystemIntegration_RenderSymbolFontWithoutEmbeddedFont_P // Act using var document = PdfDocument.Open(new MemoryStream(bytes)); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: at least one visibly-painted (non-transparent) pixel proves a real Noto // substitute glyph was actually rendered, not merely that no exception was thrown. @@ -757,7 +765,7 @@ public void CanvasNetPdf_SystemIntegration_NotoSansRegularBundledFont_ResolvesSy } /// - /// Regression guard: proves still fails closed end to + /// Regression guard: proves still fails closed end to /// end (Phase 6) for a non-Symbol/ZapfDingbats font whose /FontDescriptor/Flags /// declares the Symbolic bit without also declaring Nonsymbolic, and has no /// embedded /FontFile2 - proving the new Symbol/ZapfDingbats Noto-substitution @@ -828,7 +836,7 @@ private static byte[] BuildSyntheticFontFallbackPdf(string descriptorEntries, st } /// - /// Proves paints an axial (/ShadingType 2) shading + /// Proves paints an axial (/ShadingType 2) shading /// pattern fill end-to-end through the public API: a synthetic, in-memory single-page PDF /// (no binary fixture) declaring a /Pattern-color-space fill driven by a /// /FunctionType 2 function, proving the painted gradient visibly varies from @@ -849,7 +857,7 @@ public void CanvasNetPdf_SystemIntegration_AxialShadingPatternFill_PaintsVisibly // Act using var document = PdfDocument.Open(new MemoryStream(bytes)); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: near-black at the start coordinate, near-white at the end coordinate. Assert.True(surface[2, 50].R < 50); @@ -857,7 +865,7 @@ public void CanvasNetPdf_SystemIntegration_AxialShadingPatternFill_PaintsVisibly } /// - /// Proves paints a colored (/PaintType 1) tiling + /// Proves paints a colored (/PaintType 1) tiling /// pattern fill end-to-end through the public API: a synthetic, in-memory single-page PDF /// (no binary fixture) declaring a /Pattern-color-space fill driven by a 10x10 /// pattern cell (left half red, right half blue), proving the painted result repeats both @@ -880,7 +888,7 @@ public void CanvasNetPdf_SystemIntegration_ColoredTilingPatternFill_PaintsRepeat // Act using var document = PdfDocument.Open(new MemoryStream(bytes)); - using var surface = document.Render(0, 100, 100); + using var surface = document.Render(0, 100, 100, Transparent); // Assert: both tile colors appear, at multiple repeated tile offsets. Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[2, 50]); From bdc5e2b4b9620f2b5335fc96e020907d2831c9a0 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 2 Oct 2026 21:03:21 -0400 Subject: [PATCH 02/15] Fix encryption ordering and stream-payload scanning in linear-scan fallback Addresses two critical findings from PR review of the linear-scan fallback recovery path: - Initialize encryption before decoding compressed object streams during fallback recovery, so encrypted ObjStm containers are decrypted correctly instead of being misread as plaintext. - Exclude stream payload content from raw object-offset scanning, so an 'N G obj'-shaped byte sequence embedded inside binary/compressed stream data (or a PDF content-stream comment) is no longer mistaken for a real indirect object header and does not corrupt the reconstructed offset table. Fixing the stream-payload exclusion surfaced two further regressions, also fixed here: - The endstream search incorrectly required a non-identifier byte immediately before the match, which can reject genuine matches since that byte is arbitrary stream payload data. - Skipping past a stream payload was being attempted after every raw 'obj' byte match, including the tail of 'endobj', causing spurious forward scans that could skip over and silently drop small, nearby objects (such as single-object ObjStm containers). Verified against the originally reported crashing file and all 115 files across 5 external PDF corpora: no crashes, same open-failure counts as baseline. Also: - Adds regression tests for both findings, including one proven to fail without the encryption-ordering fix. - Adds a test proving the DPI-based Render overload forwards custom PdfRenderOptions (background color) unchanged. - Documents the DPI-based Render overload in the design doc (previously undocumented). - Fixes a stale test-name reference in reqstream/verification docs left over from the earlier PdfRenderOptions background-color change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/canvas-net-pdf/pdf-document.md | 6 + docs/reqstream/canvas-net-pdf.yaml | 2 +- .../canvas-net-pdf/pdf-document.yaml | 3 + docs/verification/canvas-net-pdf.md | 11 +- .../PdfDocument.Xref.cs | 198 +++++++++++++++--- .../PdfDocument.cs | 2 +- .../PdfDocumentEncryptionTests.cs | 115 ++++++++++ .../PdfDocumentTests.cs | 88 ++++++++ 8 files changed, 388 insertions(+), 37 deletions(-) diff --git a/docs/design/canvas-net-pdf/pdf-document.md b/docs/design/canvas-net-pdf/pdf-document.md index a0bd7646..21dedd51 100644 --- a/docs/design/canvas-net-pdf/pdf-document.md +++ b/docs/design/canvas-net-pdf/pdf-document.md @@ -277,6 +277,12 @@ re-parsing it each time — a property a purely static API could not express. never clamped to, or derived from, the page's own `/MediaBox` size. A caller that needs the previous fully transparent background back (for example to composite the result over something else itself) passes `new PdfRenderOptions { BackgroundColor = new(0, 0, 0, 0) }`. +- **`Render(int pageIndex, float dpi, PdfRenderOptions? options = null)`** — disposed-check and + `pageIndex` range-check identical to `GetPageInfo`, then `ArgumentOutOfRangeException` for a + non-positive or non-finite `dpi`, computes `width`/`height` by scaling `GetPageInfo(pageIndex)`'s + own point-sized dimensions by `dpi / 72.0` (rounding away from zero), and forwards unchanged to + `Render(int, int, int, PdfRenderOptions?)` above — `options` (and its `BackgroundColor` + default/override semantics) is simply passed through as-is. - **`Dispose()`** — idempotent (mirrors `Surface`'s exact pattern): `if (_disposed) return;` then `_disposed = true;`. No finalizer (only managed memory — the buffered bytes and parsed object model — is held). diff --git a/docs/reqstream/canvas-net-pdf.yaml b/docs/reqstream/canvas-net-pdf.yaml index 58c40559..2ab5eed9 100644 --- a/docs/reqstream/canvas-net-pdf.yaml +++ b/docs/reqstream/canvas-net-pdf.yaml @@ -104,7 +104,7 @@ sections: - CanvasNetPdf-PdfDocument-Render - CanvasNetPdf-PdfDocument-RenderWithDpi tests: - - CanvasNetPdf_SystemIntegration_PdfRender_ReturnsBlankSizedSurface + - CanvasNetPdf_SystemIntegration_PdfRender_ReturnsOpaqueWhiteSizedSurface - CanvasNetPdf_SystemIntegration_PdfUnsupportedFormatValidation_MalformedContentStreamThrowsInvalidDataException - id: CanvasNetPdf-Dispose diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index bd2d097d..e7ed63ca 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -118,6 +118,8 @@ sections: well-formed documents still be opened instead of always failing outright. tests: - PdfDocument_Open_MalformedStartxref_FallsBackToLinearScan + - PdfDocument_Open_LinearScanFallback_ObjMarkerInsideStreamPayload_DoesNotCorruptOffsets + - PdfDocument_Open_Encrypted_LinearScanFallback_RecoversObjectsCompressedInObjectStream - id: CanvasNetPdf-PdfDocument-PageTreeTraversal title: >- @@ -369,6 +371,7 @@ sections: - PdfDocument_RenderWithDpi_InvalidDpi_ThrowsArgumentOutOfRangeException - PdfDocument_RenderWithDpi_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException - PdfDocument_RenderWithDpi_AfterDispose_ThrowsObjectDisposedException + - PdfDocument_RenderWithDpi_PropagatesOptionsBackgroundColor - id: CanvasNetPdf-PdfDocument-ContentStreamDispatch title: >- diff --git a/docs/verification/canvas-net-pdf.md b/docs/verification/canvas-net-pdf.md index a544796c..f95e7755 100644 --- a/docs/verification/canvas-net-pdf.md +++ b/docs/verification/canvas-net-pdf.md @@ -53,15 +53,16 @@ containing a page with a non-zero `/Rotate` value through the public API, then c `/MediaBox` and `Rotation` reports the effective normalized value, confirming page-tree inheritance and rotation normalization integrate correctly with the public API. -### Integration: Pdf Render Returns Blank Sized Surface +### Integration: Pdf Render Returns Opaque White Sized Surface -**Test**: `CanvasNetPdf_SystemIntegration_PdfRender_ReturnsBlankSizedSurface` +**Test**: `CanvasNetPdf_SystemIntegration_PdfRender_ReturnsOpaqueWhiteSizedSurface` Exercises end-to-end system behavior for `Render`: calls the public `Render` API for a valid page index and caller-chosen size, against a fixture with no `/Contents`. Asserts the returned -`Surface` has exactly the requested dimensions and every pixel is the default (fully transparent) -value, confirming the documented "a page with no content renders blank" behavior is honored at -the system's own public entry point. +`Surface` has exactly the requested dimensions and every pixel is opaque white, confirming the +documented "a page with no content renders at its default background color" behavior (opaque +white, matching most real-world PDF renderers) is honored at the system's own public entry +point. ### Integration: Pdf Render Filled Rectangle And Stroked Line Paints Expected Pixels diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index 40493baf..cc1bb3fd 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -416,16 +416,44 @@ private bool IsValidCatalogRoot(PdfObject trailer) /// Recovers a usable trailer by scanning the whole buffer for N G obj markers when /// normal cross-reference/trailer parsing fails or does not resolve to a valid catalog. /// + /// + /// The password to authenticate the document's /Encrypt dictionary with, if the + /// recovered trailer declares one - forwarded to . See + /// 's own remarks for the full authentication semantics. + /// /// A trailer dictionary with at least a working /Root entry. /// /// Thrown when no document catalog can be located even via this fallback scan. /// - private PdfObject BuildLinearScanFallback() + private PdfObject BuildLinearScanFallback(string? password) { _xref = ScanObjectOffsets(); - RegisterCompressedObjectsFromObjectStreams(); var explicitTrailer = ScanForTrailerDictionary(); + + // Establish the file decryption key (if any) now, before decoding any compressed object + // streams below: a compressed object stream in an encrypted document is itself encrypted + // ciphertext, and attempting to inflate that ciphertext as if it were plain FlateDecode + // data before the correct key is known would fail - silently skipping every object nested + // in that stream as "unrecoverable" even though the key was available all along. A + // malformed /Encrypt dictionary is tolerated here (ignored) exactly as elsewhere in this + // fallback, since it must not prevent recovering whatever objects still can be recovered; + // an incorrect/missing password is not tolerated - it is allowed to propagate, matching + // the primary (non-fallback) parsing path's own fail-closed behavior. + if (explicitTrailer is not null) + { + try + { + InitializeEncryption(explicitTrailer, password); + } + catch (InvalidDataException) + { + // Ignore a malformed /Encrypt dictionary at this stage; keep attempting recovery. + } + } + + RegisterCompressedObjectsFromObjectStreams(); + if (explicitTrailer is not null && IsValidCatalogRoot(explicitTrailer)) { return explicitTrailer; @@ -466,16 +494,23 @@ private PdfObject BuildLinearScanFallback() /// being misread as an implausibly long number or string token that swallows a genuine /// "obj" marker a few bytes later). Matching the exact literal pattern /// "<digits> <digits> obj" byte-by-byte is far more resilient to such noise. + /// For the same reason, each matched object's stream/endstream payload (if + /// any) is skipped over rather than scanned byte-by-byte: binary/compressed payload bytes + /// can coincidentally spell out a byte-perfect "N G obj" marker of their own, which would + /// otherwise register a bogus offset - possibly even overwriting a legitimate object + /// number's real offset, since a later match wins. /// private Dictionary ScanObjectOffsets() { var xref = new Dictionary(); var buffer = _buffer; + var i = 0; - for (var i = 0; i + 3 <= buffer.Length; i++) + while (i + 3 <= buffer.Length) { if (buffer[i] != (byte)'o' || buffer[i + 1] != (byte)'b' || buffer[i + 2] != (byte)'j') { + i++; continue; } @@ -484,18 +519,140 @@ private Dictionary ScanObjectOffsets() // is not immediately followed by another identifier byte. if (i + 3 < buffer.Length && IsMarkerIdentifierByte(buffer[i + 3])) { + i++; continue; } if (TryParseObjectHeaderBackward(buffer, i, out var objectNumber, out var headerStart)) { xref[objectNumber] = XrefEntry.CreateDirect(headerStart); + i = SkipPastStreamPayload(buffer, i + 3); + } + else + { + // Not a genuine "N G obj" header - for example the tail of the "endobj" keyword + // itself. There is no associated stream payload to skip, so only advance past this + // "obj" occurrence and keep scanning normally. + i += 3; } } return xref; } + /// + /// If a literal stream keyword belonging to the object just matched by + /// is found before the next endobj (searched within + /// a generous bounded window, since a legitimate dictionary preceding stream is + /// never remotely this large), returns the index just past the matching endstream + /// keyword so the caller's scan resumes after the payload instead of inside it. Returns + /// unchanged when no such stream keyword is found + /// (including when the search window is exhausted first), leaving the ordinary + /// byte-by-byte scan to continue from there. + /// + private static int SkipPastStreamPayload(byte[] buffer, int searchStart) + { + // A real PDF dictionary between "N G obj" and its "stream" keyword is at most a few + // hundred bytes; this window is deliberately generous while still being bounded, so a + // missing/relocated "stream" keyword can never make this search scan arbitrarily far + // into unrelated later objects. + const int maxDictionaryWindow = 65536; + + var windowEnd = Math.Min(searchStart + maxDictionaryWindow, buffer.Length); + var streamIndex = IndexOfKeyword(buffer, searchStart, windowEnd, "stream"u8); + if (streamIndex < 0) + { + return searchStart; + } + + var endObjIndex = IndexOfKeyword(buffer, searchStart, windowEnd, "endobj"u8); + if (endObjIndex >= 0 && endObjIndex < streamIndex) + { + // This object has no stream of its own - the "stream" keyword found belongs to some + // later, unrelated object - so there is nothing to skip here. + return searchStart; + } + + // Per the PDF specification, the keyword "stream" is followed by a CRLF or bare LF line + // ending (never a bare CR) before the actual payload bytes begin. + var dataStart = streamIndex + "stream"u8.Length; + if (dataStart < buffer.Length && buffer[dataStart] == (byte)'\r') + { + dataStart++; + } + + if (dataStart < buffer.Length && buffer[dataStart] == (byte)'\n') + { + dataStart++; + } + + // Unlike the dictionary search above, the payload itself can legitimately be large, so + // this search for the matching "endstream" is intentionally unbounded. + var endStreamIndex = IndexOfKeyword(buffer, dataStart, buffer.Length, "endstream"u8, requirePrecedingBoundary: false); + return endStreamIndex < 0 ? dataStart : endStreamIndex + "endstream"u8.Length; + } + + /// + /// Finds the first standalone occurrence of - one not + /// immediately followed by another identifier byte, and (unless + /// is ) not immediately + /// preceded by one either - so a match is never found inside a larger identifier or + /// incidental binary-noise byte run - at or after and before + /// . Returns -1 if no such occurrence exists in that range. + /// + /// The raw document buffer to search. + /// The byte index at which to start searching (inclusive). + /// The byte index at which to stop searching (exclusive). + /// The literal keyword bytes to search for. + /// + /// Whether the byte immediately before a candidate match must be a non-identifier byte. + /// This must be for a keyword such as endstream, whose + /// preceding byte is the final byte of an arbitrary, possibly binary, payload rather than + /// a continuation of a textual PDF token - that byte can legitimately be alphanumeric by + /// coincidence, and requiring otherwise would wrongly reject a genuine match. + /// + private static int IndexOfKeyword( + byte[] buffer, + int startIndex, + int searchLimit, + ReadOnlySpan keyword, + bool requirePrecedingBoundary = true) + { + var maxStart = Math.Min(searchLimit, buffer.Length) - keyword.Length; + for (var i = Math.Max(startIndex, 0); i <= maxStart; i++) + { + var isMatch = true; + for (var k = 0; k < keyword.Length; k++) + { + if (buffer[i + k] != keyword[k]) + { + isMatch = false; + break; + } + } + + if (!isMatch) + { + continue; + } + + if (requirePrecedingBoundary && i > 0 && IsMarkerIdentifierByte(buffer[i - 1])) + { + continue; + } + + var after = i + keyword.Length; + if (after < buffer.Length && IsMarkerIdentifierByte(buffer[after])) + { + continue; + } + + return i; + } + + return -1; + } + /// /// Attempts to parse a N G object number/generation header immediately preceding /// the byte index of a literal obj keyword match, walking backward over the @@ -682,39 +839,18 @@ private void RegisterCompressedObjectsFromObjectStreams() private PdfObject? ScanForTrailerDictionary() { var buffer = _buffer; - var keyword = "trailer"u8; PdfObject? last = null; + var position = 0; - for (var i = 0; i + keyword.Length <= buffer.Length; i++) + while (true) { - var isMatch = true; - for (var k = 0; k < keyword.Length; k++) - { - if (buffer[i + k] != keyword[k]) - { - isMatch = false; - break; - } - } - - if (!isMatch) + var found = IndexOfKeyword(buffer, position, buffer.Length, "trailer"u8); + if (found < 0) { - continue; - } - - // Reject a match inside a larger identifier/binary-noise run: a genuine keyword is - // not immediately preceded or followed by another identifier byte. - if (i > 0 && IsMarkerIdentifierByte(buffer[i - 1])) - { - continue; - } - - var after = i + keyword.Length; - if (after < buffer.Length && IsMarkerIdentifierByte(buffer[after])) - { - continue; + break; } + var after = found + "trailer"u8.Length; var tokenizer = new PdfTokenizer(buffer) { Position = after }; try { @@ -728,6 +864,8 @@ private void RegisterCompressedObjectsFromObjectStreams() { // Ignore a malformed trailer at this position and keep scanning. } + + position = after; } return last; diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs index 429d3b7b..cd7cb4dc 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs @@ -264,7 +264,7 @@ private PdfDocument(byte[] buffer, string? password) _objectCache.Clear(); _encryptionKey = null; _encryptionCipher = EncryptionCipher.None; - trailer = BuildLinearScanFallback(); + trailer = BuildLinearScanFallback(password); InitializeEncryption(trailer, password); } diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs index e51e7015..3fc5db7a 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs @@ -438,6 +438,60 @@ private static byte[] BuildEncryptedPdfWithObjectStream( return [.. buffer]; } + /// + /// Builds an in-memory, single-page, encrypted PDF with no xref table and no + /// startxref at all - mirroring malformed-startxref.pdf/ + /// object-stream-linear-scan-fallback.pdf's own "forces + /// BuildLinearScanFallback to be the document's only route to a working object + /// table" precedent - so normal cross-reference parsing cannot run at all and the raw + /// byte-pattern linear scan is the only way any object (including the Encrypt dictionary + /// itself) is ever found. A single literal trailer keyword dictionary (naming + /// /Encrypt 5 0 R) is appended at the end, exactly as + /// ScanForTrailerDictionary expects to recover it. Object 7 (a plain dictionary + /// with one string entry) is compressed inside object 6's RC4-encrypted (as a whole) + /// /Type /ObjStm container - recoverable only if the fallback initializes + /// encryption (from this same recovered trailer) before attempting to decode that + /// container's ciphertext bytes as if they were already plaintext FlateDecode data. + /// + private static byte[] BuildEncryptedPdfLinearScanFallbackWithObjectStream( + string encryptDictBody, + byte[] idBytes, + byte[] encryptedContentBytes, + byte[] encryptedObjStmBytes) + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamBody(encryptedContentBytes), + Encoding.ASCII.GetBytes(encryptDictBody), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + // Object 6: the /Type /ObjStm container holding the compressed object 7. /First 4 + // matches the fixed "7 0\n" 4-byte header used ahead of the single contained object's + // body below. + buffer.AddRange("6 0 obj\n"u8.ToArray()); + buffer.AddRange(Encoding.ASCII.GetBytes( + $"<< /Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /Length {encryptedObjStmBytes.Length} >>\nstream\n")); + buffer.AddRange(encryptedObjStmBytes); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + var idHex = ToHex(idBytes); + buffer.AddRange(Encoding.ASCII.GetBytes( + $"trailer\n<< /Size 8 /Root 1 0 R /Encrypt 5 0 R /ID [<{idHex}> <{idHex}>] >>\n%%EOF\n")); + return [.. buffer]; + } + /// /// Builds an in-memory, single-page, /Type /XRef cross-reference-stream-based, /// encrypted PDF whose /Type /Catalog object itself (object 1) is compressed inside @@ -1100,6 +1154,67 @@ public void PdfDocument_Open_Encrypted_ObjStmContainedObjects_AreNeverDoubleDecr Assert.Equal(Encoding.ASCII.GetBytes(greeting), compressedObject.Get("Greeting")!.Bytes); } + /// + /// Proves that an encrypted document recovered entirely via the linear-scan fallback (no + /// xref table or startxref at all - only a raw trailer keyword for + /// ScanForTrailerDictionary to find) can still recover an object compressed inside + /// a /Type /ObjStm container, even though that container's own raw bytes are + /// themselves RC4-encrypted ciphertext. Before the fix, BuildLinearScanFallback + /// called RegisterCompressedObjectsFromObjectStreams before ever initializing + /// encryption from the just-recovered trailer, so the container's ciphertext bytes would + /// be handed to FlateDecode as if already plaintext - failing to inflate, being + /// silently skipped, and permanently losing every object compressed inside it. The fix + /// initializes encryption from the recovered trailer immediately after it is found and + /// before any compressed object stream is decoded, so object 7's own /Greeting + /// string - reachable only through object 6's encrypted container - is correctly + /// recovered. + /// + [Fact] + public void PdfDocument_Open_Encrypted_LinearScanFallback_RecoversObjectsCompressedInObjectStream() + { + const int keyLengthBytes = 5; + const int revision = 2; + const int permissions = -3904; + var idBytes = (byte[])[.. Enumerable.Range(0, 16).Select(i => (byte)(0xE0 + i))]; + + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var fileKey = ComputeFileKeyAlgorithm2(PasswordPadding, oBytes, permissions, idBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, idBytes, revision); + + var contentObjectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: false); + var encryptedContent = Rc4(contentObjectKey, Encoding.ASCII.GetBytes(PlaintextContent)); + + // Object 7 (compressed inside object 6's ObjStm container at index 0): a plain + // dictionary with one string entry. The object stream header is "7 0\n" (object number 7 + // at relative offset 0), so /First is 4 (the header's own byte length). + const string greeting = "Hello, Fallback-Recovered World!"; + var objStmPlaintext = Encoding.ASCII.GetBytes($"7 0\n<< /Greeting ({greeting}) >>"); + var compressedObjStm = ZlibCompress(objStmPlaintext); + + // Object 6's own raw (compressed) bytes are RC4-encrypted as a whole with object 6's own + // per-object key - never re-encrypted per contained object. + var objStmObjectKey = ComputeObjectKeyAlgorithm1(fileKey, 6, 0, isAes: false); + var encryptedObjStm = Rc4(objStmObjectKey, compressedObjStm); + + var encryptDictBody = $"<< /Filter /Standard /V 1 /R {revision} /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + var pdfBytes = BuildEncryptedPdfLinearScanFallbackWithObjectStream(encryptDictBody, idBytes, encryptedContent, encryptedObjStm); + + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Object 4's own encrypted /Contents stream decrypted correctly. + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + + // Object 7's own /Greeting string, reached only via decompression of object 6's + // encryption-dependent container bytes, is correct - proving the fallback established + // the file decryption key before attempting to decode that container. + var getObject = typeof(PdfDocument).GetMethod("GetObject", BindingFlags.NonPublic | BindingFlags.Instance)!; + var compressedObject = (PdfDocument.PdfObject)getObject.Invoke(document, [7])!; + Assert.Equal(Encoding.ASCII.GetBytes(greeting), compressedObject.Get("Greeting")!.Bytes); + } + /// /// Proves that an encrypted document whose /Type /Catalog object itself is /// compressed inside an encrypted /Type /ObjStm container still opens and renders diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 545b1bce..6cd534c3 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -2009,6 +2009,76 @@ public void PdfDocument_Open_LinearScanFallback_ResolvesPageCompressedInObjectSt Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); } + /// + /// Builds an in-memory, single-page PDF with no xref table or startxref at + /// all (forcing BuildLinearScanFallback's raw byte scan to be the only route to a + /// working object table, mirroring malformed-startxref.pdf's own precedent), whose + /// object 4 (the /Contents stream) payload embeds a PDF comment + /// (% ...noise... 3 0 obj ...noise...) that byte-for-byte matches the literal + /// N G obj pattern ScanObjectOffsets searches for - a benign, syntactically + /// valid content-stream comment that nonetheless coincidentally spells out what looks + /// like a header for object 3 (the real Page object, already declared earlier in the + /// file). Without excluding stream payloads from the raw byte scan, this later, + /// in-payload match would overwrite object 3's real, legitimate offset (the scan's + /// documented "later match wins" semantics) with a bogus offset pointing into the middle + /// of object 4's own stream payload - corrupting an unrelated object's cross-reference + /// entry from binary/text noise that was never meant to be parsed as PDF syntax. + /// + private static byte[] BuildLinearScanFallbackWithObjMarkerInsideStreamPayload() + { + var contentBytes = System.Text.Encoding.ASCII.GetBytes( + "10 10 40 40 re f\n% noise noise 3 0 obj noise noise\n"); + + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + return [.. buffer]; + } + + /// + /// Proves that a byte sequence resembling an N G obj marker inside a stream + /// payload (here, a syntactically valid content-stream comment that happens to spell out + /// 3 0 obj - see + /// ) is never mistaken + /// for a genuine object header by the linear-scan fallback's raw byte scan: the document + /// still resolves object 3 (the Page) to its real, correct offset and renders the expected + /// filled rectangle, rather than having that offset silently overwritten by the bogus + /// in-payload match. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_ObjMarkerInsideStreamPayload_DoesNotCorruptOffsets() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithObjMarkerInsideStreamPayload(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + var info = document.GetPageInfo(0); + Assert.Equal(100, info.Width); + Assert.Equal(100, info.Height); + + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + #endregion #region Page tree @@ -2354,6 +2424,24 @@ public void PdfDocument_RenderWithDpi_InvalidDpi_ThrowsArgumentOutOfRangeExcepti Assert.Throws(() => document.Render(0, dpi)); } + /// Proves that forwards its options parameter unchanged to , so a custom is honored by the DPI-based overload too, not just the default opaque-white background. + [Fact] + public void PdfDocument_RenderWithDpi_PropagatesOptionsBackgroundColor() + { + // Arrange + using var document = PdfDocument.Open(Fixture("classic-xref-single-page.pdf")); + var options = new PdfRenderOptions { BackgroundColor = new Canvas.Rgba32(0, 255, 0, 255) }; + + // Act + using var surface = document.Render(0, 72f, options); + + // Assert: the page paints no content of its own, so every pixel still equals the + // custom background color supplied via options - proving it was actually forwarded, + // not silently dropped in favor of the opaque-white default. + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[0, 0]); + Assert.Equal(new Canvas.Rgba32(0, 255, 0, 255), surface[surface.Width - 1, surface.Height - 1]); + } + /// Proves that rejects an out-of-range page index the same way does. [Fact] public void PdfDocument_RenderWithDpi_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException() From cd93d674427eb0202c427833c56ee0624119c54d Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 2 Oct 2026 21:28:10 -0400 Subject: [PATCH 03/15] Address second-round PR review findings in PDF linear-scan fallback - SkipPastStreamPayload now tokenizes the dictionary and only treats 'stream' as genuine when it is the literal token immediately after the dictionary's closing '>>', instead of a raw keyword search that could be fooled by a dictionary value containing the word 'stream'. - ScanObjectOffsets now tracks and returns the stream-payload byte ranges it skips; ScanForTrailerDictionary uses these ranges to reject 'trailer' keyword matches that fall inside a stream payload, preventing a later, coincidental 'trailer' sequence inside stream content from replacing the genuine trailer (and silently dropping its /Encrypt entry). - Corrected TryNextToken's stale XML doc comment, which claimed ScanObjectOffsets/ScanForTrailerDictionary tokenize the entire raw buffer; both now use raw-byte scanning instead. - Added regression tests for both fallback-recovery fixes, each verified to fail against the prior (unfixed) code. - Linked the two new tests under the LinearScanFallback requirement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-net-pdf/pdf-document.yaml | 2 + .../PdfDocument.Xref.cs | 141 ++++++++++++------ .../PdfDocumentEncryptionTests.cs | 104 +++++++++++++ .../PdfDocumentTests.cs | 66 ++++++++ 4 files changed, 271 insertions(+), 42 deletions(-) diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index e7ed63ca..beffabf0 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -120,6 +120,8 @@ sections: - PdfDocument_Open_MalformedStartxref_FallsBackToLinearScan - PdfDocument_Open_LinearScanFallback_ObjMarkerInsideStreamPayload_DoesNotCorruptOffsets - PdfDocument_Open_Encrypted_LinearScanFallback_RecoversObjectsCompressedInObjectStream + - PdfDocument_Open_LinearScanFallback_StreamWordInsideDictionaryValue_DoesNotSkipFollowingObject + - PdfDocument_Open_LinearScanFallback_TrailerKeywordInsideStreamPayload_DoesNotReplaceRealTrailer - id: CanvasNetPdf-PdfDocument-PageTreeTraversal title: >- diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index cc1bb3fd..8fe83be2 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -427,9 +427,9 @@ private bool IsValidCatalogRoot(PdfObject trailer) /// private PdfObject BuildLinearScanFallback(string? password) { - _xref = ScanObjectOffsets(); + _xref = ScanObjectOffsets(out var streamPayloadRanges); - var explicitTrailer = ScanForTrailerDictionary(); + var explicitTrailer = ScanForTrailerDictionary(streamPayloadRanges); // Establish the file decryption key (if any) now, before decoding any compressed object // streams below: a compressed object stream in an encrypted document is itself encrypted @@ -500,9 +500,16 @@ private PdfObject BuildLinearScanFallback(string? password) /// otherwise register a bogus offset - possibly even overwriting a legitimate object /// number's real offset, since a later match wins. /// - private Dictionary ScanObjectOffsets() + /// + /// Populated with the [Start, End) byte range of every stream payload skipped over + /// while scanning, so that can likewise exclude + /// that same payload content from its own independent raw-byte search for the + /// trailer keyword. + /// + private Dictionary ScanObjectOffsets(out List<(int Start, int End)> streamPayloadRanges) { var xref = new Dictionary(); + var ranges = new List<(int Start, int End)>(); var buffer = _buffer; var i = 0; @@ -526,7 +533,14 @@ private Dictionary ScanObjectOffsets() if (TryParseObjectHeaderBackward(buffer, i, out var objectNumber, out var headerStart)) { xref[objectNumber] = XrefEntry.CreateDirect(headerStart); - i = SkipPastStreamPayload(buffer, i + 3); + var searchStart = i + 3; + var next = SkipPastStreamPayload(buffer, searchStart); + if (next != searchStart) + { + ranges.Add((searchStart, next)); + } + + i = next; } else { @@ -537,57 +551,80 @@ private Dictionary ScanObjectOffsets() } } + streamPayloadRanges = ranges; return xref; } /// - /// If a literal stream keyword belonging to the object just matched by - /// is found before the next endobj (searched within - /// a generous bounded window, since a legitimate dictionary preceding stream is - /// never remotely this large), returns the index just past the matching endstream + /// If the object just matched by is a dictionary + /// immediately followed by a literal stream keyword (per the PDF specification's + /// own stream-object grammar), returns the index just past the matching endstream /// keyword so the caller's scan resumes after the payload instead of inside it. Returns - /// unchanged when no such stream keyword is found - /// (including when the search window is exhausted first), leaving the ordinary - /// byte-by-byte scan to continue from there. + /// unchanged when the object is not a dictionary, or its + /// dictionary is not immediately followed by stream - including when the + /// dictionary's own entries merely contain the word "stream" as a name, string, or other + /// value, which must never be mistaken for the real keyword. /// private static int SkipPastStreamPayload(byte[] buffer, int searchStart) { - // A real PDF dictionary between "N G obj" and its "stream" keyword is at most a few - // hundred bytes; this window is deliberately generous while still being bounded, so a - // missing/relocated "stream" keyword can never make this search scan arbitrarily far - // into unrelated later objects. - const int maxDictionaryWindow = 65536; - - var windowEnd = Math.Min(searchStart + maxDictionaryWindow, buffer.Length); - var streamIndex = IndexOfKeyword(buffer, searchStart, windowEnd, "stream"u8); - if (streamIndex < 0) + // Parsing the dictionary with the same tokenizer-based logic the primary (non-fallback) + // parsing path uses (see ParseDictionaryOrStream) - rather than raw-searching for the + // next standalone "stream" keyword within a bounded window - guarantees the keyword + // found truly terminates this object's own dictionary, instead of being a coincidental + // "/stream" name, a "(...stream...)" string, or some other unrelated dictionary value, or + // even the stream keyword belonging to a different, later object entirely. + var tokenizer = new PdfTokenizer(buffer) { Position = searchStart }; + PdfToken openToken; + try + { + openToken = tokenizer.NextToken(); + } + catch (InvalidDataException) { return searchStart; } - var endObjIndex = IndexOfKeyword(buffer, searchStart, windowEnd, "endobj"u8); - if (endObjIndex >= 0 && endObjIndex < streamIndex) + if (openToken.Kind != PdfTokenKind.DictStart) + { + // This object's value is not a dictionary at all (for example a bare number, string, + // or array), so it cannot possibly have a stream payload to skip. + return searchStart; + } + + try + { + ParseDictionaryEntries(tokenizer, 0); + } + catch (InvalidDataException) { - // This object has no stream of its own - the "stream" keyword found belongs to some - // later, unrelated object - so there is nothing to skip here. return searchStart; } - // Per the PDF specification, the keyword "stream" is followed by a CRLF or bare LF line - // ending (never a bare CR) before the actual payload bytes begin. - var dataStart = streamIndex + "stream"u8.Length; - if (dataStart < buffer.Length && buffer[dataStart] == (byte)'\r') + var savedPosition = tokenizer.Position; + PdfToken next; + try + { + next = tokenizer.NextToken(); + } + catch (InvalidDataException) { - dataStart++; + return searchStart; } - if (dataStart < buffer.Length && buffer[dataStart] == (byte)'\n') + if (next.Kind != PdfTokenKind.Keyword || next.Text != "stream") { - dataStart++; + // A plain dictionary object (not a stream) - nothing follows to skip past. + tokenizer.Position = savedPosition; + return searchStart; } - // Unlike the dictionary search above, the payload itself can legitimately be large, so - // this search for the matching "endstream" is intentionally unbounded. + SkipStreamLineEnding(tokenizer); + var dataStart = tokenizer.Position; + + // Unlike the dictionary above, the payload itself can legitimately be large and binary, + // so this search for the matching "endstream" keyword is intentionally unbounded and + // operates on raw bytes rather than tokens (an arbitrary payload byte is not valid PDF + // syntax for a tokenizer to walk through). var endStreamIndex = IndexOfKeyword(buffer, dataStart, buffer.Length, "endstream"u8, requirePrecedingBoundary: false); return endStreamIndex < 0 ? dataStart : endStreamIndex + "endstream"u8.Length; } @@ -800,14 +837,17 @@ private void RegisterCompressedObjectsFromObjectStreams() /// /// Reads the next token, treating a malformed token - for example a hex string containing /// a byte that is neither a hex digit nor whitespace - as unparsable noise rather than - /// letting the exception abort the whole scan. This matters because the linear-scan - /// fallback paths (, ) - /// tokenize the entire raw document buffer, including compressed/binary stream payloads - /// that were never meant to be parsed as PDF syntax and can easily contain a stray - /// < byte by coincidence. On failure the tokenizer is resynchronized by - /// advancing at least one byte past the token's start position (guaranteeing forward - /// progress even when the failing token consumed zero bytes before throwing), and - /// is returned so the caller can simply keep scanning. + /// letting the exception abort the whole header scan. This matters because + /// tokenizes an object stream's + /// own decompressed header bytes (the N1 O1 N2 O2 ... pairs following its + /// /Type /ObjStm dictionary), which - unlike a well-formed document's normal + /// content - cannot be trusted to be valid PDF syntax when this linear-scan fallback is + /// running at all. On failure the tokenizer is resynchronized by advancing at least one + /// byte past the token's start position (guaranteeing forward progress even when the + /// failing token consumed zero bytes before throwing), and is + /// returned so the caller can simply stop registering further entries from that one + /// object stream (its other already-registered entries, and every other object stream, + /// are unaffected). /// private static PdfToken? TryNextToken(PdfTokenizer tokenizer) { @@ -836,7 +876,15 @@ private void RegisterCompressedObjectsFromObjectStreams() /// real-world binary stream noise routinely derails a general-purpose tokenizer long /// before it would ever reach a genuine "trailer" keyword. /// - private PdfObject? ScanForTrailerDictionary() + /// + /// The stream payload ranges already identified by ; a + /// "trailer" byte-sequence match found inside one of these ranges is ignored rather than + /// parsed, since it is necessarily just coincidental content inside some object's + /// compressed/binary stream data (for example embedded verbatim inside a PDF being + /// recovered, or compressed content that happens to decompress-match nothing in + /// particular) rather than the document's real trailer keyword. + /// + private PdfObject? ScanForTrailerDictionary(List<(int Start, int End)> streamPayloadRanges) { var buffer = _buffer; PdfObject? last = null; @@ -851,6 +899,15 @@ private void RegisterCompressedObjectsFromObjectStreams() } var after = found + "trailer"u8.Length; + + if (streamPayloadRanges.Exists(range => found >= range.Start && found < range.End)) + { + // This "trailer" byte sequence is inside a stream payload, not real PDF syntax - + // skip it without attempting to parse a dictionary here. + position = after; + continue; + } + var tokenizer = new PdfTokenizer(buffer) { Position = after }; try { diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs index 3fc5db7a..f6d98f11 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs @@ -1362,4 +1362,108 @@ public void PdfDocument_Open_FallbackTrailerWithoutEncrypt_DiscardsStaleEncrypti Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); } + + /// + /// Builds an in-memory, single-page, encrypted PDF with no cross-reference table or + /// startxref at all (forcing BuildLinearScanFallback's raw byte scan to be + /// the only route to a working trailer, mirroring + /// BuildEncryptedPdfLinearScanFallbackWithObjectStream's own precedent): the + /// genuine trailer dictionary (carrying the real /Encrypt entry) is placed + /// immediately after objects 1-3, physically before object 4's own stream - whose + /// raw byte span (after its declared /Length, but still before its literal + /// endstream keyword) carries an appended, plaintext decoy + /// trailer << /Root 1 0 R >> dictionary with no /Encrypt entry at + /// all. Since the decoy's byte position is physically after the genuine trailer, + /// the raw trailer-keyword scan's own documented "a later occurrence... overrides + /// an earlier one" semantics would let the decoy win and silently drop /Encrypt - + /// exactly the scenario ScanForTrailerDictionary's stream-payload exclusion + /// guards against. + /// + private static byte[] BuildEncryptedPdfLinearScanFallbackWithDecoyTrailerInsideStreamPayload( + string encryptDictBody, + byte[] idBytes, + byte[] encryptedContentBytes) + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + var idHex = ToHex(idBytes); + buffer.AddRange(Encoding.ASCII.GetBytes( + $"trailer\n<< /Size 6 /Root 1 0 R /Encrypt 5 0 R /ID [<{idHex}> <{idHex}>] >>\n")); + + // Object 4: /Length covers only the genuine RC4-encrypted ciphertext; the decoy trailer + // text is appended afterward, inside the same stream's raw byte span but past its + // declared /Length, so it is never mistaken for real decoded content - only for + // ScanForTrailerDictionary's raw "trailer" keyword search, which does not consult + // /Length at all. + buffer.AddRange("4 0 obj\n"u8.ToArray()); + buffer.AddRange(Encoding.ASCII.GetBytes($"<< /Length {encryptedContentBytes.Length} >>\nstream\n")); + buffer.AddRange(encryptedContentBytes); + buffer.AddRange("\n% decoy trailer << /Root 1 0 R >>\n"u8.ToArray()); + buffer.AddRange("endstream\nendobj\n"u8.ToArray()); + + buffer.AddRange("5 0 obj\n"u8.ToArray()); + buffer.AddRange(Encoding.ASCII.GetBytes(encryptDictBody)); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that a trailer << ... >> byte sequence occurring inside a + /// stream payload - here, a syntactically valid content-stream comment appended past the + /// stream's own declared /Length (see + /// ) - + /// is never mistaken by ScanForTrailerDictionary for the document's real + /// trailer, even though it is physically positioned later in the file and so would + /// otherwise win under the raw scan's own "later occurrence overrides an earlier one" + /// semantics. The genuine trailer's /Encrypt entry must survive for object 4's own + /// RC4-encrypted content to decrypt and render correctly; losing it to the decoy (which + /// has no /Encrypt entry at all) would leave the encryption key unestablished and + /// the content rendered as raw, still-encrypted ciphertext instead. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_TrailerKeywordInsideStreamPayload_DoesNotReplaceRealTrailer() + { + const int keyLengthBytes = 5; + const int revision = 2; + const int permissions = -3904; + var idBytes = (byte[])[.. Enumerable.Range(0, 16).Select(i => (byte)(0xD0 + i))]; + + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var fileKey = ComputeFileKeyAlgorithm2(PasswordPadding, oBytes, permissions, idBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, idBytes, revision); + var encryptDictBody = $"<< /Filter /Standard /V 1 /R {revision} /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + + var contentObjectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: false); + var encryptedContent = Rc4(contentObjectKey, Encoding.ASCII.GetBytes(PlaintextContent)); + + var pdfBytes = BuildEncryptedPdfLinearScanFallbackWithDecoyTrailerInsideStreamPayload(encryptDictBody, idBytes, encryptedContent); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert: object 4's RC4-encrypted content decrypted and rendered correctly - without the + // fix, the decoy trailer (lacking /Encrypt) would have won, leaving the encryption key + // unestablished and these bytes rendered as raw, still-encrypted ciphertext instead. + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } } + diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 6cd534c3..163f506a 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -2079,6 +2079,72 @@ public void PdfDocument_Open_LinearScanFallback_ObjMarkerInsideStreamPayload_Doe Assert.Equal(default, surface[5, 5]); } + /// + /// Builds an in-memory, single-page PDF with no xref table or startxref at + /// all (forcing BuildLinearScanFallback's raw byte scan to be the only route to a + /// working object table), where object 3 (the Page) is a plain dictionary - not a stream - + /// whose own value content happens to contain the standalone word "stream" (inside a + /// string literal, a perfectly ordinary and syntactically valid dictionary value). Object + /// 4 (the real /Contents stream) immediately follows it. + /// + private static byte[] BuildLinearScanFallbackWithStreamWordInsideDictionaryValue() + { + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Description (this is a stream of data, not a real stream keyword) >>"u8 + .ToArray(), + BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + return [.. buffer]; + } + + /// + /// Proves that the word "stream" occurring inside a plain (non-stream) dictionary's own + /// value content - rather than as the dictionary's real, immediately-following + /// stream keyword - is never mistaken by the linear-scan fallback for the start of + /// that object's payload (see + /// ). Before this + /// was fixed, the raw byte scan would treat that coincidental "stream" word as genuine, + /// then search forward for the next "endstream" keyword - which belongs to the real, + /// unrelated object 4 - and skip straight past object 4's own "4 0 obj" header in the + /// process, silently dropping it from the reconstructed cross-reference table. This test + /// proves object 4 (the page's /Contents stream) still resolves and renders + /// correctly instead. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_StreamWordInsideDictionaryValue_DoesNotSkipFollowingObject() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithStreamWordInsideDictionaryValue(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + var info = document.GetPageInfo(0); + Assert.Equal(100, info.Width); + Assert.Equal(100, info.Height); + + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + #endregion #region Page tree From 588ac59ad7654bd85a4dad58eb4ab3628f460db5 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 2 Oct 2026 21:52:22 -0400 Subject: [PATCH 04/15] fix(pdf): reject non-line-start trailer/object headers; use declared stream length for payload boundary Addresses third round of review findings on the PDF linear-scan fallback: - ScanForTrailerDictionary and TryParseObjectHeaderBackward now require candidate 'trailer'/'N G obj' keyword matches to begin at the start of a line, rejecting byte-perfect matches embedded inside PDF comments or string literals outside any stream payload. - SkipPastStreamPayload now prefers the stream dictionary's own declared /Length (validated against a literal 'endstream' keyword immediately following it) to bound the payload range, falling back to the previous raw 'endstream' search only when /Length is missing, indirect, or inconsistent with the buffer. This prevents a coincidental 'endstream' byte sequence partway through a payload from ending the protected range too early. - Renamed PdfDocument_ContentStream_NoContents_RendersBlankSurface to RendersWithoutThrowing to match what the test actually asserts. - Added regression tests for each of the above, each verified to fail without its corresponding fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-net-pdf/pdf-document.yaml | 5 +- .../canvas-net-pdf/pdf-document.md | 2 +- .../PdfDocument.Xref.cs | 110 ++++++++++++++- .../PdfDocumentEncryptionTests.cs | 93 ++++++++++++ .../PdfDocumentTests.cs | 132 +++++++++++++++++- 5 files changed, 338 insertions(+), 4 deletions(-) diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index beffabf0..1016e65b 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -122,6 +122,9 @@ sections: - PdfDocument_Open_Encrypted_LinearScanFallback_RecoversObjectsCompressedInObjectStream - PdfDocument_Open_LinearScanFallback_StreamWordInsideDictionaryValue_DoesNotSkipFollowingObject - PdfDocument_Open_LinearScanFallback_TrailerKeywordInsideStreamPayload_DoesNotReplaceRealTrailer + - PdfDocument_Open_LinearScanFallback_ObjMarkerInsideNonStreamDictionaryValue_DoesNotCorruptOffset + - PdfDocument_Open_LinearScanFallback_EarlyFalseEndstreamInPayload_DoesNotCorruptOffsets + - PdfDocument_Open_LinearScanFallback_TrailerKeywordInCommentOutsideStream_DoesNotReplaceRealTrailer - id: CanvasNetPdf-PdfDocument-PageTreeTraversal title: >- @@ -347,7 +350,7 @@ sections: - PdfDocument_Render_ValidPageIndex_ReturnsCorrectlySizedOpaqueWhiteSurface - PdfDocument_Render_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException - PdfDocument_Render_InvalidWidth_PropagatesSurfaceArgumentOutOfRangeException - - PdfDocument_ContentStream_NoContents_RendersBlankSurface + - PdfDocument_ContentStream_NoContents_RendersWithoutThrowing - PdfDocument_Render_NoOptions_DefaultsToOpaqueWhiteBackground - PdfDocument_Render_CustomBackgroundColor_ClearsSurfaceToThatColor - PdfDocument_Render_TransparentBackgroundColor_ReproducesOldFullyTransparentBehavior diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index d0c95be9..550e18a2 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -343,7 +343,7 @@ Opens a single-page fixture and calls `GetPageInfo` with an out-of-range index. **Tests**: `PdfDocument_Render_ValidPageIndex_ReturnsCorrectlySizedOpaqueWhiteSurface`, `PdfDocument_Render_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException`, `PdfDocument_Render_InvalidWidth_PropagatesSurfaceArgumentOutOfRangeException`, -`PdfDocument_ContentStream_NoContents_RendersBlankSurface`, +`PdfDocument_ContentStream_NoContents_RendersWithoutThrowing`, `PdfDocument_Render_NoOptions_DefaultsToOpaqueWhiteBackground`, `PdfDocument_Render_CustomBackgroundColor_ClearsSurfaceToThatColor`, `PdfDocument_Render_TransparentBackgroundColor_ReproducesOldFullyTransparentBehavior` diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index 8fe83be2..e3e4e003 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -591,9 +591,10 @@ private static int SkipPastStreamPayload(byte[] buffer, int searchStart) return searchStart; } + Dictionary entries; try { - ParseDictionaryEntries(tokenizer, 0); + entries = ParseDictionaryEntries(tokenizer, 0); } catch (InvalidDataException) { @@ -621,6 +622,21 @@ private static int SkipPastStreamPayload(byte[] buffer, int searchStart) SkipStreamLineEnding(tokenizer); var dataStart = tokenizer.Position; + // Prefer the stream dictionary's own declared /Length to bound the payload - the + // spec-mandated way to determine where stream data ends - using the raw "endstream" + // keyword search below only as a lightweight consistency check against it, and as the + // sole fallback when /Length is absent, an indirect reference (not yet resolvable while + // the cross-reference table this scan is building is itself still incomplete), or + // inconsistent with the buffer. Relying on a raw, unbounded "endstream" search alone would + // end the protected range too early whenever the payload's own bytes happen to contain a + // coincidental "endstream" byte sequence of their own, followed by a non-identifier byte. + if (entries.TryGetValue("Length", out var lengthValue) && + lengthValue.Kind == PdfKind.Number && + TryGetDeclaredStreamEnd(buffer, dataStart, lengthValue.Number, out var declaredEnd)) + { + return declaredEnd; + } + // Unlike the dictionary above, the payload itself can legitimately be large and binary, // so this search for the matching "endstream" keyword is intentionally unbounded and // operates on raw bytes rather than tokens (an arbitrary payload byte is not valid PDF @@ -629,6 +645,56 @@ private static int SkipPastStreamPayload(byte[] buffer, int searchStart) return endStreamIndex < 0 ? dataStart : endStreamIndex + "endstream"u8.Length; } + /// + /// Validates a stream dictionary's declared /Length against the buffer - it must be + /// a non-negative integer landing fully within the buffer, and the bytes immediately + /// following it (after tolerating an optional end-of-line sequence, since several + /// real-world producers include one despite it not being strictly required) must be the + /// literal endstream keyword - and if so, returns the index just past that keyword. + /// + private static bool TryGetDeclaredStreamEnd(byte[] buffer, int dataStart, double declaredLength, out int payloadEnd) + { + payloadEnd = 0; + if (declaredLength < 0 || !double.IsInteger(declaredLength) || declaredLength > buffer.Length) + { + return false; + } + + var declaredDataEnd = dataStart + (long)declaredLength; + if (declaredDataEnd > buffer.Length) + { + return false; + } + + var afterData = (int)declaredDataEnd; + if (afterData < buffer.Length && buffer[afterData] == (byte)'\r') + { + afterData++; + } + + if (afterData < buffer.Length && buffer[afterData] == (byte)'\n') + { + afterData++; + } + + var endStreamIndex = IndexOfKeyword( + buffer, + afterData, + Math.Min(afterData + "endstream"u8.Length, buffer.Length), + "endstream"u8, + requirePrecedingBoundary: false); + if (endStreamIndex != afterData) + { + // The declared /Length does not land on a literal "endstream" keyword, so it cannot + // be trusted (for example it is stale, wrong, or an indirect reference the caller + // already excluded) - let the caller fall back to its own raw "endstream" search. + return false; + } + + payloadEnd = endStreamIndex + "endstream"u8.Length; + return true; + } + /// /// Finds the first standalone occurrence of - one not /// immediately followed by another identifier byte, and (unless @@ -742,6 +808,15 @@ private static bool TryParseObjectHeaderBackward(byte[] buffer, int objIndex, ou } } + if (!IsAtLineStart(buffer, numberStart)) + { + // A genuine indirect-object header is conventionally written at the start of its own + // line; rejecting a candidate that is not protects against a coincidental byte-exact + // "N G obj" match embedded inside unrelated surrounding text, such as a PDF comment or + // a literal string, which must never overwrite a real object's offset. + return false; + } + objectNumber = (int)parsedNumber; headerStart = numberStart; return true; @@ -765,6 +840,28 @@ private static int SkipMarkerBytesBackward(byte[] buffer, int cursor, Func IsMarkerDigit(b) || (b is >= (byte)'a' and <= (byte)'z') || (b is >= (byte)'A' and <= (byte)'Z'); + /// + /// Returns whether begins at the start of a line - the start of + /// the buffer, or immediately after a line break, allowing for intervening horizontal + /// whitespace/indentation (spaces or tabs) only. Real-world PDF object and trailer headers + /// are conventionally written at the start of their own line; requiring this of a raw + /// byte-scan candidate rejects the keyword otherwise being mistaken when it instead occurs + /// embedded inside unrelated surrounding text - for example inside a PDF comment + /// (% see object 3 0 obj below) or a literal string ((3 0 obj)) - that a + /// scan operating on raw bytes, rather than tokens, cannot otherwise distinguish from a + /// genuine header. + /// + private static bool IsAtLineStart(byte[] buffer, int index) + { + var cursor = index; + while (cursor > 0 && buffer[cursor - 1] is (byte)' ' or (byte)'\t') + { + cursor--; + } + + return cursor == 0 || buffer[cursor - 1] is (byte)'\n' or (byte)'\r'; + } + /// /// After a linear scan has located every directly-offset object (N G obj markers), /// also registers the objects nested inside any discovered object streams @@ -900,6 +997,17 @@ private void RegisterCompressedObjectsFromObjectStreams() var after = found + "trailer"u8.Length; + if (!IsAtLineStart(buffer, found)) + { + // A genuine trailer keyword is conventionally written at the start of its own + // line; rejecting a candidate that is not protects against a coincidental + // "trailer" byte sequence embedded inside unrelated surrounding text, such as a + // PDF comment or a literal string, which must never be mistaken for the + // document's real trailer. + position = after; + continue; + } + if (streamPayloadRanges.Exists(range => found >= range.Start && found < range.End)) { // This "trailer" byte sequence is inside a stream payload, not real PDF syntax - diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs index f6d98f11..8e304042 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs @@ -1465,5 +1465,98 @@ public void PdfDocument_Open_LinearScanFallback_TrailerKeywordInsideStreamPayloa Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); } + + /// + /// Builds an in-memory, single-page, encrypted PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback): the genuine trailer + /// dictionary (carrying the real /Encrypt entry) is followed by a PDF comment line + /// - not inside any stream payload - whose text happens to spell out a byte-perfect + /// trailer << ... >> sequence of its own (lacking /Encrypt), but + /// embedded mid-line after "see ", never at the start of a line the way a genuine + /// trailer keyword always is. + /// + private static byte[] BuildEncryptedPdfLinearScanFallbackWithDecoyTrailerInCommentOutsideStream( + string encryptDictBody, + byte[] idBytes, + byte[] encryptedContentBytes) + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + buffer.AddRange("4 0 obj\n"u8.ToArray()); + buffer.AddRange(Encoding.ASCII.GetBytes($"<< /Length {encryptedContentBytes.Length} >>\nstream\n")); + buffer.AddRange(encryptedContentBytes); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + buffer.AddRange("5 0 obj\n"u8.ToArray()); + buffer.AddRange(Encoding.ASCII.GetBytes(encryptDictBody)); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + var idHex = ToHex(idBytes); + buffer.AddRange(Encoding.ASCII.GetBytes( + $"trailer\n<< /Size 6 /Root 1 0 R /Encrypt 5 0 R /ID [<{idHex}> <{idHex}>] >>\n")); + + // This decoy occurs outside any stream payload, so only the fallback trailer scan's own + // line-start requirement - not stream-payload exclusion - can reject it. It is physically + // positioned after the genuine trailer above, so it would otherwise win under the scan's + // "later occurrence overrides an earlier one" semantics. + buffer.AddRange("% see trailer << /Root 1 0 R >> for reference\n"u8.ToArray()); + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that a trailer << ... >> byte sequence embedded mid-line + /// inside a PDF comment - outside any stream payload (see + /// ) + /// - is never mistaken by ScanForTrailerDictionary for the document's real + /// trailer, even though it is physically positioned later in the file and so would + /// otherwise win under the raw scan's own "later occurrence overrides an earlier one" + /// semantics. The genuine trailer's /Encrypt entry must survive for object 4's own + /// RC4-encrypted content to decrypt and render correctly. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_TrailerKeywordInCommentOutsideStream_DoesNotReplaceRealTrailer() + { + const int keyLengthBytes = 5; + const int revision = 2; + const int permissions = -3904; + var idBytes = (byte[])[.. Enumerable.Range(0, 16).Select(i => (byte)(0xE0 + i))]; + + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var fileKey = ComputeFileKeyAlgorithm2(PasswordPadding, oBytes, permissions, idBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, idBytes, revision); + var encryptDictBody = $"<< /Filter /Standard /V 1 /R {revision} /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + + var contentObjectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: false); + var encryptedContent = Rc4(contentObjectKey, Encoding.ASCII.GetBytes(PlaintextContent)); + + var pdfBytes = BuildEncryptedPdfLinearScanFallbackWithDecoyTrailerInCommentOutsideStream(encryptDictBody, idBytes, encryptedContent); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert: object 4's RC4-encrypted content decrypted and rendered correctly - without the + // fix, the decoy trailer comment (lacking /Encrypt) would have won, leaving the + // encryption key unestablished and these bytes rendered as raw, still-encrypted + // ciphertext instead. + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } } diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 163f506a..8eeb2e51 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -2145,6 +2145,136 @@ public void PdfDocument_Open_LinearScanFallback_StreamWordInsideDictionaryValue_ Assert.Equal(default, surface[5, 5]); } + /// + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback): the real object 3 (the Page) + /// is defined with a genuine, early 3 0 obj header, while an unrelated, later + /// object 5's own plain (non-stream) dictionary contains a /Decoy string value + /// whose text happens to spell out a byte-perfect 3 0 obj sequence of its own - + /// but embedded mid-line, never at the start of a line the way a genuine indirect-object + /// header always is. + /// + private static byte[] BuildLinearScanFallbackWithObjMarkerInsideNonStreamDictionaryValue() + { + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes), + "<< /Decoy (unrelated text mentions 3 0 obj in passing) >>"u8.ToArray(), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + return [.. buffer]; + } + + /// + /// Proves that a byte-perfect N G obj sequence embedded mid-line inside an + /// unrelated, later object's own dictionary value (see + /// ) is + /// never mistaken for a genuine indirect-object header, even though it is positioned + /// later in the file than the real header for the same object number and so would + /// otherwise win under the scan's own "later occurrence overrides an earlier one" + /// semantics - corrupting that object's real, correct offset. The real object 3 (the + /// page) must still resolve and render correctly using its own genuine, earlier header. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_ObjMarkerInsideNonStreamDictionaryValue_DoesNotCorruptOffset() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithObjMarkerInsideNonStreamDictionaryValue(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback): an unreferenced object 4's own + /// stream payload deliberately contains an early, coincidental endstream byte + /// sequence followed by further payload bytes that themselves spell out a byte-perfect, + /// line-start 3 0 obj header - mimicking the real object 3's own header - all + /// covered by object 4's declared /Length, with the genuine endstream + /// keyword only at the true end of that declared length. + /// + private static byte[] BuildLinearScanFallbackWithEarlyFalseEndstreamInStreamPayload() + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>"u8.ToArray(), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + // Object 4 is an unreferenced, irrelevant stream. Its declared /Length covers the whole + // payload below, through the real, final "endstream" keyword - but the payload's own + // bytes contain an early, false "endstream" occurrence partway through, followed by a + // false, line-start "3 0 obj" header of their own. + var payloadBody = "XXXXX\nendstream\n3 0 obj\n<< /Bogus (decoy) >>\nendobj\n"u8.ToArray(); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"4 0 obj\n<< /Length {payloadBody.Length} >>\nstream\n")); + buffer.AddRange(payloadBody); + buffer.AddRange("endstream\nendobj\n"u8.ToArray()); + + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes("5 0 obj\n")); + buffer.AddRange(BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes)); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that an early, coincidental endstream byte sequence occurring partway + /// through a stream's own payload (see + /// ) does not + /// end the protected payload range too soon. Ending it early would leave the remainder of + /// the real payload bytes - including a false, line-start 3 0 obj header embedded + /// within them - to be scanned again as ordinary document bytes, corrupting the real object + /// 3's correct offset. The real object 3 (the page) must still resolve and render + /// correctly. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_EarlyFalseEndstreamInPayload_DoesNotCorruptOffsets() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithEarlyFalseEndstreamInStreamPayload(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + #endregion #region Page tree @@ -2646,7 +2776,7 @@ public void PdfDocument_ContentStream_BdcWithInlinePropertiesDictionary_IsParsed /// Proves that a page with no /Contents key at all renders without throwing. [Fact] - public void PdfDocument_ContentStream_NoContents_RendersBlankSurface() + public void PdfDocument_ContentStream_NoContents_RendersWithoutThrowing() { // Arrange using var document = PdfDocument.Open(Fixture("no-contents-page.pdf")); From a4fb01fffe313c79fc325d9df5172b3769b1c3f1 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 3 Oct 2026 07:09:12 -0400 Subject: [PATCH 05/15] fix(pdf): require endstream to be followed by endobj in fallback stream-length search Addresses a fourth-round review finding: when a stream's /Length is indirect, absent, or inconsistent with the buffer, SkipPastStreamPayload's fallback endstream search previously accepted the very first raw 'endstream' byte match unconditionally. Coincidental binary payload noise can spell 'endstream' partway through genuine stream data, so accepting that match resumed scanning from inside the payload, letting embedded object/trailer markers there be mistaken for real document syntax. FindStructurallyValidEndstream now rejects a candidate match unless it is itself immediately followed (after an optional end-of-line sequence) by the literal 'endobj' keyword - the structure every genuine stream object has - continuing the search until one satisfying that check is found. Added regression test PdfDocument_Open_LinearScanFallback_EarlyFalseEndstreamWithIndirectLength_DoesNotCorruptOffsets, verified to fail without this fix. Re-ran the originally reported crashing file and all 115 files across the 5 external PDF corpora with an A/B comparison confirming zero behavioral change from this fix on real-world documents. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-net-pdf/pdf-document.yaml | 1 + .../PdfDocument.Xref.cs | 71 +++++++++++++++-- .../PdfDocumentTests.cs | 78 +++++++++++++++++++ 3 files changed, 145 insertions(+), 5 deletions(-) diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index 1016e65b..6899069a 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -125,6 +125,7 @@ sections: - PdfDocument_Open_LinearScanFallback_ObjMarkerInsideNonStreamDictionaryValue_DoesNotCorruptOffset - PdfDocument_Open_LinearScanFallback_EarlyFalseEndstreamInPayload_DoesNotCorruptOffsets - PdfDocument_Open_LinearScanFallback_TrailerKeywordInCommentOutsideStream_DoesNotReplaceRealTrailer + - PdfDocument_Open_LinearScanFallback_EarlyFalseEndstreamWithIndirectLength_DoesNotCorruptOffsets - id: CanvasNetPdf-PdfDocument-PageTreeTraversal title: >- diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index e3e4e003..692eee60 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -638,11 +638,72 @@ private static int SkipPastStreamPayload(byte[] buffer, int searchStart) } // Unlike the dictionary above, the payload itself can legitimately be large and binary, - // so this search for the matching "endstream" keyword is intentionally unbounded and - // operates on raw bytes rather than tokens (an arbitrary payload byte is not valid PDF - // syntax for a tokenizer to walk through). - var endStreamIndex = IndexOfKeyword(buffer, dataStart, buffer.Length, "endstream"u8, requirePrecedingBoundary: false); - return endStreamIndex < 0 ? dataStart : endStreamIndex + "endstream"u8.Length; + // so this search for the matching "endstream" keyword operates on raw bytes rather than + // tokens (an arbitrary payload byte is not valid PDF syntax for a tokenizer to walk + // through). It is not satisfied by the first raw match: binary payload noise can + // coincidentally spell "endstream" partway through genuine data, and accepting that match + // would resume scanning from inside the payload, letting embedded object/trailer markers + // there be mistaken for real document syntax. A genuine "endstream" is always itself + // immediately followed (after an optional end-of-line sequence) by "endobj" - so matches + // are rejected until one satisfying that structural check is found, or the buffer runs + // out. + return FindStructurallyValidEndstream(buffer, dataStart); + } + + /// + /// Searches forward from for the first endstream + /// keyword match that is itself immediately followed (after tolerating an optional + /// end-of-line sequence) by the literal endobj keyword - the structure every + /// genuine stream object has - skipping over any earlier match that fails this check as + /// coincidental payload noise. Returns if no match at all, + /// structurally valid or not, exists before the end of the buffer. + /// + private static int FindStructurallyValidEndstream(byte[] buffer, int dataStart) + { + var position = dataStart; + while (true) + { + var endStreamIndex = IndexOfKeyword(buffer, position, buffer.Length, "endstream"u8, requirePrecedingBoundary: false); + if (endStreamIndex < 0) + { + return dataStart; + } + + var afterKeyword = endStreamIndex + "endstream"u8.Length; + if (IsFollowedByEndObjKeyword(buffer, afterKeyword)) + { + return afterKeyword; + } + + position = afterKeyword; + } + } + + /// + /// Returns whether , after tolerating an optional end-of-line + /// sequence, is immediately followed by the literal endobj keyword - the structure + /// every genuine endstream keyword is followed by. + /// + private static bool IsFollowedByEndObjKeyword(byte[] buffer, int index) + { + var afterEol = index; + if (afterEol < buffer.Length && buffer[afterEol] == (byte)'\r') + { + afterEol++; + } + + if (afterEol < buffer.Length && buffer[afterEol] == (byte)'\n') + { + afterEol++; + } + + var endObjIndex = IndexOfKeyword( + buffer, + afterEol, + Math.Min(afterEol + "endobj"u8.Length, buffer.Length), + "endobj"u8, + requirePrecedingBoundary: false); + return endObjIndex == afterEol; } /// diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 8eeb2e51..6095a308 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -2275,6 +2275,84 @@ public void PdfDocument_Open_LinearScanFallback_EarlyFalseEndstreamInPayload_Doe Assert.Equal(default, surface[5, 5]); } + /// + /// Builds the same scenario as + /// (an early, + /// coincidental endstream byte sequence partway through a stream's own payload, + /// followed by a false, line-start 3 0 obj header), except the stream's own + /// /Length is an indirect reference rather than a direct integer - so + /// TryGetDeclaredStreamEnd cannot trust it, forcing the fallback raw + /// endstream search to be exercised instead of the declared-length path. + /// + private static byte[] BuildLinearScanFallbackWithEarlyFalseEndstreamAndIndirectLength() + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>"u8.ToArray(), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + // Object 4 is an unreferenced, irrelevant stream whose /Length is an indirect reference + // (object 6), so the declared-length boundary check cannot be used. Its payload contains + // an early, false "endstream" occurrence that is NOT itself immediately followed by + // "endobj" (it is instead followed by a false, line-start "3 0 obj" header) - so it must + // be rejected in favor of the later, real "endstream" that IS followed by "endobj". + var payloadBody = "XXXXX\nendstream\n3 0 obj\n<< /Bogus (decoy) >>\nendobj\nYYYY"u8.ToArray(); + buffer.AddRange("4 0 obj\n<< /Length 6 0 R >>\nstream\n"u8.ToArray()); + buffer.AddRange(payloadBody); + buffer.AddRange("endstream\nendobj\n"u8.ToArray()); + + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes("5 0 obj\n")); + buffer.AddRange(BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes)); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + // Object 6: the /Length value object 4's stream dictionary points to. Its own value is + // irrelevant to this test - it is never trusted as a declared length boundary merely by + // being referenced indirectly. + buffer.AddRange("6 0 obj\n9999\nendobj\n"u8.ToArray()); + + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that when a stream's own /Length is an indirect reference (so the + /// declared-length boundary cannot be trusted), the fallback raw endstream search + /// rejects an early, coincidental endstream byte sequence that is not itself + /// immediately followed by endobj, instead continuing to the later, real + /// endstream that is. Accepting the first, false match would end the protected + /// payload range too soon, exposing the remainder of the payload - including a false, + /// line-start 3 0 obj header embedded within it - to be scanned again as ordinary + /// document bytes, corrupting the real object 3's correct offset. The real object 3 (the + /// page) must still resolve and render correctly. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_EarlyFalseEndstreamWithIndirectLength_DoesNotCorruptOffsets() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithEarlyFalseEndstreamAndIndirectLength(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + #endregion #region Page tree From e7dc9abe4e13b67b94fc948e1aef806221f9cc96 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 3 Oct 2026 07:55:20 -0400 Subject: [PATCH 06/15] Fix PDF linear-scan fallback: lexical scanning, broader exception handling Replaces the line-start heuristic in the linear-scan fallback's object-header and trailer detection with genuine PDF lexical-context tracking (AdvancePastNonSyntax/SkipLiteralString/SkipHexString), so keyword matches inside comments and string literals are structurally unreachable rather than heuristically rejected, and valid headers not at the start of a line are no longer wrongly rejected. Broadens the compressed-object-stream recovery catch clause to also handle UnsupportedImageFeatureException (which shares no common base with InvalidDataException), so an unrelated object stream with an unsupported filter no longer aborts recovery of other, decodable object streams. Reframes the remaining raw-byte 'endstream' search (used when no trustworthy /Length is available) as an explicitly accepted, inherent PDF-format limitation matching documented qpdf/mutool industry practice, rather than a gap to keep patching - a worst-case false match can only cause partial/no recovery of an already-malformed document, never a crash. Empirically verified via an external-corpus harness that 29/116 real-world PDFs exercise this fallback path, and that the originally-reported crashing file now opens successfully (PartialOk) instead of crashing. Adds 3 new regression tests, each individually verified to fail without its corresponding fix. Adds qpdf/mutool to the spelling dictionary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .cspell.yaml | 2 + .../canvas-net-pdf/pdf-document.yaml | 3 + .../canvas-net-pdf/pdf-document.md | 24 ++ .../PdfDocument.Xref.cs | 262 ++++++++++++++---- .../PdfDocumentTests.cs | 197 +++++++++++++ 5 files changed, 436 insertions(+), 52 deletions(-) diff --git a/.cspell.yaml b/.cspell.yaml index 564f05a8..b66ed943 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -302,6 +302,8 @@ words: - decryptor - Decryptor - keystream + - qpdf + - mutool # Exclude common build artifacts, dependencies, and vendored third-party code ignorePaths: diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index 6899069a..5799f0a5 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -126,6 +126,9 @@ sections: - PdfDocument_Open_LinearScanFallback_EarlyFalseEndstreamInPayload_DoesNotCorruptOffsets - PdfDocument_Open_LinearScanFallback_TrailerKeywordInCommentOutsideStream_DoesNotReplaceRealTrailer - PdfDocument_Open_LinearScanFallback_EarlyFalseEndstreamWithIndirectLength_DoesNotCorruptOffsets + - PdfDocument_Open_LinearScanFallback_ObjectHeaderNotAtLineStart_StillResolves + - PdfDocument_Open_LinearScanFallback_ObjectHeaderDecoyInsideMultilineString_DoesNotCorruptOffsets + - PdfDocument_Open_LinearScanFallback_UnsupportedFilterObjectStream_DoesNotAbortRecovery - id: CanvasNetPdf-PdfDocument-PageTreeTraversal title: >- diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index 550e18a2..7e76665a 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -149,6 +149,30 @@ public API. Asserts `PageCount` and the page's `GetPageInfo` size match the fixt content, confirming the linear-scan fallback successfully reconstructs an object-offset table and locates the catalog without any cross-reference section present. +Further fixture-backed tests (`PdfDocument_Open_LinearScanFallback_*` and +`PdfDocument_Open_Encrypted_LinearScanFallback_*`, listed in full in the requirements +traceability data) exercise the fallback's resilience against decoy byte sequences that coincide +with its "N G obj"/`stream`/`endstream`/`trailer` keyword matches - inside stream payloads, +dictionary values, PDF comments, and multi-line string literals - as well as its handling of +compressed-object recovery when an unrelated object stream declares a filter this library does +not support for generic stream decoding. In particular, +`PdfDocument_Open_LinearScanFallback_ObjectHeaderNotAtLineStart_StillResolves` and +`PdfDocument_Open_LinearScanFallback_ObjectHeaderDecoyInsideMultilineString_DoesNotCorruptOffsets` +confirm that object-header detection is governed by genuine PDF lexical structure (comments and +string literals are skipped structurally before any keyword match is attempted) rather than a +line-start convention, which is neither required by the PDF grammar nor sufficient to reject a +decoy lexically embedded inside a multi-line string; and +`PdfDocument_Open_LinearScanFallback_UnsupportedFilterObjectStream_DoesNotAbortRecovery` confirms +that an unsupported stream filter on one object stream does not abort recovery of objects +compressed in other, decodable object streams. The raw-byte search for a stream payload's +terminating `endstream` keyword (used when no trustworthy `/Length` is available) remains an +acknowledged, inherent limitation of the PDF format itself - binary payload data can +legitimately, coincidentally contain a byte-exact `endstream` sequence of its own, which no +finite pattern-matching rule can fully distinguish from the genuine terminator - matching the +documented behavior of other mainstream recovery implementations (for example qpdf and mutool); a +worst-case false match here can at most cause partial or no recovery of an already-malformed +document, never a crash. + #### CanvasNetPdf-PdfDocument-PageTreeTraversal: Page Tree Traversal Reports All Pages **Test**: `PdfDocument_PageTree_Traversal_ReportsAllPagesInDocumentOrder` diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index 692eee60..e29f0dd4 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -1,5 +1,6 @@ // cspell:ignore uncatchable using System.IO.Compression; +using DemaConsulting.CanvasNet.Codecs; namespace DemaConsulting.CanvasNet.Pdf; @@ -499,6 +500,18 @@ private PdfObject BuildLinearScanFallback(string? password) /// can coincidentally spell out a byte-perfect "N G obj" marker of their own, which would /// otherwise register a bogus offset - possibly even overwriting a legitimate object /// number's real offset, since a later match wins. + /// + /// Before checking each position for an "obj" match, + /// is consulted to skip over any PDF comment or literal/hex string starting there. + /// This is a soundness property, not a formatting-convention heuristic: a decoy byte + /// sequence that is only reachable by first entering a comment or string can never be + /// matched, regardless of how it is laid out (for example across several lines, or not + /// at the start of any of them). This replaces an earlier line-start-based heuristic + /// that was both unsound (a decoy could still be crafted to start at the beginning of + /// a line while remaining lexically inside a multi-line string) and overly strict (a + /// genuine header is not actually required by the PDF grammar to begin its own line, + /// so that heuristic could also reject legitimate documents). + /// /// /// /// Populated with the [Start, End) byte range of every stream payload skipped over @@ -513,9 +526,17 @@ private Dictionary ScanObjectOffsets(out List<(int Start, int En var buffer = _buffer; var i = 0; - while (i + 3 <= buffer.Length) + while (i < buffer.Length) { - if (buffer[i] != (byte)'o' || buffer[i + 1] != (byte)'b' || buffer[i + 2] != (byte)'j') + var afterNonSyntax = AdvancePastNonSyntax(buffer, i); + if (afterNonSyntax != i) + { + i = afterNonSyntax; + continue; + } + + if (i + 3 > buffer.Length || + buffer[i] != (byte)'o' || buffer[i + 1] != (byte)'b' || buffer[i + 2] != (byte)'j') { i++; continue; @@ -640,13 +661,26 @@ private static int SkipPastStreamPayload(byte[] buffer, int searchStart) // Unlike the dictionary above, the payload itself can legitimately be large and binary, // so this search for the matching "endstream" keyword operates on raw bytes rather than // tokens (an arbitrary payload byte is not valid PDF syntax for a tokenizer to walk - // through). It is not satisfied by the first raw match: binary payload noise can - // coincidentally spell "endstream" partway through genuine data, and accepting that match - // would resume scanning from inside the payload, letting embedded object/trailer markers - // there be mistaken for real document syntax. A genuine "endstream" is always itself - // immediately followed (after an optional end-of-line sequence) by "endobj" - so matches - // are rejected until one satisfying that structural check is found, or the buffer runs - // out. + // through). This is a deliberate, acknowledged limitation rather than something this + // check fully closes: binary payload data can legitimately, coincidentally contain a + // byte-exact "endstream" (and even "endstream"+"endobj") sequence of its own partway + // through, before the real end of the payload, and no finite pattern-matching rule can + // distinguish that from the genuine terminator without a trustworthy declared length - + // this is an inherent consequence of the PDF format itself (a stream's data is defined to + // be arbitrary bytes, with no in-band escaping of its own terminator), not a gap specific + // to this implementation. It is also the reason every other mainstream recovery + // implementation we are aware of (for example the documented "attempting to recover + // stream length" fallback in qpdf, and the "clean" repair pass in mutool) takes the same + // approach of searching raw bytes for the next "endstream" when /Length cannot be + // trusted, without claiming to solve this case fully either. Requiring the match to + // additionally be followed by "endobj" (the one genuine structural neighbor every real + // stream object has) is retained here purely as a cheap, strictly-safer-than-nothing + // consistency check that rejects some, but provably not all, coincidental matches; a + // worst-case false match here can at most cause this one already-malformed document's + // recovery to produce partial or no content (the documented contract of this fallback + // path), never a crash, since every caller of the resulting offsets already handles a + // subsequently-unparseable object by catching and + // treating that one object as unrecoverable. return FindStructurallyValidEndstream(buffer, dataStart); } @@ -658,6 +692,14 @@ private static int SkipPastStreamPayload(byte[] buffer, int searchStart) /// coincidental payload noise. Returns if no match at all, /// structurally valid or not, exists before the end of the buffer. /// + /// + /// This is only ever reached when the stream's own declared /Length is absent, an + /// unresolvable indirect reference, or inconsistent with the buffer - that is, the + /// document is already corrupt in a way the PDF specification provides no defined recovery + /// for. See the caller's remarks for why no amount of additional pattern-matching here can + /// fully close the resulting ambiguity, and why that residual risk is accepted rather than + /// chased with further heuristics. + /// private static int FindStructurallyValidEndstream(byte[] buffer, int dataStart) { var position = dataStart; @@ -869,15 +911,6 @@ private static bool TryParseObjectHeaderBackward(byte[] buffer, int objIndex, ou } } - if (!IsAtLineStart(buffer, numberStart)) - { - // A genuine indirect-object header is conventionally written at the start of its own - // line; rejecting a candidate that is not protects against a coincidental byte-exact - // "N G obj" match embedded inside unrelated surrounding text, such as a PDF comment or - // a literal string, which must never overwrite a real object's offset. - return false; - } - objectNumber = (int)parsedNumber; headerStart = numberStart; return true; @@ -902,25 +935,125 @@ private static bool IsMarkerIdentifierByte(byte b) => IsMarkerDigit(b) || (b is >= (byte)'a' and <= (byte)'z') || (b is >= (byte)'A' and <= (byte)'Z'); /// - /// Returns whether begins at the start of a line - the start of - /// the buffer, or immediately after a line break, allowing for intervening horizontal - /// whitespace/indentation (spaces or tabs) only. Real-world PDF object and trailer headers - /// are conventionally written at the start of their own line; requiring this of a raw - /// byte-scan candidate rejects the keyword otherwise being mistaken when it instead occurs - /// embedded inside unrelated surrounding text - for example inside a PDF comment - /// (% see object 3 0 obj below) or a literal string ((3 0 obj)) - that a - /// scan operating on raw bytes, rather than tokens, cannot otherwise distinguish from a - /// genuine header. + /// If is the first byte of a PDF comment (% through + /// the end of its line) or a literal string - either round-bracket (...), with + /// backslash-escaped bytes and balanced nesting, or angle-bracket <...> + /// hex, carefully distinguished from a dictionary's << delimiter - returns + /// the index of the first byte past it. Otherwise returns + /// unchanged. /// - private static bool IsAtLineStart(byte[] buffer, int index) + /// + /// + /// This is what lets and + /// reject an N G obj/trailer + /// byte sequence embedded inside a PDF comment or string by construction, rather than + /// by a formatting-convention heuristic. An earlier approach instead required such a + /// candidate to begin at the start of its own line - a real-world convention, but not + /// one the PDF grammar actually requires, and not a sound check either way: a decoy + /// could still be crafted to sit at the start of a line while remaining lexically + /// inside an enclosing multi-line string, and a genuine header not conventionally + /// placed at a line's start (the grammar permits, for example, two object headers on + /// the same physical line) would be wrongly rejected. Tracking the actual PDF lexical + /// grammar for comments and strings instead closes the false-positive case completely + /// (a comment or string's contents are structurally unreachable as a keyword match) + /// while also removing the false-negative one (nothing about a header's position on + /// its line matters any more). + /// + /// + /// If a ( or < byte is encountered that turns out not to be the start + /// of a real string - for example binary noise containing a stray delimiter byte with + /// no matching close before the end of the buffer - this degrades safely by leaving + /// the position unchanged, so the caller simply treats that one byte as ordinary + /// content instead of skipping an unbounded remainder of the file. + /// + /// + private static int AdvancePastNonSyntax(byte[] buffer, int position) { - var cursor = index; - while (cursor > 0 && buffer[cursor - 1] is (byte)' ' or (byte)'\t') + var b = buffer[position]; + if (b == (byte)'%') { - cursor--; + var end = position + 1; + while (end < buffer.Length && buffer[end] is not ((byte)'\n' or (byte)'\r')) + { + end++; + } + + return end; } - return cursor == 0 || buffer[cursor - 1] is (byte)'\n' or (byte)'\r'; + if (b == (byte)'(') + { + return SkipLiteralString(buffer, position); + } + + if (b == (byte)'<' && (position + 1 >= buffer.Length || buffer[position + 1] != (byte)'<')) + { + return SkipHexString(buffer, position); + } + + return position; + } + + /// + /// Skips a literal string starting at the ( byte index , + /// tracking balanced nested parentheses and backslash-escaped bytes per the PDF + /// specification's string-literal grammar, and returns the index just past the matching + /// closing ). Returns unchanged if no matching close is + /// found before the end of the buffer. + /// + private static int SkipLiteralString(byte[] buffer, int start) + { + var depth = 1; + var cursor = start + 1; + while (cursor < buffer.Length) + { + var b = buffer[cursor]; + if (b == (byte)'\\') + { + // An escaped byte (including an escaped parenthesis) never affects nesting depth. + cursor += 2; + continue; + } + + if (b == (byte)'(') + { + depth++; + } + else if (b == (byte)')') + { + depth--; + if (depth == 0) + { + return cursor + 1; + } + } + + cursor++; + } + + return start; + } + + /// + /// Skips a hex string starting at the < byte index and + /// returns the index just past the matching closing >. Returns + /// unchanged if no closing > is found before the end of + /// the buffer. + /// + private static int SkipHexString(byte[] buffer, int start) + { + var cursor = start + 1; + while (cursor < buffer.Length) + { + if (buffer[cursor] == (byte)'>') + { + return cursor + 1; + } + + cursor++; + } + + return start; } /// @@ -931,6 +1064,17 @@ private static bool IsAtLineStart(byte[] buffer, int index) /// - so without this pass, any page or resource reachable only through an object stream /// would be wrongly reported as missing whenever normal cross-reference parsing has failed /// and this linear scan is the document's only remaining route to a working object table. + /// This scan examines every directly-located /Type /ObjStm candidate eagerly and + /// independently of whether it is actually needed to resolve the document's catalog, so a + /// single unrelated object stream must never be allowed to abort recovery of every other + /// object: both (malformed stream data) and + /// (well-formed data using a filter this + /// library does not implement, for example a stream filter decoding path it does not + /// support) are caught here for that reason - the two types are deliberately not a common + /// base type (see 's own remarks), so both + /// must be listed explicitly to treat an unusable object stream the same way regardless of + /// which of the two reasons made it unusable: skip only that one stream and keep + /// recovering every other object normally. /// private void RegisterCompressedObjectsFromObjectStreams() { @@ -953,6 +1097,10 @@ private void RegisterCompressedObjectsFromObjectStreams() { continue; } + catch (UnsupportedImageFeatureException) + { + continue; + } if (container.Get("N") is not { Kind: PdfKind.Number } countObject) { @@ -1032,7 +1180,11 @@ private void RegisterCompressedObjectsFromObjectStreams() /// locates the keyword itself via a raw byte search rather than /// tokenizing the whole buffer, for the same reason does: /// real-world binary stream noise routinely derails a general-purpose tokenizer long - /// before it would ever reach a genuine "trailer" keyword. + /// before it would ever reach a genuine "trailer" keyword. As in + /// , is consulted at + /// every position so a "trailer" byte sequence embedded inside a comment or string can + /// never be mistaken for the real keyword, soundly rather than via a formatting + /// convention. /// /// /// The stream payload ranges already identified by ; a @@ -1046,37 +1198,33 @@ private void RegisterCompressedObjectsFromObjectStreams() { var buffer = _buffer; PdfObject? last = null; - var position = 0; + var i = 0; - while (true) + while (i < buffer.Length) { - var found = IndexOfKeyword(buffer, position, buffer.Length, "trailer"u8); - if (found < 0) + var afterNonSyntax = AdvancePastNonSyntax(buffer, i); + if (afterNonSyntax != i) { - break; + i = afterNonSyntax; + continue; } - var after = found + "trailer"u8.Length; - - if (!IsAtLineStart(buffer, found)) + var payloadRange = streamPayloadRanges.Find(range => i >= range.Start && i < range.End); + if (payloadRange != default) { - // A genuine trailer keyword is conventionally written at the start of its own - // line; rejecting a candidate that is not protects against a coincidental - // "trailer" byte sequence embedded inside unrelated surrounding text, such as a - // PDF comment or a literal string, which must never be mistaken for the - // document's real trailer. - position = after; + // Jump straight past the whole payload range - it is not real PDF syntax, so + // neither comment/string tracking nor keyword matching apply inside it. + i = payloadRange.End; continue; } - if (streamPayloadRanges.Exists(range => found >= range.Start && found < range.End)) + if (!IsKeywordAt(buffer, i, "trailer"u8)) { - // This "trailer" byte sequence is inside a stream payload, not real PDF syntax - - // skip it without attempting to parse a dictionary here. - position = after; + i++; continue; } + var after = i + "trailer"u8.Length; var tokenizer = new PdfTokenizer(buffer) { Position = after }; try { @@ -1091,12 +1239,22 @@ private void RegisterCompressedObjectsFromObjectStreams() // Ignore a malformed trailer at this position and keep scanning. } - position = after; + i = after; } return last; } + /// + /// Returns whether matches the buffer exactly at + /// , applying the same identifier-boundary rules as + /// (so a match inside a larger identifier, such as the tail + /// of a longer word, is rejected) rather than merely comparing bytes. + /// + private static bool IsKeywordAt(byte[] buffer, int position, ReadOnlySpan keyword) => + position + keyword.Length <= buffer.Length && + IndexOfKeyword(buffer, position, position + keyword.Length, keyword) == position; + private static string? GetNameValue(PdfObject dictionary, string key) => dictionary.Get(key) is { Kind: PdfKind.Name } name ? name.Text : null; diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 6095a308..90d5e322 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -2353,6 +2353,203 @@ public void PdfDocument_Open_LinearScanFallback_EarlyFalseEndstreamWithIndirectL Assert.Equal(default, surface[5, 5]); } + /// + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback), whose object 1 (the catalog) + /// and object 2 (the Pages node) headers are deliberately written on the same physical + /// line, separated only by a single space after the first object's endobj rather + /// than a line break - valid per the PDF grammar (whitespace alone separates tokens; an + /// indirect object is not required to begin its own line) but previously rejected by a + /// line-start heuristic this fallback no longer relies on. + /// + private static byte[] BuildLinearScanFallbackWithObjectHeaderNotAtLineStart() + { + var catalogBytes = "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(); + var pagesBytes = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(); + var pageBytes = "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(); + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + + buffer.AddRange("1 0 obj\n"u8.ToArray()); + buffer.AddRange(catalogBytes); + buffer.AddRange(" endobj 2 0 obj\n"u8.ToArray()); + buffer.AddRange(pagesBytes); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + buffer.AddRange("3 0 obj\n"u8.ToArray()); + buffer.AddRange(pageBytes); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + buffer.AddRange("4 0 obj\n"u8.ToArray()); + buffer.AddRange(BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes)); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that an object header not conventionally placed at the start of its own line + /// (see ) is still + /// located by the linear-scan fallback. A previous line-start heuristic would wrongly + /// reject this entirely valid header, leaving the Pages node unresolvable so the + /// document could not be opened. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_ObjectHeaderNotAtLineStart_StillResolves() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithObjectHeaderNotAtLineStart(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback): the real object 3 (the page) is + /// written normally, but a later, unreferenced object 9 contains a literal string value + /// spanning several lines whose second line is a byte-perfect, line-start 3 0 obj + /// decoy header, followed by syntax (bogus) that cannot be parsed as a PDF value. + /// A scanner that merely checks "is this candidate at the start of a line" - without + /// tracking whether it is lexically inside a string at all - cannot tell this decoy apart + /// from a genuine header, and (being the later match) would overwrite the real object 3's + /// offset with this bogus one. + /// + private static byte[] BuildLinearScanFallbackWithObjectHeaderDecoyInsideMultilineString() + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>"u8.ToArray(), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes("5 0 obj\n")); + buffer.AddRange(BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes)); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + // Object 9 is an unreferenced, irrelevant dictionary whose /Note value is a literal + // string spanning several lines. The decoy "3 0 obj" text on the string's second line + // begins at the start of that line - satisfying a line-start-only check - but it is + // lexically still inside the enclosing "(...)" string, not real top-level PDF syntax. + buffer.AddRange("9 0 obj\n<< /Note (line one\n3 0 obj\nbogus\nline four) >>\nendobj\n"u8.ToArray()); + + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that a byte-perfect, line-start N G obj decoy embedded inside another + /// object's literal string value (see + /// ) is + /// never mistaken for a real header, regardless of its position on its own line: the real + /// object 3 (the page) must still resolve and render correctly, rather than the decoy's + /// bogus offset overwriting its real one. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_ObjectHeaderDecoyInsideMultilineString_DoesNotCorruptOffsets() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithObjectHeaderDecoyInsideMultilineString(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback): the catalog is reachable only + /// compressed inside object 1's /Type /ObjStm container (so recovering it depends + /// on the fallback's compressed-object recovery pass succeeding), while an unrelated, + /// directly-located object 7 is also a /Type /ObjStm container declaring a stream + /// filter (/DCTDecode) this library does not support for generic stream decoding - + /// something that is rejected with rather + /// than . + /// + private static byte[] BuildLinearScanFallbackWithUnsupportedFilterObjectStream() + { + var catalogBytes = "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(); + var objStmHeader = "6 0\n"u8.ToArray(); + var objStmContent = new List(); + objStmContent.AddRange(objStmHeader); + objStmContent.AddRange(catalogBytes); + var compressedObjStm = ZlibCompress([.. objStmContent]); + + var bodies = new List + { + BuildStreamObjectBody($"/Type /ObjStm /N 1 /First {objStmHeader.Length} /Filter /FlateDecode", compressedObjStm), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamObjectBody(string.Empty, []), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + // Object 7: an unrelated, directly-located /Type /ObjStm container declaring an + // unsupported filter. It contributes nothing needed to resolve the catalog or page, and + // must not prevent either from being recovered. + buffer.AddRange("7 0 obj\n"u8.ToArray()); + buffer.AddRange(BuildStreamObjectBody("/Type /ObjStm /N 1 /First 4 /Filter /DCTDecode", "0 0\n1"u8.ToArray())); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that an unrelated /Type /ObjStm object stream declaring a stream filter + /// this library does not support (see + /// ) does not abort + /// recovery of every other object: the real catalog, compressed inside a different, + /// supported object stream, must still resolve and the page must still render correctly. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_UnsupportedFilterObjectStream_DoesNotAbortRecovery() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithUnsupportedFilterObjectStream(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(default, surface[5, 5]); + } + #endregion #region Page tree From bc036d85fc1a1a9e4803738e40d497ef6f2b0b3e Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 3 Oct 2026 08:23:11 -0400 Subject: [PATCH 07/15] Fix trailer name-token false-positive and O(n*m) scan in fallback recovery - IsKeywordAt now rejects a match when the preceding byte is '/', since a PDF name token (e.g. /trailer) begins with '/' directly adjacent to its characters per the PDF name-object grammar. Without this exclusion, a decoy name-keyed dictionary such as '/trailer << /Root 1 0 R >>' could be mistaken for the real 'trailer' keyword and override the genuine trailer, losing /Encrypt and breaking decryption. - ScanForTrailerDictionary replaced its per-position List.Find linear lookup against streamPayloadRanges with a monotonically advancing range index, exploiting that the ranges are produced by a single increasing-position scan. This changes the fallback trailer scan from O(document-bytes * stream-count) to O(document- bytes), removing a CPU/timeout risk on malformed inputs with many stream objects. - Added regression tests for both fixes: PdfDocument_Open_LinearScanFallback_TrailerKeywordAsNameToken_DoesNotReplaceRealTrailer PdfDocument_Open_LinearScanFallback_ManyStreamPayloadRangesBeforeTrailer_StillResolvesTrailer The name-token fix was verified to make its test fail when reverted and pass when restored. - Updated requirements traceability and verification documentation for CanvasNetPdf-PdfDocument-LinearScanFallback to reference both new tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-net-pdf/pdf-document.yaml | 2 + .../canvas-net-pdf/pdf-document.md | 10 +++ .../PdfDocument.Xref.cs | 27 ++++-- .../PdfDocumentEncryptionTests.cs | 87 +++++++++++++++++++ .../PdfDocumentTests.cs | 63 ++++++++++++++ 5 files changed, 184 insertions(+), 5 deletions(-) diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index 5799f0a5..f673058f 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -129,6 +129,8 @@ sections: - PdfDocument_Open_LinearScanFallback_ObjectHeaderNotAtLineStart_StillResolves - PdfDocument_Open_LinearScanFallback_ObjectHeaderDecoyInsideMultilineString_DoesNotCorruptOffsets - PdfDocument_Open_LinearScanFallback_UnsupportedFilterObjectStream_DoesNotAbortRecovery + - PdfDocument_Open_LinearScanFallback_TrailerKeywordAsNameToken_DoesNotReplaceRealTrailer + - PdfDocument_Open_LinearScanFallback_ManyStreamPayloadRangesBeforeTrailer_StillResolvesTrailer - id: CanvasNetPdf-PdfDocument-PageTreeTraversal title: >- diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index 7e76665a..3f93431e 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -172,6 +172,16 @@ finite pattern-matching rule can fully distinguish from the genuine terminator - documented behavior of other mainstream recovery implementations (for example qpdf and mutool); a worst-case false match here can at most cause partial or no recovery of an already-malformed document, never a crash. +`PdfDocument_Open_LinearScanFallback_TrailerKeywordAsNameToken_DoesNotReplaceRealTrailer` further +confirms that a `/trailer` PDF *name* token (introduced by a literal `/` immediately before text +that happens to read "trailer") is never mistaken for the bare `trailer` keyword - only the +latter denotes a genuine trailer dictionary - and +`PdfDocument_Open_LinearScanFallback_ManyStreamPayloadRangesBeforeTrailer_StillResolvesTrailer` +confirms the trailer scan still locates the real trailer correctly when preceded by many +unrelated stream objects, exercising its stream-payload-range membership check (a +monotonically-advancing index into the ranges `ScanObjectOffsets` already discovered in +increasing order, keeping the overall scan linear in document size rather than quadratic in +stream-object count) across many entries. #### CanvasNetPdf-PdfDocument-PageTreeTraversal: Page Tree Traversal Reports All Pages diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index e29f0dd4..719f836e 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -1192,13 +1192,19 @@ private void RegisterCompressedObjectsFromObjectStreams() /// parsed, since it is necessarily just coincidental content inside some object's /// compressed/binary stream data (for example embedded verbatim inside a PDF being /// recovered, or compressed content that happens to decompress-match nothing in - /// particular) rather than the document's real trailer keyword. + /// particular) rather than the document's real trailer keyword. is produced by 's single + /// left-to-right pass in increasing, non-overlapping Start order, so membership is + /// tested here with a single monotonically-advancing index alongside the scan position + /// rather than a per-position linear search of the whole list - keeping this scan overall + /// linear in the buffer size instead of quadratic in stream-object count. /// private PdfObject? ScanForTrailerDictionary(List<(int Start, int End)> streamPayloadRanges) { var buffer = _buffer; PdfObject? last = null; var i = 0; + var rangeIndex = 0; while (i < buffer.Length) { @@ -1209,12 +1215,17 @@ private void RegisterCompressedObjectsFromObjectStreams() continue; } - var payloadRange = streamPayloadRanges.Find(range => i >= range.Start && i < range.End); - if (payloadRange != default) + while (rangeIndex < streamPayloadRanges.Count && i >= streamPayloadRanges[rangeIndex].End) + { + rangeIndex++; + } + + if (rangeIndex < streamPayloadRanges.Count && + i >= streamPayloadRanges[rangeIndex].Start && i < streamPayloadRanges[rangeIndex].End) { // Jump straight past the whole payload range - it is not real PDF syntax, so // neither comment/string tracking nor keyword matching apply inside it. - i = payloadRange.End; + i = streamPayloadRanges[rangeIndex].End; continue; } @@ -1249,10 +1260,16 @@ private void RegisterCompressedObjectsFromObjectStreams() /// Returns whether matches the buffer exactly at /// , applying the same identifier-boundary rules as /// (so a match inside a larger identifier, such as the tail - /// of a longer word, is rejected) rather than merely comparing bytes. + /// of a longer word, is rejected) rather than merely comparing bytes. A match is also + /// rejected when immediately preceded by /: per the PDF specification a name token + /// begins with / directly followed by its regular characters with no intervening + /// whitespace, so (for example) /trailer is a name whose text happens to + /// read "trailer" - a legitimate dictionary key or value any real document can contain - + /// never the trailer keyword itself, and must not be parsed as one. /// private static bool IsKeywordAt(byte[] buffer, int position, ReadOnlySpan keyword) => position + keyword.Length <= buffer.Length && + (position == 0 || buffer[position - 1] != (byte)'/') && IndexOfKeyword(buffer, position, position + keyword.Length, keyword) == position; private static string? GetNameValue(PdfObject dictionary, string key) => diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs index 8e304042..f4652e55 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs @@ -1558,5 +1558,92 @@ public void PdfDocument_Open_LinearScanFallback_TrailerKeywordInCommentOutsideSt Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); } + + private static byte[] BuildEncryptedPdfLinearScanFallbackWithDecoyTrailerAsNameToken( + string encryptDictBody, + byte[] idBytes, + byte[] encryptedContentBytes) + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + buffer.AddRange("4 0 obj\n"u8.ToArray()); + buffer.AddRange(Encoding.ASCII.GetBytes($"<< /Length {encryptedContentBytes.Length} >>\nstream\n")); + buffer.AddRange(encryptedContentBytes); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + buffer.AddRange("5 0 obj\n"u8.ToArray()); + buffer.AddRange(Encoding.ASCII.GetBytes(encryptDictBody)); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + var idHex = ToHex(idBytes); + buffer.AddRange(Encoding.ASCII.GetBytes( + $"trailer\n<< /Size 6 /Root 1 0 R /Encrypt 5 0 R /ID [<{idHex}> <{idHex}>] >>\n")); + + // This decoy is a PDF *name* (/trailer) directly preceded by the slash that introduces a + // name token, immediately followed by a dictionary literal of its own - not the bare + // `trailer` keyword, even though its trailing bytes read identically. It sits outside any + // stream payload, comment, or string, and is physically positioned after the genuine + // trailer above, so it would otherwise win under the scan's "later occurrence overrides + // an earlier one" semantics were it mistaken for a real trailer keyword match. + buffer.AddRange("/trailer << /Root 1 0 R >>\n"u8.ToArray()); + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that a /trailer name token - whose text happens to read + /// "trailer" but which is immediately preceded by the / that introduces a PDF name + /// (see + /// ) - is + /// never mistaken by ScanForTrailerDictionary for the bare trailer keyword, + /// even though it is physically positioned later in the file and so would otherwise win + /// under the raw scan's own "later occurrence overrides an earlier one" semantics. The + /// genuine trailer's /Encrypt entry must survive for object 4's own RC4-encrypted + /// content to decrypt and render correctly. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_TrailerKeywordAsNameToken_DoesNotReplaceRealTrailer() + { + const int keyLengthBytes = 5; + const int revision = 2; + const int permissions = -3904; + var idBytes = (byte[])[.. Enumerable.Range(0, 16).Select(i => (byte)(0xE0 + i))]; + + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var fileKey = ComputeFileKeyAlgorithm2(PasswordPadding, oBytes, permissions, idBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, idBytes, revision); + var encryptDictBody = $"<< /Filter /Standard /V 1 /R {revision} /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + + var contentObjectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: false); + var encryptedContent = Rc4(contentObjectKey, Encoding.ASCII.GetBytes(PlaintextContent)); + + var pdfBytes = BuildEncryptedPdfLinearScanFallbackWithDecoyTrailerAsNameToken(encryptDictBody, idBytes, encryptedContent); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert: object 4's RC4-encrypted content decrypted and rendered correctly - without the + // fix, the decoy "/trailer << /Root 1 0 R >>" name token (lacking /Encrypt) would have + // won, leaving the encryption key unestablished and these bytes rendered as raw, + // still-encrypted ciphertext instead. + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } } diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 90d5e322..d39d57d0 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -2550,6 +2550,69 @@ public void PdfDocument_Open_LinearScanFallback_UnsupportedFilterObjectStream_Do Assert.Equal(default, surface[5, 5]); } + /// + /// Builds an in-memory, single-page PDF with no cross-reference table at all (forcing + /// linear-scan fallback) preceded by many unrelated small stream objects, so that + /// ScanForTrailerDictionary's own streamPayloadRanges list accumulates many + /// entries before the real trailer keyword is ever reached. + /// + private static byte[] BuildLinearScanFallbackWithManyStreamPayloadRangesBeforeTrailer() + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamObjectBody(string.Empty, []), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + // Many unrelated, directly-located stream objects - each one contributes its own + // [Start, End) entry to streamPayloadRanges before the trailer keyword is reached. + var nextObjectNumber = bodies.Count + 1; + for (var i = 0; i < 500; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{nextObjectNumber++} 0 obj\n")); + buffer.AddRange(BuildStreamObjectBody(string.Empty, System.Text.Encoding.ASCII.GetBytes($"filler-payload-{i}"))); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + buffer.AddRange("trailer\n<< /Size 1 /Root 1 0 R >>\n"u8.ToArray()); + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that the real trailer is still correctly located when it is preceded by many + /// unrelated stream objects (see + /// ), + /// exercising ScanForTrailerDictionary's monotonically-advancing stream-payload- + /// range index across many entries rather than just the one or two ranges earlier, + /// smaller fixtures produce. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_ManyStreamPayloadRangesBeforeTrailer_StillResolvesTrailer() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithManyStreamPayloadRangesBeforeTrailer(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(default, surface[5, 5]); + } + #endregion #region Page tree From 670cb821f977c472a6474188c5888f0c2c578bf0 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 3 Oct 2026 08:36:53 -0400 Subject: [PATCH 08/15] Fix trailer keyword boundary check to match PDF tokenizer regular-byte rule IsMarkerIdentifierByte (the keyword-boundary check used by IndexOfKeyword and IsKeywordAt) only recognized ASCII letters and digits as part of a token. PdfTokenizer itself defines a keyword token as any run of "regular" bytes - any byte that is neither PDF whitespace nor a PDF delimiter (( ) < > [ ] { } / %) - which is broader than letters/digits (for example '#', '!', '$' are all regular bytes). Because of this mismatch, a byte sequence such as "foo#trailer" was wrongly treated as ending the preceding token at '#', exposing "trailer" as a standalone keyword match even though PdfTokenizer.ReadKeyword would read "foo#trailer" as a single token. A decoy trailer dictionary built this way could replace the real trailer, losing /Root or /Encrypt. Renamed IsMarkerIdentifierByte to IsMarkerRegularByte and redefined it as "not whitespace and not a delimiter", matching PdfTokenizer's own IsRegular rule exactly, with a new IsMarkerDelimiter helper mirroring the tokenizer's IsDelimiter. Updated the surrounding XML docs (IndexOfKeyword, IsKeywordAt, and the endstream-search rationale comment) to describe the regular-byte rule instead of the narrower identifier-byte terminology. Added PdfDocument_Open_LinearScanFallback_TrailerKeywordAfterHashByte_DoesNotReplaceRealTrailer, verified to fail (all 3 target frameworks) when the fix is reverted to the old alphanumeric-only check, and to pass with it restored. Updated requirements traceability and verification documentation for CanvasNetPdf-PdfDocument-LinearScanFallback accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-net-pdf/pdf-document.yaml | 1 + .../canvas-net-pdf/pdf-document.md | 7 ++ .../PdfDocument.Xref.cs | 57 ++++++++---- .../PdfDocumentEncryptionTests.cs | 89 +++++++++++++++++++ 4 files changed, 138 insertions(+), 16 deletions(-) diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index f673058f..3f7af556 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -131,6 +131,7 @@ sections: - PdfDocument_Open_LinearScanFallback_UnsupportedFilterObjectStream_DoesNotAbortRecovery - PdfDocument_Open_LinearScanFallback_TrailerKeywordAsNameToken_DoesNotReplaceRealTrailer - PdfDocument_Open_LinearScanFallback_ManyStreamPayloadRangesBeforeTrailer_StillResolvesTrailer + - PdfDocument_Open_LinearScanFallback_TrailerKeywordAfterHashByte_DoesNotReplaceRealTrailer - id: CanvasNetPdf-PdfDocument-PageTreeTraversal title: >- diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index 3f93431e..14328d8a 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -182,6 +182,13 @@ unrelated stream objects, exercising its stream-payload-range membership check ( monotonically-advancing index into the ranges `ScanObjectOffsets` already discovered in increasing order, keeping the overall scan linear in document size rather than quadratic in stream-object count) across many entries. +`PdfDocument_Open_LinearScanFallback_TrailerKeywordAfterHashByte_DoesNotReplaceRealTrailer` +confirms that a `trailer` suffix embedded inside a longer regular-byte run such as +`foo#trailer` is never mistaken for a standalone keyword match: keyword-boundary detection +recognizes exactly the same "regular byte" rule (any byte that is neither PDF whitespace nor a +PDF delimiter) that `PdfTokenizer` itself uses to decide where a keyword token ends, rather than +the narrower "ASCII letter or digit" check an earlier version relied on, which under-approximated +the tokenizer's own notion of a token boundary and could be defeated by a byte such as `#`. #### CanvasNetPdf-PdfDocument-PageTreeTraversal: Page Tree Traversal Reports All Pages diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index 719f836e..36b95acf 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -542,10 +542,11 @@ private Dictionary ScanObjectOffsets(out List<(int Start, int En continue; } - // Reject matches inside a larger identifier (for example the tail of "endobj", or a + // Reject matches inside a larger token (for example the tail of "endobj", or a // binary-noise byte run that merely contains "obj" as a substring): a genuine keyword - // is not immediately followed by another identifier byte. - if (i + 3 < buffer.Length && IsMarkerIdentifierByte(buffer[i + 3])) + // is not immediately followed by another regular (non-whitespace, non-delimiter) byte + // - the same rule PdfTokenizer.ReadKeyword uses to decide where a keyword token ends. + if (i + 3 < buffer.Length && IsMarkerRegularByte(buffer[i + 3])) { i++; continue; @@ -650,7 +651,7 @@ private static int SkipPastStreamPayload(byte[] buffer, int searchStart) // the cross-reference table this scan is building is itself still incomplete), or // inconsistent with the buffer. Relying on a raw, unbounded "endstream" search alone would // end the protected range too early whenever the payload's own bytes happen to contain a - // coincidental "endstream" byte sequence of their own, followed by a non-identifier byte. + // coincidental "endstream" byte sequence of their own, followed by a non-regular byte. if (entries.TryGetValue("Length", out var lengthValue) && lengthValue.Kind == PdfKind.Number && TryGetDeclaredStreamEnd(buffer, dataStart, lengthValue.Number, out var declaredEnd)) @@ -800,22 +801,35 @@ private static bool TryGetDeclaredStreamEnd(byte[] buffer, int dataStart, double /// /// Finds the first standalone occurrence of - one not - /// immediately followed by another identifier byte, and (unless + /// immediately followed by another regular byte, and (unless /// is ) not immediately - /// preceded by one either - so a match is never found inside a larger identifier or - /// incidental binary-noise byte run - at or after and before + /// preceded by one either - so a match is never found inside a larger token or incidental + /// binary-noise byte run - at or after and before /// . Returns -1 if no such occurrence exists in that range. + /// + /// "Regular byte" here means exactly what itself treats as + /// part of a keyword token: any byte that is not PDF whitespace and not one of the PDF + /// delimiter characters (( ) < > [ ] { } / %). An earlier version of this + /// check instead only recognized ASCII letters and digits as boundary bytes, which + /// under-approximated the tokenizer's own notion of a token boundary: a byte such as + /// # is neither a letter/digit nor PDF whitespace/a delimiter, so a sequence + /// like /foo#trailer was wrongly treated as ending the identifier at #, + /// exposing trailer as a standalone match even though + /// would read foo#trailer as a single keyword token. Matching the tokenizer's + /// own boundary rule exactly closes that gap. + /// /// /// The raw document buffer to search. /// The byte index at which to start searching (inclusive). /// The byte index at which to stop searching (exclusive). /// The literal keyword bytes to search for. /// - /// Whether the byte immediately before a candidate match must be a non-identifier byte. + /// Whether the byte immediately before a candidate match must be a non-regular byte. /// This must be for a keyword such as endstream, whose /// preceding byte is the final byte of an arbitrary, possibly binary, payload rather than - /// a continuation of a textual PDF token - that byte can legitimately be alphanumeric by - /// coincidence, and requiring otherwise would wrongly reject a genuine match. + /// a continuation of a textual PDF token - that byte can legitimately be alphanumeric (or + /// any other regular byte) by coincidence, and requiring otherwise would wrongly reject a + /// genuine match. /// private static int IndexOfKeyword( byte[] buffer, @@ -842,13 +856,13 @@ private static int IndexOfKeyword( continue; } - if (requirePrecedingBoundary && i > 0 && IsMarkerIdentifierByte(buffer[i - 1])) + if (requirePrecedingBoundary && i > 0 && IsMarkerRegularByte(buffer[i - 1])) { continue; } var after = i + keyword.Length; - if (after < buffer.Length && IsMarkerIdentifierByte(buffer[after])) + if (after < buffer.Length && IsMarkerRegularByte(buffer[after])) { continue; } @@ -931,8 +945,19 @@ private static int SkipMarkerBytesBackward(byte[] buffer, int cursor, Func b is >= (byte)'0' and <= (byte)'9'; - private static bool IsMarkerIdentifierByte(byte b) => - IsMarkerDigit(b) || (b is >= (byte)'a' and <= (byte)'z') || (b is >= (byte)'A' and <= (byte)'Z'); + private static bool IsMarkerDelimiter(byte b) => + b is (byte)'(' or (byte)')' or (byte)'<' or (byte)'>' or (byte)'[' or (byte)']' or (byte)'{' or (byte)'}' or (byte)'/' or (byte)'%'; + + /// + /// Whether is a "regular" byte under the PDF grammar - that is, + /// neither PDF whitespace nor a PDF delimiter character - matching exactly the rule + /// itself uses (via its own private IsRegular) to decide + /// which bytes belong to the same keyword token. This is intentionally broader than + /// "ASCII letter or digit": PDF permits other bytes (for example #, !, + /// $) inside a regular run, and a boundary check narrower than the tokenizer's own + /// would wrongly treat such a byte as ending a token when the tokenizer would not. + /// + private static bool IsMarkerRegularByte(byte b) => !IsMarkerWhitespace(b) && !IsMarkerDelimiter(b); /// /// If is the first byte of a PDF comment (% through @@ -1258,8 +1283,8 @@ private void RegisterCompressedObjectsFromObjectStreams() /// /// Returns whether matches the buffer exactly at - /// , applying the same identifier-boundary rules as - /// (so a match inside a larger identifier, such as the tail + /// , applying the same regular-byte boundary rules as + /// (so a match inside a larger token, such as the tail /// of a longer word, is rejected) rather than merely comparing bytes. A match is also /// rejected when immediately preceded by /: per the PDF specification a name token /// begins with / directly followed by its regular characters with no intervening diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs index f4652e55..09a8abb2 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs @@ -1645,5 +1645,94 @@ public void PdfDocument_Open_LinearScanFallback_TrailerKeywordAsNameToken_DoesNo Assert.Equal(Black, surface[30, 70]); Assert.Equal(default, surface[5, 5]); } + + private static byte[] BuildEncryptedPdfLinearScanFallbackWithDecoyTrailerAfterHashByte( + string encryptDictBody, + byte[] idBytes, + byte[] encryptedContentBytes) + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + buffer.AddRange("4 0 obj\n"u8.ToArray()); + buffer.AddRange(Encoding.ASCII.GetBytes($"<< /Length {encryptedContentBytes.Length} >>\nstream\n")); + buffer.AddRange(encryptedContentBytes); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + buffer.AddRange("5 0 obj\n"u8.ToArray()); + buffer.AddRange(Encoding.ASCII.GetBytes(encryptDictBody)); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + var idHex = ToHex(idBytes); + buffer.AddRange(Encoding.ASCII.GetBytes( + $"trailer\n<< /Size 6 /Root 1 0 R /Encrypt 5 0 R /ID [<{idHex}> <{idHex}>] >>\n")); + + // "#" is neither PDF whitespace nor a PDF delimiter, so it is itself a "regular" byte: per + // the PDF grammar, "foo#trailer" is a single keyword token (identical to how + // PdfTokenizer.ReadKeyword would read it), not "foo" followed by a standalone "trailer" + // keyword. It sits outside any stream payload, comment, or string, and is physically + // positioned after the genuine trailer above, so it would otherwise win under the scan's + // "later occurrence overrides an earlier one" semantics were its "trailer" suffix wrongly + // treated as a standalone keyword match. + buffer.AddRange("foo#trailer << /Root 1 0 R >>\n"u8.ToArray()); + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that a trailer suffix embedded inside a longer regular-byte run such as + /// foo#trailer (see + /// ) - where + /// the preceding byte # is neither PDF whitespace nor a PDF delimiter, and so does + /// not end the token under the PDF grammar - is never mistaken by + /// ScanForTrailerDictionary for a standalone trailer keyword, even though it + /// is physically positioned later in the file and so would otherwise win under the raw + /// scan's own "later occurrence overrides an earlier one" semantics. The genuine trailer's + /// /Encrypt entry must survive for object 4's own RC4-encrypted content to decrypt + /// and render correctly. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_TrailerKeywordAfterHashByte_DoesNotReplaceRealTrailer() + { + const int keyLengthBytes = 5; + const int revision = 2; + const int permissions = -3904; + var idBytes = (byte[])[.. Enumerable.Range(0, 16).Select(i => (byte)(0xE0 + i))]; + + var oBytes = ComputeOwnerEntryAlgorithm3(keyLengthBytes, revision, PasswordPadding, PasswordPadding); + var fileKey = ComputeFileKeyAlgorithm2(PasswordPadding, oBytes, permissions, idBytes, keyLengthBytes, revision); + var uBytes = ComputeUserEntryAlgorithm45(fileKey, idBytes, revision); + var encryptDictBody = $"<< /Filter /Standard /V 1 /R {revision} /O <{ToHex(oBytes)}> /U <{ToHex(uBytes)}> /P {permissions} >>"; + + var contentObjectKey = ComputeObjectKeyAlgorithm1(fileKey, 4, 0, isAes: false); + var encryptedContent = Rc4(contentObjectKey, Encoding.ASCII.GetBytes(PlaintextContent)); + + var pdfBytes = BuildEncryptedPdfLinearScanFallbackWithDecoyTrailerAfterHashByte(encryptDictBody, idBytes, encryptedContent); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert: object 4's RC4-encrypted content decrypted and rendered correctly - without the + // fix, the decoy "foo#trailer << /Root 1 0 R >>" (lacking /Encrypt) would have won, + // leaving the encryption key unestablished and these bytes rendered as raw, + // still-encrypted ciphertext instead. + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } } From 7dae58de6b8d38aa5c73425dfc66420b79c8c942 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 3 Oct 2026 09:06:31 -0400 Subject: [PATCH 09/15] Fix backward object-header boundary check to exclude name-token prefix TryParseObjectHeaderBackward rejected a candidate object number only when it was preceded by another regular byte (e.g. decoy3 0 obj), but PDF names begin with the delimiter '/' directly adjacent to their characters, so a decoy such as '/3 0 obj' still passed as a standalone number token and could overwrite a legitimate xref entry during linear-scan fallback recovery. Extend the boundary check to also reject a preceding '/', mirroring the equivalent name-token exclusion already applied to fallback keyword scanning. Added PdfDocument_Open_LinearScanFallback_ObjectNumberAsNameToken_DoesNotCorruptOffset, rigor-verified to fail without the fix (3/3 target frameworks) and pass with it restored. Updated requirements traceability and verification docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-net-pdf/pdf-document.yaml | 2 + .../canvas-net-pdf/pdf-document.md | 10 ++ .../PdfDocument.Xref.cs | 21 ++- .../PdfDocumentTests.cs | 129 ++++++++++++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index 3f7af556..c5a19879 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -132,6 +132,8 @@ sections: - PdfDocument_Open_LinearScanFallback_TrailerKeywordAsNameToken_DoesNotReplaceRealTrailer - PdfDocument_Open_LinearScanFallback_ManyStreamPayloadRangesBeforeTrailer_StillResolvesTrailer - PdfDocument_Open_LinearScanFallback_TrailerKeywordAfterHashByte_DoesNotReplaceRealTrailer + - PdfDocument_Open_LinearScanFallback_ObjectNumberAsTokenSuffix_DoesNotCorruptOffset + - PdfDocument_Open_LinearScanFallback_ObjectNumberAsNameToken_DoesNotCorruptOffset - id: CanvasNetPdf-PdfDocument-PageTreeTraversal title: >- diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index 14328d8a..bcc4175b 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -189,6 +189,16 @@ recognizes exactly the same "regular byte" rule (any byte that is neither PDF wh PDF delimiter) that `PdfTokenizer` itself uses to decide where a keyword token ends, rather than the narrower "ASCII letter or digit" check an earlier version relied on, which under-approximated the tokenizer's own notion of a token boundary and could be defeated by a byte such as `#`. +`PdfDocument_Open_LinearScanFallback_ObjectNumberAsTokenSuffix_DoesNotCorruptOffset` confirms the +same regular-byte boundary rule is also applied when parsing an object header's `N G obj` number +backward from a matched `obj` keyword: a decoy such as `decoy3 0 obj` is rejected because the +object-number digit `3` is only the suffix of the larger regular-byte token `decoy3`, not a +standalone number token with a genuine boundary immediately before it. +`PdfDocument_Open_LinearScanFallback_ObjectNumberAsNameToken_DoesNotCorruptOffset` confirms the +analogous PDF name-token exclusion for the same backward parse: a decoy such as `/3 0 obj` is +rejected because `/3` is lexically a PDF name token whose text happens to read "3", never a +standalone object-number token, even though `/` is itself a delimiter (not a regular byte) and so +is not caught by the regular-byte boundary check alone. #### CanvasNetPdf-PdfDocument-PageTreeTraversal: Page Tree Traversal Reports All Pages diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index 36b95acf..5a830de0 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -876,7 +876,11 @@ private static int IndexOfKeyword( /// /// Attempts to parse a N G object number/generation header immediately preceding /// the byte index of a literal obj keyword match, walking backward over the - /// generation digits, the separating whitespace, and the object number digits. + /// generation digits, the separating whitespace, and the object number digits. The object + /// number's own leading boundary is also verified: it must not be immediately preceded by + /// another regular byte (which would make the digits only the suffix of a larger token) + /// or by / (which would make them a PDF name token's text rather than a standalone + /// number). /// private static bool TryParseObjectHeaderBackward(byte[] buffer, int objIndex, out int objectNumber, out int headerStart) { @@ -913,6 +917,21 @@ private static bool TryParseObjectHeaderBackward(byte[] buffer, int objIndex, ou } var numberStart = cursor; + + // Require a genuine token boundary immediately before the object number: without this, a + // decoy such as "foo3 0 obj" would be accepted as object 3, even though "foo3" is + // lexically a single regular-byte token under the PDF grammar and "3" is only its suffix, + // not a standalone number token of its own. A preceding '/' must also be rejected even + // though '/' is itself a delimiter (not a regular byte): per the PDF specification a name + // token begins with '/' directly followed by its regular characters with no intervening + // whitespace, so (for example) "/3 0 obj" has "/3" as a name token whose text happens to + // read "3" - a legitimate dictionary value any real document can contain - never a + // standalone object-number token, and must not be parsed as one. + if (numberStart > 0 && (IsMarkerRegularByte(buffer[numberStart - 1]) || buffer[numberStart - 1] == (byte)'/')) + { + return false; + } + var parsedNumber = 0L; for (var i = numberStart; i < numberEnd; i++) { diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index d39d57d0..6a8b352d 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -2613,6 +2613,135 @@ public void PdfDocument_Open_LinearScanFallback_ManyStreamPayloadRangesBeforeTra Assert.Equal(default, surface[5, 5]); } + /// + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback): the real object 3 (the Page) + /// is defined with a genuine, early 3 0 obj header, while later, unstructured raw + /// bytes - not inside any comment, string, or stream payload - spell out + /// decoy3 0 obj: a byte-perfect 3 0 obj sequence whose object number digit + /// is only the suffix of the larger regular-byte token decoy3, not a standalone + /// number token of its own. + /// + private static byte[] BuildLinearScanFallbackWithObjectNumberAsTokenSuffix() + { + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + // This decoy is physically positioned after the real "3 0 obj" header above, so it would + // otherwise win under the scan's "later occurrence overrides an earlier one" semantics + // were its "3" digit wrongly treated as a standalone object-number token. It sits outside + // any comment, string, or stream payload, reaching the raw "obj" scan directly. + buffer.AddRange("decoy3 0 obj\n<< /Foo true >>\nendobj\n"u8.ToArray()); + + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that an object number embedded as only the numeric suffix of a larger + /// regular-byte token (see + /// ) is never mistaken + /// for a genuine, standalone object-number token, even though the decoy is physically + /// positioned later in the file than the real header for the same object number and so + /// would otherwise win under the scan's own "later occurrence overrides an earlier one" + /// semantics - corrupting that object's real, correct offset. The real object 3 (the + /// page) must still resolve and render correctly using its own genuine, earlier header. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_ObjectNumberAsTokenSuffix_DoesNotCorruptOffset() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithObjectNumberAsTokenSuffix(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback): the real object 3 (the Page) + /// is defined with a genuine, early 3 0 obj header, while later, unstructured raw + /// bytes - not inside any comment, string, or stream payload - spell out + /// /3 0 obj: a byte-perfect 3 0 obj sequence whose object number digit is + /// immediately preceded by /, making it lexically the PDF name token + /// /3 (whose text happens to read "3") rather than a standalone number token. + /// + private static byte[] BuildLinearScanFallbackWithObjectNumberAsNameToken() + { + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + // This decoy is physically positioned after the real "3 0 obj" header above, so it would + // otherwise win under the scan's "later occurrence overrides an earlier one" semantics + // were its "/3" name token wrongly treated as a standalone object-number token. It sits + // outside any comment, string, or stream payload, reaching the raw "obj" scan directly. + buffer.AddRange("/3 0 obj\n<< /Foo true >>\nendobj\n"u8.ToArray()); + + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that an object number embedded as a PDF name token's text (see + /// ) is never mistaken + /// for a genuine, standalone object-number token, even though the decoy is physically + /// positioned later in the file than the real header for the same object number and so + /// would otherwise win under the scan's own "later occurrence overrides an earlier one" + /// semantics - corrupting that object's real, correct offset. The real object 3 (the + /// page) must still resolve and render correctly using its own genuine, earlier header. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_ObjectNumberAsNameToken_DoesNotCorruptOffset() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithObjectNumberAsNameToken(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + #endregion #region Page tree From 2f41054247edfda4507459647d92ca56117ac364 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 3 Oct 2026 09:43:17 -0400 Subject: [PATCH 10/15] Fix quadratic scan, hex-string, startxref, and object-offset integrity bugs in PDF fallback recovery Addresses the two newest GitHub review findings plus three issues found via local code-review subagents, all in PdfDocument.Xref.cs's fallback/recovery scanning and classic cross-reference resolution: - AdvancePastNonSyntax: bound total scan work to O(n) by permanently disabling further literal/hex-string-open attempts for the remainder of a single pass once any one string scan fails to find its close before EOF, closing a quadratic-rescan CPU/timeout DoS on a crafted file with many unmatched '(' bytes (discussion_r4173305699). - AdvancePastNonSyntax: exclude both characters of a dictionary's opening '<<' from hex-string detection (previously only the first '<' was excluded), preventing the second '<' from being misread as a hex-string start whose early-closing '>' could expose embedded decoy object/trailer syntax (discussion_r4173305727). - FindStartXrefOffset: replace the unguarded LastIndexOf byte search with a new LastIndexOfKeyword helper applying the same regular-byte boundary rule used elsewhere in this file, so a byte-exact 'startxref' sequence that is only the suffix of a larger token (e.g. 'xstartxref') is never mistaken for a standalone marker - found via local review, not yet reported upstream. - ParseIndirectObjectAt: validate a cross-reference entry's declared offset against its original 64-bit value before narrowing to int, preventing an out-of-range offset from silently wrapping around to an in-bounds value and bypassing bounds validation entirely - found via local review. - ParseIndirectObjectAt: cross-check the parsed header's own declared object number against the number being resolved (mirroring the equivalent check already performed for compressed objects), rejecting a cross-reference entry whose offset leads to a different, shape-compatible object's header instead of silently accepting and relabeling its content - found via local review. Each fix is covered by a new regression test and was rigor-verified by temporarily reverting the fix, confirming the test fails (or, for the quadratic-scan fix, times out) without it, then restoring the fix and re-confirming the test passes - in all cases across net8.0/net9.0/net10.0. Verified locally before push per request: build.ps1 (7380/7380 tests pass), fix.ps1 (no unexpected diff), lint.ps1 (clean), reqstream --lint (clean), and the external verify-harness corpus check (0 crashes/timeouts across 115 files plus the known previously-crashing fixture). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .cspell.yaml | 2 + .../canvas-net-pdf/pdf-document.yaml | 5 + .../canvas-net-pdf/pdf-document.md | 39 +++ .../PdfDocument.Xref.cs | 119 ++++++- .../PdfDocumentTests.cs | 324 ++++++++++++++++++ 5 files changed, 470 insertions(+), 19 deletions(-) diff --git a/.cspell.yaml b/.cspell.yaml index b66ed943..2db8977b 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -41,6 +41,8 @@ words: - rasterizing - startxref - Startxref + - xstartxref + - mystartxref - SYNT - undecoded - BITMAPCOREHEADER diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index c5a19879..f1a07a7f 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -69,6 +69,8 @@ sections: and must be supported for broad real-world document compatibility. tests: - PdfDocument_Open_ClassicXref_ResolvesRootAndPage + - PdfDocument_Open_ClassicXref_EntryOffsetBeyondInt32Range_ThrowsInvalidDataException + - PdfDocument_Open_ClassicXref_EntryPointingAtWrongObjectHeader_ThrowsInvalidDataException - id: CanvasNetPdf-PdfDocument-XrefStream title: >- @@ -134,6 +136,9 @@ sections: - PdfDocument_Open_LinearScanFallback_TrailerKeywordAfterHashByte_DoesNotReplaceRealTrailer - PdfDocument_Open_LinearScanFallback_ObjectNumberAsTokenSuffix_DoesNotCorruptOffset - PdfDocument_Open_LinearScanFallback_ObjectNumberAsNameToken_DoesNotCorruptOffset + - PdfDocument_Open_LinearScanFallback_SecondAngleBracketAsHexStringStart_DoesNotCorruptOffset + - PdfDocument_Open_LinearScanFallback_LongUnterminatedLiteralStringRun_CompletesPromptly + - PdfDocument_Open_ClassicXref_StartxrefAsTokenSuffix_DoesNotRedirectToBogusOffset - id: CanvasNetPdf-PdfDocument-PageTreeTraversal title: >- diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index bcc4175b..c9eeecc5 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -113,6 +113,20 @@ Opens `PdfFixtures/classic-xref-single-page.pdf` (a classic `xref` table + `trai public API. Asserts `PageCount` and the single page's `GetPageInfo` size/rotation match the fixture's known content. +`PdfDocument_Open_ClassicXref_EntryOffsetBeyondInt32Range_ThrowsInvalidDataException` confirms +that a cross-reference entry's declared offset is validated against its original 64-bit value +before ever being narrowed to `int`: the test entry declares the real page object's genuine +offset plus 2^32 (still representable in the ten fixed-width digits the classic cross-reference +format allows), chosen specifically because narrowing it to `int` via an unchecked, wraparound +cast recovers the exact original, in-bounds offset - a bug that validated the already-narrowed +value would wrongly accept and use. `PdfDocument_Open_ClassicXref_EntryPointingAtWrongObjectHeader_ThrowsInvalidDataException` +confirms that an entry's declared offset must lead to a header whose own declared object number +matches the number being resolved: the test corrupts the page object's entry to point at a +second, independently well-formed and shape-compatible `/Type /Page` object appended after the +document's own `%%EOF` (never reachable through any genuine `xref`/`trailer` path), proving the +mismatch is rejected outright rather than silently accepted and relabeled with the requested +object number - which would otherwise render the wrong page without any error. + #### CanvasNetPdf-PdfDocument-XrefStream: Xref Stream Resolves Root and Page **Test**: `PdfDocument_Open_XrefStream_ResolvesRootAndPage` @@ -199,6 +213,31 @@ analogous PDF name-token exclusion for the same backward parse: a decoy such as rejected because `/3` is lexically a PDF name token whose text happens to read "3", never a standalone object-number token, even though `/` is itself a delimiter (not a regular byte) and so is not caught by the regular-byte boundary check alone. +`PdfDocument_Open_LinearScanFallback_SecondAngleBracketAsHexStringStart_DoesNotCorruptOffset` +confirms that both characters of a dictionary's opening `<<` are excluded from hex-string +detection, not just the first: a decoy `3 0 obj` embedded inside a hex-string-shaped run +immediately following a genuine `<<` is never reached, because the second `<` is itself +recognized as dictionary syntax (by also checking the *preceding* byte, not only the following +one) rather than mistaken for the start of a `<...>` hex string whose premature, early-closing +`>` would otherwise expose the decoy to keyword matching. +`PdfDocument_Open_LinearScanFallback_LongUnterminatedLiteralStringRun_CompletesPromptly` confirms +the fallback scan completes in bounded, linear time even when fed a long run of unmatched `(` +bytes: rather than re-scanning the entire remaining buffer from every failed literal/hex-string +open position (which is quadratic in a crafted file's size and so a CPU/timeout +denial-of-service), the scan permanently stops attempting further string opens for the remainder +of a single pass once any one string scan fails to find its close before EOF. This is a +deliberate, documented heuristic trade-off - a legitimate recovery opportunity immediately after +such a degenerate byte run could in principle be missed - accepted because the project's +established policy treats a heuristic fallback scanner's incompleteness as acceptable as long as +it never hangs or crashes. +`PdfDocument_Open_ClassicXref_StartxrefAsTokenSuffix_DoesNotRedirectToBogusOffset` confirms that +the fallback search for a `startxref` marker (used when a genuine marker could not already be +located) applies the same regular-byte boundary rule as every other keyword match in this file: +a byte-exact `startxref` sequence that is only the suffix of a larger regular-byte token (for +example `xstartxref`) is never mistaken for a standalone marker, even when it is physically +positioned later in the file, byte-identical, and points at an independently valid second +cross-reference section - which would otherwise cause the wrong document revision to be silently +resolved instead of the genuine one. #### CanvasNetPdf-PdfDocument-PageTreeTraversal: Page Tree Traversal Reports All Pages diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index 5a830de0..31226096 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -354,14 +354,13 @@ private static ulong ReadBigEndian(byte[] buffer, int offset, int width) /// Finds the byte offset recorded after the last startxref keyword in the buffer. private int FindStartXrefOffset() { - var marker = "startxref"u8.ToArray(); - var index = LastIndexOf(_buffer, marker); + var index = LastIndexOfKeyword(_buffer, "startxref"u8); if (index < 0) { throw new InvalidDataException("No 'startxref' keyword found."); } - var tokenizer = new PdfTokenizer(_buffer) { Position = index + marker.Length }; + var tokenizer = new PdfTokenizer(_buffer) { Position = index + "startxref"u8.Length }; var offsetToken = tokenizer.NextToken(); if (offsetToken.Kind != PdfTokenKind.Number) { @@ -371,24 +370,50 @@ private int FindStartXrefOffset() return (int)offsetToken.Number; } - private static int LastIndexOf(byte[] haystack, byte[] needle) + /// + /// Finds the last occurrence of in + /// that is a genuine standalone token rather than a substring of some larger token - + /// mirroring the same regular-byte boundary rule applies when + /// scanning forward, so a decoy such as mystartxref cannot be mistaken for the real + /// startxref keyword, and a legitimate earlier match is not shadowed by a later one + /// that is merely the tail or prefix of a larger run of regular bytes. This does not by + /// itself exclude a boundary-respecting match that happens to sit inside a PDF comment or + /// string, unlike the comment/string-aware scans in and + /// - a narrower, best-effort improvement over the + /// previous unguarded raw byte search, not a claim of full lexical correctness. + /// + private static int LastIndexOfKeyword(byte[] haystack, ReadOnlySpan keyword) { - for (var i = haystack.Length - needle.Length; i >= 0; i--) + var maxStart = haystack.Length - keyword.Length; + for (var i = maxStart; i >= 0; i--) { var matched = true; - for (var j = 0; j < needle.Length; j++) + for (var j = 0; j < keyword.Length; j++) { - if (haystack[i + j] != needle[j]) + if (haystack[i + j] != keyword[j]) { matched = false; break; } } - if (matched) + if (!matched) + { + continue; + } + + if (i > 0 && IsMarkerRegularByte(haystack[i - 1])) { - return i; + continue; + } + + var after = i + keyword.Length; + if (after < haystack.Length && IsMarkerRegularByte(haystack[after])) + { + continue; } + + return i; } return -1; @@ -525,10 +550,11 @@ private Dictionary ScanObjectOffsets(out List<(int Start, int En var ranges = new List<(int Start, int End)>(); var buffer = _buffer; var i = 0; + var unterminatedStringEncountered = false; while (i < buffer.Length) { - var afterNonSyntax = AdvancePastNonSyntax(buffer, i); + var afterNonSyntax = AdvancePastNonSyntax(buffer, i, ref unterminatedStringEncountered); if (afterNonSyntax != i) { i = afterNonSyntax; @@ -986,6 +1012,14 @@ private static bool IsMarkerDelimiter(byte b) => /// the index of the first byte past it. Otherwise returns /// unchanged. /// + /// The byte buffer being scanned. + /// The byte index to classify. + /// + /// Set to once a literal or hex string attempt has run all the + /// way to the end of the buffer without ever closing, and consulted on every call + /// thereafter to skip further string-opening attempts outright. See the remarks below for + /// why this is necessary for a bounded worst-case cost. + /// /// /// /// This is what lets and @@ -1008,10 +1042,24 @@ private static bool IsMarkerDelimiter(byte b) => /// of a real string - for example binary noise containing a stray delimiter byte with /// no matching close before the end of the buffer - this degrades safely by leaving /// the position unchanged, so the caller simply treats that one byte as ordinary - /// content instead of skipping an unbounded remainder of the file. + /// content instead of skipping an unbounded remainder of the file. However, such a + /// failed attempt only reaches that conclusion by scanning every remaining byte in the + /// buffer looking for a close that never comes; if a crafted or corrupted input + /// contains a long run of such bytes (for example many consecutive unmatched ( + /// bytes), retrying the same unbounded scan from every one of them would make this + /// fallback scanner quadratic in the buffer size - a CPU/timeout denial-of-service + /// risk, not merely a completeness limitation. bounds this: the first such failure anywhere + /// in a scan permanently disables further string-opening attempts for the remainder of + /// that same scan, so at most one failed attempt ever walks the buffer's tail, keeping + /// total work linear. This sacrifices recovering any genuine obj/trailer + /// marker that might coincidentally follow such a byte run, which is an acceptable + /// heuristic limitation here: this path only runs once normal cross-reference parsing + /// has already failed, and the PDF specification does not guarantee perfect recovery + /// is even possible for arbitrarily malformed input. /// /// - private static int AdvancePastNonSyntax(byte[] buffer, int position) + private static int AdvancePastNonSyntax(byte[] buffer, int position, ref bool unterminatedStringEncountered) { var b = buffer[position]; if (b == (byte)'%') @@ -1025,14 +1073,30 @@ private static int AdvancePastNonSyntax(byte[] buffer, int position) return end; } + if (unterminatedStringEncountered) + { + return position; + } + if (b == (byte)'(') { - return SkipLiteralString(buffer, position); + var after = SkipLiteralString(buffer, position); + unterminatedStringEncountered = after == position; + return after; } - if (b == (byte)'<' && (position + 1 >= buffer.Length || buffer[position + 1] != (byte)'<')) + // Only the first '<' of a dictionary's '<<' delimiter is excluded from hex-string + // detection by checking the following byte; the second '<' must also be excluded, which + // requires checking the *preceding* byte instead, since the following byte after the + // second '<' is ordinary dictionary content rather than another '<'. + var isDictionaryDelimiter = + (position + 1 < buffer.Length && buffer[position + 1] == (byte)'<') || + (position > 0 && buffer[position - 1] == (byte)'<'); + if (b == (byte)'<' && !isDictionaryDelimiter) { - return SkipHexString(buffer, position); + var after = SkipHexString(buffer, position); + unterminatedStringEncountered = after == position; + return after; } return position; @@ -1249,10 +1313,11 @@ private void RegisterCompressedObjectsFromObjectStreams() PdfObject? last = null; var i = 0; var rangeIndex = 0; + var unterminatedStringEncountered = false; while (i < buffer.Length) { - var afterNonSyntax = AdvancePastNonSyntax(buffer, i); + var afterNonSyntax = AdvancePastNonSyntax(buffer, i, ref unterminatedStringEncountered); if (afterNonSyntax != i) { i = afterNonSyntax; @@ -1357,7 +1422,7 @@ private PdfObject GetObject(int number) PdfObject result; if (entry.Type == XrefEntryType.Direct) { - result = ParseIndirectObjectAt((int)entry.Offset, number); + result = ParseIndirectObjectAt(entry.Offset, number); } else { @@ -1373,10 +1438,14 @@ private PdfObject GetObject(int number) } } - private PdfObject ParseIndirectObjectAt(int offset, int expectedNumber) + private PdfObject ParseIndirectObjectAt(long offset, int expectedNumber) { + // Validate against the original 64-bit offset before narrowing to int: narrowing first + // would let an out-of-range declared offset (for example one larger than int.MaxValue) + // silently wrap around to some in-bounds value and bypass this very check. ValidateBufferOffset(offset, _buffer.Length, $"Indirect object {expectedNumber}"); - var tokenizer = new PdfTokenizer(_buffer) { Position = offset }; + var position = (int)offset; + var tokenizer = new PdfTokenizer(_buffer) { Position = position }; var numberToken = tokenizer.NextToken(); var generationToken = tokenizer.NextToken(); var objToken = tokenizer.NextToken(); @@ -1387,6 +1456,18 @@ private PdfObject ParseIndirectObjectAt(int offset, int expectedNumber) throw new InvalidDataException($"Malformed indirect object header for object {expectedNumber} at offset {offset}."); } + // Cross-check the header's own declared object number against the number the caller + // looked up in the cross-reference table - mirroring the equivalent check already + // performed for compressed objects below. Without this, a corrupted or crafted + // cross-reference entry whose offset points at a different object's header would be + // silently accepted and relabeled with the wrong (requested) object number/identity. + if ((int)numberToken.Number != expectedNumber) + { + throw new InvalidDataException( + $"Indirect object header at offset {offset} declares object number {(int)numberToken.Number}, " + + $"which does not match expected object number {expectedNumber}."); + } + var generation = (int)generationToken.Number; var result = ParseValue(tokenizer); result.ObjectNumber = expectedNumber; diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 6a8b352d..d2a48efc 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -2742,6 +2742,330 @@ public void PdfDocument_Open_LinearScanFallback_ObjectNumberAsNameToken_DoesNotC Assert.Equal(default, surface[5, 5]); } + /// + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback): the real object 3 (the Page) + /// is defined with a genuine, early 3 0 obj header, while later, unstructured raw + /// bytes form a dictionary opener << immediately followed by a literal string + /// (dummy>3 0 obj<< /Foo true >>\nendobj\ntail) whose content itself + /// contains a byte-perfect, properly-bounded 3 0 obj header. A lexically correct + /// scanner must recognize the literal string's own opening ( and treat its entire + /// balanced content - including the embedded "3 0 obj" text - as unreachable string data, + /// never as object syntax. + /// + private static byte[] BuildLinearScanFallbackWithSecondAngleBracketAsHexStringStart() + { + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + // If the dictionary opener's second '<' is wrongly treated as the start of a hex string, + // that premature (and incorrect) hex-string scan stops at the literal '>' right after + // "dummy" - well before the real literal string's own '(' is ever examined - leaving the + // embedded "3 0 obj" reachable as if it were ordinary syntax and overwriting the real + // object 3's offset recorded above. + buffer.AddRange("<<(dummy>3 0 obj<< /Foo true >>\nendobj\ntail)>>\n"u8.ToArray()); + + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that the second < of a dictionary's << opener is never + /// mistaken for the start of a hex string (see + /// ), which + /// would otherwise let a literal string's own embedded > byte terminate a bogus + /// hex-string scan early, exposing an object header lexically nested inside that string's + /// content to the raw object scan and corrupting the real object's offset. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_SecondAngleBracketAsHexStringStart_DoesNotCorruptOffset() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithSecondAngleBracketAsHexStringStart(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback): the real object 3 (the Page) + /// is defined with a genuine, early 3 0 obj header, while later, unstructured raw + /// bytes consist of a very long run of unmatched, unescaped ( bytes with no + /// closing ) anywhere before the end of the buffer. + /// + private static byte[] BuildLinearScanFallbackWithLongUnterminatedLiteralStringRun() + { + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + // Without a bound on repeated failed literal-string scans, each of these 200,000 bytes + // would independently trigger its own scan of the remaining buffer looking for a '(' + // close that never comes, making this fallback scanner quadratic in the run's length. + buffer.AddRange(new byte[200_000]); + for (var i = buffer.Count - 200_000; i < buffer.Count; i++) + { + buffer[i] = (byte)'('; + } + + buffer.AddRange("\n%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that a long run of unmatched, never-closing literal-string ( bytes (see + /// ) is handled + /// in time bounded by the buffer size rather than quadratic in the run's length: without + /// that bound, a crafted or corrupted file could make this heuristic fallback scanner + /// consume CPU far out of proportion to the file's size - a denial-of-service risk, not + /// merely a completeness limitation. The real object 3 (the page) must still resolve and + /// render correctly. + /// + [Fact] + public async Task PdfDocument_Open_LinearScanFallback_LongUnterminatedLiteralStringRun_CompletesPromptly() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithLongUnterminatedLiteralStringRun(); + + // Act: run on a background thread with a generous bounded timeout - if the quadratic-scan + // fix regresses, this fails fast with a TimeoutException instead of hanging the test run. + using var document = await Task.Run(() => PdfDocument.Open(new MemoryStream(pdfBytes))) + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Builds an in-memory, single-page, classic-xref PDF (via ) + /// with genuine, correctly-bounded xref/trailer/startxref syntax, + /// followed by a second, structurally complete and independently valid classic + /// cross-reference section (objects 5-8, a differently-shaped single page) whose own + /// startxref-like marker is spelled xstartxref - a byte-perfect + /// startxref sequence whose preceding byte x makes it only the suffix of the + /// larger regular-byte token xstartxref, not a standalone keyword token of its own. + /// Because the decoy section is itself fully valid (not merely a bogus offset), a search + /// that wrongly selects it does not throw - it silently resolves the wrong document + /// revision instead, so this is not masked by the linear-scan fallback that normally + /// recovers from a thrown exception. + /// + private static byte[] BuildClassicXrefPdfWithStartxrefAsTokenSuffix() + { + var buffer = BuildSinglePagePdf(100, 100, "10 10 40 40 re f").ToList(); + + // A second, independently valid document revision (objects 5-8) with deliberately + // different content (a small square far from the real document's black square), used + // purely as a decoy target: if the corrupted search wrongly selects its "xstartxref", the + // active trailer silently becomes this revision's instead of throwing. + var contentB = "60 60 10 10 re f"u8.ToArray(); + var bodiesB = new List + { + "<< /Type /Catalog /Pages 6 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [7 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 6 0 R /Contents 8 0 R >>"u8.ToArray(), + BuildStreamObjectBody($"/Length {contentB.Length}", contentB), + }; + + var offsetsB = new List(); + for (var i = 0; i < bodiesB.Count; i++) + { + offsetsB.Add(buffer.Count); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 5} 0 obj\n")); + buffer.AddRange(bodiesB[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + var xrefOffsetB = buffer.Count; + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"xref\n5 {bodiesB.Count}\n")); + foreach (var offset in offsetsB) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{offset:D10} 00000 n \n")); + } + + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes("trailer\n<< /Size 9 /Root 5 0 R >>\n")); + + // This decoy is physically positioned after the real "startxref" marker above, so it + // would otherwise be selected by a raw last-byte-match search were its "startxref" text + // wrongly treated as a standalone keyword token - redirecting cross-reference parsing to + // this structurally valid, but different, decoy revision. + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"xstartxref\n{xrefOffsetB}\n%%EOF\n")); + return [.. buffer]; + } + + /// + /// Proves that a startxref keyword embedded as only the suffix of a larger + /// regular-byte token (see + /// ) is never mistaken for a + /// genuine, standalone startxref marker, even though the decoy is physically + /// positioned later in the file than the real marker, points at an independently valid + /// cross-reference section, and so would otherwise be selected by a raw last-byte-match + /// search and silently resolve to the wrong document revision instead of the genuine one. + /// + [Fact] + public void PdfDocument_Open_ClassicXref_StartxrefAsTokenSuffix_DoesNotRedirectToBogusOffset() + { + // Arrange + var pdfBytes = BuildClassicXrefPdfWithStartxrefAsTokenSuffix(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Returns the character offset, within (the ASCII text of a PDF + /// built by ), of the fixed-width 10-digit offset field + /// belonging to the single classic cross-reference subsection's entry for + /// - used by tests that corrupt one entry's declared + /// offset in place without disturbing the table's fixed-width line layout. + /// + private static int FindClassicXrefEntryOffsetStart(string text, int objectNumber) + { + var xrefKeywordIndex = text.IndexOf("xref\n", StringComparison.Ordinal); + var entryStart = text.IndexOf('\n', xrefKeywordIndex + "xref\n".Length) + 1; + for (var i = 0; i < objectNumber; i++) + { + entryStart = text.IndexOf('\n', entryStart) + 1; + } + + return entryStart; + } + + /// + /// Builds an in-memory, single-page, classic-xref PDF (via ) + /// whose object 3 (the page) entry is rewritten to declare its own genuine offset plus + /// 2^32 (still within the ten fixed-width digits the classic cross-reference format + /// allows, and so a value a conforming reader must reject as out of bounds) - chosen + /// specifically because narrowing it to via an unchecked, + /// wraparound cast recovers the exact original, genuinely valid offset. A naive + /// implementation that narrows to before validating bounds would + /// therefore accept this out-of-range offset and parse object 3 completely normally, + /// silently masking the very validation bypass this test exists to catch; validating the + /// original 64-bit value first instead rejects it outright. + /// + private static byte[] BuildClassicXrefPdfWithEntryOffsetBeyondInt32Range() + { + var pdfBytes = BuildSinglePagePdf(100, 100, "10 10 40 40 re f"); + var text = System.Text.Encoding.ASCII.GetString(pdfBytes); + var entryStart = FindClassicXrefEntryOffsetStart(text, 3); + var realOffset = long.Parse(text.Substring(entryStart, 10), System.Globalization.CultureInfo.InvariantCulture); + var wrapped = (realOffset + (1L << 32)).ToString("D10", System.Globalization.CultureInfo.InvariantCulture); + var corrupted = text[..entryStart] + wrapped + text[(entryStart + 10)..]; + return System.Text.Encoding.ASCII.GetBytes(corrupted); + } + + /// + /// Proves that a classic cross-reference entry declaring an offset beyond + /// (see + /// ) throws + /// instead of being narrowed to + /// before its bounds are checked, which would let it silently wrap around to some + /// unrelated, in-bounds offset and be used as-is. + /// + [Fact] + public void PdfDocument_Open_ClassicXref_EntryOffsetBeyondInt32Range_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = BuildClassicXrefPdfWithEntryOffsetBeyondInt32Range(); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + + /// + /// Builds an in-memory, single-page, classic-xref PDF (via ) + /// with genuine, correctly-bounded xref/trailer/startxref syntax, + /// followed by two independently valid, orphaned objects appended after the real + /// document's own %%EOF - object 5 a complete /Type /Page dictionary (with + /// its own distinct /Contents stream, object 6, drawing a different shape) never + /// reachable through any genuine xref/trailer path. Object 3's (the real + /// page's) cross-reference entry is then rewritten to declare object 5's genuine offset + /// instead of its own. Because object 5 is itself shape-compatible with a legitimate leaf + /// page (not a different /Type or a non-dictionary stream, whose mismatch would be + /// caught - for an unrelated reason - by the page-tree's own /Type validation), a + /// missing object-number cross-check would let this resolve completely successfully with + /// object 5's content, silently rendering the wrong page instead of throwing. + /// + private static byte[] BuildClassicXrefPdfWithEntryPointingAtWrongObjectHeader() + { + var buffer = BuildSinglePagePdf(100, 100, "10 10 40 40 re f").ToList(); + + var decoyContent = "60 60 10 10 re f"u8.ToArray(); + var object5Offset = buffer.Count; + buffer.AddRange("5 0 obj\n<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>\nendobj\n"u8.ToArray()); + buffer.AddRange("6 0 obj\n"u8.ToArray()); + buffer.AddRange(BuildStreamObjectBody(string.Empty, decoyContent)); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + var text = System.Text.Encoding.ASCII.GetString([.. buffer]); + var object3EntryStart = FindClassicXrefEntryOffsetStart(text, 3); + var corrupted = text[..object3EntryStart] + + object5Offset.ToString("D10", System.Globalization.CultureInfo.InvariantCulture) + + text[(object3EntryStart + 10)..]; + return System.Text.Encoding.ASCII.GetBytes(corrupted); + } + + /// + /// Proves that a classic cross-reference entry whose declared offset leads to a + /// different, genuine (and shape-compatible) object's header (see + /// ) throws + /// instead of silently accepting the wrong object's + /// content and relabeling it with the requested object number. + /// + [Fact] + public void PdfDocument_Open_ClassicXref_EntryPointingAtWrongObjectHeader_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = BuildClassicXrefPdfWithEntryPointingAtWrongObjectHeader(); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + #endregion #region Page tree From 0bf18ab88cca2db3b408eb057a3fedd0a6f63072 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 3 Oct 2026 10:11:35 -0400 Subject: [PATCH 11/15] Fix PDF fallback recovery lexical-boundary gaps in name-token exclusion and comment handling Addresses three new GitHub review findings on PdfDocument.Xref.cs's fallback-recovery scanner: - LastIndexOfKeyword (used by FindStartXrefOffset): reject a preceding '/' byte, mirroring IsKeywordAt's existing name-token exclusion, so a PDF name token such as /startxref can never be mistaken for the bare startxref keyword and redirect cross-reference parsing to the wrong revision. - IsFollowedByEndObjKeyword (used by FindStructurallyValidEndstream): skip any PDF whitespace and comments - not merely an optional CRLF sequence - before checking for the following endobj keyword, matching the PDF grammar's own token-separation rules. - TryParseObjectHeaderBackward: honor PDF comments as token separators between an object header's number, generation, and obj keyword, via a new bounded backward whitespace-and-comment skip, so a header such as \1 0 % comment\\nobj\ is recognized the same way a real forward parser would find it. Both new comment-skipping helpers (forward and backward) are bounded to a small fixed window to keep the scan strictly linear and avoid reintroducing the quadratic-scan risk fixed earlier in this same file. Added five new regression tests covering all three fixes plus the existing token-suffix exclusion pattern, each rigor-verified by temporarily reverting the fix and confirming the new test fails before restoring it. Updated requirements traceability and verification documentation for the new tests. Full local verification: build.ps1 (7389/7389 tests passed across net8.0/net9.0/net10.0), fix.ps1 (clean diff), lint.ps1 (clean), and an external 115-file PDF corpus check (0 crashes/timeouts). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-net-pdf/pdf-document.yaml | 3 + .../canvas-net-pdf/pdf-document.md | 17 ++ .../PdfDocument.Xref.cs | 158 ++++++++++-- .../PdfDocumentTests.cs | 224 ++++++++++++++++++ 4 files changed, 379 insertions(+), 23 deletions(-) diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index f1a07a7f..5e965d0f 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -139,6 +139,9 @@ sections: - PdfDocument_Open_LinearScanFallback_SecondAngleBracketAsHexStringStart_DoesNotCorruptOffset - PdfDocument_Open_LinearScanFallback_LongUnterminatedLiteralStringRun_CompletesPromptly - PdfDocument_Open_ClassicXref_StartxrefAsTokenSuffix_DoesNotRedirectToBogusOffset + - PdfDocument_Open_ClassicXref_StartxrefAsNameToken_DoesNotRedirectToBogusOffset + - PdfDocument_Open_LinearScanFallback_CommentBeforeEndobj_DoesNotCorruptOffsets + - PdfDocument_Open_LinearScanFallback_CommentInObjectHeader_ResolvesObject - id: CanvasNetPdf-PdfDocument-PageTreeTraversal title: >- diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index c9eeecc5..cd3a8448 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -238,6 +238,23 @@ example `xstartxref`) is never mistaken for a standalone marker, even when it is positioned later in the file, byte-identical, and points at an independently valid second cross-reference section - which would otherwise cause the wrong document revision to be silently resolved instead of the genuine one. +`PdfDocument_Open_ClassicXref_StartxrefAsNameToken_DoesNotRedirectToBogusOffset` confirms the +analogous PDF name-token exclusion for that same `startxref` marker search: a decoy +`/startxref` is rejected because it is lexically a PDF name token whose text happens to read +"startxref", never a standalone keyword, mirroring the existing `/trailer` name-token exclusion +`IsKeywordAt` already applies when scanning forward. +`PdfDocument_Open_LinearScanFallback_CommentBeforeEndobj_DoesNotCorruptOffsets` confirms that a +genuine `endstream` keyword separated from the `endobj` keyword that follows it by ordinary +whitespace and a PDF comment (not merely an optional CRLF sequence) is still recognized as +structurally valid: the PDF grammar permits any amount of whitespace, and comments, between +tokens, and treating such a layout as coincidental payload noise would leave the stream's payload +unprotected, exposing a false, embedded object header to corrupt an earlier, genuine object's +offset. +`PdfDocument_Open_LinearScanFallback_CommentInObjectHeader_ResolvesObject` confirms that a PDF +comment separating an object header's generation number from its `obj` keyword - valid per the +PDF grammar, since a comment is whitespace-equivalent token separation - does not make the +backward object-header scan miss that object entirely; fallback recovery must recognize the same +header a real forward parser would find. #### CanvasNetPdf-PdfDocument-PageTreeTraversal: Page Tree Traversal Reports All Pages diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index 31226096..2795be57 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -376,9 +376,17 @@ private int FindStartXrefOffset() /// mirroring the same regular-byte boundary rule applies when /// scanning forward, so a decoy such as mystartxref cannot be mistaken for the real /// startxref keyword, and a legitimate earlier match is not shadowed by a later one - /// that is merely the tail or prefix of a larger run of regular bytes. This does not by - /// itself exclude a boundary-respecting match that happens to sit inside a PDF comment or - /// string, unlike the comment/string-aware scans in and + /// that is merely the tail or prefix of a larger run of regular bytes. A match is also + /// rejected when immediately preceded by /, mirroring 's + /// own name-token exclusion: per the PDF specification a name token begins with / + /// directly followed by its regular characters with no intervening whitespace, so (for + /// example) /startxref is a name whose text happens to read "startxref" - + /// a legitimate dictionary key or value any real document can contain - never the + /// startxref keyword itself, and must not be parsed as one; without this exclusion + /// such a name occurring after the genuine marker could win this reverse search and + /// redirect parsing to the wrong cross-reference section. This does not by itself exclude + /// a boundary-respecting match that happens to sit inside a PDF comment or string, unlike + /// the comment/string-aware scans in and /// - a narrower, best-effort improvement over the /// previous unguarded raw byte search, not a claim of full lexical correctness. /// @@ -402,7 +410,7 @@ private static int LastIndexOfKeyword(byte[] haystack, ReadOnlySpan keywor continue; } - if (i > 0 && IsMarkerRegularByte(haystack[i - 1])) + if (i > 0 && (IsMarkerRegularByte(haystack[i - 1]) || haystack[i - 1] == (byte)'/')) { continue; } @@ -749,30 +757,68 @@ private static int FindStructurallyValidEndstream(byte[] buffer, int dataStart) } /// - /// Returns whether , after tolerating an optional end-of-line - /// sequence, is immediately followed by the literal endobj keyword - the structure - /// every genuine endstream keyword is followed by. + /// Returns whether , after tolerating any PDF whitespace and + /// comments between tokens (not merely a single optional end-of-line sequence - PDF + /// permits any amount of whitespace, and comments are themselves whitespace-equivalent, + /// between the endstream and endobj keywords), is immediately followed by + /// the literal endobj keyword - the structure every genuine endstream + /// keyword is followed by. /// private static bool IsFollowedByEndObjKeyword(byte[] buffer, int index) { - var afterEol = index; - if (afterEol < buffer.Length && buffer[afterEol] == (byte)'\r') - { - afterEol++; - } - - if (afterEol < buffer.Length && buffer[afterEol] == (byte)'\n') - { - afterEol++; - } - + var afterSeparators = SkipMarkerWhitespaceAndCommentsForward(buffer, index); var endObjIndex = IndexOfKeyword( buffer, - afterEol, - Math.Min(afterEol + "endobj"u8.Length, buffer.Length), + afterSeparators, + Math.Min(afterSeparators + "endobj"u8.Length, buffer.Length), "endobj"u8, requirePrecedingBoundary: false); - return endObjIndex == afterEol; + return endObjIndex == afterSeparators; + } + + /// + /// Maximum number of bytes will walk + /// forward. Bounded for the same reason as : so + /// that a crafted file cannot turn this separator skip, performed once per candidate + /// endstream match inside 's loop, into + /// quadratic work. Real producers never place hundreds of bytes of whitespace or comments + /// between endstream and endobj; failing to look past this bound only risks + /// missing that one structural-validity check, never a crash or a hang. + /// + private const int MaxForwardSeparatorScanLength = 256; + + /// + /// Walks forward over any run of PDF whitespace and comments (a + /// % through end of line) - mirroring how itself treats + /// a comment as insignificant, token-separating content. Bounded by + /// bytes to keep this a strictly linear-time + /// operation overall. + /// + private static int SkipMarkerWhitespaceAndCommentsForward(byte[] buffer, int cursor) + { + var limit = Math.Min(buffer.Length, cursor + MaxForwardSeparatorScanLength); + while (cursor < limit) + { + if (IsMarkerWhitespace(buffer[cursor])) + { + cursor++; + continue; + } + + if (buffer[cursor] == (byte)'%') + { + while (cursor < limit && buffer[cursor] is not ((byte)'\n' or (byte)'\r')) + { + cursor++; + } + + continue; + } + + break; + } + + return cursor; } /// @@ -915,7 +961,7 @@ private static bool TryParseObjectHeaderBackward(byte[] buffer, int objIndex, ou var cursor = objIndex; var sectionEnd = cursor; - cursor = SkipMarkerBytesBackward(buffer, cursor, IsMarkerWhitespace); + cursor = SkipMarkerWhitespaceAndCommentsBackward(buffer, cursor); if (cursor == sectionEnd) { return false; @@ -929,7 +975,7 @@ private static bool TryParseObjectHeaderBackward(byte[] buffer, int objIndex, ou } sectionEnd = cursor; - cursor = SkipMarkerBytesBackward(buffer, cursor, IsMarkerWhitespace); + cursor = SkipMarkerWhitespaceAndCommentsBackward(buffer, cursor); if (cursor == sectionEnd) { return false; @@ -975,6 +1021,72 @@ private static bool TryParseObjectHeaderBackward(byte[] buffer, int objIndex, ou return true; } + /// + /// Maximum number of bytes will scan + /// backward from a candidate comment's end while searching for its leading %. + /// Deliberately small and fixed so that even a maliciously crafted file consisting of one + /// enormous line cannot turn this backward comment check into O(n) work repeated at up to + /// O(n) different candidate match positions - which would reintroduce the same quadratic + /// blowup that 's own unterminated-literal-string guard + /// exists to prevent. A genuine PDF comment this long between an object header's number, + /// generation, and obj tokens is not a realistic producer output; failing to + /// recognize one beyond this bound only risks the fallback recovery scanner missing one + /// candidate header, never a crash or a hang. + /// + private const int MaxBackwardCommentScanLength = 256; + + /// + /// Walks backward over a run of PDF whitespace and, when one + /// immediately precedes it, a single PDF comment (a % through end of line) - + /// mirroring how itself treats a comment as insignificant, + /// token-separating content when scanning forward. This lets fallback recovery's backward + /// object-header scan recognize headers such as 1 0 % comment\nobj that a real + /// forward parser would find, which a whitespace-only backward skip would otherwise miss. + /// Bounded by bytes per candidate comment to + /// keep the overall scan strictly linear. + /// + private static int SkipMarkerWhitespaceAndCommentsBackward(byte[] buffer, int cursor) + { + while (true) + { + var beforeWhitespace = cursor; + cursor = SkipMarkerBytesBackward(buffer, cursor, IsMarkerWhitespace); + + // Search a bounded window immediately before the current position for a '%' that + // isn't separated from here by an end-of-line byte - if found, everything from the + // '%' up to here is a single-line comment's body, which the PDF tokenizer treats as + // whitespace-equivalent and so must this backward scan. + var scanLimit = Math.Max(0, cursor - MaxBackwardCommentScanLength); + var percentIndex = -1; + for (var i = cursor - 1; i >= scanLimit; i--) + { + if (buffer[i] is (byte)'\n' or (byte)'\r') + { + break; + } + + if (buffer[i] == (byte)'%') + { + percentIndex = i; + break; + } + } + + if (percentIndex < 0) + { + return cursor; + } + + cursor = percentIndex; + if (cursor == beforeWhitespace) + { + // Defensive: a '%' is never whitespace, so this iteration always makes forward + // (backward) progress; stop rather than loop forever if that invariant ever broke. + return cursor; + } + } + } + /// Walks backward while the preceding byte satisfies . private static int SkipMarkerBytesBackward(byte[] buffer, int cursor, Func predicate) { diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index d2a48efc..0e58d31e 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -2956,6 +2956,230 @@ public void PdfDocument_Open_ClassicXref_StartxrefAsTokenSuffix_DoesNotRedirectT Assert.Equal(default, surface[5, 5]); } + /// + /// Builds an in-memory, single-page, classic-xref PDF (via ) + /// with genuine, correctly-bounded xref/trailer/startxref syntax, + /// followed by a second, structurally complete and independently valid classic + /// cross-reference section (objects 5-8, a differently-shaped single page) preceded by a + /// decoy PDF name token /startxref - a byte-perfect startxref sequence whose + /// preceding byte / makes it only a name token's text, not a standalone keyword + /// token of its own. Because the decoy section is itself fully valid (not merely a bogus + /// offset), a search that wrongly selects it does not throw - it silently resolves the + /// wrong document revision instead, so this is not masked by the linear-scan fallback that + /// normally recovers from a thrown exception. + /// + private static byte[] BuildClassicXrefPdfWithStartxrefAsNameToken() + { + var buffer = BuildSinglePagePdf(100, 100, "10 10 40 40 re f").ToList(); + + // A second, independently valid document revision (objects 5-8) with deliberately + // different content (a small square far from the real document's black square), used + // purely as a decoy target: if the corrupted search wrongly selects its "/startxref", the + // active trailer silently becomes this revision's instead of throwing. + var contentB = "60 60 10 10 re f"u8.ToArray(); + var bodiesB = new List + { + "<< /Type /Catalog /Pages 6 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [7 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 6 0 R /Contents 8 0 R >>"u8.ToArray(), + BuildStreamObjectBody($"/Length {contentB.Length}", contentB), + }; + + var offsetsB = new List(); + for (var i = 0; i < bodiesB.Count; i++) + { + offsetsB.Add(buffer.Count); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 5} 0 obj\n")); + buffer.AddRange(bodiesB[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + var xrefOffsetB = buffer.Count; + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"xref\n5 {bodiesB.Count}\n")); + foreach (var offset in offsetsB) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{offset:D10} 00000 n \n")); + } + + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes("trailer\n<< /Size 9 /Root 5 0 R >>\n")); + + // This decoy is physically positioned after the real "startxref" marker above, so it + // would otherwise be selected by a raw last-byte-match search were its "startxref" text + // (the trailing part of the "/startxref" name token) wrongly treated as a standalone + // keyword token - redirecting cross-reference parsing to this structurally valid, but + // different, decoy revision. + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"/startxref\n{xrefOffsetB}\n%%EOF\n")); + return [.. buffer]; + } + + /// + /// Proves that a startxref keyword embedded as only the text of a PDF name token + /// (see ) is never mistaken for a + /// genuine, standalone startxref marker, even though the decoy is physically + /// positioned later in the file than the real marker, points at an independently valid + /// cross-reference section, and so would otherwise be selected by a raw last-byte-match + /// search and silently resolve to the wrong document revision instead of the genuine one. + /// + [Fact] + public void PdfDocument_Open_ClassicXref_StartxrefAsNameToken_DoesNotRedirectToBogusOffset() + { + // Arrange + var pdfBytes = BuildClassicXrefPdfWithStartxrefAsNameToken(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback): object 4 is an unreferenced, + /// irrelevant stream whose /Length is an indirect reference (object 6), so the + /// declared-length boundary check cannot be used, forcing FindStructurallyValidEndstream + /// to be exercised. Its payload contains a false, line-start 3 0 obj header + /// (overwriting the real, earlier object 3 if the payload is left unprotected) followed by + /// the genuine endstream keyword, itself separated from the following, genuine + /// endobj keyword by ordinary whitespace and a PDF comment - a layout the PDF + /// grammar permits (any amount of whitespace, and comments, may separate tokens) but which + /// a CRLF-only separator check would reject, wrongly treating this genuine endstream match + /// as coincidental payload noise and leaving the whole payload - including the false + /// 3 0 obj decoy - unprotected and open to being scanned again. + /// + private static byte[] BuildLinearScanFallbackWithCommentBeforeEndobj() + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>"u8.ToArray(), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + var payloadBody = "XXXX\n3 0 obj\n<< /Bogus (decoy) >>\nendobj\nYYYY"u8.ToArray(); + buffer.AddRange("4 0 obj\n<< /Length 6 0 R >>\nstream\n"u8.ToArray()); + buffer.AddRange(payloadBody); + buffer.AddRange("\nendstream % trailing comment\nendobj\n"u8.ToArray()); + + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes("5 0 obj\n")); + buffer.AddRange(BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes)); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + // Object 6: the /Length value object 4's stream dictionary points to. Its own value is + // irrelevant to this test - it is never trusted as a declared length boundary merely by + // being referenced indirectly. + buffer.AddRange("6 0 obj\n9999\nendobj\n"u8.ToArray()); + + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that whitespace and a PDF comment between a genuine endstream keyword and + /// the endobj keyword that follows it (see + /// ) do not cause that + /// genuine match to be wrongly rejected as coincidental payload noise. Rejecting it would + /// leave the stream's payload unprotected, exposing a false, line-start 3 0 obj + /// header embedded within it to be scanned again as if it were real document syntax - + /// overwriting the real, earlier object 3's correct offset with the decoy's. The real + /// object 3 (the page) must still resolve and render correctly using its own genuine, + /// earlier header. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_CommentBeforeEndobj_DoesNotCorruptOffsets() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithCommentBeforeEndobj(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback), whose object 3 (the page) + /// header places a PDF comment between its generation number and the obj keyword - + /// 3 0 % comment\nobj - valid per the PDF grammar (a comment is whitespace-equivalent + /// token separation) but previously unrecognized by a backward object-header scan that + /// only walked raw whitespace bytes, making this genuine object appear entirely missing + /// during fallback recovery. + /// + private static byte[] BuildLinearScanFallbackWithCommentInObjectHeader() + { + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + buffer.AddRange("1 0 obj\n"u8.ToArray()); + buffer.AddRange(bodies[0]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + buffer.AddRange("2 0 obj\n"u8.ToArray()); + buffer.AddRange(bodies[1]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + // Object 3's header places a comment between the generation number and "obj". + buffer.AddRange("3 0 % object header comment\nobj\n"u8.ToArray()); + buffer.AddRange(bodies[2]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + buffer.AddRange("4 0 obj\n"u8.ToArray()); + buffer.AddRange(bodies[3]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that a PDF comment separating an object header's generation number from its + /// obj keyword (see ) + /// does not make the backward object-header scan miss that object entirely. A real forward + /// parser treats the comment as insignificant whitespace and finds the header; fallback + /// recovery's backward scan must recognize the same header so the object resolves + /// correctly instead of appearing to be missing from the document. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_CommentInObjectHeader_ResolvesObject() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithCommentInObjectHeader(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + /// /// Returns the character offset, within (the ASCII text of a PDF /// built by ), of the fixed-width 10-digit offset field From 8712558bd1c66f5b929f6a97047932ea3d680136 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 3 Oct 2026 10:39:14 -0400 Subject: [PATCH 12/15] Fix two more PDF fallback-scanner lexical-boundary gaps found by local review Found by running local code-review and rubber-duck review agents against the same bug class as the preceding GitHub-reported findings, per request to catch these locally instead of via paid online review rounds: - TryGetDeclaredStreamEnd only tolerated a single optional CRLF between a stream's correctly declared /Length payload end and the endstream keyword, not arbitrary whitespace and comments (same gap already fixed in IsFollowedByEndObjKeyword and TryParseObjectHeaderBackward). A genuinely correct declared length separated by a comment was wrongly distrusted, falling through to the much weaker raw endstream scan and risking acceptance of an early, coincidental endstream/endobj pair inside the payload - ending the protected payload range too soon. - SkipMarkerWhitespaceAndCommentsForward's bounded 256-byte scan window could return a position still inside an unterminated comment if the comment's own end-of-line terminator fell outside that window while the buffer continued further - letting a caller (IsFollowedByEndObjKeyword) misread comment-body bytes that happened to spell a keyword as a real one. Now returns the comment's own start position in that ambiguous case, which can never match a keyword, so the caller safely treats it as no boundary found rather than guessing. Added regression tests for both (reverting each fix independently confirmed the corresponding new test fails, then passes once restored). Full build.ps1 (7395/7395 across net8.0/net9.0/net10.0), fix.ps1, and lint.ps1 all clean; external 115-file corpus check unchanged (0 crashes/timeouts). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-net-pdf/pdf-document.yaml | 2 + .../canvas-net-pdf/pdf-document.md | 16 ++ .../PdfDocument.Xref.cs | 40 +++-- .../PdfDocumentTests.cs | 170 ++++++++++++++++++ 4 files changed, 214 insertions(+), 14 deletions(-) diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index 5e965d0f..625a544a 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -142,6 +142,8 @@ sections: - PdfDocument_Open_ClassicXref_StartxrefAsNameToken_DoesNotRedirectToBogusOffset - PdfDocument_Open_LinearScanFallback_CommentBeforeEndobj_DoesNotCorruptOffsets - PdfDocument_Open_LinearScanFallback_CommentInObjectHeader_ResolvesObject + - PdfDocument_Open_LinearScanFallback_CommentBeforeDeclaredLengthEndstream_DoesNotCorruptOffsets + - PdfDocument_Open_LinearScanFallback_UnterminatedCommentAtForwardScanBound_DoesNotCorruptOffsets - id: CanvasNetPdf-PdfDocument-PageTreeTraversal title: >- diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index cd3a8448..8fc25106 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -255,6 +255,22 @@ comment separating an object header's generation number from its `obj` keyword - PDF grammar, since a comment is whitespace-equivalent token separation - does not make the backward object-header scan miss that object entirely; fallback recovery must recognize the same header a real forward parser would find. +`PdfDocument_Open_LinearScanFallback_CommentBeforeDeclaredLengthEndstream_DoesNotCorruptOffsets` +confirms that whitespace and a PDF comment between the end of a stream's correctly declared +`/Length` payload and its terminating `endstream` keyword - valid per the PDF grammar, not merely +an optional CRLF sequence - do not cause that genuinely correct declared length to be distrusted. +Distrusting it would fall through to the much weaker raw fallback scan, which accepts an early, +coincidental `endstream`/`endobj` pair embedded within the payload itself, ending the protected +payload range too soon and exposing a false object header within it to corrupt an earlier, +genuine object's offset. +`PdfDocument_Open_LinearScanFallback_UnterminatedCommentAtForwardScanBound_DoesNotCorruptOffsets` +confirms that when the bounded forward whitespace-and-comment scan runs out before an in-progress +comment reaches its own end-of-line terminator, the bytes sitting exactly at that bound are never +mistaken for a genuine keyword even when they spell one exactly - because, per the PDF comment +grammar, they may in fact still be part of the comment's own (longer than the bound) body. +Accepting them regardless would let an early, coincidental `endstream` be mistaken for the +stream's real terminator, ending the protected payload range too soon and exposing a false object +header to corrupt an earlier, genuine object's offset. #### CanvasNetPdf-PdfDocument-PageTreeTraversal: Page Tree Traversal Reports All Pages diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index 2795be57..2cf02569 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -794,6 +794,16 @@ private static bool IsFollowedByEndObjKeyword(byte[] buffer, int index) /// bytes to keep this a strictly linear-time /// operation overall. /// + /// + /// If a comment's own end-of-line terminator is not reached before that bound - which, + /// given the bound's small size, can only happen when the comment is still genuinely + /// unterminated at the point the scan gives up, with more buffer remaining beyond it - + /// this returns the index of that comment's own leading % rather than guessing how + /// far past it the comment extends. Returning any later position risked a caller (for + /// example ) mistaking bytes that are still part of + /// the comment's own body for a real keyword immediately following it - silently accepting + /// a false boundary rather than conservatively reporting none. + /// private static int SkipMarkerWhitespaceAndCommentsForward(byte[] buffer, int cursor) { var limit = Math.Min(buffer.Length, cursor + MaxForwardSeparatorScanLength); @@ -807,11 +817,22 @@ private static int SkipMarkerWhitespaceAndCommentsForward(byte[] buffer, int cur if (buffer[cursor] == (byte)'%') { + var commentStart = cursor; while (cursor < limit && buffer[cursor] is not ((byte)'\n' or (byte)'\r')) { cursor++; } + if (cursor == limit && limit < buffer.Length) + { + // The bounded scan ran out before this comment reached its own end-of-line, + // and the buffer continues past this point - whether that later content is + // really past the comment, or still part of its (longer than our bound) body, + // cannot be determined without unbounded work. Report the comment's own start + // as the result instead of the ambiguous cutoff position. + return commentStart; + } + continue; } @@ -824,9 +845,10 @@ private static int SkipMarkerWhitespaceAndCommentsForward(byte[] buffer, int cur /// /// Validates a stream dictionary's declared /Length against the buffer - it must be /// a non-negative integer landing fully within the buffer, and the bytes immediately - /// following it (after tolerating an optional end-of-line sequence, since several - /// real-world producers include one despite it not being strictly required) must be the - /// literal endstream keyword - and if so, returns the index just past that keyword. + /// following it (after tolerating any PDF whitespace and comments, since the PDF grammar + /// permits both - not merely an optional end-of-line sequence, despite that being what + /// most real-world producers emit) must be the literal endstream keyword - and if + /// so, returns the index just past that keyword. /// private static bool TryGetDeclaredStreamEnd(byte[] buffer, int dataStart, double declaredLength, out int payloadEnd) { @@ -842,17 +864,7 @@ private static bool TryGetDeclaredStreamEnd(byte[] buffer, int dataStart, double return false; } - var afterData = (int)declaredDataEnd; - if (afterData < buffer.Length && buffer[afterData] == (byte)'\r') - { - afterData++; - } - - if (afterData < buffer.Length && buffer[afterData] == (byte)'\n') - { - afterData++; - } - + var afterData = SkipMarkerWhitespaceAndCommentsForward(buffer, (int)declaredDataEnd); var endStreamIndex = IndexOfKeyword( buffer, afterData, diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 0e58d31e..3132e76e 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -3180,6 +3180,176 @@ public void PdfDocument_Open_LinearScanFallback_CommentInObjectHeader_ResolvesOb Assert.Equal(default, surface[5, 5]); } + /// + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback): object 4 is an unreferenced + /// stream with a genuine, direct, correctly-declared /Length covering its whole + /// payload, but whose terminating endstream keyword is separated from the end of + /// that declared payload by ordinary whitespace and a PDF comment rather than a bare + /// end-of-line sequence - valid per the PDF grammar, but previously rejected by a + /// declared-length validator that only tolerated an optional CRLF. The payload itself + /// contains an early, coincidental endstream immediately followed by a genuine + /// endobj (so it passes the structural "followed by endobj" check on its own), and + /// then a false, line-start 3 0 obj header - if the comment-separated, genuinely + /// declared length is wrongly distrusted, the raw fallback scan accepts this early, + /// coincidental match instead, ending the protected payload range too soon and exposing + /// the false header to corrupt the real, earlier object 3's offset. + /// + private static byte[] BuildLinearScanFallbackWithCommentBeforeDeclaredLengthEndstream() + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>"u8.ToArray(), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + // An early, coincidental "endstream" that IS immediately followed by "endobj" (so it + // would be accepted by the raw fallback scan's own structural check on its own merits), + // followed by a false, line-start "3 0 obj" header - both fully inside the declared + // /Length below, and so never reachable by the raw scan at all when the declared length + // is correctly trusted. + var payloadBody = "AAAA\nendstream\nendobj\nBBBB\n3 0 obj\n<< /Bogus (decoy) >>\nendobj\nCCCC"u8.ToArray(); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"4 0 obj\n<< /Length {payloadBody.Length} >>\nstream\n")); + buffer.AddRange(payloadBody); + buffer.AddRange(" % trailing comment\nendstream\nendobj\n"u8.ToArray()); + + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes("5 0 obj\n")); + buffer.AddRange(BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes)); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that whitespace and a PDF comment between the end of a stream's correctly + /// declared /Length payload and its terminating endstream keyword (see + /// ) do not + /// cause that genuinely correct declared length to be distrusted. Distrusting it would + /// fall through to the much weaker raw fallback scan, which accepts an early, coincidental + /// endstream/endobj pair embedded within the payload itself - ending the + /// protected payload range too soon and exposing a false, line-start 3 0 obj header + /// within it to corrupt the real, earlier object 3's correct offset. The real object 3 + /// (the page) must still resolve and render correctly using its own genuine, earlier + /// header. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_CommentBeforeDeclaredLengthEndstream_DoesNotCorruptOffsets() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithCommentBeforeDeclaredLengthEndstream(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + + /// + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback): object 4 is an unreferenced + /// stream with an indirect, unresolvable /Length (forcing the raw + /// FindStructurallyValidEndstream fallback search), whose payload begins with an + /// early, coincidental endstream keyword immediately followed by a PDF comment that + /// deliberately does not reach its own end-of-line terminator within + /// SkipMarkerWhitespaceAndCommentsForward's bounded scan window, with the literal + /// text endobj positioned exactly at that window's cutoff. Followed by that is a + /// false, line-start 3 0 obj header, and then the stream's genuine, final + /// endstream/endobj pair. A separator scan that mistook the bounded cutoff + /// for a genuine comment terminator would read the endobj text sitting there - which + /// is, per the PDF comment grammar, still ambiguous (possibly still part of the + /// unterminated comment) - as confirmation that the early, coincidental endstream is + /// structurally valid, ending the protected payload range far too soon. + /// + private static byte[] BuildLinearScanFallbackWithUnterminatedCommentAtForwardScanBound() + { + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>"u8.ToArray(), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + // "endstream" immediately followed by a comment of exactly 256 bytes ('%' plus 255 'A' + // bytes) with no end-of-line anywhere in it - the same length as + // SkipMarkerWhitespaceAndCommentsForward's own bounded scan window - so that window's + // cutoff lands exactly on the following "endobj" text, which (since the comment never + // actually reached a real terminator) must not be mistaken for a genuine, standalone + // keyword. + var unterminatedComment = "%" + new string('A', 255); + var payloadBody = "endstream" + unterminatedComment + "endobj" + + "\nBBBB\n3 0 obj\n<< /Bogus (decoy) >>\nendobj\nCCCC"; + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"4 0 obj\n<< /Length 6 0 R >>\nstream\n")); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes(payloadBody)); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes("5 0 obj\n")); + buffer.AddRange(BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes)); + buffer.AddRange("\nendobj\n"u8.ToArray()); + + // Object 6: the /Length value object 4's stream dictionary points to. Its own value is + // irrelevant to this test - it is never trusted as a declared length boundary merely by + // being referenced indirectly. + buffer.AddRange("6 0 obj\n9999\nendobj\n"u8.ToArray()); + + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that when the bounded forward whitespace-and-comment scan runs out before an + /// in-progress comment reaches its own end-of-line terminator (see + /// ), the + /// bytes sitting exactly at that bound are never mistaken for a genuine keyword even when + /// they spell one exactly - because, per the PDF comment grammar, they may in fact still be + /// part of the comment's own (longer than the bound) body. Accepting them regardless would + /// let an early, coincidental endstream be mistaken for the stream's real terminator, + /// ending the protected payload range too soon and exposing a false, line-start + /// 3 0 obj header to corrupt the real, earlier object 3's correct offset. The real + /// object 3 (the page) must still resolve and render correctly using its own genuine, + /// earlier header. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_UnterminatedCommentAtForwardScanBound_DoesNotCorruptOffsets() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithUnterminatedCommentAtForwardScanBound(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + /// /// Returns the character offset, within (the ASCII text of a PDF /// built by ), of the fixed-width 10-digit offset field From e487cb90943d24c180f6bd02010571475dff482c Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 3 Oct 2026 12:00:17 -0400 Subject: [PATCH 13/15] Fix object-number/generation-number integer-truncation bugs; harden trailer-scan range ordering - Add TryGetNonNegativeInt helper validating a parsed PdfToken.Number (double) is representable as a non-negative int before narrowing, replacing bare (int) casts that could silently truncate a non-integer value (e.g. 1.5 -> 1) instead of rejecting malformed input. Applied in RegisterCompressedObjectsFromObjectStreams, LoadCompressedObject, and ParseIndirectObjectAt. - Reorder ScanForTrailerDictionary's stream-payload-range membership check to run before AdvancePastNonSyntax is consulted, mirroring ScanObjectOffsets' already-safe pattern: a payload beginning with an unescaped '(' or '<' must never be treated as real PDF string syntax before the range guard has a chance to skip it. - Add regression tests for all three truncation fixes (classic-xref object header, direct xref-stream compressed-object path, and the linear-scan-fallback compressed-object path), each verified via a revert/restore cycle to fail without the corresponding fix. - Update reqstream/verification docs for the new tests, and document the trailer-scan reordering's reasoning (including why a dedicated fixture for it does not discriminate buggy vs. fixed behavior given how streamPayloadRanges are computed). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-net-pdf/pdf-document.yaml | 3 + .../canvas-net-pdf/pdf-document.md | 37 ++++ .../PdfDocument.Xref.cs | 88 ++++++-- .../PdfDocumentTests.cs | 207 +++++++++++++++++- 4 files changed, 317 insertions(+), 18 deletions(-) diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index 625a544a..cd66e87d 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -71,6 +71,7 @@ sections: - PdfDocument_Open_ClassicXref_ResolvesRootAndPage - PdfDocument_Open_ClassicXref_EntryOffsetBeyondInt32Range_ThrowsInvalidDataException - PdfDocument_Open_ClassicXref_EntryPointingAtWrongObjectHeader_ThrowsInvalidDataException + - PdfDocument_Open_ClassicXref_NonIntegerObjectNumberHeader_ThrowsInvalidDataException - id: CanvasNetPdf-PdfDocument-XrefStream title: >- @@ -95,6 +96,7 @@ sections: compressed this way, many modern documents' pages would be unreachable. tests: - PdfDocument_Open_ObjectStream_DecompressesAndResolvesCompressedPage + - PdfDocument_Open_CompressedObjectNonIntegerNumber_ThrowsInvalidDataException - id: CanvasNetPdf-PdfDocument-HybridXref title: >- @@ -144,6 +146,7 @@ sections: - PdfDocument_Open_LinearScanFallback_CommentInObjectHeader_ResolvesObject - PdfDocument_Open_LinearScanFallback_CommentBeforeDeclaredLengthEndstream_DoesNotCorruptOffsets - PdfDocument_Open_LinearScanFallback_UnterminatedCommentAtForwardScanBound_DoesNotCorruptOffsets + - PdfDocument_Open_LinearScanFallback_CompressedObjectNonIntegerNumber_ThrowsInvalidDataException - id: CanvasNetPdf-PdfDocument-PageTreeTraversal title: >- diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index 8fc25106..4ea365c6 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -126,6 +126,12 @@ second, independently well-formed and shape-compatible `/Type /Page` object appe document's own `%%EOF` (never reachable through any genuine `xref`/`trailer` path), proving the mismatch is rejected outright rather than silently accepted and relabeled with the requested object number - which would otherwise render the wrong page without any error. +`PdfDocument_Open_ClassicXref_NonIntegerObjectNumberHeader_ThrowsInvalidDataException` confirms +that an object header's declared number token is validated as representable by a non-negative +`int` before being compared against the number being resolved: the parsed token value is a +floating-point `double`, so a bare narrowing cast (for example `(int)value`) would silently +truncate a non-integer value such as `1.5` to `1`, wrongly treating a malformed header as a match +for object 1 instead of rejecting it outright. #### CanvasNetPdf-PdfDocument-XrefStream: Xref Stream Resolves Root and Page @@ -144,6 +150,13 @@ dictionary) through the public API. Asserts `PageCount` and the compressed page' size match the fixture's known content, confirming FlateDecode decompression and per-object resolution within the stream both work correctly. +`PdfDocument_Open_CompressedObjectNonIntegerNumber_ThrowsInvalidDataException` confirms that a +compressed object's own declared object number (read from inside the decompressed `/Type /ObjStm` +body) is likewise validated as a non-negative `int` before use as a dictionary key and before +comparison against the number being resolved, for the same reason as the classic-xref header +check above: the parsed token is a `double`, and an unchecked narrowing cast would silently +truncate a non-integer value rather than reject it. + #### CanvasNetPdf-PdfDocument-HybridXref: Hybrid Xref Resolves Compressed Entry via XRefStm **Test**: `PdfDocument_Open_HybridXref_ResolvesCompressedEntryViaXRefStm` @@ -238,6 +251,30 @@ example `xstartxref`) is never mistaken for a standalone marker, even when it is positioned later in the file, byte-identical, and points at an independently valid second cross-reference section - which would otherwise cause the wrong document revision to be silently resolved instead of the genuine one. +`PdfDocument_Open_LinearScanFallback_CompressedObjectNonIntegerNumber_ThrowsInvalidDataException` +confirms the same non-negative-`int` validation applied to a compressed object's declared number +(see `CanvasNetPdf-PdfDocument-ObjectStream` above) is also enforced when an object stream is +reached only through this fallback's own eager object-stream registration pass, rather than +through the primary cross-reference path - both call sites share the same validation helper, but +each is independently reachable and so independently verified. + +The stream-payload-range membership check in the trailer-keyword scan (used by this fallback to +locate an explicit `trailer` dictionary when no trustworthy cross-reference chain is available) +is ordered to run *before* any comment/string-literal tracking is attempted at a given scan +position, mirroring the proven-safe pattern `ScanObjectOffsets` already uses for the same ranges: +a stream payload beginning with an unescaped `(` or `<` byte must never be treated as the start of +real PDF string syntax, since the literal/hex-string skip tracks a matching close byte only +against the whole buffer's end, not the payload's own end, and so could in principle carry the +scan arbitrarily far - including past a genuine `trailer` keyword - before a check running only +*after* that attempt ever gets a chance to redirect it. No dedicated fixture reproduces this +ordering actually changing behavior: `streamPayloadRanges` always begins immediately after an +object's `obj` keyword match, at a position that is reached one byte at a time by the scan and is +always a few bytes of guaranteed-safe dictionary-header text (which `AdvancePastNonSyntax` never +treats specially) before any payload content proper, so the range check applied either before or +after already catches the position deterministically at that safe boundary in every construction +attempted. The reordering is kept regardless, both because it is strictly safer with no +behavioral downside and because it removes the dependency on this incidental property, which is +not guaranteed by anything the PDF format itself requires. `PdfDocument_Open_ClassicXref_StartxrefAsNameToken_DoesNotRedirectToBogusOffset` confirms the analogous PDF name-token exclusion for that same `startxref` marker search: a decoy `/startxref` is rejected because it is lexically a PDF name token whose text happens to read diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index 2cf02569..533f3631 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -1360,10 +1360,20 @@ private void RegisterCompressedObjectsFromObjectStreams() break; } + // A malformed, non-integer or out-of-range declared object number (for example + // "1.5" or a value far beyond int range) cannot be used as a dictionary key at + // all - narrowing it with a bare (int) cast first would either throw nothing and + // silently truncate (for "1.5") or produce an unspecified wrapped value (for an + // out-of-range magnitude). Treat either case the same as any other malformed + // header token: stop trusting the rest of this object stream's header. + if (!TryGetNonNegativeInt(numberToken.Value.Number, out var containedNumber)) + { + break; + } + // A direct "N G obj" marker (if one somehow also exists for this number) is more // trustworthy than an assumed containment index, so never overwrite an existing // entry here. - var containedNumber = (int)numberToken.Value.Number; if (!_xref.ContainsKey(containedNumber)) { _xref[containedNumber] = XrefEntry.CreateCompressed(streamNumber, i); @@ -1372,6 +1382,28 @@ private void RegisterCompressedObjectsFromObjectStreams() } } + /// + /// Returns whether is a finite integer exactly representable as + /// a non-negative - the validity rule every numeric field parsed from + /// untrusted PDF syntax must satisfy before being narrowed and used as a dictionary key, + /// array index, or identity comparison. A bare (int) cast on a value that fails + /// this check either silently truncates a non-integer value (for example 1.5 + /// becoming 1) or produces an unspecified, platform-dependent wrapped result (for a + /// magnitude outside 's range) - both of which risk accepting a malformed + /// declaration as if it were a different, legitimate one instead of rejecting it. + /// + private static bool TryGetNonNegativeInt(double value, out int result) + { + if (value >= 0 && value <= int.MaxValue && double.IsInteger(value)) + { + result = (int)value; + return true; + } + + result = 0; + return false; + } + /// /// Reads the next token, treating a malformed token - for example a hex string containing /// a byte that is neither a hex digit nor whitespace - as unparsable noise rather than @@ -1441,13 +1473,6 @@ private void RegisterCompressedObjectsFromObjectStreams() while (i < buffer.Length) { - var afterNonSyntax = AdvancePastNonSyntax(buffer, i, ref unterminatedStringEncountered); - if (afterNonSyntax != i) - { - i = afterNonSyntax; - continue; - } - while (rangeIndex < streamPayloadRanges.Count && i >= streamPayloadRanges[rangeIndex].End) { rangeIndex++; @@ -1456,12 +1481,27 @@ private void RegisterCompressedObjectsFromObjectStreams() if (rangeIndex < streamPayloadRanges.Count && i >= streamPayloadRanges[rangeIndex].Start && i < streamPayloadRanges[rangeIndex].End) { - // Jump straight past the whole payload range - it is not real PDF syntax, so - // neither comment/string tracking nor keyword matching apply inside it. + // Jump straight past the whole payload range before considering it for anything + // else: it is not real PDF syntax, so neither comment/string tracking nor keyword + // matching apply inside it. This check must run before AdvancePastNonSyntax is + // ever given this position - a payload beginning with '(' or '<' would otherwise + // be misread as the start of a literal/hex string and scanned by + // AdvancePastNonSyntax as if it were PDF syntax, which tracks nested + // parentheses/escapes only against the whole buffer's end, not this range's own + // End. That can carry the scan deep into - or even past - arbitrary binary + // payload content before this guard ever gets a chance to clip it, potentially + // skipping right over the document's real trailer keyword. i = streamPayloadRanges[rangeIndex].End; continue; } + var afterNonSyntax = AdvancePastNonSyntax(buffer, i, ref unterminatedStringEncountered); + if (afterNonSyntax != i) + { + i = afterNonSyntax; + continue; + } + if (!IsKeywordAt(buffer, i, "trailer"u8)) { i++; @@ -1585,10 +1625,15 @@ private PdfObject ParseIndirectObjectAt(long offset, int expectedNumber) // performed for compressed objects below. Without this, a corrupted or crafted // cross-reference entry whose offset points at a different object's header would be // silently accepted and relabeled with the wrong (requested) object number/identity. - if ((int)numberToken.Number != expectedNumber) + // Validating the header's declared number as a non-negative integer before comparing it + // (rather than narrowing it to int via a bare cast first) matters: a malformed, + // non-integer declaration such as "1.5 0 obj" would otherwise truncate to 1 and + // coincidentally compare equal to an expected object number of 1, silently accepting a + // header the PDF grammar does not actually permit instead of failing closed. + if (!TryGetNonNegativeInt(numberToken.Number, out var declaredNumber) || declaredNumber != expectedNumber) { throw new InvalidDataException( - $"Indirect object header at offset {offset} declares object number {(int)numberToken.Number}, " + + $"Indirect object header at offset {offset} declares object number {numberToken.Number}, " + $"which does not match expected object number {expectedNumber}."); } @@ -1672,12 +1717,27 @@ private PdfObject LoadCompressedObject(int streamNumber, int indexInStream, int if (i == indexInStream) { - relativeOffset = (int)offsetToken.Number; - if ((int)numberToken.Number != expectedNumber) + // Validating the header's declared number as a non-negative integer before + // comparing it (rather than narrowing it to int via a bare cast first) matters: a + // malformed, non-integer declaration such as "1.5" would otherwise truncate to 1 + // and coincidentally compare equal to an expected object number of 1, silently + // accepting a header the PDF grammar does not actually permit instead of failing + // closed. The offset is validated the same way since it is genuinely used (not + // merely compared): a malformed, non-integer or out-of-range declared offset must + // be rejected outright rather than silently truncated or wrapped to an unrelated, + // in-range value that could coincidentally still pass the buffer-range check + // performed on it below. + if (!TryGetNonNegativeInt(numberToken.Number, out var containedNumber) || containedNumber != expectedNumber) { throw new InvalidDataException( $"Object stream {streamNumber} entry {indexInStream} does not match expected object number {expectedNumber}."); } + + if (!TryGetNonNegativeInt(offsetToken.Number, out relativeOffset)) + { + throw new InvalidDataException( + $"Object stream {streamNumber} entry {indexInStream} declares a malformed relative offset."); + } } } diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 3132e76e..52665392 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -1679,6 +1679,78 @@ public void PdfDocument_Open_CompressedObjectStreamSelfCycle_ThrowsInvalidDataEx Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); } + /// + /// Builds an in-memory, /Type /XRef cross-reference-stream-only PDF whose object 1 + /// - an object-stream (/Type /ObjStm) container - holds exactly one compressed + /// entry whose header declares a non-integer object number ("2.5", not "2"), even though + /// the cross-reference stream's own entry for object 2 claims it lives inside this + /// container at index 0. Narrowing "2.5" to an via a bare cast before + /// comparing it against the expected object number (2) would truncate it to 2 and + /// coincidentally match, silently accepting a header the PDF grammar does not actually + /// permit; comparing the un-narrowed value instead correctly rejects the mismatch. + /// + private static byte[] BuildCompressedObjectNonIntegerNumberPdf() + { + var catalogBytes = "<< /Type /Catalog >>"u8.ToArray(); + var objStmHeader = "2.5 0\n"u8.ToArray(); + var objStmContent = new List(); + objStmContent.AddRange(objStmHeader); + objStmContent.AddRange(catalogBytes); + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + + var objStmOffset = buffer.Count; + buffer.AddRange("1 0 obj\n"u8.ToArray()); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /ObjStm /N 1 /First {objStmHeader.Length} >>\nstream\n")); + buffer.AddRange(objStmContent); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + // Object 3: the self-referential, /Filter-less cross-reference stream, declaring 4 raw + // 6-byte entries (/W [1 4 1]): object 0 (free), object 1 (direct, the ObjStm container), + // object 2 (compressed, claiming to live inside object stream 1 at index 0), and object 3 + // (this very xref stream, direct, at its own real offset). + var xrefStreamOffset = buffer.Count; + + static byte[] Entry(byte type, int field2, byte field3) => + [ + type, + (byte)(field2 >> 24), (byte)(field2 >> 16), (byte)(field2 >> 8), (byte)field2, + field3, + ]; + + var entryBytes = new List(); + entryBytes.AddRange(Entry(0, 0, 0)); + entryBytes.AddRange(Entry(1, objStmOffset, 0)); + entryBytes.AddRange(Entry(2, 1, 0)); + entryBytes.AddRange(Entry(1, xrefStreamOffset, 0)); + + buffer.AddRange("3 0 obj\n"u8.ToArray()); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"<< /Type /XRef /Size 4 /W [1 4 1] /Root 2 0 R /Length {entryBytes.Count} >>\nstream\n")); + buffer.AddRange(entryBytes); + buffer.AddRange("\nendstream\nendobj\n"u8.ToArray()); + + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"startxref\n{xrefStreamOffset}\n%%EOF\n")); + return [.. buffer]; + } + + /// + /// Proves that a compressed object's header declaring a non-integer object number (for + /// example "2.5" where "2" is expected) is rejected with + /// rather than silently accepted via truncation to a coincidentally-matching integer. + /// + [Fact] + public void PdfDocument_Open_CompressedObjectNonIntegerNumber_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = BuildCompressedObjectNonIntegerNumberPdf(); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + /// /// Builds an in-memory, /Type /XRef cross-reference-stream-only PDF whose object 1 /// - an object-stream (/Type /ObjStm) container - declares its own /Length @@ -2009,6 +2081,69 @@ public void PdfDocument_Open_LinearScanFallback_ResolvesPageCompressedInObjectSt Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); } + /// + /// Builds an in-memory, single-page PDF with no xref table or startxref at + /// all (forcing BuildLinearScanFallback's raw byte scan to be the only route to a + /// working object table), where object 3 (the Page, referenced by object 2's /Kids) + /// is never directly defined - it exists only compressed inside object 4's + /// /Type /ObjStm container, whose header entry declares a non-integer object + /// number ("3.5", not "3"). Narrowing "3.5" to an via a bare cast before + /// registering it as a compressed-object entry would truncate it to 3 and coincidentally + /// register a working (if grammatically illegitimate) entry for object 3; correctly + /// rejecting the malformed header instead leaves object 3 permanently undefined. + /// + private static byte[] BuildLinearScanFallbackWithNonIntegerCompressedObjectNumberPdf() + { + var pageBytes = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>"u8.ToArray(); + var objStmHeader = "3.5 0\n"u8.ToArray(); + var objStmContent = new List(); + objStmContent.AddRange(objStmHeader); + objStmContent.AddRange(pageBytes); + + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + // Index 0 -> object 1, index 1 -> object 2, index 2 -> object 4, index 3 -> object 5: + // object 3 (the Page) is deliberately skipped here - it exists only compressed inside + // object 4's /ObjStm, never as its own directly-scannable "3 0 obj" marker. + var numbers = new[] { 1, 2, 4, 5 }; + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + BuildStreamObjectBody($"/Type /ObjStm /N 1 /First {objStmHeader.Length}", [.. objStmContent]), + BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{numbers[i]} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + return [.. buffer]; + } + + /// + /// Proves that a compressed object's header declaring a non-integer object number (for + /// example "3.5" where "3" is expected, see + /// ) is never + /// registered under its truncated integer value during linear-scan-fallback recovery: the + /// referencing object remains genuinely undefined and resolution fails with + /// , rather than silently succeeding against an object + /// the PDF grammar does not actually permit. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_CompressedObjectNonIntegerNumber_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithNonIntegerCompressedObjectNumberPdf(); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + /// /// Builds an in-memory, single-page PDF with no xref table or startxref at /// all (forcing BuildLinearScanFallback's raw byte scan to be the only route to a @@ -2551,10 +2686,11 @@ public void PdfDocument_Open_LinearScanFallback_UnsupportedFilterObjectStream_Do } /// - /// Builds an in-memory, single-page PDF with no cross-reference table at all (forcing - /// linear-scan fallback) preceded by many unrelated small stream objects, so that - /// ScanForTrailerDictionary's own streamPayloadRanges list accumulates many - /// entries before the real trailer keyword is ever reached. + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback) preceded by many unrelated small + /// stream objects, so that ScanForTrailerDictionary's own + /// streamPayloadRanges list accumulates many entries before the real trailer + /// keyword is ever reached. /// private static byte[] BuildLinearScanFallbackWithManyStreamPayloadRangesBeforeTrailer() { @@ -3460,6 +3596,69 @@ public void PdfDocument_Open_ClassicXref_EntryPointingAtWrongObjectHeader_Throws Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); } + /// + /// Builds an in-memory, single-page, classic-xref PDF whose object 3 (the Page) header is + /// deliberately written as 3.5 0 obj rather than 3 0 obj - a malformed, + /// non-integer declared object number - even though the classic xref table's own + /// entry for object 3 correctly points at this exact offset. Narrowing "3.5" to an + /// via a bare cast before comparing it against the expected object + /// number (3) would truncate it to 3 and coincidentally match, silently accepting a + /// header the PDF grammar does not actually permit; comparing the un-narrowed value + /// instead correctly rejects the mismatch. + /// + private static byte[] BuildClassicXrefPdfWithNonIntegerObjectNumberHeader() + { + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes), + }; + var headerNumbers = new[] { "1", "2", "3.5", "4" }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + var offsets = new List(); + for (var i = 0; i < bodies.Count; i++) + { + offsets.Add(buffer.Count); + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{headerNumbers[i]} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + var xrefOffset = buffer.Count; + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"xref\n0 {bodies.Count + 1}\n")); + buffer.AddRange("0000000000 65535 f \n"u8.ToArray()); + foreach (var offset in offsets) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{offset:D10} 00000 n \n")); + } + + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes( + $"trailer\n<< /Size {bodies.Count + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF\n")); + return [.. buffer]; + } + + /// + /// Proves that a direct indirect-object header declaring a non-integer object number (for + /// example "3.5" where "3" is expected, see + /// ) throws + /// rather than silently accepting it via truncation to + /// a coincidentally-matching integer. + /// + [Fact] + public void PdfDocument_Open_ClassicXref_NonIntegerObjectNumberHeader_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = BuildClassicXrefPdfWithNonIntegerObjectNumberHeader(); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + #endregion #region Page tree From 44f78348f5b5480cfcd4b5fe6f244a005701b445 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 3 Oct 2026 12:53:48 -0400 Subject: [PATCH 14/15] Harden object-stream /N and /First validation; document unterminated-string trade-off - RegisterCompressedObjectsFromObjectStreams and LoadCompressedObject now validate their declared /N (and /First, for LoadCompressedObject) as non-negative integers instead of narrowing via a bare (int) cast, closing the same truncation bug class already fixed for per-entry object numbers. LoadCompressedObject is reachable from normal, non-fallback parsing of any PDF using real xref-stream compressed objects, giving this fix a broader blast radius than the sibling fallback-only check. - Tried and reverted a stricter AdvancePastNonSyntax behavior that stopped the linear-scan fallback entirely on the first unterminated string: a local code-review pass found this caused any incidental unmatched delimiter anywhere in a file - ordinary binary noise, not an attack - to abandon recovery of an otherwise perfectly valid document. Restored the original quadratic-protection-only behavior and documented the trade-off explicitly in the XML doc remarks. - Added PdfDocument_Open_LinearScanFallback_UnrelatedUnterminatedLiteralStringBeforeObjectHeader_StillResolvesDocument to regression-test that an unrelated unterminated-string run before real object headers does not break recovery. - Removed the now-invalid regression test for the reverted strict behavior. - Updated reqstream and verification docs for the net test changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-net-pdf/pdf-document.yaml | 2 + .../canvas-net-pdf/pdf-document.md | 45 +++++- .../PdfDocument.Xref.cs | 58 +++++-- .../PdfDocumentTests.cs | 142 ++++++++++++++++++ 4 files changed, 229 insertions(+), 18 deletions(-) diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index cd66e87d..de5f00ba 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -147,6 +147,8 @@ sections: - PdfDocument_Open_LinearScanFallback_CommentBeforeDeclaredLengthEndstream_DoesNotCorruptOffsets - PdfDocument_Open_LinearScanFallback_UnterminatedCommentAtForwardScanBound_DoesNotCorruptOffsets - PdfDocument_Open_LinearScanFallback_CompressedObjectNonIntegerNumber_ThrowsInvalidDataException + - PdfDocument_Open_LinearScanFallback_ObjectStreamNonIntegerCount_ThrowsInvalidDataException + - PdfDocument_Open_LinearScanFallback_UnrelatedUnterminatedLiteralStringBeforeObjectHeader_StillResolvesDocument - id: CanvasNetPdf-PdfDocument-PageTreeTraversal title: >- diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index 4ea365c6..9005d80e 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -157,6 +157,21 @@ comparison against the number being resolved, for the same reason as the classic check above: the parsed token is a `double`, and an unchecked narrowing cast would silently truncate a non-integer value rather than reject it. +An object stream's own declared `/N` (entry count) and `/First` (header length) are validated the +same way, as non-negative `int` values, before being used to bound the header-reading loop and to +locate compressed object bodies: an unchecked narrowing cast on either would silently truncate a +non-integer declaration (for example `/N 1.5`) instead of rejecting a container the PDF grammar +does not actually permit. This mirrors the already-tested validation +`PdfDocument_Open_ObjectStream_DeclaredCountExceedsDecodedLength_ThrowsInvalidDataException` +exercises for the eager, fallback-only object-stream pre-scan; here the equivalent check guards +the separate, primary (non-fallback) compressed-object resolution path reached by ordinary +`/Type /XRef` documents, which is reachable far more often in practice. No dedicated fixture +targets a fractional `/N`/`/First` on this specific path: constructing one that fails only because +of that value (and not for an unrelated reason, such as the minimal test document's catalog having +no `/Pages` to resolve) proved to require a disproportionate amount of hand-built binary +cross-reference-stream scaffolding for a fix that is otherwise a direct, low-risk mirror of an +already-verified sibling check; the fix is covered by direct reasoning instead. + #### CanvasNetPdf-PdfDocument-HybridXref: Hybrid Xref Resolves Compressed Entry via XRefStm **Test**: `PdfDocument_Open_HybridXref_ResolvesCompressedEntryViaXRefStm` @@ -257,6 +272,24 @@ confirms the same non-negative-`int` validation applied to a compressed object's reached only through this fallback's own eager object-stream registration pass, rather than through the primary cross-reference path - both call sites share the same validation helper, but each is independently reachable and so independently verified. +`PdfDocument_Open_LinearScanFallback_ObjectStreamNonIntegerCount_ThrowsInvalidDataException` +confirms the fallback's own eager object-stream registration pass applies the same non-negative- +`int` validation to a declared `/N` (entry count) before narrowing it: an object stream declaring +`/N 1.5` is rejected outright even though an unchecked `(int)` cast would have truncated it to `1` +and coincidentally matched the real single-entry content that follows. +`PdfDocument_Open_LinearScanFallback_UnrelatedUnterminatedLiteralStringBeforeObjectHeader_StillResolvesDocument` +confirms the deliberate trade-off documented on `AdvancePastNonSyntax` (see its remarks) holds in +practice: a run of unmatched, never-closing `(` bytes that is unrelated binary noise - not a +crafted decoy - appearing *before* a document's genuine object headers does not abandon recovery +of the rest of an otherwise perfectly valid document. An earlier revision of this fallback instead +stopped scanning entirely the first time such a failed string-open was detected, which this test +would have failed against; that stricter approach was reverted because it was measured to turn +any incidental unmatched delimiter anywhere in the file into total recovery failure for documents +that otherwise resolve correctly - a worse outcome than the narrow, byte-perfect decoy scenario it +closed. No fixture targets that narrower decoy scenario directly (a genuine `obj`/`trailer` +keyword appearing immediately after such a run, redefining an earlier object): accepting it as +reachable, ordinary syntax is this trade-off's intentional, documented behavior, not a defect to +regress-test against. The stream-payload-range membership check in the trailer-keyword scan (used by this fallback to locate an explicit `trailer` dictionary when no trustworthy cross-reference chain is available) @@ -525,7 +558,8 @@ options parameter was added. **Tests**: `PdfDocument_RenderWithDpi_ScalesPageSizeByDpiOver72`, `PdfDocument_RenderWithDpi_InvalidDpi_ThrowsArgumentOutOfRangeException`, `PdfDocument_RenderWithDpi_OutOfRangePageIndex_ThrowsArgumentOutOfRangeException`, -`PdfDocument_RenderWithDpi_AfterDispose_ThrowsObjectDisposedException` +`PdfDocument_RenderWithDpi_AfterDispose_ThrowsObjectDisposedException`, +`PdfDocument_RenderWithDpi_PropagatesOptionsBackgroundColor` Calls `Render(int, float)` with several DPI values (`[Theory]`: 72, 36, and 144) against a single-page fixture and asserts the returned `Surface`'s width/height exactly match the page's @@ -535,10 +569,11 @@ dimensions itself. Calls `Render(int, float)` with a non-positive and a non-fini `PositiveInfinity`) DPI (`[Theory]`), an out-of-range page index, and after the document has been disposed, asserting `ArgumentOutOfRangeException` for the first two cases and `ObjectDisposedException` for the last, matching the validation contract already proven for -`Render(int, int, int)`. The overload forwards its own `options` parameter unchanged to -`Render(int, int, int, PdfRenderOptions?)`, so no separate DPI-specific background-color test is -needed - `CanvasNetPdf-PdfDocument-Render`'s own tests above already cover every -`BackgroundColor` behavior this overload shares. +`Render(int, int, int)`. Renders a blank single-page fixture via `Render(int, float, PdfRenderOptions?)` +with a custom `PdfRenderOptions.BackgroundColor` and asserts every pixel equals that custom +color rather than the opaque-white default, proving the overload actually forwards its own +`options` parameter through to `Render(int, int, int, PdfRenderOptions?)` instead of silently +dropping it. #### CanvasNetPdf-PdfDocument-ContentStreamDispatch: Unknown Operators Are Skipped, Malformed Recognized Operators Throw diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index 533f3631..ba57034a 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -1176,11 +1176,30 @@ private static bool IsMarkerDelimiter(byte b) => /// name="unterminatedStringEncountered"/> bounds this: the first such failure anywhere /// in a scan permanently disables further string-opening attempts for the remainder of /// that same scan, so at most one failed attempt ever walks the buffer's tail, keeping - /// total work linear. This sacrifices recovering any genuine obj/trailer - /// marker that might coincidentally follow such a byte run, which is an acceptable - /// heuristic limitation here: this path only runs once normal cross-reference parsing - /// has already failed, and the PDF specification does not guarantee perfect recovery - /// is even possible for arbitrarily malformed input. + /// total work linear. + /// + /// + /// This does mean a byte-perfect obj/trailer marker that happens to be + /// embedded past such a failed attempt is still reachable as ordinary syntax, rather + /// than being treated as unconditionally unreachable "string content" through to the + /// end of the buffer. Unlike a comment or a successfully closed string (both + /// of which are unambiguously bounded regions a decoy cannot escape), a string that + /// never closes does not actually tell us where its author intended it to end - a + /// real-world corrupted or recovered PDF routinely contains unrelated binary noise + /// with stray unmatched delimiters long before any genuine later object, and treating + /// every subsequent byte as permanently unreachable would abandon recovery of that + /// entire remainder, including perfectly legitimate objects - a strictly worse outcome + /// for this best-effort recovery path than tolerating the narrow, byte-perfect decoy + /// scenario this trade-off accepts. (An earlier revision of this method instead made + /// the caller stop scanning entirely the first time this flag became true; that was + /// measured to turn any incidental unmatched delimiter anywhere in the file - including + /// ordinary binary stream noise unrelated to any attack - into total recovery failure + /// for documents that otherwise resolve correctly, which is a worse trade-off than the + /// narrow decoy scenario it closed.) This is an acceptable heuristic limitation here: + /// this path only runs once normal cross-reference parsing has already failed, and the + /// PDF specification does not guarantee perfect recovery is even possible for + /// arbitrarily malformed input - this scanner's own documented policy is to never crash + /// or hang, not to recover a perfect result from every conceivable corruption. /// /// private static int AdvancePastNonSyntax(byte[] buffer, int position, ref bool unterminatedStringEncountered) @@ -1339,8 +1358,12 @@ private void RegisterCompressedObjectsFromObjectStreams() continue; } - var count = (int)countObject.Number; - if (count < 0 || count > decoded.Length) + // Reject a malformed, non-integer, or out-of-range declared "/N" (for example "1.5" + // or a value far beyond int range) the same way the per-entry object numbers below + // already are - a bare (int) cast would otherwise either silently truncate or produce + // an unspecified wrapped value, both of which could make a grammatically invalid + // object stream be accepted as having some plausible entry count. + if (!TryGetNonNegativeInt(countObject.Number, out var count) || count > decoded.Length) { continue; } @@ -1671,12 +1694,21 @@ private PdfObject LoadCompressedObject(int streamNumber, int indexInStream, int var decoded = GetStreamDecodedBytes(container); - var count = container.Get("N") is { Kind: PdfKind.Number } countObject - ? (int)countObject.Number - : throw new InvalidDataException("Object stream is missing /N."); - var first = container.Get("First") is { Kind: PdfKind.Number } firstObject - ? (int)firstObject.Number - : throw new InvalidDataException("Object stream is missing /First."); + // Reject a malformed, non-integer, or out-of-range declared "/N"/"/First" the same way + // RegisterCompressedObjectsFromObjectStreams's own eager pre-scan already does for /N - a + // bare (int) cast would otherwise silently truncate (for example "1.5" to 1) and accept a + // grammatically invalid object stream instead of failing closed. + if (container.Get("N") is not { Kind: PdfKind.Number } countObject || + !TryGetNonNegativeInt(countObject.Number, out var count)) + { + throw new InvalidDataException("Object stream is missing /N."); + } + + if (container.Get("First") is not { Kind: PdfKind.Number } firstObject || + !TryGetNonNegativeInt(firstObject.Number, out var first)) + { + throw new InvalidDataException("Object stream is missing /First."); + } if (indexInStream < 0 || indexInStream >= count) { diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 52665392..24667202 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -2144,6 +2144,72 @@ public void PdfDocument_Open_LinearScanFallback_CompressedObjectNonIntegerNumber Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); } + /// + /// Builds an in-memory, single-page PDF with no xref table or startxref at + /// all (forcing linear-scan fallback), whose object 3 - the Page - is never directly + /// defined - it exists only compressed inside object 4's /Type /ObjStm container, + /// whose dictionary declares a non-integer entry count (/N 1.5, not /N 1) + /// even though the container's own header content still spells out exactly one + /// well-formed 3 0 entry. Narrowing "1.5" to an via a bare cast + /// before using it as the number of header entries to read would truncate it to 1 and + /// coincidentally read that one legitimate-looking entry anyway, accepting a container the + /// PDF grammar does not actually permit; correctly rejecting the malformed /N + /// instead discards the whole container's entries, leaving object 3 permanently + /// undefined. + /// + private static byte[] BuildLinearScanFallbackWithNonIntegerObjectStreamCountPdf() + { + var pageBytes = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>"u8.ToArray(); + var objStmHeader = "3 0\n"u8.ToArray(); + var objStmContent = new List(); + objStmContent.AddRange(objStmHeader); + objStmContent.AddRange(pageBytes); + + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + // Index 0 -> object 1, index 1 -> object 2, index 2 -> object 4, index 3 -> object 5: + // object 3 (the Page) is deliberately skipped here - it exists only compressed inside + // object 4's /ObjStm, never as its own directly-scannable "3 0 obj" marker. + var numbers = new[] { 1, 2, 4, 5 }; + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + BuildStreamObjectBody($"/Type /ObjStm /N 1.5 /First {objStmHeader.Length}", [.. objStmContent]), + BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{numbers[i]} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + return [.. buffer]; + } + + /// + /// Proves that an object stream's declared entry count (for example /N 1.5 where + /// /N 1 is expected, see + /// ) is never + /// narrowed to its truncated integer value and used to read header entries during + /// linear-scan-fallback recovery: the container is rejected outright and the compressed + /// object it would otherwise have declared remains genuinely undefined, so resolution + /// fails with rather than silently succeeding against a + /// container the PDF grammar does not actually permit. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_ObjectStreamNonIntegerCount_ThrowsInvalidDataException() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithNonIntegerObjectStreamCountPdf(); + + // Act & Assert + Assert.Throws(() => PdfDocument.Open(new MemoryStream(pdfBytes))); + } + /// /// Builds an in-memory, single-page PDF with no xref table or startxref at /// all (forcing BuildLinearScanFallback's raw byte scan to be the only route to a @@ -3011,6 +3077,82 @@ public async Task PdfDocument_Open_LinearScanFallback_LongUnterminatedLiteralStr Assert.Equal(default, surface[5, 5]); } + /// + /// Builds an in-memory, single-page PDF with no cross-reference table or + /// startxref at all (forcing linear-scan fallback): a run of unmatched, + /// never-closing literal-string ( bytes with no closing ) anywhere in the + /// remainder of the buffer - unrelated binary noise, not an attempted decoy - appears + /// before the real object 3 (the Page) header rather than after it, so the real + /// header is only reachable by a scan that keeps looking for genuine markers once a + /// string-opening attempt has failed, instead of abandoning the rest of the buffer + /// entirely. + /// + private static byte[] BuildLinearScanFallbackWithUnrelatedUnterminatedLiteralStringBeforeObjectHeader() + { + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + + // Unrelated binary noise containing an unmatched '(' with no ')' anywhere in the rest of + // the buffer (including inside the real headers that follow) - this is exactly the kind + // of incidental corruption/noise this fallback scanner must still recover past, not a + // crafted decoy of its own. + buffer.AddRange(new byte[500]); + for (var i = buffer.Count - 500; i < buffer.Count; i++) + { + buffer[i] = (byte)'('; + } + + buffer.AddRange("\n"u8.ToArray()); + + var contentBytes = "10 10 40 40 re f"u8.ToArray(); + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(), + BuildStreamObjectBody($"/Length {contentBytes.Length}", contentBytes), + }; + + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + buffer.AddRange("%%EOF\n"u8.ToArray()); + return [.. buffer]; + } + + /// + /// Proves that an unmatched, never-closing literal-string run that is unrelated binary + /// noise rather than a crafted decoy (see + /// ) + /// does not prevent the fallback scanner from recovering every genuine object header that + /// follows it: once a string-opening attempt fails to find its close, the scanner must + /// keep searching the remainder of the buffer for real markers (bounded, via , + /// to at most one further failed string-opening attempt), rather than abandoning recovery + /// of the rest of an otherwise perfectly valid document. Real-world corrupted or recovered + /// PDFs routinely contain unrelated binary noise with stray unmatched delimiters well + /// before any genuine object header. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_UnrelatedUnterminatedLiteralStringBeforeObjectHeader_StillResolvesDocument() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithUnrelatedUnterminatedLiteralStringBeforeObjectHeader(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(Black, surface[30, 70]); + Assert.Equal(default, surface[5, 5]); + } + /// /// Builds an in-memory, single-page, classic-xref PDF (via ) /// with genuine, correctly-bounded xref/trailer/startxref syntax, From 64739d45ce684059191af69d3d6e10f26c4d765e Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 3 Oct 2026 14:06:17 -0400 Subject: [PATCH 15/15] Preserve physical ordering when a compressed object redefines a direct one RegisterCompressedObjectsFromObjectStreams previously refused to overwrite any already-registered xref entry, regardless of which definition physically occurred later in the file. This contradicted ScanObjectOffsets' own 'later occurrence wins' rule for direct N G obj headers, and meant an incrementally updated document that redefines an object via a compressed object stream after an earlier direct definition would silently keep the stale direct definition. Add GetPhysicalPosition to compare a direct offset against a compressed entry's containing stream offset, and only overwrite an existing entry when the candidate is physically later. Also evict any stale _objectCache entry for the object number being overwritten, since an earlier iteration of the same registration loop may already have resolved and cached the prior value while probing other object numbers for ObjStm candidacy. Add a regression test proving the later compressed redefinition - not the earlier direct one - is what gets resolved and rendered. Also fix stale Render(int, float)/Render(int, int, int) signatures in the RenderWithDpi verification doc section to the current PdfRenderOptions?-accepting overloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-net-pdf/pdf-document.yaml | 1 + .../canvas-net-pdf/pdf-document.md | 33 +++++--- .../PdfDocument.Xref.cs | 35 +++++++- .../PdfDocumentTests.cs | 81 +++++++++++++++++++ 4 files changed, 134 insertions(+), 16 deletions(-) diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index de5f00ba..70688569 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -148,6 +148,7 @@ sections: - PdfDocument_Open_LinearScanFallback_UnterminatedCommentAtForwardScanBound_DoesNotCorruptOffsets - PdfDocument_Open_LinearScanFallback_CompressedObjectNonIntegerNumber_ThrowsInvalidDataException - PdfDocument_Open_LinearScanFallback_ObjectStreamNonIntegerCount_ThrowsInvalidDataException + - PdfDocument_Open_LinearScanFallback_LaterCompressedRedefinitionOverridesEarlierDirectObject - PdfDocument_Open_LinearScanFallback_UnrelatedUnterminatedLiteralStringBeforeObjectHeader_StillResolvesDocument - id: CanvasNetPdf-PdfDocument-PageTreeTraversal diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index 9005d80e..580e8eed 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -277,6 +277,15 @@ confirms the fallback's own eager object-stream registration pass applies the sa `int` validation to a declared `/N` (entry count) before narrowing it: an object stream declaring `/N 1.5` is rejected outright even though an unchecked `(int)` cast would have truncated it to `1` and coincidentally matched the real single-entry content that follows. +`PdfDocument_Open_LinearScanFallback_LaterCompressedRedefinitionOverridesEarlierDirectObject` +confirms the fallback's eager object-stream registration pass applies the same "a later physical +occurrence overrides an earlier one" rule `ScanObjectOffsets` already applies to direct `N G obj` +headers: when an object number is first defined directly, earlier in the file, and then +redefined later via a compressed `/Type /ObjStm` entry - a realistic shape for an incrementally +updated document - the later, compressed definition is resolved, not the earlier direct one. An +earlier revision instead refused to overwrite any already-registered entry regardless of physical +position, silently keeping the stale direct definition; this test would have failed against that +behavior. `PdfDocument_Open_LinearScanFallback_UnrelatedUnterminatedLiteralStringBeforeObjectHeader_StillResolvesDocument` confirms the deliberate trade-off documented on `AdvancePastNonSyntax` (see its remarks) holds in practice: a run of unmatched, never-closing `(` bytes that is unrelated binary noise - not a @@ -553,7 +562,7 @@ dedicated transparent-override test passes `new PdfRenderOptions { BackgroundCol 0) }` and asserts every pixel reproduces the fully transparent behavior `Render` had before this options parameter was added. -#### CanvasNetPdf-PdfDocument-RenderWithDpi: Render(int, float) Scales Page Size by DPI/72 and Validates Arguments +#### CanvasNetPdf-PdfDocument-RenderWithDpi: Render(int, float, PdfRenderOptions?) Scales by DPI/72 **Tests**: `PdfDocument_RenderWithDpi_ScalesPageSizeByDpiOver72`, `PdfDocument_RenderWithDpi_InvalidDpi_ThrowsArgumentOutOfRangeException`, @@ -561,19 +570,19 @@ options parameter was added. `PdfDocument_RenderWithDpi_AfterDispose_ThrowsObjectDisposedException`, `PdfDocument_RenderWithDpi_PropagatesOptionsBackgroundColor` -Calls `Render(int, float)` with several DPI values (`[Theory]`: 72, 36, and 144) against a -single-page fixture and asserts the returned `Surface`'s width/height exactly match the page's -own point-space size scaled by `dpi / 72` and rounded to the nearest pixel, confirming the +Calls `Render(int, float, PdfRenderOptions?)` with several DPI values (`[Theory]`: 72, 36, and 144) +against a single-page fixture and asserts the returned `Surface`'s width/height exactly match the +page's own point-space size scaled by `dpi / 72` and rounded to the nearest pixel, confirming the overload preserves the page's aspect ratio rather than requiring the caller to compute pixel -dimensions itself. Calls `Render(int, float)` with a non-positive and a non-finite (`NaN`, -`PositiveInfinity`) DPI (`[Theory]`), an out-of-range page index, and after the document has been -disposed, asserting `ArgumentOutOfRangeException` for the first two cases and +dimensions itself. Calls `Render(int, float, PdfRenderOptions?)` with a non-positive and a +non-finite (`NaN`, `PositiveInfinity`) DPI (`[Theory]`), an out-of-range page index, and after the +document has been disposed, asserting `ArgumentOutOfRangeException` for the first two cases and `ObjectDisposedException` for the last, matching the validation contract already proven for -`Render(int, int, int)`. Renders a blank single-page fixture via `Render(int, float, PdfRenderOptions?)` -with a custom `PdfRenderOptions.BackgroundColor` and asserts every pixel equals that custom -color rather than the opaque-white default, proving the overload actually forwards its own -`options` parameter through to `Render(int, int, int, PdfRenderOptions?)` instead of silently -dropping it. +`Render(int, int, int, PdfRenderOptions?)`. Renders a blank single-page fixture via +`Render(int, float, PdfRenderOptions?)` with a custom `PdfRenderOptions.BackgroundColor` and +asserts every pixel equals that custom color rather than the opaque-white default, proving the +overload actually forwards its own `options` parameter through to +`Render(int, int, int, PdfRenderOptions?)` instead of silently dropping it. #### CanvasNetPdf-PdfDocument-ContentStreamDispatch: Unknown Operators Are Skipped, Malformed Recognized Operators Throw diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs index ba57034a..bd3e66c0 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs @@ -1394,17 +1394,44 @@ private void RegisterCompressedObjectsFromObjectStreams() break; } - // A direct "N G obj" marker (if one somehow also exists for this number) is more - // trustworthy than an assumed containment index, so never overwrite an existing - // entry here. - if (!_xref.ContainsKey(containedNumber)) + // Apply the same "a later physical occurrence overrides an earlier one" rule + // ScanObjectOffsets already uses for direct "N G obj" markers, rather than always + // keeping whatever was registered first: an incrementally updated document can + // legitimately redefine an object - originally a direct header - later in the + // file via a compressed object stream, and that later definition must win. Every + // object inside a given stream shares that stream's own physical offset as its + // position, since entries inside an ObjStm have no individual byte position of + // their own. + var candidatePosition = _xref[streamNumber].Offset; + if (!_xref.TryGetValue(containedNumber, out var existingEntry) || + candidatePosition > GetPhysicalPosition(existingEntry)) { _xref[containedNumber] = XrefEntry.CreateCompressed(streamNumber, i); + + // An earlier iteration of this same loop may already have called GetObject on + // containedNumber (for example to inspect some other stream candidate that + // turned out not to be an ObjStm), caching its now-superseded value. Evict + // that stale cache entry so a later GetObject(containedNumber) call re-reads + // from (and reflects) the xref entry we just overwrote above. + _objectCache.Remove(containedNumber); } } } } + /// + /// Returns the physical buffer offset an is anchored to, used by + /// to decide which of two + /// candidate definitions for the same object number physically occurs later in the file. + /// A entry's own offset is used directly; a + /// entry has no byte position of its own, so its + /// containing object stream's own (always direct) offset is used as its position instead + /// - every object inside a given stream physically occurs together, at that stream's + /// location. + /// + private long GetPhysicalPosition(XrefEntry entry) => + entry.Type == XrefEntryType.Compressed ? _xref[entry.StreamNumber].Offset : entry.Offset; + /// /// Returns whether is a finite integer exactly representable as /// a non-negative - the validity rule every numeric field parsed from diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 24667202..7cb18da4 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -2081,6 +2081,87 @@ public void PdfDocument_Open_LinearScanFallback_ResolvesPageCompressedInObjectSt Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[50, 50]); } + /// + /// Builds an in-memory, single-page PDF with no xref table or startxref at + /// all (forcing BuildLinearScanFallback's raw byte scan to be the only route to a + /// working object table), where object 3 (the Page) is first defined directly and early + /// in the file (pointing its /Contents at object 4, a red-filling stream), then + /// redefined later in physical file order via a compressed /Type /ObjStm container + /// (object 6, pointing /Contents instead at object 5, a blue-filling stream) - + /// mirroring a realistic incrementally-updated document where a later save redefines an + /// object via a compressed object stream rather than a fresh direct definition. The two + /// candidate definitions are otherwise identical (same parent, same media box) so the only + /// observable difference is which content stream ends up referenced. + /// + private static byte[] BuildLinearScanFallbackWithLaterCompressedPageRedefinitionPdf() + { + var redPageBytes = "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>"u8.ToArray(); + var bluePageBytes = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>"u8.ToArray(); + var objStmHeader = "3 0\n"u8.ToArray(); + var objStmContent = new List(); + objStmContent.AddRange(objStmHeader); + objStmContent.AddRange(bluePageBytes); + + var redContentBytes = "1 0 0 rg 0 0 100 100 re f"u8.ToArray(); + var blueContentBytes = "0 0 1 rg 0 0 100 100 re f"u8.ToArray(); + + // Objects appear, in physical file order, as: 1 (Catalog), 2 (Pages), 3 (Page, the + // ORIGINAL direct definition pointing at the red content stream), 4 (red content + // stream), 5 (blue content stream), 6 (the ObjStm, physically last, compressing a + // redefinition of object 3 that instead points at the blue content stream). Object 3's + // only direct "3 0 obj" header is the original/red one; its later/blue redefinition + // exists solely compressed inside object 6. + var numbers = new[] { 1, 2, 3, 4, 5, 6 }; + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + redPageBytes, + BuildStreamObjectBody($"/Length {redContentBytes.Length}", redContentBytes), + BuildStreamObjectBody($"/Length {blueContentBytes.Length}", blueContentBytes), + BuildStreamObjectBody($"/Type /ObjStm /N 1 /First {objStmHeader.Length}", [.. objStmContent]), + }; + + var buffer = new List(); + buffer.AddRange("%PDF-1.7\n"u8.ToArray()); + for (var i = 0; i < bodies.Count; i++) + { + buffer.AddRange(System.Text.Encoding.ASCII.GetBytes($"{numbers[i]} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange("\nendobj\n"u8.ToArray()); + } + + return [.. buffer]; + } + + /// + /// Proves that when a compressed object stream redefines an object number that was + /// already registered by an earlier direct definition, the fallback's compressed-object + /// registration (RegisterCompressedObjectsFromObjectStreams) keeps the definition + /// that physically occurs later in the file - matching ScanObjectOffsets's own + /// "later occurrence wins" rule for direct objects - rather than unconditionally + /// preferring whichever definition happened to be registered first. See + /// : object 3 + /// (the Page) is first defined directly (pointing at a red content stream) and then + /// redefined, later in the file, compressed inside an /Type /ObjStm (pointing at a + /// blue content stream). The rendered page must be blue, proving the later compressed + /// redefinition - not the earlier direct one - was resolved. + /// + [Fact] + public void PdfDocument_Open_LinearScanFallback_LaterCompressedRedefinitionOverridesEarlierDirectObject() + { + // Arrange + var pdfBytes = BuildLinearScanFallbackWithLaterCompressedPageRedefinitionPdf(); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdfBytes)); + + // Assert + Assert.Equal(1, document.PageCount); + using var surface = document.Render(0, 100, 100, Transparent); + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[50, 50]); + } + /// /// Builds an in-memory, single-page PDF with no xref table or startxref at /// all (forcing BuildLinearScanFallback's raw byte scan to be the only route to a