Skip to content

Commit ac5712a

Browse files
committed
feat(container): honor --spaces for legible-text containers (flagless crc-probe)
--spaces was silently ignored in -C/--container mode. Now literal spaces are kept in the .pbf.json `data` value so text (e.g. Markdown) reads naturally inside valid single-line JSON (letters/digits/. @ ^ _ already pass through; newlines stay ¶ glyphs). No schema flag and version stays 1: the crc32_encoded oracle disambiguates spaces-as-data from transport noise via a keep-then-strip probe. An ambiguity warning fires when literal spaces coexist with the space glyph (formatting injected into a non-spaces container); the glyph is read from the map, not hardcoded. --tabs/--crlf/-w/--preserve + -C now hard-error. Applied across all 5 container surfaces (Lua, Node, Zig, standalone-C, FFI-C) and the shared test/test_container guard. Also: documented the printable-ASCII passthrough in character_map.txt (verified behavior-neutral); fixed an FFI NULL-deref (preserve_chars is a char* there, not an array). All 17 aarch64-darwin nix checks green.
1 parent 775ab43 commit ac5712a

11 files changed

Lines changed: 304 additions & 48 deletions

PLAN.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,32 @@ maintained_by: agent
66

77
# PLAN — issue #1: decode workflow + printable-binary-file.json
88

9+
## Container honors `--spaces` (legible-markdown containers) — DONE (2026-07-21 EDT)
10+
11+
Goal (Peter): put a markdown file into a `.pbf.json` container but keep it legible;
12+
`--spaces` was silently ignored in container mode. Fixed across all 5 container
13+
surfaces (Lua, Node, Zig, standalone-C, FFI-C) + shared `test/test_container`.
14+
15+
- [x] `--spaces` honored in container encode: literal spaces in the JSON `data`
16+
value (letters/digits/`. @ ^ _` already pass through, so markdown reads
17+
naturally; newlines stay `` glyphs → still valid single-line JSON). (2026-07-21 03:00 PM EDT)
18+
- [x] **Flagless** design (Peter's call): NO `spaces` schema field, `version` stays 1.
19+
Decode uses a **crc-probe** — try keeping literal spaces; if `crc32_encoded`
20+
mismatches, strip them as transport noise. The crc is the disambiguation oracle. (2026-07-21 03:00 PM EDT)
21+
- [x] Ambiguity warning: a non-spaces container (space-glyph present) with a
22+
transport-injected literal space → recover by stripping + WARN that literal
23+
spaces were assumed formatting because the space glyph was also present.
24+
Space glyph read from the map (DRY), never hardcoded. (2026-07-21 03:00 PM EDT)
25+
- [x] `--tabs`/`--crlf`/`-w`/`--preserve` + `--container` = HARD ERROR (would break
26+
JSON-validity + transport-resistance). (2026-07-21 03:00 PM EDT)
27+
- [x] Corrected `character_map.txt` header comment: documents the 66/94 printable-ASCII
28+
passthrough (the legibility property), verified behavior-neutral. (2026-07-21 03:00 PM EDT)
29+
- [x] Guard #4 (map redefines space→space) proven UNNECESSARY: the "first
30+
whitespace-delimited token" parser + 256-glyph-count check make a literal-space
31+
glyph structurally unloadable (physics over policy). (2026-07-21 03:00 PM EDT)
32+
- [x] Fixed FFI NULL-deref: `preserve_chars` is `char *` (NULL) in FFI opts, not an
33+
array — guard now NULL-checks. Caught by hermetic nix check, not exit code. (2026-07-21 03:00 PM EDT)
34+
935
## Docs and benchmark parity — active (2026-07-17 EDT)
1036

1137
- [x] Document every supported implementation—especially Rust, WebAssembly, and

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,25 @@ paste) and restores the file under its original name. Optional metadata
207207
(timestamps, POSIX permissions/owner) is included when the tool can read it and
208208
omitted otherwise; decode never fails on a missing optional field.
209209

