Skip to content

Commit ae68f22

Browse files
committed
fix(container): whitespace transport-resistance (issue #1)
A .pbf.json pasted into an email body / reflowed by a text transport could break two ways, both now fixed + locked with tests: - strict crc32_encoded rejected whitespace the glyph payload ignores -> compute it over the CANONICAL payload (data with [CR/LF/TAB/SPACE] stripped). Clean containers are byte-for-byte unchanged (backward-compatible); only transport- injected whitespace is tolerated. - JSON forbids raw newlines inside strings (a hard line-wrap of the long data line) -> lenient parse: strip raw control whitespace and retry on parse failure. Applied to decodeFromContainer AND the decodeText router (web + Node CLI path). Decode strips canonical whitespace before decoding (default decode does not ignore it). Integrity intact: genuine non-whitespace corruption is still rejected (crc32 of the original bytes, post-decode). Adds 8 JS transport tests (A-G + router + corruption-still-caught) and a transport-mangle case to the parameterized test/test_container, so every executable inherits the guard.
1 parent a0d29e8 commit ae68f22

4 files changed

Lines changed: 93 additions & 5 deletions

File tree

docs/plans/2026-06-26-printable-binary-file-container-design.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,29 @@ Drop a file or paste below — auto-detects ENCODE vs DECODE.
181181
**Recommendation:** A — it makes decode unmistakable (directly answers the
182182
issue), and the metadata panel + container option are equally expressible in B.
183183
Awaiting Peter's pick before building index.html.
184+
185+
## Transport resistance (whitespace) — added 2026-06-27
186+
The container must survive being pasted into email bodies / reflowed by text
187+
transports that inject whitespace. Empirically (test/js + test/test_container):
188+
- Whitespace BETWEEN JSON tokens (reindent, CRLF, trailing spaces) was always
189+
fine — `JSON.parse` ignores it.
190+
- Whitespace landing INSIDE `data` originally broke decode two ways, both fixed:
191+
1. **strict `crc32_encoded`** rejected any byte change, even whitespace the
192+
glyph payload is indifferent to → **fix:** `crc32_encoded` is computed over
193+
the *canonical* payload (data with `[\r\n\t ]` stripped). The default
194+
encoding emits none of those, so clean containers' checksum is UNCHANGED
195+
(backward-compatible); only transport-injected whitespace is tolerated.
196+
2. **JSON forbids a raw newline inside a string** (a hard line-wrap of the long
197+
`data` line) → **fix:** a lenient parse — on `JSON.parse` failure, strip raw
198+
control whitespace (`[\r\n\t]`, never legitimate in our metadata strings or
199+
the glyph payload) and retry.
200+
- Decode strips the canonical whitespace from `data` before `decode()` (default
201+
decode does NOT ignore whitespace; that is opt-in `-S`). **Integrity is intact:
202+
a genuine non-whitespace corruption is still rejected** (crc32 of the original
203+
bytes, checked post-decode).
204+
205+
**Required of EVERY impl** (the "all executables" goal): canonicalize the payload
206+
(`strip [\r\n\t ]`) for the `crc32_encoded` check + before decoding, and parse
207+
leniently (hand-rolled C/Lua parsers get this free by skipping whitespace while
208+
scanning `data`; strict parsers like JS `JSON.parse` / Zig `std.json` need the
209+
strip-and-retry fallback). `test/test_container` enforces this per impl.

js/printable_binary.js

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -676,12 +676,35 @@ class PrintableBinary {
676676
* @param {{filename?:string, modified_ms?:number, created_ms?:number, mode?:string, owner?:string, group?:string}} [meta]
677677
* @returns {object} the container
678678
*/
679+
/**
680+
* Strip transport-injected whitespace from an encoded payload. The default
681+
* encoding emits no literal spaces/tabs/CR/LF (all are glyph'd), so a clean
682+
* payload is unchanged; this only removes whitespace a text transport (email
683+
* wrap, reflow) added — making the container as whitespace-tolerant as raw
684+
* printable-binary while keeping crc integrity intact for real corruption.
685+
*/
686+
_canonicalPayload(data) {
687+
return String(data).replace(/[\r\n\t ]/g, '');
688+
}
689+
690+
/**
691+
* JSON.parse with a transport-tolerant fallback: a hard line-wrap can inject a
692+
* raw newline into the long `data` string, which JSON forbids unescaped inside
693+
* a string literal. On a parse failure, strip raw control whitespace (never
694+
* legitimate in our short metadata strings nor in the glyph payload) and retry.
695+
*/
696+
_parseLenient(text) {
697+
try { return JSON.parse(text); }
698+
catch (_e) { return JSON.parse(String(text).replace(/[\r\n\t]/g, '')); }
699+
}
700+
701+
679702
encodeToContainer(bytes, meta = {}) {
680703
if (!(bytes instanceof Uint8Array)) {
681704
throw new Error("encodeToContainer expects a Uint8Array");
682705
}
683706
const data = this.encode(bytes);
684-
const dataBytes = sharedTextEncoder.encode(data);
707+
const dataBytes = sharedTextEncoder.encode(this._canonicalPayload(data));
685708
const container = {
686709
format: "printable-binary-file",
687710
version: 1,
@@ -709,7 +732,7 @@ class PrintableBinary {
709732
* @returns {{ bytes: Uint8Array, meta: object }}
710733
*/
711734
decodeFromContainer(input) {
712-
const c = typeof input === "string" ? JSON.parse(input) : input;
735+
const c = typeof input === "string" ? this._parseLenient(input) : input;
713736
if (!c || typeof c !== "object") {
714737
throw new Error("Container must be a JSON object or JSON string");
715738
}
@@ -719,13 +742,14 @@ class PrintableBinary {
719742
if (typeof c.data !== "string") {
720743
throw new Error("Container 'data' must be a string");
721744
}
745+
const cleanData = this._canonicalPayload(c.data);
722746
if (c.crc32_encoded !== undefined && c.crc32_encoded !== null) {
723-
const got = this.crc32hex(sharedTextEncoder.encode(c.data));
747+
const got = this.crc32hex(sharedTextEncoder.encode(cleanData));
724748
if (got !== String(c.crc32_encoded).toLowerCase()) {
725749
throw new Error(`Container crc32_encoded mismatch (got ${got}, expected ${c.crc32_encoded}) — 'data' is corrupted`);
726750
}
727751
}
728-
const bytes = this.decode(c.data);
752+
const bytes = this.decode(cleanData);
729753
if (c.byte_length !== undefined && c.byte_length !== null && bytes.length !== c.byte_length) {
730754
throw new Error(`Container byte_length mismatch (decoded ${bytes.length}, expected ${c.byte_length})`);
731755
}
@@ -756,7 +780,7 @@ class PrintableBinary {
756780
const trimmed = String(text).trim();
757781
if (trimmed.startsWith('{')) {
758782
let parsed = null;
759-
try { parsed = JSON.parse(trimmed); } catch (_e) { parsed = null; }
783+
try { parsed = this._parseLenient(trimmed); } catch (_e) { parsed = null; }
760784
if (parsed && parsed.format === 'printable-binary-file') {
761785
const { bytes, meta } = this.decodeFromContainer(parsed);
762786
return { bytes, filename: meta.filename || 'decoded.bin', meta, kind: 'container' };

test/js/test_printable_binary.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,38 @@ console.log('\n--- decodeText (decode-mode router) Tests ---');
374374
assert(threw, "decodeText: corrupt container is rejected");
375375
}
376376

377+
// --- Container transport-resistance (whitespace) Tests, issue #1 ---
378+
console.log('\n--- Container transport-resistance (whitespace) Tests ---');
379+
{
380+
const bytes = new Uint8Array([0,1,2,9,10,13,32,65,66,67,0x7b,34,92,128,200,255]);
381+
const container = encoder.encodeToContainer(bytes, { filename: "t.bin", modified_ms: 123 });
382+
const json = JSON.stringify(container, null, 2);
383+
const eq = (a) => Array.from(a).length === bytes.length && Array.from(a).every((b,i)=>b===bytes[i]);
384+
const insertEvery = (s,n,sep)=>Array.from(s).map((ch,i)=>(i>0&&i%n===0)?sep+ch:ch).join('');
385+
const decOk = (label, text) => {
386+
try { const {bytes:out}=encoder.decodeFromContainer(text); assert(eq(out), label); }
387+
catch(e){ assert(false, label + ' (threw: ' + e.message.slice(0,45) + ')'); }
388+
};
389+
390+
decOk("transport A: reindented JSON structure", json.replace(/\n/g,'\n ').replace(/: /g,' : '));
391+
decOk("transport B: CRLF line endings", json.replace(/\n/g,'\r\n'));
392+
decOk("transport C: trailing spaces per line", json.split('\n').map(l=>l+' ').join('\n'));
393+
{ const c=JSON.parse(json); c.data=insertEvery(c.data,4,' '); decOk("transport D: spaces inside data (escaped)", JSON.stringify(c)); }
394+
{ const c=JSON.parse(json); c.data=insertEvery(c.data,8,'\n'); decOk("transport E: newlines inside data (escaped)", JSON.stringify(c)); }
395+
decOk("transport F: raw spaces injected into data", json.replace(/("data": ".{8})/, '$1 '));
396+
decOk("transport G: raw newline wrapped into data", json.replace(/("data": ".{8})/, '$1\n'));
397+
398+
// the decode-mode router (web UI + Node CLI path) must be transport-resistant too
399+
{ const m = json.replace(/("data": ")/, "$1\n "); const r = encoder.decodeText(m);
400+
assert(r.kind === "container" && eq(r.bytes), "transport: decodeText routes a mangled container (not misread as raw)"); }
401+
402+
// integrity preserved: a GENUINE (non-whitespace) corruption must STILL be rejected
403+
let threw=false;
404+
try { const c=JSON.parse(json); c.data=c.data.slice(0,-3)+'AzQ'; encoder.decodeFromContainer(JSON.stringify(c)); }
405+
catch(_e){ threw=true; }
406+
assert(threw, "transport: genuine (non-whitespace) corruption still rejected");
407+
}
408+
377409
console.log('Test Summary:');
378410
console.log(`Total tests: ${testCount}`);
379411
console.log(`Passed: ${passCount}`);

test/test_container

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,11 @@ if cmp -s "$TMP/orig.bin" "$TMP/restored.bin"; then ok "round-trip bytes identic
3434
sed 's/\("data": *"\)/\1ZZZ/' "$TMP/orig.pbf.json" > "$TMP/corrupt.pbf.json"
3535
if "$IMPL" -d -C "$TMP/corrupt.pbf.json" > /dev/null 2>&1; then bad "corrupt container rejected (nonzero exit)"; else ok "corrupt container rejected (nonzero exit)"; fi
3636

37+
# 6. transport resistance: inject a raw newline + spaces INTO the data value (as an
38+
# email wrap/reflow would). The glyph payload is whitespace-agnostic, so decode must
39+
# still recover the original bytes (canonicalize + lenient JSON parse).
40+
awk '{ sub(/"data": "/, "&\n ") } 1' "$TMP/orig.pbf.json" > "$TMP/wrapped.pbf.json"
41+
if "$IMPL" -d -C "$TMP/wrapped.pbf.json" > "$TMP/restored2.bin" 2>/dev/null && cmp -s "$TMP/orig.bin" "$TMP/restored2.bin"; then ok "transport-mangled container still decodes (whitespace-resistant)"; else bad "transport-mangled container still decodes (whitespace-resistant)"; fi
42+
3743
echo "container CLI test [$IMPL]: $pass passed, $fail failed"
3844
[ "$fail" -eq 0 ]

0 commit comments

Comments
 (0)