forked from dealfonso/sapp
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ascii85): ASCII85Decode encode + decode + chain wiring (PR #03, stacked on #5) #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
5e6eede
feat(ascii85): ASCII85Decode encode + decode + chain wiring (SAPP PR …
rjzondervan e49b83b
Merge remote-tracking branch 'origin/feat/runlength-decode' into feat…
rjzondervan 66baeaf
feat(ascii85): address Wilco's first-pass strict review
rjzondervan ed058d9
Merge remote-tracking branch 'origin/feat/runlength-decode' into feat…
rjzondervan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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). | ||
|
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(); | ||
|
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) { | ||
|
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); | ||
|
WilcoLouwerse marked this conversation as resolved.
WilcoLouwerse marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.