Skip to content

Commit e76f6ef

Browse files
committed
feat(zig-cli): -C/--container — emit & consume .pbf.json (issue #1)
The Zig CLI now understands the container: a hand-rolled flat-JSON envelope (naturally transport-resistant — reads 'data' to its closing quote, then canonicalizes) over the shared core crc32/encode/decode. '-C file' emits a self-verifying .pbf.json; '-d -C' restores the original bytes. Adds test-container-zig (the parameterized round-trip/self-verify/transport guard against the Zig CLI) and test-container-cross (the MFIC differential: Node and Zig containers decode each other byte-identically).
1 parent ae68f22 commit e76f6ef

4 files changed

Lines changed: 224 additions & 0 deletions

File tree

.dirtree-state

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ annotate=[
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
88
test/test_container = Container (.pbf.json) CLI round-trip + self-verify test, parameterized by IMPLEMENTATION_TO_TEST (issue #1)
9+
test/test_container_cross = Cross-impl container differential: impl-A container decodes via impl-B (MFIC, issue #1)
910
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
1011
]

flake.nix

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,33 @@
337337
installPhase = "mkdir -p $out && touch $out/passed";
338338
};
339339

340+
# Container (.pbf.json) for the Zig CLI (issue #1).
341+
test-container-zig = pkgs.stdenv.mkDerivation {
342+
name = "test-container-zig";
343+
src = ./.;
344+
nativeBuildInputs = with pkgs; [ zig ];
345+
buildPhase = ''
346+
export HOME=$TMPDIR
347+
zig build -Doptimize=ReleaseFast
348+
IMPLEMENTATION_TO_TEST=./zig-out/bin/printable-binary-zig bash ./test/test_container
349+
'';
350+
installPhase = "mkdir -p $out && touch $out/passed";
351+
};
352+
353+
# Cross-impl container differential: Node <-> Zig must agree (issue #1).
354+
test-container-cross = pkgs.stdenv.mkDerivation {
355+
name = "test-container-cross";
356+
src = ./.;
357+
nativeBuildInputs = with pkgs; [ zig nodejs_24 ];
358+
buildPhase = ''
359+
export HOME=$TMPDIR
360+
patchShebangs bin/printable-binary-node.js
361+
zig build -Doptimize=ReleaseFast
362+
IMPL_A=./bin/printable-binary-node.js IMPL_B=./zig-out/bin/printable-binary-zig bash ./test/test_container_cross
363+
'';
364+
installPhase = "mkdir -p $out && touch $out/passed";
365+
};
366+
340367
# Dogfood the C FFI boundary: build the C FFI CLI against the Zig static
341368
# lib and round-trip through it (encode/decode + hexlike).
342369
test-ffi-cli = pkgs.stdenv.mkDerivation {

src/zig/main.zig

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const Options = struct {
2929
range_end: ?i64 = null,
3030
no_double_encode_check: bool = false,
3131
hexlike: bool = false,
32+
container: bool = false,
3233
};
3334

3435
const MappingsMode = enum { none, table, json, csv };
@@ -290,6 +291,8 @@ fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) !Options {
290291
opts.no_double_encode_check = true;
291292
} else if (std.mem.eql(u8, name, "hexlike")) {
292293
opts.hexlike = true;
294+
} else if (std.mem.eql(u8, name, "container")) {
295+
opts.container = true;
293296
} else if (std.mem.eql(u8, name, "help")) {
294297
opts.help_mode = true;
295298
} else {
@@ -312,6 +315,7 @@ fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) !Options {
312315
},
313316
'S' => opts.strip_whitespace = true,
314317
'X' => opts.hexlike = true,
318+
'C' => opts.container = true,
315319
'h' => opts.help_mode = true,
316320
'f' => {
317321
if (j + 1 < arg.len) {
@@ -367,6 +371,8 @@ fn printUsage(io: std.Io) void {
367371
\\ shown as uppercase hex runs prefixed by Οχ (Greek Omicron+Chi,
368372
\\ NOT ASCII 0x — beware when copying hex for other purposes).
369373
\\ Use with -d to decode hexlike-encoded data back to binary.
374+
\\ -C, --container Container mode: encode a file to a self-verifying .pbf.json
375+
\\ (keeps filename + crc32). With -d, decode a container back.
370376
\\
371377
\\Encode options (preserve literal characters instead of encoding):
372378
\\ -s, --spaces Preserve literal spaces (don't encode to visible glyph)
@@ -587,6 +593,14 @@ pub fn main(init: std.process.Init) !void {
587593
input = raw_input[range.offset .. range.offset + range.length];
588594
}
589595

596+
if (opts.container) {
597+
handleContainer(io, allocator, opts, input) catch |err| {
598+
writeStats("Container error: {}\n", .{err});
599+
std.process.exit(1);
600+
};
601+
return;
602+
}
603+
590604
if (opts.decode_mode) {
591605
// Decode mode - call core library
592606
if (opts.passthrough_mode) {
@@ -717,3 +731,161 @@ pub fn main(init: std.process.Init) !void {
717731
}
718732
}
719733
}
734+
735+
// ============================================================================
736+
// Container (.pbf.json) support (issue #1)
737+
//
738+
// Hand-rolled flat-JSON, naturally transport-resistant: the `data` value is read
739+
// to its closing quote regardless of any whitespace a text transport injected
740+
// inside it, then canonicalized (whitespace stripped) before the crc check and
741+
// decode. crc32 is vector-pinned in the core (CRC32("123456789")=0xCBF43926), so
742+
// every implementation agrees. Architecture A2: the JSON envelope is assembled
743+
// here, the codec + crc32 come from the shared core.
744+
// ============================================================================
745+
746+
/// Strip transport whitespace (space/tab/CR/LF) from an encoded payload. The
747+
/// default encoding emits none of these (all glyph'd), so a clean payload is
748+
/// unchanged; only transport-injected whitespace is removed.
749+
fn canonicalPayload(allocator: std.mem.Allocator, data: []const u8) ![]u8 {
750+
var count: usize = 0;
751+
for (data) |c| {
752+
if (c != ' ' and c != '\t' and c != '\r' and c != '\n') count += 1;
753+
}
754+
const out = try allocator.alloc(u8, count);
755+
var n: usize = 0;
756+
for (data) |c| {
757+
if (c != ' ' and c != '\t' and c != '\r' and c != '\n') {
758+
out[n] = c;
759+
n += 1;
760+
}
761+
}
762+
return out;
763+
}
764+
765+
/// Extract the raw string value of `key` from a flat JSON object (bytes between
766+
/// the quotes; no unescaping — our data/crc fields carry no escapes). Tolerant
767+
/// of surrounding whitespace. null if absent.
768+
fn jsonGetString(json: []const u8, key: []const u8) ?[]const u8 {
769+
var keybuf: [64]u8 = undefined;
770+
const needle = std.fmt.bufPrint(&keybuf, "\"{s}\"", .{key}) catch return null;
771+
const kpos = std.mem.indexOf(u8, json, needle) orelse return null;
772+
var i = kpos + needle.len;
773+
while (i < json.len and (json[i] == ' ' or json[i] == '\t' or json[i] == '\r' or json[i] == '\n' or json[i] == ':')) : (i += 1) {}
774+
if (i >= json.len or json[i] != '"') return null;
775+
i += 1;
776+
const start = i;
777+
while (i < json.len) : (i += 1) {
778+
if (json[i] == '\\') {
779+
i += 1;
780+
continue;
781+
}
782+
if (json[i] == '"') break;
783+
}
784+
if (i >= json.len) return null;
785+
return json[start..i];
786+
}
787+
788+
/// JSON-escape `s` into a freshly allocated buffer.
789+
fn jsonEscapeAlloc(allocator: std.mem.Allocator, s: []const u8) ![]u8 {
790+
var extra: usize = 0;
791+
for (s) |c| {
792+
if (c == '"' or c == '\\' or c == '\n' or c == '\r' or c == '\t') extra += 1;
793+
}
794+
const out = try allocator.alloc(u8, s.len + extra);
795+
var n: usize = 0;
796+
for (s) |c| {
797+
switch (c) {
798+
'"' => {
799+
out[n] = '\\';
800+
out[n + 1] = '"';
801+
n += 2;
802+
},
803+
'\\' => {
804+
out[n] = '\\';
805+
out[n + 1] = '\\';
806+
n += 2;
807+
},
808+
'\n' => {
809+
out[n] = '\\';
810+
out[n + 1] = 'n';
811+
n += 2;
812+
},
813+
'\r' => {
814+
out[n] = '\\';
815+
out[n + 1] = 'r';
816+
n += 2;
817+
},
818+
'\t' => {
819+
out[n] = '\\';
820+
out[n + 1] = 't';
821+
n += 2;
822+
},
823+
else => {
824+
out[n] = c;
825+
n += 1;
826+
},
827+
}
828+
}
829+
return out;
830+
}
831+
832+
fn basenameOf(path: []const u8) []const u8 {
833+
var start: usize = 0;
834+
for (path, 0..) |c, idx| {
835+
if (c == '/' or c == '\\') start = idx + 1;
836+
}
837+
return path[start..];
838+
}
839+
840+
fn handleContainer(io: std.Io, allocator: std.mem.Allocator, opts: Options, input: []const u8) !void {
841+
if (opts.decode_mode) {
842+
const data_raw = jsonGetString(input, "data") orelse {
843+
writeStats("Error: not a printable-binary-file container (missing 'data')\n", .{});
844+
std.process.exit(1);
845+
};
846+
const clean = try canonicalPayload(allocator, data_raw);
847+
defer allocator.free(clean);
848+
if (jsonGetString(input, "crc32_encoded")) |want| {
849+
var gb: [8]u8 = undefined;
850+
const got = std.fmt.bufPrint(&gb, "{x:0>8}", .{pb.crc32(clean)}) catch unreachable;
851+
if (!std.mem.eql(u8, got, want)) {
852+
writeStats("Error: container crc32_encoded mismatch (data corrupted)\n", .{});
853+
std.process.exit(1);
854+
}
855+
}
856+
const decoded = pb.decode(allocator, clean, .{}) catch |err| {
857+
writeStats("Container decode error: {}\n", .{err});
858+
std.process.exit(1);
859+
};
860+
defer allocator.free(decoded);
861+
if (jsonGetString(input, "crc32")) |want| {
862+
var gb: [8]u8 = undefined;
863+
const got = std.fmt.bufPrint(&gb, "{x:0>8}", .{pb.crc32(decoded)}) catch unreachable;
864+
if (!std.mem.eql(u8, got, want)) {
865+
writeStats("Error: container crc32 mismatch (decoded data corrupted)\n", .{});
866+
std.process.exit(1);
867+
}
868+
}
869+
writeStats("Decoded container: {d} bytes\n", .{decoded.len});
870+
try writeOutput(io, decoded, false);
871+
} else {
872+
const data = pb.encode(allocator, input, .{}) catch |err| {
873+
writeStats("Container encode error: {}\n", .{err});
874+
std.process.exit(1);
875+
};
876+
defer allocator.free(data);
877+
const clean = try canonicalPayload(allocator, data);
878+
defer allocator.free(clean);
879+
var ob: [8]u8 = undefined;
880+
var eb: [8]u8 = undefined;
881+
const crc_orig = std.fmt.bufPrint(&ob, "{x:0>8}", .{pb.crc32(input)}) catch unreachable;
882+
const crc_enc = std.fmt.bufPrint(&eb, "{x:0>8}", .{pb.crc32(clean)}) catch unreachable;
883+
const fname_in: []const u8 = if (opts.input_file) |p| (if (std.mem.eql(u8, p, "-")) "" else basenameOf(p)) else "";
884+
const fname = try jsonEscapeAlloc(allocator, fname_in);
885+
defer allocator.free(fname);
886+
// `data` is LAST so all metadata sits up front.
887+
const json = try std.fmt.allocPrint(allocator, "{{\n \"format\": \"printable-binary-file\",\n \"version\": 1,\n \"filename\": \"{s}\",\n \"byte_length\": {d},\n \"crc32\": \"{s}\",\n \"crc32_encoded\": \"{s}\",\n \"data\": \"{s}\"\n}}\n", .{ fname, input.len, crc_orig, crc_enc, data });
888+
defer allocator.free(json);
889+
try writeOutput(io, json, false);
890+
}
891+
}

test/test_container_cross

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env bash
2+
# Cross-impl container differential (MFIC): a container emitted by impl A must
3+
# decode byte-identically via impl B, and vice versa. External oracle against
4+
# any envelope/crc/encoding drift between implementations. (issue #1)
5+
set -u
6+
A="${IMPL_A:-./bin/printable-binary-node.js}"
7+
B="${IMPL_B:-./result/bin/printable-binary-zig}"
8+
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
9+
pass=0; fail=0
10+
ok() { echo "$1"; pass=$((pass+1)); }
11+
bad() { echo "$1"; fail=$((fail+1)); }
12+
echo "=== cross-impl container differential: A=$A B=$B ==="
13+
printf 'cross-impl \x00\x01\xff payload\n{"not":"json"}\ttabbed\n' > "$TMP/orig.bin"
14+
15+
"$A" -C "$TMP/orig.bin" > "$TMP/a.pbf.json" 2>/dev/null
16+
"$B" -d -C "$TMP/a.pbf.json" > "$TMP/b_out.bin" 2>/dev/null
17+
cmp -s "$TMP/orig.bin" "$TMP/b_out.bin" && ok "A-encoded container decodes byte-identically via B" || bad "A-encoded container decodes byte-identically via B"
18+
19+
"$B" -C "$TMP/orig.bin" > "$TMP/b.pbf.json" 2>/dev/null
20+
"$A" -d -C "$TMP/b.pbf.json" > "$TMP/a_out.bin" 2>/dev/null
21+
cmp -s "$TMP/orig.bin" "$TMP/a_out.bin" && ok "B-encoded container decodes byte-identically via A" || bad "B-encoded container decodes byte-identically via A"
22+
23+
echo "cross-impl container [$A <-> $B]: $pass passed, $fail failed"
24+
[ "$fail" -eq 0 ]

0 commit comments

Comments
 (0)