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
32 changes: 31 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,39 @@ jobs:
working-directory: spec
run: npx --yes cddl@0.21.1 validate protocol.cddl

conformance-verify:
name: Conformance Verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: '22'

- name: Install conformance package
working-directory: conformance
run: npm ci

- name: Confirm generate.mjs's output matches the committed vector files
working-directory: conformance
run: |
cp handshake.v1.json /tmp/handshake-committed.json
cp tokens.v1.json /tmp/tokens-committed.json
cp frames.v1.json /tmp/frames-committed.json
npm run generate
if ! diff -u /tmp/handshake-committed.json handshake.v1.json || ! diff -u /tmp/tokens-committed.json tokens.v1.json || ! diff -u /tmp/frames-committed.json frames.v1.json; then
echo "::error::conformance/*.v1.json is out of date. Run 'npm run generate' in conformance/ and commit the result -- never edit the vector files directly."
exit 1
fi

- name: Verify every vector round-trips through cbor2
working-directory: conformance
run: npm run verify

required-checks:
name: Required Checks
needs: [cddl-validate]
needs: [cddl-validate, conformance-verify]
if: always()
runs-on: ubuntu-latest
steps:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ ts/

## Implementations

None yet. The schema exists (`spec/protocol.cddl`); `rust/` and `ts/packages/core` don't exist as code, only as the structure above. Once they do:
None yet. The schema exists (`spec/protocol.cddl`), and so does `conformance/`'s golden test vector suite; `rust/` and `ts/packages/core` don't exist as code, only as the structure above. Once they do:

- **[Cascade](https://github.com/Mearman/cascade)** refactors its own hand-written protocol code onto `rust/` as an ordinary Cargo dependency, rather than maintaining a parallel implementation.
- **[agent-comms](https://github.com/ExaDev/agent-comms)** refactors its own wire-protocol and transport code onto `ts/packages/core` as an ordinary pnpm dependency, the same way.
Expand Down
23 changes: 23 additions & 0 deletions conformance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# conformance/

Golden test vectors: every implementation's CI must decode each vector's `wire_hex` to its `message` and re-encode `message` back to exactly `wire_hex`. This is the actual forcing function against drift between implementations -- a schema alone never proves interop, only shared vectors do, the same lesson Cascade's own `docs/conformance/*.v1.json` was built to enforce for its XDR-based protocol.

`handshake.v1.json` covers `handshake-frame`. `tokens.v1.json` covers `capability-token` (including a delegation chain, one token's `parent` pointing at another) and `handle-record`. `frames.v1.json` covers every other `$frame-variant` in `spec/frame.cddl`.

## Regenerating

```
npm install
npm run generate # writes {handshake,tokens,frames}.v1.json from generate.mjs's vector definitions
npm run verify # decodes every committed vector and confirms it round-trips
```

`generate.mjs` is the actual source of truth, not the JSON files: every vector's `message` is authored as plain JS data matching a CDDL rule's fields, and `wire_hex` is derived mechanically by canonically CBOR-encoding it via [`cbor2`](https://www.npmjs.com/package/cbor2)'s CDE (CBOR Common Deterministic Encoding) mode -- the RFC 8949 4.2 core deterministic rules DAG-CBOR itself builds on -- never hand-typed. CI regenerates and diffs against the committed files the same way `spec/`'s own `cddl-validate` job does for `protocol.cddl`, so the two can never silently drift apart.

`codec.mjs` defines the one JSON convention every vector's `message` needs: since JSON has no byte-string type, a CDDL `bstr` field is written as `{ "hex": "<lowercase hex>" }` rather than a raw string or number array. `toWire`/`fromWire` convert between that marker shape and the real bytes CBOR needs on the way in and out.

Signature and public-key bytes throughout are clearly-synthetic filler (`aa`/`bb`/`ee`/`ff`-repeated hex), not real cryptographic material -- these vectors freeze the wire-exact envelope shape (map key ordering, field presence, the recursive delegation-chain nesting), not a working signature, the same scope Cascade's own frozen vectors commit to for fields with no real crypto behind them.

## Gotcha: `cbor2` doesn't recognise a Node `Buffer` as a byte string

Feeding a plain Node `Buffer` (rather than a plain `Uint8Array`) into `cbor2`'s `encode()` silently produces the wrong output: `Buffer` overrides `toJSON()`, and `cbor2`'s type dispatch falls through to a generic-object encoder that serialises it as a garbled `{ type: "Buffer", data: [...] }` CBOR map instead of a byte string, with no error raised. Confirmed directly while writing this generator -- caught only because the verifier's round-trip check failed with an unreadable diff. `toWire()` in `codec.mjs` guards against this explicitly, converting every marker to a genuine `Uint8Array` via `Uint8Array.from(Buffer.from(hex, "hex"))` rather than passing a `Buffer` straight to `encode()`.
53 changes: 53 additions & 0 deletions conformance/codec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Shared JSON<->wire helpers for the conformance vector generator and verifier.
//
// JSON has no byte-string type, so every CDDL `bstr` field is represented in a vector's `message` as `{ "hex": "<lowercase hex>" }` rather than a raw string or array of numbers -- this keeps `message` valid, diffable JSON while still letting the codec reconstruct exactly the bytes CBOR needs. `toWire` walks a `message` value replacing every such marker with a real byte buffer before encoding; `fromWire` walks a decoded value the other way, turning every real byte string back into the same marker shape so it can be compared against the original `message` with a plain deep-equal.

export function hex(value) {
return { hex: value.toLowerCase() };
}

export function isHexMarker(value) {
return (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value).length === 1 &&
typeof value.hex === "string"
);
}

export function toWire(value) {
if (isHexMarker(value)) {
// A plain Uint8Array, not a Node Buffer: cbor2's encoder dispatches on the exact constructor and doesn't recognise Buffer as a byte string, falling back to Buffer's own toJSON() and encoding it as a garbled {type, data} map instead -- confirmed directly, not a hypothetical.
return Uint8Array.from(Buffer.from(value.hex, "hex"));
}
if (Array.isArray(value)) {
return value.map(toWire);
}
if (value !== null && typeof value === "object") {
const out = {};
for (const [k, v] of Object.entries(value)) out[k] = toWire(v);
return out;
}
return value;
}

export function fromWire(value) {
if (value instanceof Uint8Array) {
return hex(Buffer.from(value).toString("hex"));
}
if (Array.isArray(value)) {
return value.map(fromWire);
}
if (value instanceof Map) {
const out = {};
for (const [k, v] of value.entries()) out[String(k)] = fromWire(v);
return out;
}
if (value !== null && typeof value === "object") {
const out = {};
for (const [k, v] of Object.entries(value)) out[k] = fromWire(v);
return out;
}
return value;
}
Loading