@@ -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
3435const 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+ }
0 commit comments