forked from dealfonso/sapp
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(asciihex): ASCIIHexDecode encode + decode + chain wiring (PR #01, stacked on #3) #4
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
23a9667
feat(asciihex): ASCIIHexDecode encode + decode + chain wiring (SAPP P…
rjzondervan 41016a4
Merge branch 'feat/filter-chain-dispatch' into feat/asciihex-decode
rjzondervan 70d9a3a
feat(asciihex): address Wilco's first-pass strict review
rjzondervan 053f9b2
Merge remote-tracking branch 'origin/feat/filter-chain-dispatch' into…
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,280 @@ | ||
| <?php | ||
| /** | ||
| * ASCIIHexDecode round-trip verification gate. | ||
| * | ||
| * Anchors the OpenSpec contract for `feat-asciihex-decode`: | ||
| * | ||
| * REQ-1 — ASCIIHexDecode SHALL decode per PDF 1.7 §7.4.2 | ||
| * REQ-2 — ASCIIHexEncode SHALL emit uppercase hex pairs with EOD | ||
| * REQ-3 — ASCIIHexDecode round-trip MUST be lossless | ||
| * REQ-4 — ASCIIHexDecode SHALL fail safely on illegal characters | ||
| * REQ-5 — Chain dispatcher MUST recognise /ASCIIHexDecode on decode + encode | ||
| * | ||
| * Exit code 0 on success; 1 on any assertion failure. | ||
| * | ||
| * 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) { | ||
|
WilcoLouwerse marked this conversation as resolved.
|
||
| $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 | ||
| * ---------------------------------------------------------------- */ | ||
| { | ||
| $decoded = invokeProtectedStatic('ASCIIHexDecode', ['48656C6C6F>', null]); | ||
| if ($decoded !== 'Hello') { | ||
| $failures[] = "REQ-1 (even-length): expected 'Hello', got " . bin2hex((string) $decoded); | ||
| } | ||
|
|
||
| // Odd-length: 41B> → \x41\xB0 (trailing B paired with implicit 0) | ||
| $decoded = invokeProtectedStatic('ASCIIHexDecode', ['41B>', null]); | ||
| if ($decoded !== "\x41\xB0") { | ||
| $failures[] = "REQ-1 (odd-length pad): expected 0x41 0xB0, got " . bin2hex((string) $decoded); | ||
| } | ||
|
|
||
| // Whitespace anywhere | ||
| $decoded = invokeProtectedStatic('ASCIIHexDecode', ["48 65\n6C\t6C\r\n6F>", null]); | ||
| if ($decoded !== 'Hello') { | ||
| $failures[] = "REQ-1 (whitespace): expected 'Hello', got " . bin2hex((string) $decoded); | ||
| } | ||
|
|
||
| // Lowercase hex | ||
| $decoded = invokeProtectedStatic('ASCIIHexDecode', ['48656c6c6f>', null]); | ||
| if ($decoded !== 'Hello') { | ||
| $failures[] = "REQ-1 (lowercase): expected 'Hello', got " . bin2hex((string) $decoded); | ||
| } | ||
|
|
||
| // Trailing bytes after EOD ignored | ||
| $decoded = invokeProtectedStatic('ASCIIHexDecode', ['48656C6C6F>garbage', null]); | ||
| if ($decoded !== 'Hello') { | ||
| $failures[] = "REQ-1 (trailing after EOD): expected 'Hello', got " . bin2hex((string) $decoded); | ||
| } | ||
| } | ||
|
|
||
| /* ---------------------------------------------------------------- * | ||
| * REQ-2 — encode scenarios | ||
| * ---------------------------------------------------------------- */ | ||
| { | ||
| // Basic encode | ||
| $encoded = invokeProtectedStatic('ASCIIHexEncode', ['Hello', null]); | ||
| if ($encoded !== '48656C6C6F>') { | ||
| $failures[] = "REQ-2 (basic): expected '48656C6C6F>', got '$encoded'"; | ||
| } | ||
|
|
||
| // Empty input → just EOD | ||
| $encoded = invokeProtectedStatic('ASCIIHexEncode', ['', null]); | ||
| if ($encoded !== '>') { | ||
| $failures[] = "REQ-2 (empty): expected '>', got '$encoded'"; | ||
| } | ||
|
|
||
| // Line wrap at 80 columns — input of 50 bytes encodes to 100 hex chars, | ||
| // must produce at least one '\n' and no line > 80 chars | ||
| $longInput = str_repeat('A', 50); | ||
| $encoded = invokeProtectedStatic('ASCIIHexEncode', [$longInput, null]); | ||
| if (strpos($encoded, "\n") === false) { | ||
| $failures[] = "REQ-2 (line wrap): 50-byte input did not produce any newlines"; | ||
| } | ||
| foreach (explode("\n", $encoded) as $line) { | ||
| if (strlen($line) > 80) { | ||
| $failures[] = "REQ-2 (line wrap): line longer than 80 chars (" . strlen($line) . ")"; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /* ---------------------------------------------------------------- * | ||
| * REQ-3 — lossless round-trip on 1024-byte random binary | ||
| * ---------------------------------------------------------------- */ | ||
| { | ||
| $original = random_bytes(1024); | ||
| $encoded = invokeProtectedStatic('ASCIIHexEncode', [$original, null]); | ||
| $decoded = invokeProtectedStatic('ASCIIHexDecode', [$encoded, null]); | ||
| if ($decoded !== $original) { | ||
| $failures[] = "REQ-3 (binary round-trip): 1024-byte input did not survive encode→decode (got " . strlen((string) $decoded) . " bytes back)"; | ||
| } | ||
| } | ||
|
|
||
| /* ---------------------------------------------------------------- * | ||
| * REQ-4 — illegal character fail-safe (returns false per dispatcher contract) | ||
| * ---------------------------------------------------------------- */ | ||
| { | ||
| $original = '48656C!6C6F>'; // contains '!' | ||
| ob_start(); | ||
| $decoded = invokeProtectedStatic('ASCIIHexDecode', [$original, null]); | ||
| ob_end_clean(); | ||
| if ($decoded !== false) { | ||
| $failures[] = "REQ-4 (illegal char): expected false on failure, got " . var_export($decoded, true); | ||
| } | ||
| } | ||
|
|
||
| /* ---------------------------------------------------------------- * | ||
| * REQ-4 — chain-failure propagation: when an outer ASCIIHexDecode | ||
| * layer is malformed, the chain dispatcher MUST short-circuit and | ||
| * return false rather than feeding garbage to the next filter. | ||
| * ---------------------------------------------------------------- */ | ||
| { | ||
| $filterList = new PDFValueList(); | ||
| $filterList->push(new PDFValueType('ASCIIHexDecode')); | ||
| $filterList->push(new PDFValueType('FlateDecode')); | ||
|
|
||
| // Raw stream is malformed hex (contains '!') — outer ASCIIHex | ||
| // arm MUST fail before the inner FlateDecode arm ever runs. | ||
| $obj = buildObj($filterList, '48656C!6C6F>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 ASCIIHex failure (got " . var_export($decoded, true) . ")"; | ||
| } | ||
| } | ||
|
|
||
| /* ---------------------------------------------------------------- * | ||
| * Missing-EOD tolerance (Adobe-compatible documented behaviour) | ||
| * ---------------------------------------------------------------- */ | ||
| { | ||
| // Input without trailing `>` — decoder should treat the whole | ||
| // input as the data region. | ||
| $decoded = invokeProtectedStatic('ASCIIHexDecode', ['48656C6C6F', null]); | ||
| if ($decoded !== 'Hello') { | ||
| $failures[] = "Missing-EOD tolerance: expected 'Hello', got " . bin2hex((string) $decoded); | ||
| } | ||
| } | ||
|
|
||
| /* ---------------------------------------------------------------- * | ||
| * EOD-first / empty edge cases (REQ-1 boundary coverage) | ||
| * ---------------------------------------------------------------- */ | ||
| { | ||
| $decoded = invokeProtectedStatic('ASCIIHexDecode', ['>', null]); | ||
| if ($decoded !== '') { | ||
| $failures[] = "EOD-first empty: expected '', got " . bin2hex((string) $decoded); | ||
| } | ||
| $decoded = invokeProtectedStatic('ASCIIHexDecode', [" \n \t >", null]); | ||
| if ($decoded !== '') { | ||
| $failures[] = "Whitespace+EOD empty: expected '', got " . bin2hex((string) $decoded); | ||
| } | ||
| } | ||
|
|
||
| /* ---------------------------------------------------------------- * | ||
| * REQ-2/3 — empty input round-trip + 40-byte 80-col boundary | ||
| * ---------------------------------------------------------------- */ | ||
| { | ||
| // Round-trip of empty input (decode(encode('')) === ''). | ||
| $encoded = invokeProtectedStatic('ASCIIHexEncode', ['', null]); | ||
| $decoded = invokeProtectedStatic('ASCIIHexDecode', [$encoded, null]); | ||
| if ($decoded !== '') { | ||
| $failures[] = "REQ-2 (empty round-trip): expected '' got " . bin2hex((string) $decoded); | ||
| } | ||
|
|
||
| // 40-byte input = 80 hex chars — encodes to a single line of | ||
| // exactly 80 chars. The encoder MUST insert a newline before `>` | ||
| // so no line exceeds 80 chars (or at least clearly handle the | ||
| // boundary; the encoder ships with a `strlen % 80 === 0` guard). | ||
| $payload = str_repeat('A', 40); | ||
| $encoded = invokeProtectedStatic('ASCIIHexEncode', [$payload, null]); | ||
| foreach (explode("\n", $encoded) as $line) { | ||
| if (strlen($line) > 80) { | ||
| $failures[] = "REQ-2 (40-byte 80-col edge): line longer than 80 chars (" . strlen($line) . ")"; | ||
| break; | ||
| } | ||
| } | ||
| // Round-trip MUST still produce the original. | ||
| $decoded = invokeProtectedStatic('ASCIIHexDecode', [$encoded, null]); | ||
| if ($decoded !== $payload) { | ||
| $failures[] = "REQ-2 (40-byte round-trip): round-trip mismatch on 80-col boundary"; | ||
| } | ||
| } | ||
|
|
||
| /* ---------------------------------------------------------------- * | ||
| * REQ-5 — chain dispatcher recognises /ASCIIHexDecode | ||
| * ---------------------------------------------------------------- */ | ||
| { | ||
| // Single-filter chain via array form: [/ASCIIHexDecode] | ||
| $filterList = new PDFValueList(); | ||
| $filterList->push(new PDFValueType('ASCIIHexDecode')); | ||
|
|
||
| $obj = buildObj($filterList); | ||
| $plaintext = 'plain-text payload — ASCIIHex only'; | ||
| $obj->set_stream($plaintext, false); | ||
|
|
||
| $encoded = $obj->get_stream(true); | ||
| if ($encoded === $plaintext) { | ||
| $failures[] = "REQ-5 (chain-only-ASCIIHex set_stream): did not encode (stored verbatim)"; | ||
| } | ||
| if (substr($encoded, -1) !== '>') { | ||
| $failures[] = "REQ-5 (chain-only-ASCIIHex set_stream): encoded stream missing trailing EOD '>'"; | ||
| } | ||
|
|
||
| $decoded = $obj->get_stream(false); | ||
| if ($decoded !== $plaintext) { | ||
| $failures[] = "REQ-5 (chain-only-ASCIIHex round-trip): expected plaintext, got " . bin2hex((string) $decoded); | ||
| } | ||
| } | ||
|
|
||
| { | ||
| // Two-filter chain: [/ASCIIHexDecode /FlateDecode] | ||
| // Encode order is REVERSE: FlateDecode first (innermost), then | ||
| // ASCIIHexDecode (outermost). Decode order is FORWARD: ASCIIHex | ||
| // first to strip the hex envelope, then Flate to decompress. | ||
| $filterList = new PDFValueList(); | ||
|
WilcoLouwerse marked this conversation as resolved.
WilcoLouwerse marked this conversation as resolved.
|
||
| $filterList->push(new PDFValueType('ASCIIHexDecode')); | ||
| $filterList->push(new PDFValueType('FlateDecode')); | ||
|
|
||
| $obj = buildObj($filterList); | ||
| $plaintext = "BT (Chained ASCIIHex outer + Flate inner) Tj ET"; | ||
| $obj->set_stream($plaintext, false); | ||
|
|
||
| $encoded = $obj->get_stream(true); | ||
| if (substr($encoded, -1) !== '>') { | ||
| $failures[] = "REQ-5 (chain ASCIIHex+Flate set_stream): encoded stream missing trailing '>' (outer ASCIIHex)"; | ||
| } | ||
|
|
||
| $decoded = $obj->get_stream(false); | ||
| if ($decoded !== $plaintext) { | ||
| $failures[] = "REQ-5 (chain ASCIIHex+Flate round-trip): expected plaintext, got " . bin2hex((string) $decoded); | ||
| } | ||
| } | ||
|
WilcoLouwerse marked this conversation as resolved.
|
||
|
|
||
| /* ---------------------------------------------------------------- * | ||
| * Report | ||
| * ---------------------------------------------------------------- */ | ||
| echo "PoC ASCIIHexDecode — round-trip verification\n"; | ||
| echo " spec: openspec/changes/feat-asciihex-decode/specs/asciihex-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); | ||
|
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.