Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ on:
pull_request:
workflow_dispatch:

# Least privilege: every job only reads the repo. Nothing here writes
# contents, issues, or packages (GitHub hardening guidance).
permissions:
contents: read

jobs:
build:
name: build (${{ matrix.lineage }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
Expand All @@ -29,22 +35,27 @@ jobs:
go-version: '1.22'
cache-dependency-path: go/go.mod

# Toolchains pinned to exact versions (not floating "latest"/"stable")
# so a moved toolchain cannot silently change what a release build
# means. Versions match the locally verified v0.2.1 build environment.
- name: Set up Rust
if: matrix.lineage == 'rust'
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-07-15)
with:
toolchain: '1.94.0'

- name: Set up D
if: matrix.lineage == 'd'
uses: dlang-community/setup-dlang@d7d85fcde7c4cd5f9a6618fce1bccc316e1e910b # v2
with:
compiler: dmd-latest
compiler: dmd-2.112.0

- name: Set up Haskell
if: matrix.lineage == 'haskell'
uses: haskell-actions/setup@cd0d9bdd65b20557f41bea4dbe43d0b5fbbfe553 # v2.11.0
with:
ghc-version: '9.6'
cabal-version: 'latest'
ghc-version: '9.6.7'
cabal-version: '3.16.1.0'

- name: Set up OCaml
if: matrix.lineage == 'ocaml'
Expand Down Expand Up @@ -100,7 +111,7 @@ jobs:
if: matrix.lineage == 'ocaml'
working-directory: ocaml
run: |
opam install --yes dune yojson digestif alcotest
opam install --yes dune.3.21.1 yojson.3.0.0 digestif.1.3.0 alcotest.1.9.1
make build test

# Sanity: the CLI the verify job will receive must exist and answer.
Expand All @@ -115,10 +126,41 @@ jobs:
path: ${{ matrix.lineage }}/bin/baion_canon_hash
if-no-files-found: error

# Sanitizer gate for the two memory-unsafe lineages: same corpus contract
# as the verifier, but the CLI is built with ASan+UBSan and aborts on any
# undefined behavior. Added in v0.2.1 after UBSan caught an out-of-range
# double→long long conversion on an admitted input (1e20) that the plain
# build masked by saturating.
sanitize:
name: sanitize (${{ matrix.lineage }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
lineage: [c, cpp]
env:
SAN_FLAGS: -fsanitize=address,undefined,float-cast-overflow -fno-sanitize-recover=all -fno-omit-frame-pointer
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Build and test with ASan+UBSan
run: |
cmake -S ${{ matrix.lineage }} -B ${{ matrix.lineage }}/build-san \
-DCMAKE_C_FLAGS="$SAN_FLAGS" \
-DCMAKE_CXX_FLAGS="$SAN_FLAGS" \
-DCMAKE_EXE_LINKER_FLAGS="$SAN_FLAGS"
cmake --build ${{ matrix.lineage }}/build-san
ctest --test-dir ${{ matrix.lineage }}/build-san --output-on-failure

- name: Run full corpus through sanitized CLI
run: python3 conformance/run_corpus_cli.py ${{ matrix.lineage }}/bin/baion_canon_hash

verify:
name: verify 7/7 byte-identity
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

Expand Down
81 changes: 81 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Changelog

## v0.2.1 — 2026-07-15

Corrective release. v0.2.0 remains published and tagged — this release
supersedes it rather than replacing it (found → preserved → corrected →
tested → superseded).

### Fixed

- **C lineage undefined behavior on admitted input** (`c/src/canonical_json.c`,
`write_number`): the integer fast-path evaluated `(double)(long long)d`
*before* its `fabs(d) < 1e15` range guard. For integer-valued fraction
tokens ≥ 2⁶³ inside the supported domain (e.g. `100000000000000000000.0`,
|v| < 10²¹), the double→`long long` conversion is undefined behavior
(C11 §6.3.1.4p1), caught by UBSan
(`runtime error: 1e+20 is outside the range of representable values of
type 'long long int'`). On tested x86-64 builds the conversion saturated
and the code fell through to the correct big-integer branch, so all seven
lineages agreed on the affected inputs — no known wrong hash was ever
produced — but undefined behavior is not a cross-platform contract. The
guard now runs before any conversion (`fabs(d) < 1e15 && trunc(d) == d`),
the same order the C++ lineage already used.

### Added

- Regression vector `huge_integer_valued_float`
(`{"x":100000000000000000000.0}`) pinned in the conformance corpus;
all seven lineages agree (25 accept + 31 reject vectors green).
- Sanitizer CI gate for the two memory-unsafe lineages (C, C++):
ASan + UBSan + float-cast-overflow with `-fno-sanitize-recover=all`,
running each lineage's tests and the full corpus through the
instrumented CLI (`conformance/run_corpus_cli.py`).

### Hardened

- C string buffer: allocation failures now latch an OOM flag and
`baion_canonicalize_json` returns NULL (defined failure — never a
partial canonical string, never a write through a failed allocation);
`realloc` no longer assigned directly to the live pointer; capacity
growth guarded against `size_t` overflow; the object-key sort array
allocation is checked.
- Supply chain: workflow declares least-privilege
`permissions: contents: read`; every job has a timeout; cJSON is
fetched by commit SHA (`acc76239…`, the commit behind v1.7.18) instead
of a movable tag; CI toolchains pinned to exact versions (Rust 1.94.0,
DMD 2.112.0, GHC 9.6.7, cabal 3.16.1.0, pinned opam package versions);
`rust/Cargo.lock` and `haskell/cabal.project.freeze` now committed.

### Known remaining limits (documented, not yet enforced)

- No enforced caps on input size, nesting depth, or member counts.
Resource limits change which documents are *rejected*, so they are a
cross-lineage contract change — all seven lineages must adopt identical
limits in the same release. Planned for a future minor version; until
then, treat the CLIs as trusted-input tools.
- The C number writer assumes the process stays in the default `C`
locale (documented in-source).

## v0.2.0 — 2026-07-15

Cross-lineage hardening release: uniform rejection contract (invalid
UTF-8, raw controls, malformed escapes/tokens, duplicate keys, U+0000,
BOM, number domain), corpus-as-data conformance, seeded fuzz agreement
layer, ES-262 shortest-digits fix. Breaking: previously-accepted lax
inputs are now uniformly rejected. See the GitHub release notes.

## v0.1.3 — 2026-07-15

Uniform rejection of duplicate object keys across all seven lineages.

## v0.1.2 — 2026-07-15

Canonicalization contract round: uniform U+0000 rejection, Go number
normalization, `build_all.sh`, verifier reject-vectors (external review
round 2).

## v0.1.1 — 2026-07-15

Initial public release: seven-lineage canonical JSON + SHA-256,
byte-identity verified.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Seven independent implementations of the same canonicalization contract — C, C

**Prerequisites** (one toolchain per lineage): a C compiler + `make`; CMake ≥ 3.20 + a C++17 compiler; Rust (`cargo`); Go ≥ 1.22; D (`dmd` + `dub`); GHC ≥ 9.6 + `cabal` (aeson ≥ 2.2 is fetched by cabal); OCaml ≥ 5.x + `dune` with `yojson`, `digestif`, `alcotest` (via opam); `python3` for the conformance tooling. A successful run ends with a 7/7 PASS table from `build_all.sh` and `PASS: 7/7 lineages agree ...` from the verifier, exit 0.

`build_all.sh` builds each lineage with its native toolchain, runs its test suite, and places the CLI at `<lineage>/bin/baion_canon_hash` — the layout the verifier requires. `verify_all_lineages.sh` then feeds every vector in the conformance corpus (`conformance/accept.jsonl` + `conformance/reject.jsonl`) to every CLI: accept vectors must hash to the corpus-pinned SHA-256 in all seven lineages, and reject vectors must be **uniformly refused** (see below). `conformance/differential_probe.py` additionally sweeps generated danger-zone cases (number bands, escape forms, document framing) and fails on any disagreement; both run in CI. All seven lineages must be present — a missing binary fails the run, and one byte of disagreement anywhere fails the run. `conformance/fuzz_agreement.py` is the third layer: a seeded randomized fuzzer (structured boundary-biased documents, byte mutations, raw garbage) that asserts seven-way agreement on every generated input.
`build_all.sh` builds each lineage with its native toolchain, runs its test suite, and places the CLI at `<lineage>/bin/baion_canon_hash` — the layout the verifier requires. `verify_all_lineages.sh` then feeds every vector in the conformance corpus (`conformance/accept.jsonl` + `conformance/reject.jsonl`) to every CLI: accept vectors must hash to the corpus-pinned SHA-256 in all seven lineages, and reject vectors must be **uniformly refused** (see below). `conformance/differential_probe.py` additionally sweeps generated danger-zone cases (number bands, escape forms, document framing) and fails on any disagreement; both run in CI. All seven lineages must be present — a missing binary fails the run, and one byte of disagreement anywhere fails the run. `conformance/fuzz_agreement.py` is the third layer: a seeded randomized fuzzer (structured boundary-biased documents, byte mutations, raw garbage) that asserts seven-way agreement on every generated input. A fourth CI gate builds the two memory-unsafe lineages (C, C++) under ASan + UBSan (`-fno-sanitize-recover=all`) and runs the full corpus through the instrumented CLIs via `conformance/run_corpus_cli.py` — any undefined behavior on an admitted input fails the build. Release history: [CHANGELOG.md](CHANGELOG.md).

## Why this exists

Expand Down
4 changes: 3 additions & 1 deletion c/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ include(FetchContent)

# Fetch cJSON (single .c/.h JSON parser).
# Offline builds: pre-populate with -DFETCHCONTENT_SOURCE_DIR_CJSON=/path/to/cJSON
# Pinned to the commit behind tag v1.7.18: tags are movable refs, so a
# reviewed commit SHA is the only immutable identity for the vendored source.
FetchContent_Declare(
cjson
GIT_REPOSITORY https://github.com/DaveGamble/cJSON.git
GIT_TAG v1.7.18
GIT_TAG acc76239bee01d8e9c858ae2cab296704e52d916 # v1.7.18
)
FetchContent_GetProperties(cjson)
if(NOT cjson_POPULATED)
Expand Down
3 changes: 2 additions & 1 deletion c/include/baion/canonical_json.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
#include <cJSON.h>
#include <stddef.h>

/* Canonicalize any cJSON value. Returns heap-allocated string. Caller must free(). */
/* Canonicalize any cJSON value. Returns heap-allocated string; caller must
* free(). Returns NULL on allocation failure — never a partial string. */
char* baion_canonicalize_json(const cJSON* value);

/* Pre-parse scan of raw JSON input bytes: returns BAION_OK if the input is
Expand Down
68 changes: 61 additions & 7 deletions c/src/canonical_json.c
Original file line number Diff line number Diff line change
Expand Up @@ -10,39 +10,75 @@

#include <cJSON.h>
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Dynamic string buffer */
/* Dynamic string buffer. Allocation failure latches `oom`; every append
* becomes a no-op once set, so the walk finishes cheaply and the public
* entry point reports one defined failure (NULL) instead of any internal
* path dereferencing a failed allocation. */
typedef struct
{
char* data;
size_t len;
size_t cap;
int oom;
} strbuf_t;

static void strbuf_init(strbuf_t* sb)
{
sb->cap = 512;
sb->data = (char*)malloc(sb->cap);
sb->len = 0;
sb->data[0] = '\0';
sb->oom = (sb->data == NULL);
if (!sb->oom)
sb->data[0] = '\0';
}

static void strbuf_ensure(strbuf_t* sb, size_t extra)
{
if (sb->len + extra + 1 > sb->cap)
if (sb->oom)
return;
/* len + extra + 1 must not wrap: a wrapped "needed" would pass the
* capacity test and let the memcpy in strbuf_append run off the end. */
if (extra > SIZE_MAX - sb->len - 1)
{
while (sb->len + extra + 1 > sb->cap)
sb->cap *= 2;
sb->data = (char*)realloc(sb->data, sb->cap);
sb->oom = 1;
return;
}
size_t needed = sb->len + extra + 1;
if (needed > sb->cap)
{
size_t cap = sb->cap;
while (cap < needed)
{
if (cap > SIZE_MAX / 2)
{
cap = needed; /* doubling would wrap; exact size is enough */
break;
}
cap *= 2;
}
/* realloc into a temporary: assigning a NULL return straight to
* sb->data would leak the original block and lose the buffer. */
char* grown = (char*)realloc(sb->data, cap);
if (!grown)
{
sb->oom = 1;
return;
}
sb->data = grown;
sb->cap = cap;
}
}

static void strbuf_append(strbuf_t* sb, const char* s, size_t n)
{
strbuf_ensure(sb, n);
if (sb->oom)
return;
memcpy(sb->data + sb->len, s, n);
sb->len += n;
sb->data[sb->len] = '\0';
Expand All @@ -51,6 +87,8 @@ static void strbuf_append(strbuf_t* sb, const char* s, size_t n)
static void strbuf_appendc(strbuf_t* sb, char c)
{
strbuf_ensure(sb, 1);
if (sb->oom)
return;
sb->data[sb->len++] = c;
sb->data[sb->len] = '\0';
}
Expand Down Expand Up @@ -134,6 +172,11 @@ static void canonicalize_object(strbuf_t* sb, const cJSON* obj)

/* Collect pointers and sort by key */
const cJSON** keys = (const cJSON**)malloc((size_t)count * sizeof(cJSON*));
if (!keys)
{
sb->oom = 1;
return;
}
int i = 0;
for (child = obj->child; child; child = child->next)
{
Expand Down Expand Up @@ -186,7 +229,11 @@ static void write_number(strbuf_t* sb, const cJSON* item)
* trailing decimal (RFC 8785 §3.2.2.3 / ECMA-262 §7.1.12.1). All
* language implementations emit 1.0 → "1", -3.0 → "-3", 1.5 → "1.5";
* disagreement on this branch breaks SHA-256 digest parity. */
if (d == (double)(long long)d && fabs(d) < 1e15)
/* Range check MUST precede the integer conversion: for |d| >= 2^63
* (e.g. 1e20, inside the admitted domain |v| < 1e21) the cast to
* long long is undefined behavior (C11 6.3.1.4p1). Same guard order
* as the C++ lineage. */
if (fabs(d) < 1e15 && trunc(d) == d)
{
char buf[32];
snprintf(buf, sizeof(buf), "%lld", (long long)d);
Expand Down Expand Up @@ -792,5 +839,12 @@ char* baion_canonicalize_json(const cJSON* value)
strbuf_t sb;
strbuf_init(&sb);
canonicalize_value(&sb, value);
if (sb.oom)
{
/* Defined failure: a partial canonical string must never reach a
* hash — free it and report NULL rather than a truncated form. */
free(sb.data);
return NULL;
}
return sb.data;
}
5 changes: 5 additions & 0 deletions c/tools/baion_canon_hash.c
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ int main(void)

char* canonical = baion_canonicalize_json(root);
cJSON_Delete(root);
if (!canonical)
{
fprintf(stderr, "baion_canon_hash: out of memory during canonicalization\n");
return -BAION_ERR_PARSE;
}

uint8_t hash[32];
baion_sha256((const uint8_t*)canonical, strlen(canonical), hash);
Expand Down
1 change: 1 addition & 0 deletions conformance/accept.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@
{"name": "same_key_sibling_objects", "input": "[{\"k\":1},{\"k\":2}]", "sha256": "98fcf287e1991c1602a189793606501715f8ae194db5dcaaf6515ed29937c20d"}
{"name": "micro_boundary", "input": "{\"x\":0.000001}", "sha256": "2d6412ab0155bb89d63b73dbf83334b26b39d03e8c832cc253c6a6caceba9733"}
{"name": "big_float_shortest_digits", "input": "{\"x\":65219416364867774.9377591}", "sha256": "96df26a780f11e7a70c701e23ff9f79ab115118d77c70945885996a1014a2d84"}
{"name": "huge_integer_valued_float", "input": "{\"x\":100000000000000000000.0}", "sha256": "356acd219b8c369fc389513fb5c3f9fc2977fff3c432bab9df5b1e3a2800b072"}
5 changes: 5 additions & 0 deletions conformance/gen_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@
# Pins ES-262 shortest-digits for an integer-valued double beyond 2^53:
# exact value is ...776 but the canonical spelling is the 16-digit ...780.
("big_float_shortest_digits", '{"x":65219416364867774.9377591}'),
# Regression for the v0.2.0 C-lineage UB (fixed v0.2.1): a fractional
# token whose value is integer-valued and >= 2^63 but inside the admitted
# domain (|v| < 1e21). The old C writer cast to long long BEFORE its
# range check — undefined behavior per C11 6.3.1.4p1, caught by UBSan.
("huge_integer_valued_float", '{"x":100000000000000000000.0}'),
]

# Uniform-rejection contract. reason is documentation for humans + remediation maps.
Expand Down
Loading
Loading