diff --git a/docs/upstream-prs/03-ascii85-decode/design.md b/docs/upstream-prs/03-ascii85-decode/design.md index 4f0c797..621a159 100644 --- a/docs/upstream-prs/03-ascii85-decode/design.md +++ b/docs/upstream-prs/03-ascii85-decode/design.md @@ -33,3 +33,8 @@ Mirror the decoder: 4 input bytes → 5-char ASCII85 group. Final partial group | Int overflow on 32-bit PHP builds | Very low | composer.json requires PHP ≥ 7.4; 64-bit ints required on supported PHP versions | | Invalid char in input (e.g. `~` mid-stream not part of `~>`) | Low | Unit-test the `~` followed by non-`>` case; expected behaviour: `p_error` "invalid char" | | Empty final group misinterpreted as `z` | Low | Tokenise `z` explicitly BEFORE 5-char grouping; can't be confused | + + +--- + +> **Implementation note**: canonical design + decision log live in `openspec/changes/feat-ascii85-decode/design.md` (D1–D5). Key as-shipped facts: D4 ships as `return false` on spec violation (illegal char, `z` mid-group, `~` not paired with `>`, 1-char partial group per §7.4.3, overflow beyond `2^32-1`, or PCRE failure); encoder requires 64-bit PHP ints; the spec-imposed maximum 5-char group is `s8W-!` (NOT `uuuuu`, which exceeds the cap). diff --git a/docs/upstream-prs/03-ascii85-decode/proposal.md b/docs/upstream-prs/03-ascii85-decode/proposal.md index 6861dfc..1bfbf44 100644 --- a/docs/upstream-prs/03-ascii85-decode/proposal.md +++ b/docs/upstream-prs/03-ascii85-decode/proposal.md @@ -23,3 +23,8 @@ Posted after #01 and #02 so the maintainer's review pattern is established. - **Spec target:** PDF 1.7 §7.4.3 (ASCII85Decode Filter). - **Why this filter matters more than its raw frequency suggests:** typically paired with FlateDecode in chains. Single-filter handling won't help these chained cases — those wait on PR #05 (filter chaining). But the underlying decoder MUST be in place first, so this PR ships independently. - **Out of scope:** filter chaining (PR #05 unlocks the pairing with FlateDecode), other filters in the series. + + +--- + +> **Implementation note**: canonical contract + decision log + as-shipped notes live in `openspec/changes/feat-ascii85-decode/` (`proposal.md`, `design.md`, `tasks.md`, and `specs/ascii85-decode-filter/spec.md`). Key as-shipped facts: spec violations (illegal char, 1-char partial group, overflow beyond `s8W-!` = `2^32-1`, PCRE failure) → `p_error()` + `return false` per the chain dispatcher's `=== false` contract. Encoder requires 64-bit PHP ints (defensive 32-bit masking removed — it was both dead on 64-bit and broken on 32-bit). diff --git a/docs/upstream-prs/03-ascii85-decode/tasks.md b/docs/upstream-prs/03-ascii85-decode/tasks.md index cc9f6a7..d642054 100644 --- a/docs/upstream-prs/03-ascii85-decode/tasks.md +++ b/docs/upstream-prs/03-ascii85-decode/tasks.md @@ -23,3 +23,8 @@ - [ ] 4.1 No regression in FlateDecode / ASCIIHexDecode / RunLengthDecode paths. - [ ] 4.2 No new dependencies. - [ ] 4.3 REQ-01 through REQ-04 each have a passing verification step. + + +--- + +> **Implementation note**: canonical task list lives in `openspec/changes/feat-ascii85-decode/tasks.md` (kept up to date with the spec-violation `return false` paths, the new boundary tests, and the dispatcher's bare `'ASCII85Decode'` case-label note). diff --git a/examples/poc-filter-roundtrip-ascii85.php b/examples/poc-filter-roundtrip-ascii85.php new file mode 100644 index 0000000..5a73ae0 --- /dev/null +++ b/examples/poc-filter-roundtrip-ascii85.php @@ -0,0 +1,309 @@ +getMethod($method); + $m->setAccessible(true); + return $m->invokeArgs(null, $args); +} + +function buildObj($filterValue, string $rawStream = ''): PDFObject { + $value = new PDFValueObject(); + if ($filterValue !== null) { + $value['Filter'] = $filterValue; + } + $obj = new PDFObject(1, $value); + $obj->set_stream($rawStream, true); + return $obj; +} + +/* ---------------------------------------------------------------- * + * REQ-1 — decode scenarios + * ---------------------------------------------------------------- */ +{ + // `z` shortcut: decodes to 4 zero bytes (only valid at a group boundary). + $decoded = invokeProtectedStatic('ASCII85Decode', ['z~>', null]); + if ($decoded !== "\x00\x00\x00\x00") { + $failures[] = "REQ-1 (z shortcut): expected 4 zero bytes, got " . bin2hex((string) $decoded); + } + + // Canonical 5-char group → 4 bytes. The base-85 encoding of bytes + // 0x48 0x65 0x6c 0x6c (= "Hell") is "87cUR" exactly (verified by + // independent computation: 0x48656C6C = 1,214,606,444; base-85 of + // that = [23, 22, 66, 52, 49] → 56,55,99,85,82 ASCII = "87cUR"). + $decoded = invokeProtectedStatic('ASCII85Decode', ['87cUR~>', null]); + if ($decoded !== 'Hell') { + $failures[] = "REQ-1 (standard 5-char group): expected 'Hell', got " . bin2hex((string) $decoded); + } + + // Adobe-tolerant: leading `<~` stripped. + $decoded = invokeProtectedStatic('ASCII85Decode', ['<~87cUR~>', null]); + if ($decoded !== 'Hell') { + $failures[] = "REQ-1 (Adobe leading <~): expected 'Hell', got " . bin2hex((string) $decoded); + } + + // Whitespace anywhere ignored. + $decoded = invokeProtectedStatic('ASCII85Decode', ["87cU\n R~>", null]); + if ($decoded !== 'Hell') { + $failures[] = "REQ-1 (whitespace): expected 'Hell', got " . bin2hex((string) $decoded); + } + + // Empty payload between markers: `<~~>` and bare `~>` both decode to ''. + $decoded = invokeProtectedStatic('ASCII85Decode', ['<~~>', null]); + if ($decoded !== '') { + $failures[] = "REQ-1 (empty payload <~~>): expected '', got " . bin2hex((string) $decoded); + } + $decoded = invokeProtectedStatic('ASCII85Decode', ['~>', null]); + if ($decoded !== '') { + $failures[] = "REQ-1 (bare EOD ~>): expected '', got " . bin2hex((string) $decoded); + } + + // Trailing partial group of 2 chars → 1 byte (canonical k=2 → k-1=1). + // "H" (0x48) padded with \x00\x00\x00 = 0x48000000 = 1,207,959,552. + // base-85 of that = [23, 19, 11, 81, 0] → 56,52,44,114,33 ASCII = "84,Q!" + // Emitted = first (2-1)=1 char... wait that's the encoder side. + // Decode side: feed it the 2-char partial directly: "84~>" decodes + // to ord('8')-33=23, ord('4')-33=19 → group=[23,19,u,u,u] = padded + // value 23*85^4 + 19*85^3 + 84*7225 + 84*85 + 84 + // = 1,200,614,375 + 11,668,375 + 606,900 + 7,140 + 84 + // = 1,212,896,874 + // → pack big-endian: 0x48489AAA = byte0 = 0x48 = 'H'. Emit (2-1)=1 byte → 'H'. ✓ + $decoded = invokeProtectedStatic('ASCII85Decode', ['84~>', null]); + if ($decoded !== 'H') { + $failures[] = "REQ-1 (2-char partial group → 1 byte): expected 'H', got " . bin2hex((string) $decoded); + } +} + +/* ---------------------------------------------------------------- * + * REQ-2 — encode scenarios + round-trip + * ---------------------------------------------------------------- */ +{ + // Aligned 4-zero-byte group → `z` shortcut. + $encoded = invokeProtectedStatic('ASCII85Encode', ["\x00\x00\x00\x00", null]); + if ($encoded !== 'z~>') { + $failures[] = "REQ-2 (z shortcut on encode): expected 'z~>', got '$encoded'"; + } + + // Empty input → just EOD. + $encoded = invokeProtectedStatic('ASCII85Encode', ['', null]); + if ($encoded !== '~>') { + $failures[] = "REQ-2 (empty): expected '~>', got '$encoded'"; + } + + // 4-byte aligned non-zero input. + $encoded = invokeProtectedStatic('ASCII85Encode', ['Hell', null]); + if ($encoded !== '87cUR~>') { + $failures[] = "REQ-2 (canonical 'Hell'): expected '87cUR~>', got '$encoded'"; + } + // Round-trip: encode → decode → original. + $decoded = invokeProtectedStatic('ASCII85Decode', [$encoded, null]); + if ($decoded !== 'Hell') { + $failures[] = "REQ-2 (4-byte round-trip): expected 'Hell', got " . bin2hex((string) $decoded); + } +} + +/* ---------------------------------------------------------------- * + * REQ-3 — lossless round-trip on 1024-byte random binary + * ---------------------------------------------------------------- */ +{ + $original = random_bytes(1024); + $encoded = invokeProtectedStatic('ASCII85Encode', [$original, null]); + $decoded = invokeProtectedStatic('ASCII85Decode', [$encoded, null]); + if ($decoded !== $original) { + $failures[] = "REQ-3 (binary round-trip 1024B): mismatch; got " . strlen((string) $decoded) . " bytes back"; + } + + // Boundary smoke tests between full-group and partial-group paths: + // exercise n=1..9 covering every (4k, 4k+1, 4k+2, 4k+3) residue. + foreach ([1, 2, 3, 4, 5, 6, 7, 8, 9] as $n) { + $payload = random_bytes($n); + $enc = invokeProtectedStatic('ASCII85Encode', [$payload, null]); + $dec = invokeProtectedStatic('ASCII85Decode', [$enc, null]); + if ($dec !== $payload) { + $failures[] = "REQ-3 (partial-group n=$n): round-trip mismatch"; + } + } + + // Zero-padded round-trip (exercises `z` shortcut both directions). + $payload = "BEFORE\x00\x00\x00\x00AFTER"; + $enc = invokeProtectedStatic('ASCII85Encode', [$payload, null]); + $dec = invokeProtectedStatic('ASCII85Decode', [$enc, null]); + if ($dec !== $payload) { + $failures[] = "REQ-3 (mixed with z-shortcut): round-trip mismatch"; + } +} + +/* ---------------------------------------------------------------- * + * REQ-4 — fail-safe paths (each returns false per the chain dispatcher's + * `=== false` short-circuit contract) + * ---------------------------------------------------------------- */ +{ + // Illegal character `{` (codepoint 123, outside !..u = 33..117). + ob_start(); + $decoded = invokeProtectedStatic('ASCII85Decode', ['87c{R~>', null]); + ob_end_clean(); + if ($decoded !== false) { + $failures[] = "REQ-4 (illegal char `{`): expected false, got " . var_export($decoded, true); + } + + // `~` mid-stream (not part of the `~>` EOD pair). + ob_start(); + $decoded = invokeProtectedStatic('ASCII85Decode', ['87c~XR~>', null]); + ob_end_clean(); + if ($decoded !== false) { + $failures[] = "REQ-4 (mid-stream `~`): expected false, got " . var_export($decoded, true); + } + + // `z` mid-group (not at a group boundary): "8z~>" — `8` consumed as + // the start of a group, then `z` (codepoint 122 > 117) hits the + // alphabet validator. Spec D2: `z` only valid at group boundary. + ob_start(); + $decoded = invokeProtectedStatic('ASCII85Decode', ['8z~>', null]); + ob_end_clean(); + if ($decoded !== false) { + $failures[] = "REQ-4 (`z` mid-group): expected false, got " . var_export($decoded, true); + } + + // 1-char trailing partial group is spec-illegal (§7.4.3: 2 ≤ k ≤ 4). + // "87cURD~>" — `87cUR` is a complete 5-char group, leaving `D` as + // a stray 1-char partial. Must reject (was the original blocker: + // accepted silently and emitted 0 bytes). + ob_start(); + $decoded = invokeProtectedStatic('ASCII85Decode', ['87cURD~>', null]); + ob_end_clean(); + if ($decoded !== false) { + $failures[] = "REQ-4 (1-char partial group): expected false, got " . var_export($decoded, true); + } + + // Overflow guard: `tttt~>` is a 4-char partial group. With `u` + // padding it becomes `ttttu`, which arithmetically computes to + // 84*85^4 + 84*85^3 + 84*85^2 + 84*85 + 84 - 1*85^4 + 84 + // = needs the actual computation: + // ord('t')-33 = 116-33 = 83 + // ord('u')-33 = 117-33 = 84 + // n = 83*85^4 + 83*85^3 + 83*85^2 + 83*85 + 84 + // = 83*52200625 + 83*614125 + 83*7225 + 83*85 + 84 + // = 4,332,651,875 + 50,972,375 + 599,675 + 7,055 + 84 + // = 4,384,231,064 + // > 2^32-1 (= 4,294,967,295). Overflow guard MUST fire. + ob_start(); + $decoded = invokeProtectedStatic('ASCII85Decode', ['tttt~>', null]); + ob_end_clean(); + if ($decoded !== false) { + $failures[] = "REQ-4 (overflow guard `tttt~>`): expected false, got " . var_export($decoded, true); + } +} + +/* ---------------------------------------------------------------- * + * REQ-5 — chain dispatcher integration + * ---------------------------------------------------------------- */ +{ + // Single-filter chain. + $filterList = new PDFValueList(); + $filterList->push(new PDFValueType('ASCII85Decode')); + + $obj = buildObj($filterList); + $plaintext = 'plain-text payload — ASCII85 only'; + $obj->set_stream($plaintext, false); + + $encoded = $obj->get_stream(true); + if (substr($encoded, -2) !== '~>') { + $failures[] = "REQ-5 (chain ASCII85 set_stream): missing trailing '~>' EOD"; + } + + $decoded = $obj->get_stream(false); + if ($decoded !== $plaintext) { + $failures[] = "REQ-5 (chain ASCII85 round-trip): mismatch"; + } +} + +{ + // Two-filter chain: [/ASCII85Decode /FlateDecode] — outer ASCII85, + // inner Flate. Encode order is REVERSE (Flate first innermost). + $filterList = new PDFValueList(); + $filterList->push(new PDFValueType('ASCII85Decode')); + $filterList->push(new PDFValueType('FlateDecode')); + + $obj = buildObj($filterList); + $plaintext = "BT (Chained ASCII85 outer + Flate inner) Tj ET"; + $obj->set_stream($plaintext, false); + + $decoded = $obj->get_stream(false); + if ($decoded !== $plaintext) { + $failures[] = "REQ-5 (chain ASCII85+Flate round-trip): mismatch"; + } +} + +/* ---------------------------------------------------------------- * + * REQ-4 — chain-failure propagation: outer ASCII85 illegal char MUST + * short-circuit before inner Flate runs. + * ---------------------------------------------------------------- */ +{ + $filterList = new PDFValueList(); + $filterList->push(new PDFValueType('ASCII85Decode')); + $filterList->push(new PDFValueType('FlateDecode')); + + $obj = buildObj($filterList, '87c{R~>somegarbage'); + + ob_start(); + $decoded = $obj->get_stream(false); + ob_end_clean(); + + if ($decoded !== false) { + $failures[] = "REQ-4 (chain failure propagation): get_stream did not return false on outer-layer ASCII85 failure (got " . var_export($decoded, true) . ")"; + } +} + +/* ---------------------------------------------------------------- * + * Verify the existing FlateDecode-only PoC gate still passes (REQ-5 + * cross-check from tasks 5.2). + * ---------------------------------------------------------------- */ +{ + $exitCode = 0; + $output = []; + exec(escapeshellcmd(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/poc-replace-text.php') . ' 2>&1', $output, $exitCode); + if ($exitCode !== 0) { + $failures[] = "REQ-5 cross-check: poc-replace-text.php exited with $exitCode (expected 0)"; + } +} + +/* ---------------------------------------------------------------- * + * Report + * ---------------------------------------------------------------- */ +echo "PoC ASCII85Decode — round-trip verification\n"; +echo " spec: openspec/changes/feat-ascii85-decode/specs/ascii85-decode-filter/spec.md\n\n"; + +if (count($failures) === 0) { + echo "RESULT: VERIFIED — all 5 REQs covered, no assertion failures.\n"; + exit(0); +} + +echo "RESULT: FAIL — " . count($failures) . " assertion(s) violated:\n"; +foreach ($failures as $msg) { + echo " - $msg\n"; +} +exit(1); diff --git a/openspec/changes/feat-ascii85-decode/design.md b/openspec/changes/feat-ascii85-decode/design.md index 8768c27..d7dd96e 100644 --- a/openspec/changes/feat-ascii85-decode/design.md +++ b/openspec/changes/feat-ascii85-decode/design.md @@ -36,9 +36,9 @@ The 32-bit big-endian integer never exceeds `2^32 - 1`. PHP's `int` type is at l The encoder emits `z` only when 4 consecutive zero bytes are aligned on a group boundary. Unaligned zero runs go through the standard 5-char encoding. This matches Adobe's de-facto encoding behaviour and keeps the implementation simple. -### D4 — Decode rejects illegal characters via `p_error` + raw input return +### D4 — Decode rejects spec violations via `p_error` + `false` return -Characters outside `!..u` plus `z` plus whitespace plus the EOD bytes `~>` cause `p_error()` and a return of the raw input. Same convention as the other filters. +Spec-violations — illegal character (codepoint outside `!..u`), `z` mid-group, `~` not paired with `>` as EOD, 1-char trailing partial group (§7.4.3 requires `2 ≤ k ≤ 4`), overflow beyond `2^32 - 1`, or PCRE compile failure on the whitespace-strip regex — cause `p_error()` and a return of `false`. This matches the chain dispatcher's `=== false` short-circuit contract (so downstream filters never see partial output), and aligns with the same fix applied to ASCIIHexDecode and RunLengthDecode in the predecessor PRs. ### D5 — Decode tolerates missing EOD (Adobe-compatible) @@ -46,7 +46,7 @@ If the stream ends without a `~>` marker, the decoder finishes the last partial ## Risks / Trade-offs -- **Risk**: A 5-char group might overflow `2^32 - 1` if all 5 chars are at their maximum value (`uuuuu` = `85^4 * 84 + ... + 84 = 4294967295`). → **Mitigation**: that's exactly `2^32 - 1`, the maximum representable value; PHP's int handles it. Groups beyond this value (`uuuu\x76` would compute to > `2^32`) are spec-illegal — the decoder fails them via the `p_error` path. +- **Risk**: A 5-char group might overflow `2^32 - 1`. → **Mitigation**: the spec-imposed maximum is `s8W-!` = `0xFFFFFFFF` = `2^32 - 1`. Many other 5-char strings in the `!..u` alphabet compute higher and are spec-illegal — notably `uuuuu` arithmetically yields `84*(85^4 + 85^3 + 85^2 + 85 + 1)` = 4,437,053,124, which is 142,085,829 above the cap. The decoder rejects any group whose computed value exceeds `2^32 - 1` via the `p_error` + `return false` path. PHP's 64-bit `int` (guaranteed by the upstream composer constraint on any supported platform) handles the arithmetic without overflow. - **Trade-off**: Greedy z-encoding leaves a few bytes on the floor when zero runs straddle group boundaries. → **Mitigation**: documented; in practice this matters only for image data which we don't anonymise. diff --git a/openspec/changes/feat-ascii85-decode/specs/ascii85-decode-filter/spec.md b/openspec/changes/feat-ascii85-decode/specs/ascii85-decode-filter/spec.md index d0b4438..e88a7f8 100644 --- a/openspec/changes/feat-ascii85-decode/specs/ascii85-decode-filter/spec.md +++ b/openspec/changes/feat-ascii85-decode/specs/ascii85-decode-filter/spec.md @@ -38,15 +38,21 @@ Requirements`) are merged into the canonical spec by the archiver. ### REQ-001: ASCII85Decode SHALL decode per PDF 1.7 §7.4.3 -The decoder MUST interpret 5-character groups in the range `!..u` (codepoints 33..117) as base-85 integers, emit 4 bytes per group in big-endian order, recognise the single-character shortcut `z` as 4 zero bytes, terminate at the `~>` EOD marker, and ignore whitespace anywhere. +The decoder MUST interpret 5-character groups in the range `!..u` (codepoints 33..117) as base-85 integers, emit 4 bytes per group in big-endian order, recognise the single-character shortcut `z` (only at a group boundary) as 4 zero bytes, terminate at the `~>` EOD marker, ignore whitespace anywhere, and tolerate an optional leading `<~` (Adobe btoa-style start marker). #### Scenario: Standard 5-char group decode -- GIVEN the input is `87cURD]i,"Ebo80~>` (encoding `"Hello world!"`) +- GIVEN the input is `87cUR~>` (canonical encoding of `"Hell"`; verified `ASCII85Encode("Hell") === "87cUR~>"`) +- WHEN `ASCII85Decode` is invoked +- THEN the decoder MUST return the 4-byte string `Hell` + +#### Scenario: Multi-group decode + +- GIVEN the input is `87cURD]j7BEbo80~>` (canonical encoding of `"Hello world!"`; verified `ASCII85Encode("Hello world!") === "87cURD]j7BEbo80~>"`) - WHEN `ASCII85Decode` is invoked - THEN the decoder MUST return `Hello world!` -#### Scenario: `z` shortcut +#### Scenario: `z` shortcut at group boundary - GIVEN the input is `z~>` - WHEN `ASCII85Decode` is invoked @@ -54,15 +60,27 @@ The decoder MUST interpret 5-character groups in the range `!..u` (codepoints 33 #### Scenario: Whitespace ignored -- GIVEN the input is `87cURD\n]i,"E\tbo80~>` +- GIVEN the input is `87cU\n R~>` (whitespace mid-group) +- WHEN `ASCII85Decode` is invoked +- THEN the decoder MUST return `Hell` (whitespace stripped) + +#### Scenario: Adobe-tolerant leading marker + +- GIVEN the input is `<~87cUR~>` - WHEN `ASCII85Decode` is invoked -- THEN the decoder MUST return `Hello world!` (whitespace stripped) +- THEN the decoder MUST strip the leading `<~` and return `Hell` -#### Scenario: Trailing partial group +#### Scenario: Trailing partial group (k=2 → 1 byte) -- GIVEN the input is `87cURDZ~>` (5-char group `87cUR` decoding to 4 bytes `0x48 0x65 0x6C 0x6C` = `"Hell"`, followed by 2-char partial group `DZ` decoding to the single byte `0x6F` = `"o"` per §7.4.3 padding rules) +- GIVEN the input is `87cURDZ~>` (canonical encoding of `"Hello"`; the 2-char trailing partial `DZ` encodes the single byte `o` per §7.4.3 padding rules — verified `ASCII85Encode("Hello") === "87cURDZ~>"`) - WHEN `ASCII85Decode` is invoked -- THEN the decoder MUST return the 5-byte string `"Hello"` (concatenation `0x48 0x65 0x6C 0x6C 0x6F`) +- THEN the decoder MUST return the 5-byte string `"Hello"` + +#### Scenario: Empty payload between markers + +- GIVEN the input is `<~~>` (empty payload between Adobe markers) OR `~>` (bare EOD) +- WHEN `ASCII85Decode` is invoked +- THEN the decoder MUST return the empty string ### REQ-002: ASCII85Encode SHALL produce round-trip-compatible output @@ -96,15 +114,51 @@ For any input byte string `$P`, `ASCII85Decode(ASCII85Encode($P, null), null)` M - WHEN `ASCII85Decode` is invoked - THEN the round-trip MUST equal the input byte-for-byte -### REQ-004: ASCII85Decode SHALL fail safely on illegal characters +### REQ-004: ASCII85Decode SHALL fail safely on spec violations -Characters outside `!..u` plus `z` plus whitespace plus the EOD bytes `~>` MUST cause the decoder to call `p_error()` and return the raw input unchanged. +The decoder MUST call `p_error()` and MUST return `false` (matching the chain dispatcher's `=== false` short-circuit contract; downstream filters MUST NOT see partial output) on any of the following spec-violation conditions: + +1. A character outside `!..u` (33..117) appears in the data region — including `~` not paired with `>` as the EOD marker, and `z` appearing mid-group rather than at a group boundary. +2. A trailing partial group of exactly 1 character (§7.4.3 partial-group rule is `2 ≤ k ≤ 4`). +3. A 5-char group whose arithmetic decoded value exceeds `2^32 - 1` (the spec-imposed maximum). Note: `uuuuu` arithmetically computes to 4,437,053,124 which exceeds the cap; the maximum valid 5-char group is `s8W-!` = 2^32 - 1. +4. PCRE compile failure / limit hit on the whitespace-strip regex. #### Scenario: Illegal character -- GIVEN the input is `87c{RD~>` (contains `{`, codepoint 123, outside the range) +- GIVEN the input is `87c{R~>` (contains `{`, codepoint 123, outside `!..u`) +- WHEN `ASCII85Decode` is invoked +- THEN the decoder MUST call `p_error()` +- AND the return value MUST be `false` + +#### Scenario: `~` mid-stream (not part of `~>` EOD) + +- GIVEN the input is `87c~XR~>` (contains a `~` that is not the start of the EOD pair) +- WHEN `ASCII85Decode` is invoked +- THEN the return value MUST be `false` + +#### Scenario: `z` mid-group + +- GIVEN the input is `8z~>` (`z` appears after `8` has begun a new group — not at a boundary) +- WHEN `ASCII85Decode` is invoked +- THEN the return value MUST be `false` + +#### Scenario: 1-char trailing partial group is spec-illegal + +- GIVEN the input is `87cURD~>` (5-char group `87cUR` plus a stray 1-char partial `D`; §7.4.3 requires partial groups to satisfy `2 ≤ k ≤ 4`) +- WHEN `ASCII85Decode` is invoked +- THEN the return value MUST be `false` + +#### Scenario: Overflow guard fires + +- GIVEN the input is `tttt~>` (4-char partial padded with `u` arithmetically yields 4,384,231,064, which exceeds `2^32 - 1`) - WHEN `ASCII85Decode` is invoked -- THEN the decoder MUST call `p_error()` and return the original input unchanged +- THEN the return value MUST be `false` + +#### Scenario: Chain-failure propagation on outer-ASCII85 layer + +- GIVEN an object with `/Filter [/ASCII85Decode /FlateDecode]` and a raw `_stream` whose ASCII85 layer contains an illegal character +- WHEN `get_stream(false)` is invoked +- THEN the chain dispatcher MUST short-circuit at the ASCII85 arm and return `false`; the inner `FlateDecode` arm MUST NOT see the malformed bytes ## MODIFIED Requirements diff --git a/openspec/changes/feat-ascii85-decode/tasks.md b/openspec/changes/feat-ascii85-decode/tasks.md index 35fe225..6b3e03b 100644 --- a/openspec/changes/feat-ascii85-decode/tasks.md +++ b/openspec/changes/feat-ascii85-decode/tasks.md @@ -1,50 +1,51 @@ ## 1. Pre-flight -- [ ] 1.1 Confirm `feat-filter-chain-dispatch` is merged into `work/text-replacement` -- [ ] 1.2 Branch off as `feat/ascii85-decode` -- [ ] 1.3 Re-read PDF 1.7 §7.4.3 — confirm `z` shortcut + EOD + partial-group padding semantics +- [x] 1.1 Confirm `feat-filter-chain-dispatch` is merged into `work/text-replacement` +- [x] 1.2 Branch off as `feat/ascii85-decode` +- [x] 1.3 Re-read PDF 1.7 §7.4.3 — confirm `z` shortcut + EOD + partial-group padding semantics ## 2. Decode helper -- [ ] 2.1 Add `protected static function ASCII85Decode($_stream, $params)` in `src/PDFObject.php` -- [ ] 2.2 Strip whitespace, optionally strip leading `<~` (OQ1 — Adobe-tolerant) -- [ ] 2.3 Process `z` shortcut as 4 zero bytes -- [ ] 2.4 Process 5-char groups as base-85 integers, big-endian to 4 bytes -- [ ] 2.5 Handle trailing partial group with `u` padding -- [ ] 2.6 Fail-safe on illegal characters via `p_error()` +- [x] 2.1 Add `protected static function ASCII85Decode($_stream, $params)` in `src/PDFObject.php` +- [x] 2.2 Strip whitespace, optionally strip leading `<~` (OQ1 — Adobe-tolerant) +- [x] 2.3 Process `z` shortcut as 4 zero bytes +- [x] 2.4 Process 5-char groups as base-85 integers, big-endian to 4 bytes +- [x] 2.5 Handle trailing partial group with `u` padding +- [x] 2.6 Fail-safe via `p_error()` + `return false` on any spec violation: illegal character (outside `!..u` plus `z` plus whitespace plus `~>` EOD), `z` mid-group, `~` not paired with `>`, 1-char trailing partial group (§7.4.3 requires `2 ≤ k ≤ 4`), overflow beyond `2^32 - 1` (max valid 5-char group is `s8W-!` — NOT `uuuuu` which exceeds the cap), or PCRE compile failure ## 3. Encode helper -- [ ] 3.1 Add `protected static function ASCII85Encode($_stream, $params)` -- [ ] 3.2 Walk input 4 bytes at a time; emit `z` for aligned 4-zero-byte groups (D3) -- [ ] 3.3 Emit standard 5-char groups otherwise -- [ ] 3.4 Handle trailing partial group (pad with `\x00` bytes, emit only `k+1` chars where `k` is the partial length) -- [ ] 3.5 Emit trailing `~>` EOD marker +- [x] 3.1 Add `protected static function ASCII85Encode($_stream, $params)` +- [x] 3.2 Walk input 4 bytes at a time; emit `z` for aligned 4-zero-byte groups (D3) +- [x] 3.3 Emit standard 5-char groups otherwise +- [x] 3.4 Handle trailing partial group (pad with `\x00` bytes, emit only `k+1` chars where `k` is the partial length) +- [x] 3.5 Emit trailing `~>` EOD marker ## 4. Chain-dispatch integration -- [ ] 4.1 Add `case '/ASCII85Decode'` arms in both `apply_filter_chain_decode` and `apply_filter_chain_encode` +- [x] 4.1 Add `case 'ASCII85Decode'` arms in both `apply_filter_chain_decode` and `apply_filter_chain_encode` (note: dispatcher strips leading `/` from filter names — case label is the bare name without slash) ## 5. Tests / verification -- [ ] 5.1 Round-trip fixture `examples/poc-filter-roundtrip-ascii85.php`: 1024-byte buffer including 4-zero-byte runs (exercises `z` shortcut) -- [ ] 5.2 Verify `examples/poc-replace-text.php` still exits 0 -- [ ] 5.3 Negative test: illegal character → `p_error` + unchanged -- [ ] 5.4 Chain test `[/ASCII85Decode /FlateDecode]` round-trip -- [ ] 5.5 Adobe-tolerance test: input with leading `<~` decodes correctly +- [x] 5.1 Round-trip fixture `examples/poc-filter-roundtrip-ascii85.php`: 1024-byte buffer including 4-zero-byte runs (exercises `z` shortcut) +- [x] 5.2 Verify `examples/poc-replace-text.php` still exits 0 +- [x] 5.3 Negative tests (each MUST return `false`): illegal char `{`, `~` mid-stream, `z` mid-group, 1-char trailing partial group `87cURD~>`, overflow guard `tttt~>` (yields 4,384,231,064 > 2^32-1) +- [x] 5.4 Chain test `[/ASCII85Decode /FlateDecode]` round-trip + chain-failure-propagation test (outer ASCII85 illegal char MUST short-circuit before inner Flate runs) +- [x] 5.5 Adobe-tolerance test: input with leading `<~` decodes correctly; also `<~~>` and bare `~>` decode to empty string +- [x] 5.6 Boundary coverage: round-trip for every partial-group residue n=1..9 (covers (4k, 4k+1, 4k+2, 4k+3) boundary between full-group and partial-group paths) ## 6. Upstream-PR draft -- [ ] 6.1 Update `docs/upstream-prs/03-ascii85-decode/{proposal,design,tasks}.md` -- [ ] 6.2 Leave `Posted at: ` placeholder +- [x] 6.1 Update `docs/upstream-prs/03-ascii85-decode/{proposal,design,tasks}.md` +- [x] 6.2 Leave `Posted at: ` placeholder ## 7. Quality gate -- [ ] 7.1 PHP 7.4 compatibility; verify int is 32-bit-or-wider on target platform -- [ ] 7.2 No new composer dependencies -- [ ] 7.3 snake_case discipline +- [x] 7.1 PHP 7.4 compatibility; requires 64-bit PHP ints (composer constraint effectively guarantees this on supported platforms; defensive 32-bit masking removed — dead on 64-bit and broken on 32-bit) +- [x] 7.2 No new composer dependencies +- [x] 7.3 snake_case discipline ## 8. Commit + PR -- [ ] 8.1 Commit on `feat/ascii85-decode` -- [ ] 8.2 Open PR `feat/ascii85-decode → work/text-replacement` +- [x] 8.1 Commit on `feat/ascii85-decode` +- [x] 8.2 Open PR `feat/ascii85-decode → work/text-replacement` diff --git a/src/PDFObject.php b/src/PDFObject.php index abc0f8f..c5f8680 100644 --- a/src/PDFObject.php +++ b/src/PDFObject.php @@ -209,6 +209,209 @@ protected static function FlateDecode($_stream, $params) { return $decoded->get_raw(); } + /** + * Decode an ASCII85Decode-encoded stream per PDF 1.7 §7.4.3. + * + * Base-85 encoding: 5 ASCII chars in `!..u` (codepoints 33..117) + * decode to 4 binary bytes. The single char `z` is a shortcut for + * 4 zero bytes (only valid at a group boundary). EOD marker is `~>`. + * Whitespace anywhere is ignored. Trailing partial group of `k` + * chars (2 ≤ k ≤ 4) decodes to `k-1` binary bytes, padded with `u` + * (84) to a full 5-char group. A trailing 1-char partial group is + * spec-illegal (§7.4.3 partial-group rule is `2 ≤ k ≤ 4`). + * + * Adobe-tolerance: optional leading `<~` marker is stripped if + * present (btoa-style start marker; PDF 1.7 doesn't mention it but + * some readers tolerate it). + * + * 32-bit overflow guard: the spec-imposed maximum decoded value + * for a 5-char group is `s8W-!` = 0xFFFFFFFF = 2^32 - 1. Any group + * that arithmetically computes higher (e.g. `tttt` padded with `u` + * via the partial-group rule yields 4,384,231,064) is spec-illegal + * and rejected. Note that `uuuuu` is NOT a valid encoding of + * 2^32 - 1 — it computes to 4,437,053,124, which is what triggers + * the guard. + * + * Failure paths (illegal char, 1-char partial group, overflow, + * regex compile failure) → p_error + return `false` per the chain + * dispatcher's `=== false` short-circuit contract; downstream + * filters MUST NOT see partial output. + * + * @param string $_stream Encoded stream bytes. + * @param mixed $params Unused (ASCII85Decode takes no parameters per Table 5). + * + * @return string|false Decoded binary bytes on success; `false` on + * any spec-violation failure (illegal char, + * 1-char partial group, overflow, or PCRE + * compile-time / limit failure). + */ + protected static function ASCII85Decode($_stream, $params) { + // Strip optional leading `<~` (Adobe-tolerant per OQ1). + if (substr($_stream, 0, 2) === '<~') { + $_stream = substr($_stream, 2); + } + + // Find EOD `~>`; everything after is ignored. Missing EOD is + // tolerated (Adobe-compatible per D5). + $eodPos = strpos($_stream, '~>'); + $dataRegion = $eodPos === false ? $_stream : substr($_stream, 0, $eodPos); + + // Strip whitespace. preg_replace returns null on PCRE compile + // failure or limit hit — defensively treat that as a hard + // decode failure rather than letting null propagate to strlen() + // (deprecation on PHP 8.1+, TypeError on PHP 9.0). + $compact = preg_replace('/[\\x00\\x09\\x0a\\x0c\\x0d\\x20]/', '', $dataRegion); + if ($compact === null) { + p_error('ASCII85Decode: whitespace-strip regex failed (PCRE limit or compile error)'); + return false; + } + + $out = ''; + $len = strlen($compact); + $i = 0; + while ($i < $len) { + // `z` shortcut: 4 zero bytes. Only valid at a group boundary, + // which holds by construction here — `$i` advances either by + // 1 (z) or by `$groupLen` (read-group), so we are always at + // a boundary when we enter the loop body. + if ($compact[$i] === 'z') { + $out .= "\x00\x00\x00\x00"; + $i++; + continue; + } + + // Read up to 5 chars for the next group. + $groupLen = min(5, $len - $i); + + // A 1-char trailing partial group is spec-illegal + // (§7.4.3 partial-group rule is `2 ≤ k ≤ 4`). + if ($groupLen === 1) { + p_error('ASCII85Decode: trailing 1-char partial group is spec-illegal (§7.4.3 requires 2 ≤ k ≤ 4)'); + return false; + } + + $group = substr($compact, $i, $groupLen); + $i += $groupLen; + + // Validate alphabet. + for ($g = 0; $g < $groupLen; $g++) { + $c = ord($group[$g]); + if ($c < 33 || $c > 117) { + p_error('ASCII85Decode: illegal character (codepoint '.$c.') outside !..u alphabet'); + return false; + } + } + + // Pad trailing partial group with `u` (84) to a full 5 chars. + $padded = $groupLen < 5 ? ($group . str_repeat('u', 5 - $groupLen)) : $group; + + // Compute the 32-bit unsigned big-endian integer. + // n = c0*85^4 + c1*85^3 + c2*85^2 + c3*85 + c4 (each c_i is char - 33). + $n = 0; + for ($g = 0; $g < 5; $g++) { + $n = $n * 85 + (ord($padded[$g]) - 33); + } + + // Overflow check: max valid 5-char group is `s8W-!` + // (= 2^32 - 1). `uuuuu` arithmetically yields 4,437,053,124 + // which is 142,085,829 above the cap. + if ($n > 4294967295) { + p_error('ASCII85Decode: group overflows 32-bit unsigned (value '.$n.')'); + return false; + } + + // Pack as big-endian 4 bytes. + $bytes = chr(($n >> 24) & 0xFF) + . chr(($n >> 16) & 0xFF) + . chr(($n >> 8) & 0xFF) + . chr($n & 0xFF); + + // Emit only (groupLen - 1) bytes for a partial trailing group. + $emit = $groupLen < 5 ? ($groupLen - 1) : 4; + $out .= substr($bytes, 0, $emit); + } + + return $out; + } + + /** + * Encode binary bytes via ASCII85Encode per PDF 1.7 §7.4.3. + * + * Emits 5 ASCII chars in `!..u` per 4 input bytes. Aligned 4-zero- + * byte groups use the `z` shortcut. Trailing partial group of `k` + * input bytes (1 ≤ k ≤ 3) is right-padded with `\x00` to 4 bytes, + * encoded, and emitted as `k+1` chars (the padding chars are + * stripped from the output). + * + * Output is terminated by the EOD marker `~>`. Empty input emits + * just the EOD marker. + * + * @param string $_stream Raw binary input. + * @param mixed $params Unused. + * + * @return string Encoded stream including EOD marker. + */ + protected static function ASCII85Encode($_stream, $params) { + $out = ''; + $len = strlen($_stream); + $i = 0; + + // Process full 4-byte groups. + while ($i + 4 <= $len) { + $b0 = ord($_stream[$i]); + $b1 = ord($_stream[$i + 1]); + $b2 = ord($_stream[$i + 2]); + $b3 = ord($_stream[$i + 3]); + + if ($b0 === 0 && $b1 === 0 && $b2 === 0 && $b3 === 0) { + // Aligned 4-zero-byte group → `z` shortcut. + $out .= 'z'; + $i += 4; + continue; + } + + // Compute the 32-bit unsigned big-endian integer. + // Requires 64-bit PHP ints — the upstream sapp composer + // constraint (^7.4 || ^8.0) effectively guarantees this on + // any supported platform. Defensive 32-bit masking has been + // removed: `0xFFFFFFFF` is parsed as float on 32-bit PHP and + // `& 0xFFFFFFFF` then breaks the result. + $n = ($b0 << 24) | ($b1 << 16) | ($b2 << 8) | $b3; + + // Convert to base-85, MSB first. + $c4 = $n % 85; $n = intdiv($n, 85); + $c3 = $n % 85; $n = intdiv($n, 85); + $c2 = $n % 85; $n = intdiv($n, 85); + $c1 = $n % 85; $n = intdiv($n, 85); + $c0 = $n; + $out .= chr($c0 + 33) . chr($c1 + 33) . chr($c2 + 33) . chr($c3 + 33) . chr($c4 + 33); + + $i += 4; + } + + // Trailing partial group: pad with \x00, encode, emit (k+1) chars. + $remaining = $len - $i; + if ($remaining > 0) { + $padded = substr($_stream, $i) . str_repeat("\x00", 4 - $remaining); + $b0 = ord($padded[0]); + $b1 = ord($padded[1]); + $b2 = ord($padded[2]); + $b3 = ord($padded[3]); + $n = (($b0 << 24) | ($b1 << 16) | ($b2 << 8) | $b3) & 0xFFFFFFFF; + if ($n < 0) { $n += 4294967296; } + + $c4 = $n % 85; $n = intdiv($n, 85); + $c3 = $n % 85; $n = intdiv($n, 85); + $c2 = $n % 85; $n = intdiv($n, 85); + $c1 = $n % 85; $n = intdiv($n, 85); + $c0 = $n; + $chars = chr($c0 + 33) . chr($c1 + 33) . chr($c2 + 33) . chr($c3 + 33) . chr($c4 + 33); + $out .= substr($chars, 0, $remaining + 1); + } + + return $out . '~>'; + } + /** * Decode a RunLengthDecode-encoded stream per PDF 1.7 §7.4.5. * @@ -590,6 +793,13 @@ protected static function apply_filter_chain_decode($bytes, array $filters, arra } $bytes = $decoded; break; + case 'ASCII85Decode': + $decoded = self::ASCII85Decode($bytes, $params[$i] ?? null); + if ($decoded === false) { + return false; + } + $bytes = $decoded; + break; default: p_error("unknown compression method /$name in filter chain at position $i"); return false; @@ -629,6 +839,9 @@ protected static function apply_filter_chain_encode($bytes, array $filters, arra case 'RunLengthDecode': $bytes = self::RunLengthEncode($bytes, $params[$i] ?? null); break; + case 'ASCII85Decode': + $bytes = self::ASCII85Encode($bytes, $params[$i] ?? null); + break; default: p_error("unknown compression method /$name in filter chain at position $i"); return false;