Skip to content

Commit 7d34d5a

Browse files
committed
feat(c-ffi-cli): -C/--container — emit & consume .pbf.json (issue #1)
The C FFI CLI now understands the container, dogfooding the FFI: pb_crc32 (now declared in the public header), pb_encode, pb_decode. Adds shared src/container_json.h (pure, transport-resistant flat-JSON helpers — canonicalize, get-string, escape, basename), reused by the standalone C next. test-container-ffi runs the parameterized guard (round-trip + self-verify + transport-resistance).
1 parent e76f6ef commit 7d34d5a

5 files changed

Lines changed: 158 additions & 0 deletions

File tree

.dirtree-state

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ ver=1.2
33
annotate=[
44
PLAN.md = Issue #1 plan: decode workflow + printable-binary-file.json container
55
docs/plans/2026-06-26-printable-binary-file-container-design.md = Design spec: printable-binary-file.json container + web decode workflow (issue #1)
6+
src/container_json.h = Shared pure transport-resistant flat-JSON helpers for the .pbf.json container (C FFI + standalone C)
67
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
78
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
89
test/test_container = Container (.pbf.json) CLI round-trip + self-verify test, parameterized by IMPLEMENTATION_TO_TEST (issue #1)

flake.nix

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,20 @@
364364
installPhase = "mkdir -p $out && touch $out/passed";
365365
};
366366

367+
# Container (.pbf.json) for the C FFI CLI — dogfoods the FFI (pb_crc32 etc).
368+
test-container-ffi = pkgs.stdenv.mkDerivation {
369+
name = "test-container-ffi";
370+
src = ./.;
371+
nativeBuildInputs = with pkgs; [ zig clang ];
372+
buildPhase = ''
373+
export HOME=$TMPDIR
374+
zig build
375+
clang -O2 -I. -o pb-ffi src/printable_binary_ffi_main.c zig-out/lib/libprintable_binary.a
376+
IMPLEMENTATION_TO_TEST=./pb-ffi bash ./test/test_container
377+
'';
378+
installPhase = "mkdir -p $out && touch $out/passed";
379+
};
380+
367381
# Dogfood the C FFI boundary: build the C FFI CLI against the Zig static
368382
# lib and round-trip through it (encode/decode + hexlike).
369383
test-ffi-cli = pkgs.stdenv.mkDerivation {

src/container_json.h

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* container_json.h — pure, transport-resistant flat-JSON helpers for the
3+
* printable-binary-file.json container (issue #1). Shared by the C FFI CLI and
4+
* the standalone C CLI. No encode/decode/crc32 here (each caller supplies those
5+
* via its own path); just the string handling. The `data` value is read to its
6+
* closing quote regardless of whitespace a transport injected inside it, then
7+
* canonicalized — the same natural transport-resistance as raw printable-binary.
8+
*/
9+
#ifndef CONTAINER_JSON_H
10+
#define CONTAINER_JSON_H
11+
12+
#include <stddef.h>
13+
#include <stdio.h>
14+
#include <stdlib.h>
15+
#include <string.h>
16+
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) {
20+
char *out = (char *)malloc(len ? len : 1);
21+
if (!out) { *out_len = 0; return NULL; }
22+
size_t n = 0;
23+
for (size_t i = 0; i < len; i++) {
24+
char c = data[i];
25+
if (c != ' ' && c != '\t' && c != '\r' && c != '\n') out[n++] = c;
26+
}
27+
*out_len = n;
28+
return out;
29+
}
30+
31+
/* Raw string value of "key" in a flat JSON object: pointer into `json` (not
32+
* NUL-terminated), *out_len set; NULL if absent. Whitespace-tolerant. */
33+
static const char *cj_get_string(const char *json, size_t json_len, const char *key, size_t *out_len) {
34+
char needle[80];
35+
int kl = snprintf(needle, sizeof needle, "\"%s\"", key);
36+
if (kl <= 0 || (size_t)kl >= sizeof needle) return NULL;
37+
const char *p = NULL;
38+
const char *end = json + json_len;
39+
for (size_t i = 0; (size_t)kl <= json_len && i <= json_len - (size_t)kl; i++) {
40+
if (memcmp(json + i, needle, (size_t)kl) == 0) { p = json + i + (size_t)kl; break; }
41+
}
42+
if (!p) return NULL;
43+
while (p < end && (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n' || *p == ':')) p++;
44+
if (p >= end || *p != '"') return NULL;
45+
p++;
46+
const char *start = p;
47+
while (p < end) {
48+
if (*p == '\\') { p += 2; continue; }
49+
if (*p == '"') break;
50+
p++;
51+
}
52+
if (p >= end) return NULL;
53+
*out_len = (size_t)(p - start);
54+
return start;
55+
}
56+
57+
/* Write `s` JSON-escaped to `fp`. */
58+
static void cj_fputs_escaped(FILE *fp, const char *s, size_t len) {
59+
for (size_t i = 0; i < len; i++) {
60+
unsigned char c = (unsigned char)s[i];
61+
switch (c) {
62+
case '"': fputs("\\\"", fp); break;
63+
case '\\': fputs("\\\\", fp); break;
64+
case '\n': fputs("\\n", fp); break;
65+
case '\r': fputs("\\r", fp); break;
66+
case '\t': fputs("\\t", fp); break;
67+
default: fputc(c, fp); break;
68+
}
69+
}
70+
}
71+
72+
/* Basename after the last '/' or '\\' (pointer into `path`). */
73+
static const char *cj_basename(const char *path) {
74+
const char *b = path;
75+
for (const char *p = path; *p; p++) {
76+
if (*p == '/' || *p == '\\') b = p + 1;
77+
}
78+
return b;
79+
}
80+
81+
#endif /* CONTAINER_JSON_H */

src/printable_binary.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,13 @@ typedef struct {
137137
*/
138138
void pb_free(char *ptr, size_t len);
139139

140+
/**
141+
* CRC-32/ISO-HDLC of `input_len` bytes at `input` (the zip/gzip/png CRC); used
142+
* for printable-binary-file.json container integrity. NULL or zero-length input
143+
* yields the CRC of empty input (0).
144+
*/
145+
uint32_t pb_crc32(const char *input, size_t input_len);
146+
140147
/**
141148
* Encode binary data to printable UTF-8.
142149
* Caller must call pb_free() on result.data when done.

src/printable_binary_ffi_main.c

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
#include <errno.h>
2121

2222
#include "printable_binary.h"
23+
#include "container_json.h"
2324

2425
/* Program options */
2526
typedef struct {
@@ -42,6 +43,7 @@ typedef struct {
4243
int64_t range_start;
4344
int64_t range_end;
4445
bool no_double_encode_check;
46+
bool container_mode;
4547
} options_t;
4648

4749
static bool env_var_truthy(const char *value) {
@@ -63,6 +65,8 @@ static void print_usage(const char *name) {
6365
fprintf(stderr, " -d, --decode Decode mode (default is encode mode)\n");
6466
fprintf(stderr, " -p, --passthrough Pass input to stdout unchanged, send encoded data to stderr\n");
6567
fprintf(stderr, " -X, --hexlike Hexlike mode (passthrough ASCII as-is, other bytes as \xce\x9f\xcf\x87-prefixed hex)\n");
68+
fprintf(stderr, " -C, --container Container mode: encode a file to a self-verifying .pbf.json\n");
69+
fprintf(stderr, " (keeps filename + crc32). With -d, decode a container back.\n");
6670
fprintf(stderr, "\nEncode options (preserve literal characters instead of encoding):\n");
6771
fprintf(stderr, " -s, --spaces Preserve literal spaces\n");
6872
fprintf(stderr, " -t, --tabs Preserve literal tabs\n");
@@ -235,6 +239,8 @@ static options_t parse_options(int argc, char *argv[]) {
235239
opts.passthrough_mode = true;
236240
} else if (strcmp(name, "hexlike") == 0) {
237241
opts.hexlike_mode = true;
242+
} else if (strcmp(name, "container") == 0) {
243+
opts.container_mode = true;
238244
} else if (strcmp(name, "spaces") == 0) {
239245
opts.spaces_mode = true;
240246
} else if (strcmp(name, "tabs") == 0) {
@@ -325,6 +331,7 @@ static options_t parse_options(int argc, char *argv[]) {
325331
case 'd': opts.decode_mode = true; break;
326332
case 'p': opts.passthrough_mode = true; break;
327333
case 'X': opts.hexlike_mode = true; break;
334+
case 'C': opts.container_mode = true; break;
328335
case 's': opts.spaces_mode = true; break;
329336
case 't': opts.tabs_mode = true; break;
330337
case 'n': opts.crlf_mode = true; break;
@@ -523,6 +530,54 @@ int main(int argc, char *argv[]) {
523530
input_len = range.length;
524531
}
525532

533+
if (opts.container_mode) {
534+
if (opts.decode_mode) {
535+
size_t dlen;
536+
const char *draw = cj_get_string(input, input_len, "data", &dlen);
537+
if (!draw) { fprintf(stderr, "Error: not a printable-binary-file container (missing 'data')\n"); free(input); return 1; }
538+
size_t clen;
539+
char *clean = cj_canonical(draw, dlen, &clen);
540+
size_t celen;
541+
const char *ce = cj_get_string(input, input_len, "crc32_encoded", &celen);
542+
if (ce) {
543+
char hx[9]; snprintf(hx, 9, "%08x", pb_crc32(clean, clen));
544+
if (celen != 8 || memcmp(hx, ce, 8) != 0) { free(clean); free(input); fprintf(stderr, "Error: container crc32_encoded mismatch (data corrupted)\n"); return 1; }
545+
}
546+
pb_ffi_result_t dr = pb_decode(clean, clen, 0);
547+
free(clean);
548+
if (dr.error_code) { free(input); fprintf(stderr, "Error: container decode failed\n"); return 1; }
549+
size_t colen;
550+
const char *co = cj_get_string(input, input_len, "crc32", &colen);
551+
if (co) {
552+
char hx[9]; snprintf(hx, 9, "%08x", pb_crc32(dr.data, dr.len));
553+
if (colen != 8 || memcmp(hx, co, 8) != 0) { pb_free(dr.data, dr.len); free(input); fprintf(stderr, "Error: container crc32 mismatch (decoded data corrupted)\n"); return 1; }
554+
}
555+
fwrite(dr.data, 1, dr.len, stdout);
556+
pb_free(dr.data, dr.len);
557+
free(input);
558+
return 0;
559+
} else {
560+
pb_ffi_result_t er = pb_encode(input, input_len, 0, NULL, 0);
561+
if (er.error_code) { free(input); fprintf(stderr, "Error: container encode failed\n"); return 1; }
562+
size_t clen;
563+
char *clean = cj_canonical(er.data, er.len, &clen);
564+
char crc_orig[9], crc_enc[9];
565+
snprintf(crc_orig, 9, "%08x", pb_crc32(input, input_len));
566+
snprintf(crc_enc, 9, "%08x", pb_crc32(clean, clen));
567+
free(clean);
568+
const char *fname = (opts.input_file && strcmp(opts.input_file, "-") != 0) ? cj_basename(opts.input_file) : "";
569+
printf("{\n \"format\": \"printable-binary-file\",\n \"version\": 1,\n \"filename\": \"");
570+
cj_fputs_escaped(stdout, fname, strlen(fname));
571+
printf("\",\n \"byte_length\": %zu,\n \"crc32\": \"%s\",\n \"crc32_encoded\": \"%s\",\n \"data\": \"", input_len, crc_orig, crc_enc);
572+
fwrite(er.data, 1, er.len, stdout);
573+
printf("\"\n}\n");
574+
pb_free(er.data, er.len);
575+
free(input);
576+
return 0;
577+
}
578+
}
579+
580+
526581
if (opts.decode_mode) {
527582
if (opts.passthrough_mode) {
528583
fprintf(stderr, "Warning: --passthrough ignored in decode mode\n");

0 commit comments

Comments
 (0)