210+
**Legible-text containers with `-s`/`--spaces`.** Add `--spaces` to keep literal
211+
spaces in the `data` value instead of encoding them to the space glyph. Since
212+
letters, digits and `. @ ^ _` already pass through as themselves, a text file —
213+
e.g. Markdown — reads naturally inside the container while staying valid,
214+
single-line JSON (newlines remain `` glyphs):
215+
216+
```bash
217+
./bin/printable-binary -C --spaces notes.md > notes.md.pbf.json
218+
# data value reads like: "♯ Title¶¶Some ⁎⁎bold⁎⁎ text.¶˗ a list item¶"
219+
```
220+
221+
No schema flag records this — the format stays `version: 1`. Decode disambiguates
222+
with the `crc32_encoded` oracle: it keeps literal spaces if they check out as
223+
data, otherwise strips them as transport formatting (and warns if the space glyph
224+
was also present, i.e. the spaces were injected into a non-`--spaces` container).
225+
`--spaces` is the only preserve flag allowed with `-C`; `--tabs`/`--crlf`/`-w`/
226+
`--preserve` are rejected, since raw tab/CR/LF would break both JSON validity and
227+
the whitespace-stripping transport-resistance.
228+
210229
### Hexlike Mode (`-X`)
211230

212231
`-X`/`--hexlike` is a hybrid view: printable ASCII passes through untouched while

bin/printable-binary

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,13 @@ local function crc32_hex(data)
2121
return bit.tohex(bit.bxor(crc, 0xFFFFFFFF))
2222
end
2323

24-
-- Strip transport whitespace (space/tab/CR/LF) from an encoded payload.
25-
local function canonical_payload(s)
24+
-- Strip transport whitespace from an encoded payload. Normally all of space/tab/
25+
-- CR/LF are transport noise. When keep_spaces is set (container --spaces), literal
26+
-- spaces are DATA, so only tab/CR/LF are stripped as noise.
27+
local function canonical_payload(s, keep_spaces)
28+
if keep_spaces then
29+
return (s:gsub("[\t\r\n]", ""))
30+
end
2631
return (s:gsub("[ \t\r\n]", ""))
2732
end
2833

