Skip to content

Commit 9dda0bb

Browse files
committed
feat(node-cli): -C/--container — emit & consume .pbf.json (issue #1)
The Node CLI now understands the container: `-C <file>` emits a self-verifying .pbf.json (filename + modified_ms + created_ms + mode from stat, plus crc32); `-d -C <container>` restores the original bytes (self-verify; corrupt -> nonzero exit). Reuses the JS lib's encodeToContainer/decodeText. Adds test/test_container — a parameterized (IMPLEMENTATION_TO_TEST) CLI round-trip + non-vacuous self-verify oracle — wired into CI as test-container-node. The same script will exercise the Zig/C/Lua CLIs as they gain -C.
1 parent 7b70844 commit 9dda0bb

4 files changed

Lines changed: 86 additions & 2 deletions

File tree

.dirtree-state

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ annotate=[
55
docs/plans/2026-06-26-printable-binary-file-container-design.md = Design spec: printable-binary-file.json container + web decode workflow (issue #1)
66
src/zig/ffi.zig = C ABI (FFI) export surface: all 12 pb_* C functions; root of libprintable_binary.a; keeps C symbols OUT of the importable printable_binary module so static (musl) consumers don't collide
77
test/module_consumer.zig = Test fixture: minimal downstream importer of the printable_binary Zig module (mirrors how difz/blip consume it) for the FFI-symbol-leak test
8+
test/test_container = Container (.pbf.json) CLI round-trip + self-verify test, parameterized by IMPLEMENTATION_TO_TEST (issue #1)
89
test/test_module_no_ffi_symbols = Regression test (nm oracle): importing the module must emit 0 pb_* symbols while the FFI static lib keeps all 12 — guards the difz-blocking duplicate-symbol bug
910
]

bin/printable-binary-node.js

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ Encoding modes:
4141
shown as uppercase hex runs prefixed by \u039F\u03C7 (Greek Omicron+Chi,
4242
NOT ASCII 0x \u2014 beware when copying hex for other purposes).
4343
Use with -d to decode hexlike-encoded data back to binary.
44+
-C, --container Container mode: encode a file to a self-verifying .pbf.json
45+
(keeps filename, dates, perms + crc32). Use with -d to decode
46+
a .pbf.json container back to the original file.
4447
4548
Range options (select byte range from input before processing):
4649
--range X-Y Byte range, 0-indexed inclusive (e.g., --range 0-9)
@@ -152,6 +155,7 @@ function parseArgs(argv) {
152155
let crlfMode = false;
153156
let preserveChars = '';
154157
let hexlikeMode = false;
158+
let containerMode = false;
155159

156160
const setMappingsFormat = (mode) => {
157161
if (mappingsFormat && mappingsFormat !== mode) {
@@ -207,6 +211,8 @@ function parseArgs(argv) {
207211
preserveChars = arg.slice(11);
208212
} else if (arg === '-X' || arg === '--hexlike') {
209213
hexlikeMode = true;
214+
} else if (arg === '-C' || arg === '--container') {
215+
containerMode = true;
210216
} else if (arg === '--no-double-encode-check') {
211217
noDoubleEncodeCheck = true;
212218
} else if (arg === '--range') {
@@ -258,6 +264,7 @@ function parseArgs(argv) {
258264
case 'p': passthrough = true; break;
259265
case 'S': stripWhitespace = true; break;
260266
case 'X': hexlikeMode = true; break;
267+
case 'C': containerMode = true; break;
261268
default:
262269
process.stderr.write(`Error: Unknown option -${ch}\n`);
263270
printUsage();
@@ -287,7 +294,7 @@ function parseArgs(argv) {
287294
}
288295
}
289296

290-
return { decodeMode, formatSpec, filePath, mappingsFormat, passthrough, spacesMode, stripWhitespace, tabsMode, crlfMode, preserveChars, rangeStart, rangeEnd, noDoubleEncodeCheck, hexlikeMode };
297+
return { decodeMode, formatSpec, filePath, mappingsFormat, passthrough, spacesMode, stripWhitespace, tabsMode, crlfMode, preserveChars, rangeStart, rangeEnd, noDoubleEncodeCheck, hexlikeMode, containerMode };
291298
}
292299

293300
async function readInput(filePath) {
@@ -314,7 +321,7 @@ function stats(msg) {
314321
}
315322

316323
async function main() {
317-
const { decodeMode, formatSpec, filePath, mappingsFormat, passthrough, spacesMode, stripWhitespace, tabsMode, crlfMode, preserveChars, rangeStart, rangeEnd, noDoubleEncodeCheck, hexlikeMode } = parseArgs(process.argv.slice(2));
324+
const { decodeMode, formatSpec, filePath, mappingsFormat, passthrough, spacesMode, stripWhitespace, tabsMode, crlfMode, preserveChars, rangeStart, rangeEnd, noDoubleEncodeCheck, hexlikeMode, containerMode } = parseArgs(process.argv.slice(2));
318325
const pb = new PrintableBinary();
319326

320327
try {
@@ -353,6 +360,30 @@ async function main() {
353360
}
354361
}
355362

363+
if (containerMode) {
364+
if (decodeMode) {
365+
const res = pb.decodeText(input.toString('utf8'));
366+
stats(`Decoded ${res.kind} container${res.filename && res.filename !== 'decoded.bin' ? ' (' + res.filename + ')' : ''}: ${res.bytes.length} bytes`);
367+
process.stdout.write(Buffer.from(res.bytes));
368+
} else {
369+
const meta = {};
370+
if (filePath) {
371+
meta.filename = filePath.split(/[\\/]/).pop();
372+
try {
373+
const st = fs.statSync(filePath);
374+
meta.modified_ms = Math.round(st.mtimeMs);
375+
if (st.birthtimeMs && st.birthtimeMs > 0) meta.created_ms = Math.round(st.birthtimeMs);
376+
meta.mode = '0' + (st.mode & 0o777).toString(8);
377+
} catch (_e) { /* metadata is best-effort */ }
378+
}
379+
const container = pb.encodeToContainer(new Uint8Array(input), meta);
380+
stats(`Encoded ${input.length} bytes -> .pbf.json container (crc32 ${container.crc32})`);
381+
process.stdout.write(JSON.stringify(container, null, 2) + '\n');
382+
}
383+
return;
384+
}
385+
386+
356387
if (decodeMode) {
357388
if (passthrough) {
358389
process.stderr.write('Warning: --passthrough ignored in decode mode\n');

flake.nix

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,20 @@
323323
installPhase = "mkdir -p $out && touch $out/passed";
324324
};
325325

326+
# Container (.pbf.json) CLI round-trip + self-verify, issue #1. Parameterized
327+
# by IMPLEMENTATION_TO_TEST; one check per impl as they gain -C/--container.
328+
test-container-node = pkgs.stdenv.mkDerivation {
329+
name = "test-container-node";
330+
src = ./.;
331+
nativeBuildInputs = with pkgs; [ nodejs_24 ];
332+
buildPhase = ''
333+
export HOME=$TMPDIR
334+
patchShebangs bin/printable-binary-node.js
335+
IMPLEMENTATION_TO_TEST=./bin/printable-binary-node.js bash ./test/test_container
336+
'';
337+
installPhase = "mkdir -p $out && touch $out/passed";
338+
};
339+
326340
# Dogfood the C FFI boundary: build the C FFI CLI against the Zig static
327341
# lib and round-trip through it (encode/decode + hexlike).
328342
test-ffi-cli = pkgs.stdenv.mkDerivation {

test/test_container

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env bash
2+
# Container (.pbf.json) CLI round-trip + self-verify test, parameterized by
3+
# IMPLEMENTATION_TO_TEST so every printable-binary executable is exercised the
4+
# same way as it gains `-C`/`--container` support. (issue #1)
5+
#
6+
# MFIC: inverse-pair oracle (decode(encode(file)) == file, byte-identical) plus a
7+
# non-vacuous self-verify (a corrupted container MUST be rejected with nonzero exit).
8+
set -u
9+
IMPL="${IMPLEMENTATION_TO_TEST:-./bin/printable-binary-node.js}"
10+
TMP="$(mktemp -d)"
11+
trap 'rm -rf "$TMP"' EXIT
12+
pass=0; fail=0
13+
ok() { echo "$1"; pass=$((pass+1)); }
14+
bad() { echo "$1"; fail=$((fail+1)); }
15+
16+
echo "=== container CLI test: $IMPL ==="
17+
18+
# 1. a file with known, binary-ish bytes
19+
printf 'Hello\x00\x01\xff binary\n{not json}\n' > "$TMP/orig.bin"
20+
21+
# 2. encode -> container
22+
if "$IMPL" -C "$TMP/orig.bin" > "$TMP/orig.pbf.json" 2>"$TMP/enc.err"; then ok "encode -C exits 0"; else bad "encode -C exits 0"; echo " $(cat "$TMP/enc.err")"; fi
23+
24+
# 3. container shape
25+
grep -q '"format"' "$TMP/orig.pbf.json" 2>/dev/null && grep -q 'printable-binary-file' "$TMP/orig.pbf.json" && ok "container has format=printable-binary-file" || bad "container has format=printable-binary-file"
26+
grep -q '"filename"' "$TMP/orig.pbf.json" 2>/dev/null && grep -q 'orig.bin' "$TMP/orig.pbf.json" && ok "container carries filename" || bad "container carries filename"
27+
grep -q '"crc32"' "$TMP/orig.pbf.json" 2>/dev/null && ok "container carries crc32" || bad "container carries crc32"
28+
29+
# 4. decode -> original bytes
30+
if "$IMPL" -d -C "$TMP/orig.pbf.json" > "$TMP/restored.bin" 2>"$TMP/dec.err"; then ok "decode -d -C exits 0"; else bad "decode -d -C exits 0"; echo " $(cat "$TMP/dec.err")"; fi
31+
if cmp -s "$TMP/orig.bin" "$TMP/restored.bin"; then ok "round-trip bytes identical"; else bad "round-trip bytes identical"; fi
32+
33+
# 5. self-verify is non-vacuous: corrupt the data string -> decode MUST fail
34+
sed 's/\("data": *"\)/\1ZZZ/' "$TMP/orig.pbf.json" > "$TMP/corrupt.pbf.json"
35+
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
36+
37+
echo "container CLI test [$IMPL]: $pass passed, $fail failed"
38+
[ "$fail" -eq 0 ]

0 commit comments

Comments
 (0)