Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/upstream-prs/03-ascii85-decode/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Comment thread
WilcoLouwerse marked this conversation as resolved.
| 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).
5 changes: 5 additions & 0 deletions docs/upstream-prs/03-ascii85-decode/proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
5 changes: 5 additions & 0 deletions docs/upstream-prs/03-ascii85-decode/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
309 changes: 309 additions & 0 deletions examples/poc-filter-roundtrip-ascii85.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
<?php
/**
* ASCII85Decode round-trip verification gate.
*
* Anchors the OpenSpec contract for `feat-ascii85-decode`:
*
* REQ-1 — ASCII85Decode SHALL decode per PDF 1.7 §7.4.3
* REQ-2 — ASCII85Encode SHALL produce round-trip-compatible output
* REQ-3 — Round-trip MUST be lossless
* REQ-4 — Illegal characters / spec-violations SHALL fail safely (return false)
* REQ-5 — Chain dispatcher MUST recognise /ASCII85Decode
*
* SPDX-License-Identifier: LGPL-3.0-or-later
*/

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use ddn\sapp\PDFObject;
use ddn\sapp\pdfvalue\PDFValueObject;
use ddn\sapp\pdfvalue\PDFValueList;
use ddn\sapp\pdfvalue\PDFValueType;

$failures = [];

function invokeProtectedStatic(string $method, array $args) {
$ref = new ReflectionClass(PDFObject::class);
$m = $ref->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).
Comment thread
WilcoLouwerse marked this conversation as resolved.
$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();
Comment thread
WilcoLouwerse marked this conversation as resolved.
$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) {
Comment thread
WilcoLouwerse marked this conversation as resolved.
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);
Comment thread
WilcoLouwerse marked this conversation as resolved.
Comment thread
WilcoLouwerse marked this conversation as resolved.
6 changes: 3 additions & 3 deletions openspec/changes/feat-ascii85-decode/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,17 @@ 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)

If the stream ends without a `~>` marker, the decoder finishes the last partial group (treating trailing `u`s as padding) and returns the result. Adobe Reader does this. Spec is mildly ambiguous on whether EOD is mandatory; the lenient interpretation is reader-compatible.

## 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.

Expand Down
Loading