@@ -32,8 +37,6 @@ end
3237
local function json_get_string(json, key)
3338
return json:match('"' .. key .. '"%s*:%s*"(.-)"')
3439
end
35-
36-
3740
-- PrintableBinary: A utility to encode/decode binary data into human-readable UTF-8
3841
-- Module definition
3942
local PrintableBinary = {}
@@ -1028,19 +1031,45 @@ if arg then
10281031
-- Container (.pbf.json) mode (issue #1): assemble/parse the flat-JSON envelope
10291032
-- over the shared encode/decode + crc32 (A2). Transport-resistant.
10301033
if container_mode then
1034+
-- Only --spaces is honored in container mode. --tabs/--crlf/-w/--preserve would
1035+
-- put raw tab/CR/LF (or arbitrary chars) into the JSON value, breaking both
1036+
-- single-line-JSON validity and the tab/CR/LF-stripping transport-resistance.
1037+
if tabs_mode or crlf_mode or preserve_chars ~= "" then
1038+
io.stderr:write("Error: --tabs/--crlf/-w/--preserve are not supported with "
1039+
.. "--container (only --spaces is honored; other whitespace stays encoded)\n")
1040+
os.exit(1)
1041+
end
10311042
if decode_mode then
10321043
local data_raw = json_get_string(input_data, "data")
10331044
if not data_raw then
10341045
io.stderr:write("Error: not a printable-binary-file container (missing 'data')\n")
10351046
os.exit(1)
10361047
end
1037-
local clean = canonical_payload(data_raw)
1048+
-- Flagless crc-probe: no schema flag records whether --spaces was used; the
1049+
-- crc32_encoded oracle disambiguates. Try keeping literal spaces (they're DATA
1050+
-- in a --spaces container); if the crc mismatches, strip them as transport noise.
1051+
local clean = canonical_payload(data_raw, true)
1052+
local spaces = true
10381053
local ce = json_get_string(input_data, "crc32_encoded")
10391054
if ce and crc32_hex(clean) ~= ce then
1040-
io.stderr:write("Error: container crc32_encoded mismatch (data corrupted)\n")
1041-
os.exit(1)
1055+
local stripped = canonical_payload(data_raw, false)
1056+
if crc32_hex(stripped) == ce then
1057+
-- Literal spaces were noise. If the space glyph is ALSO present, the payload
1058+
-- mixed real (glyph) spaces with formatting spaces -> warn we dropped them.
1059+
local space_glyph = encode_map[32]
1060+
if space_glyph and clean:find(space_glyph, 1, true) then
1061+
io.stderr:write("Warning: literal spaces in container data were assumed to be "
1062+
.. "ignorable formatting because the space glyph " .. space_glyph
1063+
.. " was also present; stripping them\n")
1064+
end
1065+
clean = stripped
1066+
spaces = false
1067+
else
1068+
io.stderr:write("Error: container crc32_encoded mismatch (data corrupted)\n")
1069+
os.exit(1)
1070+
end
10421071
end
1043-
local decoded = PrintableBinary.decode(clean, {})
1072+
local decoded = PrintableBinary.decode(clean, { spaces = spaces })
10441073
local co = json_get_string(input_data, "crc32")
10451074
if co and crc32_hex(decoded) ~= co then
10461075
io.stderr:write("Error: container crc32 mismatch (decoded data corrupted)\n")
@@ -1049,8 +1078,8 @@ if arg then
10491078
io.write(decoded)
10501079
os.exit(0)
10511080
else
1052-
local data = PrintableBinary.encode(input_data, {})
1053-
local clean = canonical_payload(data)
1081+
local data = PrintableBinary.encode(input_data, { spaces = spaces_mode })
1082+
local clean = canonical_payload(data, spaces_mode)
10541083
local crc_orig = crc32_hex(input_data)
10551084
local crc_enc = crc32_hex(clean)
10561085
local fname = ""

bin/printable-binary-node.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,13 @@ async function main() {
361361
}
362362

363363
if (containerMode) {
364+
// Only --spaces is honored in container mode; --tabs/--crlf/-w/--preserve
365+
// would put raw tab/CR/LF (or arbitrary chars) into the JSON value, breaking
366+
// single-line-JSON validity and the tab/CR/LF-stripping transport-resistance.
367+
if (tabsMode || crlfMode || preserveChars) {
368+
process.stderr.write('Error: --tabs/--crlf/-w/--preserve are not supported with --container (only --spaces is honored; other whitespace stays encoded)\n');
369+
process.exit(1);
370+
}
364371
if (decodeMode) {
365372
const res = pb.decodeText(input.toString('utf8'));
366373
stats(`Decoded ${res.kind} container${res.filename && res.filename !== 'decoded.bin' ? ' (' + res.filename + ')' : ''}: ${res.bytes.length} bytes`);
@@ -376,7 +383,7 @@ async function main() {
376383
meta.mode = '0' + (st.mode & 0o777).toString(8);
377384
} catch (_e) { /* metadata is best-effort */ }
378385
}
379-
const container = pb.encodeToContainer(new Uint8Array(input), meta);
386+
const container = pb.encodeToContainer(new Uint8Array(input), meta, { spaces: spacesMode });
380387
stats(`Encoded ${input.length} bytes -> .pbf.json container (crc32 ${container.crc32})`);
381388
process.stdout.write(JSON.stringify(container, null, 2) + '\n');
382389
}

character_map.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
## is the glyph for byte N. Exactly 256 glyph lines. The glyph is the FIRST
66
## whitespace-delimited token on the line.
77
##
8+
## ASCII passthrough (the core point): letters (A–Z, a–z), digits (0–9), and
9+
## . @ ^ _ map to THEMSELVES — 66 of the 94 printable non-space bytes — so ASCII
10+
## text stays legible in the encoded form even when mixed with control/high bytes.
11+
## The other printable bytes take look-alike glyphs to stay unambiguous: 0x20
12+
## space -> ␣, plus the 28 punctuation ! " # $ % & ' ( ) * + , - / : ; < = > ? [ \ ] ` { | } ~
13+
## (see per-line trailing notes). Byte 0x23 '#' -> ♯ is discussed below.
14+
##
815
## Comments:
916
## - A line beginning with `##` (two or more '#') is a full-line comment, skipped.
1017
## - A trailing `<glyph> ## comment` is ignored (only the first token is taken).

js/printable_binary.js

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -683,8 +683,10 @@ class PrintableBinary {
683683
* wrap, reflow) added — making the container as whitespace-tolerant as raw
684684
* printable-binary while keeping crc integrity intact for real corruption.
685685
*/
686-
_canonicalPayload(data) {
687-
return String(data).replace(/[\r\n\t ]/g, '');
686+
_canonicalPayload(data, keepSpaces) {
687+
// keepSpaces (container --spaces): literal spaces are DATA, so strip only
688+
// tab/CR/LF as transport noise; otherwise strip spaces too.
689+
return String(data).replace(keepSpaces ? /[\r\n\t]/g : /[\r\n\t ]/g, '');
688690
}
689691

690692
/**
@@ -699,12 +701,15 @@ class PrintableBinary {
699701
}
700702

701703

702-
encodeToContainer(bytes, meta = {}) {
704+
encodeToContainer(bytes, meta = {}, opts = {}) {
703705
if (!(bytes instanceof Uint8Array)) {
704706
throw new Error("encodeToContainer expects a Uint8Array");
705707
}
706-
const data = this.encode(bytes);
707-
const dataBytes = sharedTextEncoder.encode(this._canonicalPayload(data));
708+
// opts.spaces: preserve literal spaces in the payload (legible ASCII). No schema
709+
// flag is written -- decode's crc-probe disambiguates spaces-as-data vs noise.
710+
const spaces = opts.spaces === true;
711+
const data = this.encode(bytes, { spaces });
712+
const dataBytes = sharedTextEncoder.encode(this._canonicalPayload(data, spaces));
708713
const container = {
709714
format: "printable-binary-file",
710715
version: 1,
@@ -742,14 +747,30 @@ class PrintableBinary {
742747
if (typeof c.data !== "string") {
743748
throw new Error("Container 'data' must be a string");
744749
}
745-
const cleanData = this._canonicalPayload(c.data);
750+
// Flagless crc-probe: no schema flag records whether --spaces was used; the
751+
// crc32_encoded oracle disambiguates. Try keeping literal spaces (DATA in a
752+
// --spaces container); if the crc mismatches, strip them as transport noise.
753+
let cleanData = this._canonicalPayload(c.data, true);
754+
let spaces = true;
746755
if (c.crc32_encoded !== undefined && c.crc32_encoded !== null) {
747-
const got = this.crc32hex(sharedTextEncoder.encode(cleanData));
748-
if (got !== String(c.crc32_encoded).toLowerCase()) {
749-
throw new Error(`Container crc32_encoded mismatch (got ${got}, expected ${c.crc32_encoded}) — 'data' is corrupted`);
756+
const want = String(c.crc32_encoded).toLowerCase();
757+
if (this.crc32hex(sharedTextEncoder.encode(cleanData)) !== want) {
758+
const stripped = this._canonicalPayload(c.data, false);
759+
if (this.crc32hex(sharedTextEncoder.encode(stripped)) === want) {
760+
// Literal spaces were noise. If the space glyph is ALSO present, the payload
761+
// mixed real (glyph) spaces with formatting spaces -> warn we dropped them.
762+
const spaceGlyph = this.encodeMap.get(0x20);
763+
if (spaceGlyph && cleanData.includes(spaceGlyph)) {
764+
console.warn(`Warning: literal spaces in container data were assumed to be ignorable formatting because the space glyph ${spaceGlyph} was also present; stripping them`);
765+
}
766+
cleanData = stripped;
767+
spaces = false;
768+
} else {
769+
throw new Error(`Container crc32_encoded mismatch (got ${this.crc32hex(sharedTextEncoder.encode(cleanData))}, expected ${c.crc32_encoded}) — 'data' is corrupted`);
770+
}
750771
}
751772
}
752-
const bytes = this.decode(cleanData);
773+
const bytes = this.decode(cleanData, { spaces });
753774
if (c.byte_length !== undefined && c.byte_length !== null && bytes.length !== c.byte_length) {
754775
throw new Error(`Container byte_length mismatch (decoded ${bytes.length}, expected ${c.byte_length})`);
755776
}

src/container_json.h

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,33 @@
1414
#include <stdlib.h>
1515
#include <string.h>
1616

17-
/* Strip transport whitespace (space/tab/CR/LF). malloc'd result (caller frees);
18-
* *out_len set. Clean payloads (no literal whitespace) are unchanged. */
19-
static char *cj_canonical(const char *data, size_t len, size_t *out_len) {
17+
/* Strip transport whitespace from an encoded payload. malloc'd result (caller
18+
* frees); *out_len set. Normally space/tab/CR/LF are all transport noise. When
19+
* keep_spaces is nonzero (container --spaces), literal spaces are DATA, so only
20+
* tab/CR/LF are stripped as noise. */
21+
static char *cj_canonical(const char *data, size_t len, size_t *out_len, int keep_spaces) {
2022
char *out = (char *)malloc(len ? len : 1);
2123
if (!out) { *out_len = 0; return NULL; }
2224
size_t n = 0;
2325
for (size_t i = 0; i < len; i++) {
2426
char c = data[i];
25-
if (c != ' ' && c != '\t' && c != '\r' && c != '\n') out[n++] = c;
27+
int strip = (c == '\t' || c == '\r' || c == '\n') || (!keep_spaces && c == ' ');
28+
if (!strip) out[n++] = c;
2629
}
2730
*out_len = n;
2831
return out;
2932
}
3033

34+
/* Nonzero iff `needle` (nlen bytes) occurs within `hay` (hlen bytes). Used to
35+
* detect the space glyph inside a payload for the ambiguity warning. */
36+
static int cj_contains(const char *hay, size_t hlen, const char *needle, size_t nlen) {
37+
if (nlen == 0 || nlen > hlen) return 0;
38+
for (size_t i = 0; i + nlen <= hlen; i++) {
39+
if (memcmp(hay + i, needle, nlen) == 0) return 1;
40+
}
41+
return 0;
42+
}
43+
3144
/* Raw string value of "key" in a flat JSON object: pointer into `json` (not
3245
* NUL-terminated), *out_len set; NULL if absent. Whitespace-tolerant. */
3346
static const char *cj_get_string(const char *json, size_t json_len, const char *key, size_t *out_len) {

src/printable_binary.c

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1777,19 +1777,51 @@ int main(int argc, char *argv[]) {
17771777
}
17781778

17791779
if (opts.container_mode) {
1780+
/* Only --spaces is honored in container mode; --tabs/--crlf/-w/--preserve
1781+
* would put raw tab/CR/LF (or arbitrary chars) into the JSON value, breaking
1782+
* single-line-JSON validity and the tab/CR/LF-stripping transport-resistance. */
1783+
if (opts.tabs_mode || opts.crlf_mode || opts.preserve_chars[0] != '\0') {
1784+
fprintf(stderr, "Error: --tabs/--crlf/-w/--preserve are not supported with --container (only --spaces is honored; other whitespace stays encoded)\n");
1785+
return 1;
1786+
}
17801787
if (opts.decode_mode) {
17811788
size_t dlen;
17821789
const char *draw = cj_get_string(input.data, input.size, "data", &dlen);
17831790
if (!draw) { fprintf(stderr, "Error: not a printable-binary-file container (missing 'data')\n"); return 1; }
1791+
/* Flagless crc-probe: no schema flag records whether --spaces was used; the
1792+
* crc32_encoded oracle disambiguates. Try keeping literal spaces (DATA in a
1793+
* --spaces container); if the crc mismatches, strip them as transport noise. */
17841794
size_t clen;
1785-
char *clean = cj_canonical(draw, dlen, &clen);
1795+
char *clean = cj_canonical(draw, dlen, &clen, 1);
1796+
bool spaces = true;
17861797
size_t celen;
17871798
const char *ce = cj_get_string(input.data, input.size, "crc32_encoded", &celen);
17881799
if (ce) {
17891800
char hx[9]; snprintf(hx, 9, "%08x", (unsigned int)pb_crc32(clean, clen));
1790-
if (celen != 8 || memcmp(hx, ce, 8) != 0) { free(clean); fprintf(stderr, "Error: container crc32_encoded mismatch (data corrupted)\n"); return 1; }
1801+
if (celen != 8 || memcmp(hx, ce, 8) != 0) {
1802+
size_t slen;
1803+
char *stripped = cj_canonical(draw, dlen, &slen, 0);
1804+
char hs[9]; snprintf(hs, 9, "%08x", (unsigned int)pb_crc32(stripped, slen));
1805+
if (celen == 8 && memcmp(hs, ce, 8) == 0) {
1806+
/* Literal spaces were noise. If the space glyph is ALSO present, the
1807+
* payload mixed real (glyph) spaces with formatting spaces -> warn. */
1808+
const char *sg = (const char *)encode_table[' '].bytes;
1809+
size_t sglen = encode_table[' '].length;
1810+
if (cj_contains(clean, clen, sg, sglen)) {
1811+
fprintf(stderr, "Warning: literal spaces in container data were assumed to be ignorable formatting because the space glyph %.*s was also present; stripping them\n", (int)sglen, sg);
1812+
}
1813+
free(clean);
1814+
clean = stripped;
1815+
clen = slen;
1816+
spaces = false;
1817+
} else {
1818+
free(stripped); free(clean);
1819+
fprintf(stderr, "Error: container crc32_encoded mismatch (data corrupted)\n");
1820+
return 1;
1821+
}
1822+
}
17911823
}
1792-
buffer_t dec = decode_data((uint8_t *)clean, clen, false);
1824+
buffer_t dec = decode_data((uint8_t *)clean, clen, spaces);
17931825
free(clean);
17941826
size_t colen;
17951827
const char *co = cj_get_string(input.data, input.size, "crc32", &colen);
@@ -1801,13 +1833,12 @@ int main(int argc, char *argv[]) {
18011833
return 0;
18021834
} else {
18031835
options_t enc_opts = opts;
1804-
enc_opts.spaces_mode = false;
18051836
enc_opts.tabs_mode = false;
18061837
enc_opts.crlf_mode = false;
18071838
enc_opts.preserve_chars[0] = '\0';
18081839
buffer_t enc = encode_data((uint8_t *)input.data, input.size, &enc_opts);
18091840
size_t clen;
1810-
char *clean = cj_canonical(enc.data, enc.size, &clen);
1841+
char *clean = cj_canonical(enc.data, enc.size, &clen, opts.spaces_mode ? 1 : 0);
18111842
char crc_orig[9], crc_enc[9];
18121843
snprintf(crc_orig, 9, "%08x", (unsigned int)pb_crc32(input.data, input.size));
18131844
snprintf(crc_enc, 9, "%08x", (unsigned int)pb_crc32(clean, clen));

0 commit comments

Comments
 (0)