wai.delivery.fleet: an organisation's nodes enrolled by their keys, their records verified, and a report it signs (wai-fleet, the fleet's operations, corpus and second verifier) #518

Merged
dcharlot merged 6 commits from wai/fleet into main 2026-10-27 19:44:30 -04:00
33 changed files with 13490 additions and 19 deletions

View file

@ -195,6 +195,10 @@ on:
# reports, stream_audit_verify.py reads that table, and the registry's wire identifiers test
# reads its sections.
- 'wai/extensions/stream-audit.md'
# tests/fleet.rs requires this extension's §10 to list the codes the reference reports,
# fleet_verify.py reads that table, and the registry's wire identifiers test reads its
# sections. Its corpus is generated from the metering corpus, under the paths above.
- 'wai/extensions/fleet.md'
# tests/native_sink.rs requires this extension's §8 to list exactly the reasons its
# corpus reaches, and native_sink_verify.py reads that table.
- 'wai/extensions/native-sink.md'
@ -597,7 +601,7 @@ jobs:
working-directory: wai/wai-rs
run: |
cargo test --no-default-features --features floor --test floor
cargo test --no-default-features --features quickstart --bin wai --test floor --test cli_dx
cargo test --no-default-features --features quickstart --bin wai --test floor --test cli_dx --test cli_fleet
# tests/cli_http.rs runs `wai http publish`, `codecs`, `origin` and `bench` as a user
# does: objects named by their content hash with their head proofs, a sequence's union
# and floor, the origin's headers, ranges and If-Range on loopback, and a fetch by
@ -1141,7 +1145,7 @@ jobs:
name: independent verifiers · claims, JWP, key discovery and integer payloads (Python)
runs-on: linux-arm64
env:
WAI_FEATURES: key_discovery,http,video_manifest,stage_measure,live_events,live_room,capture_take,live_compose,live_record,timed_text,access_services,live_metering,energy_steering,live_moderation,dred_exact,live_ads,live_provenance,synthesis,synthesis_mark,neural_int,exact_upscale,robustness,ai_usage,contribution,settlement,browser_sink,receipt_claims,takedown,influence,direction,co_performance,age_assurance,bearer_receipts,exploration,co_watch,telemetry_aggregate,exact_tonemap,splat_exact,live_watermark,derive_edge,sink_energy,key_release,stitched_presentation,prior_wire,scene_map,avatar_face,receipt_ledger,stream_audit,native_sink,platform_encode,remote_desktop,stream_termination,live_world,format_probe,live_messaging
WAI_FEATURES: key_discovery,http,video_manifest,stage_measure,live_events,live_room,capture_take,live_compose,live_record,timed_text,access_services,live_metering,energy_steering,live_moderation,dred_exact,live_ads,live_provenance,synthesis,synthesis_mark,neural_int,exact_upscale,robustness,ai_usage,contribution,settlement,browser_sink,receipt_claims,takedown,influence,direction,co_performance,age_assurance,bearer_receipts,exploration,co_watch,telemetry_aggregate,exact_tonemap,splat_exact,live_watermark,derive_edge,sink_energy,key_release,stitched_presentation,prior_wire,scene_map,avatar_face,receipt_ledger,stream_audit,fleet,native_sink,platform_encode,remote_desktop,stream_termination,live_world,format_probe,live_messaging
WAI_PQ_FEATURES: synthesis,pq_signatures
WAI_RELEASE_FEATURES: dred_exact,synthesis_mark,robustness,avatar_face
steps:
@ -1234,7 +1238,7 @@ jobs:
--test telemetry_aggregate --test exact_tonemap --test splat_exact --test live_watermark \
--test derive_edge --test det_cost_bin --test sink_energy --test key_release \
--test stitched_presentation --test prior_wire --test scene_map --test receipt_ledger \
--test stream_audit --test native_sink --test platform_encode --test remote_desktop \
--test stream_audit --test fleet --test native_sink --test platform_encode --test remote_desktop \
--test stream_termination --test live_world --test format_probe --test live_messaging
cargo test --release --no-run --no-default-features --features "$WAI_RELEASE_FEATURES" \
--test dred_exact --test dred_exact_measure --test synthesis_mark --test robustness \
@ -1969,6 +1973,28 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib stream_audit::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test stream_audit)
python3 -I live-conformance/stream_audit_verify.py live-conformance/audit
# wai.delivery.fleet (extensions/fleet.md): the reference module's unit tests (the enrolment's
# rules, a summary line split only in the relay's form, a row summed across classes told from
# a wrong one, the four coverages) and tests/fleet.rs (vectors.json regenerates byte for byte
# from its seeds and the metering corpus and no other file is there; wai-rs gives every
# vector its stated outcome; every reason of §10 and every reused code refuses a vector; the
# accepted vectors state measured, unmetered and partial energy as §5.4 does; the week's
# records held as a service holds them sign the corpus's report byte for byte; §10 lists
# exactly the reference's codes); then the corpus's second implementation, fleet_verify.py,
# written from the extension's text and sharing no code with wai-rs, which reads per-hop
# records with live_metering_verify.py, checks every signature with WAI's strict Ed25519
# rule, recomputes every line, row, unmetered count and citation, requires every mutated
# copy of the corpus to fail, and drops each of its own checks in turn and requires the
# corpus to catch the weakened verifier; standard library only, run isolated (-I). The
# service and its smoke checks over HTTP run in wai-fleet.yml.
- name: fleet · wai-rs and fleet_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib fleet::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test fleet)
python3 -I live-conformance/fleet_verify.py live-conformance/fleet
# The native sink (extensions/native-sink.md): the reference module's unit tests (dates,
# durations and attribute lists read as a player reads them, origins serialized as RFC 6454
# does, names that state a content hash and names that do not, the acknowledgements
@ -2602,7 +2628,7 @@ jobs:
takedown influence direction co_performance age_assurance bearer_receipts exploration co_watch \
telemetry_aggregate exact_tonemap splat_exact live_watermark derive_edge pq_signatures \
synthesis,pq_signatures sink_energy key_release stitched_presentation prior_wire \
prior_wire,neural_int scene_map avatar_face receipt_ledger stream_audit native_sink \
prior_wire,neural_int scene_map avatar_face receipt_ledger stream_audit fleet native_sink \
platform_encode remote_desktop stream_termination live_world format_probe live_messaging; do
echo "::group::$f"
cargo check --no-default-features --features "$f" --tests

130
.github/workflows/wai-fleet.yml vendored Normal file
View file

@ -0,0 +1,130 @@
name: wai-fleet service
# A WAI fleet as a service (wai/wai-fleet, extensions/fleet.md §9): its tests (the corpus's report
# signed byte for byte, the directory recovered after a restart, a line a crash cut short cut off,
# one process per directory, every summary of the corpus read and written again by the reference
# relay's own types, and records the relay's own code signs collected from a file over HTTP),
# clippy with warnings as errors, and its smoke checks over HTTP against an instance started here
# with the corpus's organisation key, the report's bundle checked by fleet_verify.py. On Linux;
# on the macOS runner (a host the service would be deployed to) the release build and the smoke
# checks, without the tests, which build the reference relay.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- 'wai/wai-rs/**'
- 'wai/wai-fleet/**'
- 'wai/live-conformance/**'
- 'wai/claim-conformance/**'
- 'wai/extensions/fleet.md'
- 'joule-code/joule-code-rs/crates/joule-moq-relay/**'
- 'joule-code/joule-code-rs/crates/joule-moq/**'
- 'joule-code/joule-code-rs/crates/jwp/**'
- 'joule-code/joule-code-rs/vendor/wtransport/**'
- 'rust-toolchain.toml'
- '.github/workflows/wai-fleet.yml'
pull_request:
paths:
- 'wai/wai-rs/**'
- 'wai/wai-fleet/**'
- 'wai/live-conformance/**'
- 'wai/claim-conformance/**'
- 'wai/extensions/fleet.md'
- 'joule-code/joule-code-rs/crates/joule-moq-relay/**'
- 'joule-code/joule-code-rs/crates/joule-moq/**'
- 'joule-code/joule-code-rs/crates/jwp/**'
- 'joule-code/joule-code-rs/vendor/wtransport/**'
- 'rust-toolchain.toml'
- '.github/workflows/wai-fleet.yml'
jobs:
relevant:
name: does this change touch it
runs-on: linux-arm64
outputs:
run: ${{ steps.gate.outputs.run }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- id: gate
run: |
set -euo pipefail
r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-fleet.yml \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}")
echo "this change touches wai-fleet: $r"
echo "run=$r" >> "$GITHUB_OUTPUT"
service-linux:
name: service · linux
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: linux
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Install Rust 1.98.0
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal --component clippy
rustup default 1.98.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Tests and clippy
shell: bash
working-directory: wai/wai-fleet
run: |
set -euo pipefail
cargo test --locked --release 2>&1 | tee "$RUNNER_TEMP/test.log"
grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; }
cargo clippy --locked --release --all-targets -- -D warnings
- name: The smoke checks over HTTP
shell: bash
working-directory: wai/wai-fleet
run: |
set -euo pipefail
cargo build --locked --release
dir="$RUNNER_TEMP/fleet"; key="$RUNNER_TEMP/organisation.key"; rm -rf "$dir" "$key"
# The corpus's organisation key, from its fixed seed (a test key, never a deployment's).
python3 -I -c 'import hashlib,sys; sys.stdout.buffer.write(hashlib.sha256(b"wai:fleet/corpus/key\x00organisation").digest())' > "$key"
WAI_FLEET_KEY="$key" WAI_FLEET_DIR="$dir" WAI_FLEET_ADDR=127.0.0.1:18252 \
"${CARGO_TARGET_DIR:-target}/release/wai-fleet" serve 2> "$RUNNER_TEMP/service.log" &
pid=$!
trap 'kill $pid 2>/dev/null || true; cat "$RUNNER_TEMP/service.log"' EXIT
for _ in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:18252/v1/fleet/health && break; sleep 0.2; done
bash smoke.sh http://127.0.0.1:18252
service-macos:
name: service · macos
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: macos
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
# launchd's PATH omits cargo on the macOS host runner.
- name: Put cargo on PATH
shell: bash
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: The smoke checks over HTTP
shell: bash
working-directory: wai/wai-fleet
run: |
set -euo pipefail
nice -n 10 cargo build --locked --release
dir="$RUNNER_TEMP/fleet"; key="$RUNNER_TEMP/organisation.key"; rm -rf "$dir" "$key"
python3 -I -c 'import hashlib,sys; sys.stdout.buffer.write(hashlib.sha256(b"wai:fleet/corpus/key\x00organisation").digest())' > "$key"
WAI_FLEET_KEY="$key" WAI_FLEET_DIR="$dir" WAI_FLEET_ADDR=127.0.0.1:18252 \
"${CARGO_TARGET_DIR:-target}/release/wai-fleet" serve 2> "$RUNNER_TEMP/service.log" &
pid=$!
trap 'kill $pid 2>/dev/null || true; cat "$RUNNER_TEMP/service.log"' EXIT
for _ in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:18252/v1/fleet/health && break; sleep 0.2; done
bash smoke.sh http://127.0.0.1:18252

View file

@ -6,6 +6,36 @@ wire format may change and such changes are recorded here.
## v0.4.0 — current
### Fleet: an organisation's nodes enrolled by their keys, their records verified, and a report it signs for a period
- **The text** ([`extensions/fleet.md`](extensions/fleet.md), `wai.delivery.fleet`): the
organisation's enrolment of a node, `WFE1` (its key, its role, a label, the span it holds); the
records a node contributes as it signs them: a relay's session summaries
(`joule-moq-relay/session-summary/2`, read in the relay's own form and verified by its own
signature), every hop's per-hop records with their ledgers, and the room descriptors that place
those in time; each record's instant, its period and its enrolment, and one thing counted once;
the fleet report, `WFR1`, with a line per node and per room, sessions, links and bytes, and energy
per scope and acquisition class, never summed across either, every unmetered figure counted as
unmetered and a total over a mix stated partial; citations; the bundle a verifier recomputes the
report from; and the reasons, among them `unmetered_summed` for a report that drops an unmetered
figure or counts it as a figure of zero joules, and `line_unsourced` for a line no record gives.
- **`wai-rs`** (feature `fleet`): the enrolment, the records, the figures, the report and the
bundle's verification; `wai_fleet_vectors` and `tests/fleet.rs`.
- **`wai-fleet`** ([`wai-fleet/`](wai-fleet/)): the fleet as an HTTP service over a directory it
owns, the enrolments and records in append-only logs, every report kept with its bundle; and
`wai-fleet collect`, which gives each line of a node's summary file, a path or an `http` or
`https` address, to a running service.
- **Operations**: `wai enrolment create` and `wai enrolment list` (`enrolment.create`,
`enrolment.list`), `wai report create` and `wai report export` (`report.create`,
`report.export`), on the toolchain's agent rules; `wai verify` checks `*.wai-fleet.json` bundles.
The quickstart build carries the feature.
- **The corpus** ([`live-conformance/fleet/`](live-conformance/fleet/)): a week of two relays
cascading, a compositor and an ingest gateway, with records taken from the metering corpus, the
reference relay's recorded per-hop record among them; a fleet whose energy is whole, one whose
energy is unmetered, and one enrolled and silent; and a control of one defect for each reason.
Its second implementation, `live-conformance/fleet_verify.py`, gives every vector the same
outcome, fails every mutated copy, and catches each of its own checks dropped.
### Create: a WAI app described, built as a model writes it, and made a project by `wai create --from app.oui`
- **The widgets** ([`wai-app`](wai-app/), a library with no binary): a joule-ui registry of WAI

View file

@ -185,6 +185,7 @@ and a role are fixed once registered.
| `wai.bearer.receipts` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.capture.take` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.delivery.audit` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.delivery.fleet` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.delivery.ledger` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.delivery.settlement` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.derive.crop` | derivation | registered | — | — | patent-pool | — | — | §5 Prior artifacts, general tensors and derivation operations |
@ -343,6 +344,7 @@ and a role are fixed once registered.
|---|---|---|---|
| `#WAI-LAYER` | playlist-tag | defined | http-delivery §10.4 |
| `.wai` | path | defined | http-delivery §5.2 |
| `.wai-fleet.json` | path | defined | fleet §7.1 |
| `.wai.hp` | path | defined | http-delivery §6.2 |
| `.wai.receipts` | path | defined | http-delivery §8.2 |
| `/.well-known/wai-grants/` | well-known | defined | http-delivery §8.4 |
@ -391,6 +393,8 @@ and a role are fixed once registered.
| `WDS1` | magic | defined | remote-desktop §3.3 |
| `WEP1` | magic | defined | live-events §5 |
| `WEV1` | magic | defined | live-events §3.1 |
| `WFE1` | magic | defined | fleet §3.1 |
| `WFR1` | magic | defined | fleet §6.1 |
| `WGM1` | magic | defined | exact-tonemap §4.1 |
| `WHP1` | magic | defined | http-delivery §6.2 |
| `WJR1` | magic | defined | jwp-receipts §2.7.3 |
@ -511,6 +515,7 @@ and a role are fixed once registered.
| `wai-detection` | document-kind | defined | synthesis §7.3 |
| `wai-directed` | document-kind | defined | direction §8.1 |
| `wai-distance` | document-kind | defined | exploration §6.3 |
| `wai-fleet-bundle` | document-kind | defined | fleet §7.1 |
| `wai-influence-report` | document-kind | defined | influence §7.1 |
| `wai-int/1` | scheme | defined | prior-carriage §10 |
| `wai-key-status` | document-kind | defined | jwp-receipts §7.9 |

View file

@ -1542,6 +1542,24 @@ and stalls once 50 sessions hold them, acknowledged bytes per origin and object
role and class, never summed across classes. Anyone holding the bundles recomputes the report byte for
byte. No price, tariff or amount: [`extensions/stream-audit.md`](extensions/stream-audit.md).
And for **an organisation's fleet** — `wai.delivery.fleet`: the organisation signs an enrolment,
`WFE1`, for each node it runs, naming it by its signing key with its role (a relay, a compositor,
an ingest gateway, a room service, a ledger or an audit collector), a label and the span it holds.
Each node contributes the records it already signs, as it signs them: a relay's session summaries,
one line of its summary file each, and every hop's per-hop records (`WMH1`) with their ledgers,
placed in time by the room descriptors whose digests they name. Each is verified against its
node's enrolled key, placed in a period by its instant, and counted once. For a period, the
organisation signs a fleet report, `WFR1`: one line per node, with its client sessions, its hop
links to other relays and their bytes, and its per-hop records and their bytes, never added to the
first; one line per room, with its hops, subscriber sessions and time, and bytes; the totals; and
for each line and the totals the energy its records state, per scope (the sessions' figures, the
hops' figures) and per acquisition class with the largest declared uncertainty, never summed across
either. Every unmetered figure is counted as unmetered, never as zero joules, so a total over
measured and unmetered figures says it is partial, and a node that signed nothing in the period is
listed as silent. Each line cites the records it came from, by the identity the receipt ledger
gives a per-hop record. A verifier given the report with its enrolments and records, a bundle,
recomputes every line. No accounts, no billing: [`extensions/fleet.md`](extensions/fleet.md).
And for **a native host's player** — the native sink: an application that plays through its
platform's own player, which decodes on the platform's media engine, routes the player's requests
through the sink, which verifies a media playlist against its playlist receipt and the chain of its

579
wai/extensions/fleet.md Normal file
View file

@ -0,0 +1,579 @@
# WAI Extension: Fleet
> Status: Draft. Registers the record `wai.delivery.fleet`: the nodes an organisation runs,
> enrolled by their signing keys, and a report of what they did that anyone holding their
> records recomputes. The organisation signs an **enrolment**, `WFE1`, for each node: the
> node's Ed25519 key, its role (a relay, a compositor, an ingest gateway, a room service, a
> ledger or an audit collector), a label and the span the enrolment holds. A node contributes
> the records it already signs, as it signs them: a relay's session summaries, and every hop's
> per-hop session records ([live-metering](live-metering.md) §3) with the room descriptors that
> place them in time. For a period, the organisation signs a **fleet report**, `WFR1`: one line
> per node and one per room, with the sessions, bytes and energy their records state, each line
> citing the records it came from, and the fleet's totals. Energy keeps its acquisition class
> and its declared uncertainty, one row per class and per scope, never summed across either.
> Every unmetered figure is counted as unmetered, never as zero joules, and a total over
> measured and unmetered figures states that it is partial. A verifier given a report with its
> enrolments and records, a **bundle**, recomputes every line and refuses a report that does not
> recompute. No accounts, no billing and no new transport. The reference implementation is the
> `fleet` module of `wai-rs` (feature `fleet`) and the service `wai/wai-fleet`; the corpus is
> [`live-conformance/fleet/`](../live-conformance/fleet/), and its second implementation is
> `live-conformance/fleet_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Texts read for this revision: the WAI texts this one cites; RFC 3629; RFC 8032; RFC 8259;
> FIPS 180-4.
## 1. Scope
In scope: the organisation key and the enrolment of a node (§3); the records a node contributes,
how each is read, verified and placed in time, and how one thing is counted once (§4); the
figures a report states for each node, each room and the fleet, with their energy and citations
(§5); the report, its signature and its rules (§6); the bundle and its verification (§7); how the
fleet composes with the [receipt ledger](receipt-ledger.md) (§8); and the reference service and
its operations (§9).
Out of scope: accounts, members, single sign-on, quotas, billing and any control plane shared by
several organisations; a transport; how a node measures its energy, which
[energy-measurement](energy-measurement.md) governs; the records a node signs, which their own
texts and implementations define and this one reads unchanged; and prices, which the receipt
ledger states.
Relationship to the core spec: `wai.delivery.fleet` is a **record** ([SPEC](../SPEC.md) §5): a
signed record a WAI tool emits and verifies. It names no payload format and is not dispatchable.
## 2. Terms
- **Organisation**: the party that runs a set of nodes. Its **organisation key**, an Ed25519 key
(RFC 8032), signs its enrolments and its reports.
- **Node**: a process the organisation runs that signs records, named by its Ed25519 key, the
**node key**. A relay's node key is the key it signs its session summaries with, which is also
the key its per-hop records name as `hop` (live-metering §3.1) and its relay id among relays
(live-rooms §10.7); a compositor's or an ingest gateway's is the key its per-hop records name.
- **Role**: what a node is, by code: 1 relay, 2 compositor, 3 ingest gateway (live-metering §2's
roles, with the codes a per-hop record gives them), 4 room service, 5 ledger, 6 audit collector.
- **Time**: microseconds since 1970-01-01T00:00:00Z. A **period** is a span of time from its
`start` (included) to its `end` (excluded).
- **Record**: one signed thing a bundle holds (§4.1), with its **kind** (a u8) and its
**identity** (32 bytes). Its **signer** is the key that signed it.
- *u64*, *u32*, *u16*, *u8*: little-endian unsigned integers of that many bits. A **digest** is
SHA-256 (FIPS 180-4), 32 bytes. Hexadecimal is lowercase.
- **Class**, **tag**, **unmetered marker**: an acquisition class of energy-measurement §2.7, its
tag byte (2 `ModelBased`, 3 `Estimator`, 4 `OnChipCounter`, 5 `CalibratedInstrument`), and a
figure of 0 with no class (energy-measurement §2), which is never a figure of zero joules.
- **Scope** of an energy figure: 1 `sessions`, the figure a session summary states (§4.2); 2
`hops`, the figure a per-hop record states (§4.3).
## 3. Enrolment
### 3.1 Layout
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WFE1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `organisation` | 32 | the organisation key, which signs the enrolment |
| `node` | 32 | the node key |
| `role` | 1 | the node's role (§2) |
| `from`, `until` | 8 each | u64: the span the enrolment holds, from `from` (included) to `until` (excluded); `until` is 2^64 − 1 for a span with no end |
| `label` | 1 + *n* | a u8 *n*, then *n* bytes of UTF-8 (RFC 3629): the node's name in reports, empty for none |
| `sig` | 64 | §3.2 |
### 3.2 Digest and signature
The **digest** of an enrolment is SHA-256 over its bytes before `sig`. Its signature is Ed25519
by `organisation` over the 16 bytes `wai:fleet-enrol` (ASCII) and 0x01, followed by the bytes the
digest covers, verified strictly, as [SPEC](../SPEC.md) §7 *Signatures* requires: a key that
fails step 1 or 2 is `bad_key`, and a signature that fails a later step is `bad_signature`.
### 3.3 Reading and rules
A reader reads exactly the bytes it is given as one enrolment, as live-events §3.3 reads a
record, with `WFE1` for `WEV1` (`truncated`, `malformed`, `unknown_version`). It then refuses as
`enrolment_invalid` an enrolment that breaks any of these:
- `role` is 1 to 6;
- `from` is below `until`;
- `label` is at most 64 bytes of UTF-8 and holds no control character (U+0000 to U+001F and
U+007F);
- `node` is not `organisation`.
Then the signature (§3.2).
### 3.4 What each role contributes
| role | contributes in this revision |
|---|---|
| 1 relay | its session summaries (§4.2) and its per-hop records of role 1 (§4.3) |
| 2 compositor | its per-hop records of role 2 |
| 3 ingest gateway | its per-hop records of role 3 |
| 4 room service, 5 ledger, 6 audit collector | none: the node is enrolled and listed, and reported silent |
A record of a kind its signer's role does not contribute, and a per-hop record whose `role` is not
its signer's, is refused `role_mismatch`. A node holds one enrolment in a fleet: a second enrolment
of the same node key is refused `enrolment_duplicate`.
## 4. Records
### 4.1 Kinds and their carriage
A record is carried as a JSON object (RFC 8259) whose `kind` names its kind, with exactly the
members of its kind:
| `kind` | members | record kind | identity | signer |
|---|---|---|---|---|
| `hop` | `record` and `ledger`, hexadecimal: a per-hop record and its ledger (live-metering §3) | 2 | its digest (live-metering §3.5) | its `hop` |
| `summary` | `line`, a string: one line of a relay's summary file, without its line end (§4.2) | 10 | SHA-256 over the summary's text, *S* | its `relay_key` |
| `descriptor` | `descriptor`, hexadecimal: a room descriptor (live-rooms §3) | 11 | its digest, which is the room id (live-rooms §3.2) | none: its digest binds it (§4.4) |
Kind 2 is the kind and identity [receipt-ledger](receipt-ledger.md) §5.2 gives a per-hop record,
so a fleet report and an invoice name such a record alike (§8); kinds 10 and 11 are kinds that
table does not use. A carriage that is not such an object, whose `kind` is none of these, or
whose members are not exactly its kind's, each of its type, is refused `malformed`.
### 4.2 The session summary
The reference relay keeps a summary of every MOQT session it serves, signs it when the session
ends, and appends it to its summary file, one line each (live-metering §8). Its format is named
`joule-moq-relay/session-summary/2`.
**The line.** A line is exactly the 11 bytes `{"summary":`, the summary's text *S*, the 14 bytes
`,"signature":"`, 128 hexadecimal digits (the signature, 64 bytes), and the 2 bytes `"}`. That is
the form the reference relay writes; a line of any other form is `malformed`.
**The text.** *S* is read as JSON text (RFC 8259) of one object: valid UTF-8, no member named twice
in one object, no value inside more than 63 objects and arrays, no escaped surrogate that is not
one of a pair, and nothing after the value; otherwise it is `malformed`. An **integer** here is a
number spelled as a plain decimal integer, with no sign, fraction or exponent and no leading zero,
below 2^64. These members are read, in the order of the table, and the first that is missing or of
another type is `malformed`:
| member | type | meaning |
|---|---|---|
| `format` | string | `joule-moq-relay/session-summary/2`; any other string is `unknown_version` |
| `relay_key` | string of 64 hexadecimal digits | the relay's node key, the signer |
| `session` | integer | the relay's number for the session |
| `opened_unix_ms`, `closed_unix_ms` | integer | when the session opened and closed, in milliseconds since 1970-01-01T00:00:00Z |
| `relay_energy` | object | the session's own energy figure: `acquisition` (string) and `joules_micro` (integer) |
| `tally` | object | what the session carried |
| `tally.peer` | string of 64 hexadecimal digits, or absent | the relay at the other end, when the session is a hop link (live-metering §3.10) |
| `tally.publisher` | object | holding `object_bytes_received` (integer): the payload bytes the session published |
| `tally.subscriber` | object | holding `object_bytes_forwarded` (integer), the payload bytes forwarded to the session live, and `tracks`, an object whose every member is an object holding, or not, `fetch_bytes` (integer; 0 when absent): the payload bytes it was served by fetch and fill on that track |
Every other member is signed and is not read: the reference relay adds members as it counts more,
and the signature covers them. Of those, `tally.publisher_signed` states the figures publishers
signed for producing the objects the session received, which are the publishers' energy and not
the relay's: a report states none of them.
A summary that reads is then checked in this order: its energy, its rules, its signature.
**Energy.** `relay_energy` holds exactly its two members. With `acquisition` `unmetered` and
`joules_micro` 0, the figure is the unmetered marker; with `ModelBased` or `Estimator` and
`joules_micro` above 0, it is a figure of tag 2 or 3. Anything else is refused `energy_invalid`:
another label, a figure beside `unmetered`, a class beside zero, and the classes whose evidence
(energy-measurement §2.7) a summary has no member to carry. The reference relay states the marker
always: sessions run together in one process, and its counter cannot tell their shares apart
(live-metering §8).
**Rules.** A summary is refused `record_invalid` when `opened_unix_ms` is above `closed_unix_ms`,
when `closed_unix_ms` × 1000 is 2^64 or more, or when the bytes out it states, its
`object_bytes_forwarded` and the sum of its tracks' `fetch_bytes`, add to 2^64 or more.
**Signature and identity.** The signature is Ed25519 by `relay_key` over the 32 bytes
`joule-moq-relay:session-summary` (ASCII) and 0x01, followed by *S*, verified strictly (SPEC §7
*Signatures*: `bad_key`, `bad_signature`). The identity is SHA-256 over *S*.
### 4.3 The per-hop record
A per-hop record is read as live-metering §3.6 reads one, of the room its own `room` field names,
with that section's codes (`truncated`, `malformed`, `unknown_version`, `energy_invalid`,
`record_invalid`), and its signature is verified as §3.5 there requires (`bad_key`,
`bad_signature`). Its ledger is read as §3.6 there reads a ledger (`ledger_invalid`), and has
`grants` entries and the root `ledger`: otherwise `ledger_mismatch`. The size bound of
live-metering §3.7 is not applied: it depends on the media tracks the room published, which a fleet
does not hold, and the reference service bounds what one request may carry instead (§9).
Its figure is its `joules` with its class, in scope 2: the hop's own energy over its span, which
the reference relay states as `OnChipCounter` with its evidence where its counter was live across
the span and it served that room alone, and as the unmetered marker otherwise (live-metering §8).
### 4.4 The room descriptor
A per-hop record states its span on its room's clock. The fleet reads, of the room's descriptor
(live-rooms §3.1), only what places that span in time: `magic`, which is `WRD1` (fewer than 4
bytes: `truncated`; another: `malformed`); `version`, which is 1 (`unknown_version`); a length of
at least 141 bytes (`truncated`); `authority`, the 32 bytes after the version; and `t0`, the u64 at
byte 69. Its signature is verified as live-rooms §3.2 requires: Ed25519 by `authority` over the 14
bytes `wai:live-room` (ASCII) and 0x01, followed by every byte but the last 64, strictly (`bad_key`,
`bad_signature`). Its digest is SHA-256 over every byte but the last 64, and is the room id.
A descriptor needs no enrolment. Its digest binds it to the room id a per-hop record names, and the
record's own signature binds the room id to its hop, so the `t0` that places a record is the `t0`
of the room its hop signed for. The descriptor's other fields are the room verifier's to read
(live-rooms §9).
### 4.5 Placement in time
Each summary and each per-hop record has an **instant**:
- a summary's is `closed_unix_ms` × 1000: a session is reported in the period in which it ended;
- a per-hop record's is `t0` + `end` − 1, the last microsecond its span covers, with the `t0` of
the descriptor among the records whose digest is its `room`. A per-hop record whose room has no
such descriptor is refused `descriptor_missing`, and one whose `t0` + `end` − 1 is 2^64 or more
is in no period.
A record is **in** a period when its instant is at or after the period's `start` and before its
`end`; otherwise it is refused `outside_period`. It is **within its enrolment** when its instant
is at or after its signer's `from` and before its `until`: otherwise `outside_enrolment`. So of consecutive periods,
each record is in exactly one.
### 4.6 One thing once
Of two records, the later is refused:
- `record_duplicate` when both have one kind and one identity;
- `record_overlap` when both are summaries of one relay with one `session` and one
`opened_unix_ms` (one session summarised twice), or both are per-hop records of one hop and one
room whose spans share an instant, spans from *s*₁ to *e*₁ and from *s*₂ to *e*₂, each end
excluded, with *s*₁ < *e*₂ and *s*₂ < *e*₁ (live-metering §3.8: a hop's parts of one room session
do not overlap).
## 5. The figures
A report's figures are computed from its records: every summary and every per-hop record, each
in its period and within its enrolment, and the descriptors that place them.
### 5.1 Node lines
There is one **node line** for each node that signed at least one summary or per-hop record, in
ascending order of node key:
| figure | what it is |
|---|---|
| `node`, `role` | the node key, and its enrolment's role |
| `sessions` | its summaries with no `tally.peer`: the sessions it served for clients |
| `links` | its summaries with a `tally.peer`: its hop links to other relays |
| `bytes_in`, `bytes_out` | over its summaries with no `tally.peer`: the sum of `object_bytes_received`; the sum of `object_bytes_forwarded` and the tracks' `fetch_bytes` |
| `link_bytes_in`, `link_bytes_out` | the same, over its summaries with a `tally.peer` |
| `hops` | its per-hop records (u32) |
| `hop_bytes_in`, `hop_bytes_out` | over its per-hop records, the sum of `bytes` of their track entries in (`dir` 1) and out (`dir` 2) |
| energy | its summaries' figures in scope 1, its per-hop records' in scope 2 (§5.4) |
| citations | its summaries and per-hop records (§5.5) |
The bytes of the summaries and the bytes of the per-hop records are two accounts of a relay's
traffic and are never added: a relay's summaries count every object of every session, and its
per-hop records count the objects of each room's tracks, a hop link's at both of its ends
(live-metering §3.10). A compositor or an ingest gateway signs no summaries, and its traffic is in
its per-hop records alone.
### 5.2 Room lines
There is one **room line** for each room a per-hop record names, in ascending order of room id:
| figure | what it is |
|---|---|
| `room`, `t0` | the room id, and its descriptor's `t0` |
| `hops` | its per-hop records (u32) |
| `sessions` | the sum of their `sessions`: subscriber sessions, each counted at the hop that served it (live-metering §3.3) |
| `subscriber_us` | the sum of their `subscriber_us` over the nine layer classes |
| `bytes_in`, `bytes_out` | the sum of `bytes` of their track entries in and out |
| energy | their figures, in scope 2 |
| citations | its per-hop records and its descriptor |
A room's bytes are the bytes its hops handled: an object carried over a hop link is counted out at
one relay and in at the next.
### 5.3 Totals
The **totals** are the sums over the node lines of `sessions`, `links`, `bytes_in`, `bytes_out`,
`link_bytes_in`, `link_bytes_out`, `hops`, `hop_bytes_in` and `hop_bytes_out`; and their energy is
every summary's figure in scope 1 and every per-hop record's in scope 2.
### 5.4 Energy
Each summary states one figure, in scope 1, and each per-hop record one, in scope 2. Over the
records of a line, or of the totals, for each scope:
- **`unmetered`** counts the figures that are the unmetered marker. They are in no row, and a
reader **MUST NOT** present them as zero joules.
- There is one **row** for each tag among the metered figures: `figures`, their number; `joules`,
their sum in microjoules; and `max_ppm`, the largest `relative_ppm` among them for tags 4 and 5,
which carry a declared uncertainty, and 0 for tags 2 and 3, which do not. When each figure's
error is within its declared relative uncertainty, the sum's is within `max_ppm` of the sum.
A row never sums figures of two classes, and no row sums two scopes, as receipt-ledger §6.6
requires of an invoice: a counter reading and a model's output added are neither. A scope's
**coverage** is `whole` when it has a row and no unmetered figure, **`partial`** when it has both,
`unmetered` when it has unmetered figures alone, and `none` when it has no figure. The rows of a
partial scope are the energy of its metered records alone, beside a count of records whose energy
nobody measured, and **MUST NOT** be presented as the energy of the line or of the fleet. A report
whose `unmetered` for a scope is below the unmetered markers of its records has counted a figure
nobody measured as something else, and is refused `unmetered_summed`.
A sum that a u64 cannot hold cannot be stated, and an organisation does not sign a report that
would state one.
### 5.5 Citations
Each line cites the records it came from (§5.1, §5.2): `cited` is their number, and `cites` is
SHA-256 over the 16 bytes `wai:fleet-cites` (ASCII) and 0x01, followed by each cited record's kind
(u8) and identity (32 bytes), in ascending order of (kind, identity). Every cited record is in the
report's `records`, and a line that cites none is refused `line_unsourced`. A reader shows one line
to an auditor with the records it cites and nothing else, and the auditor recomputes it.
## 6. The report
### 6.1 Layout
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WFR1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `organisation` | 32 | the organisation key, which signs the report |
| `start`, `end` | 8 each | u64: the period it reports |
| `records` | 4 + 33 × *r* | a u32 count, then each record: `kind` (u8) and `identity` (32), in strictly ascending order of (`kind`, `identity`) |
| `enrolments` | 2 + 64 × *k* | a u16 count, then each enrolment: `node` (32) and its digest (32), in strictly ascending order of `node` |
| `nodes` | 2 + … | a u16 count, then each node line (§6.2), in strictly ascending order of `node` |
| `rooms` | 2 + … | a u16 count, then each room line (§6.2), in strictly ascending order of `room` |
| `totals` | 68 + … | §6.2 |
| `sig` | 64 | §6.3 |
### 6.2 Lines, totals and the energy block
A **node line** is `node` (32), `role` (u8), `sessions`, `links`, `bytes_in`, `bytes_out`,
`link_bytes_in` and `link_bytes_out` (u64 each), `hops` (u32), `hop_bytes_in` and `hop_bytes_out`
(u64 each), an energy block, `cited` (u32) and `cites` (32).
A **room line** is `room` (32), `t0` (u64), `hops` (u32), `sessions`, `subscriber_us`, `bytes_in`
and `bytes_out` (u64 each), an energy block, `cited` (u32) and `cites` (32).
The **totals** are `sessions`, `links`, `bytes_in`, `bytes_out`, `link_bytes_in` and
`link_bytes_out` (u64 each), `hops` (u32), `hop_bytes_in` and `hop_bytes_out` (u64 each), and an
energy block.
An **energy block** is a u8 count *e*, then *e* rows, each `scope` (u8), `tag` (u8), `figures`
(u32), `joules` (u64, µJ) and `max_ppm` (u32), in strictly ascending order of (`scope`, `tag`);
then `unmetered`, two u32: the unmetered figures of scope 1 and of scope 2.
### 6.3 Digest and signature
The **digest** of a report is SHA-256 over its bytes before `sig`. Its signature is Ed25519 by
`organisation` over the 17 bytes `wai:fleet-report` (ASCII) and 0x01, followed by the bytes the
digest covers, verified strictly (SPEC §7 *Signatures*: `bad_key`, `bad_signature`).
### 6.4 Reading and rules
A reader reads exactly the bytes it is given as one report, as §3.3 reads an enrolment with `WFR1`
for `WFE1` (`truncated`, `malformed`, `unknown_version`), and refuses as `classes_summed` a report
with a row of tag 0: a sum over figures of no one class. It refuses as `report_invalid` one that
breaks any of these:
- `start` is below `end`;
- each list is in the strictly ascending order §6.1 gives it, and each record's `kind` is 2, 10
or 11;
- each node line's `role` is 1 to 6;
- each row's `scope` is 1 or 2, its `tag` 2 to 5, its `figures` above 0, and its `max_ppm` 0 when
its tag is 2 or 3;
- a room line's energy block has no row of scope 1, and 0 unmetered figures of scope 1.
Then the signature (§6.3).
## 7. The bundle and its verification
### 7.1 The bundle
A **bundle** is one JSON object with exactly these members: `kind`, the string
`wai-fleet-bundle`; `report`, the report in hexadecimal; `enrolments`, an array of enrolments in
hexadecimal; and `records`, an array of records in their carriage (§4.1). A bundle that is not such
an object is refused `malformed`, at `bundle`. A file that holds one is named with the suffix
`.wai-fleet.json`.
The report's records are every record of the bundle, and its enrolments are every enrolment of the
bundle: a bundle is a report with exactly what it rests on.
### 7.2 Steps
A verifier given a bundle runs these steps and stops at the first refusal, which it reports with
the reason and where:
1. **The report** (§6.4), at `report`: `truncated`, `malformed`, `unknown_version`,
`classes_summed`, `report_invalid`, `bad_key`, `bad_signature`.
2. **Each enrolment**, in order, at `enrolment` and its index: it reads and its signature verifies
(§3.3: `truncated`, `malformed`, `unknown_version`, `enrolment_invalid`, `bad_key`,
`bad_signature`); its `organisation` is the report's (`wrong_organisation`); its `node` is no
earlier enrolment's (`enrolment_duplicate`); and its span meets the report's period, `from`
below the period's `end` and the period's `start` below `until` (`outside_period`).
3. **Each record**, in order, at `record` and its index: it reads and its signature verifies, by its
kind (§4.1 to §4.4, with their codes).
4. **Each summary and per-hop record**, in order, at `record` and its index: its signer is the
`node` of an enrolment (`signer_not_enrolled`); its kind and its `role` are its signer's role's
(§3.4, `role_mismatch`); it is placed (§4.5, `descriptor_missing`); it is in the report's period
(`outside_period`); and it is within its enrolment (`outside_enrolment`).
5. **Across records**, at `record` and the index of the later (§4.6): `record_duplicate`,
`record_overlap`.
6. **The figures**, recomputed from the bundle as §5 computes them, with the report's `records`
and `enrolments` (§7.1), and compared in this order:
1. a node line whose node signed no summary or per-hop record of the bundle, a room line whose
room no per-hop record of the bundle names, or a line whose `cited` is 0:
`line_unsourced`, at `report`, `nodes` or `rooms`, and the line's index;
2. a row of a line or of the totals whose `joules` is not the sum of the figures of its scope
and class, and is the sum of the figures of its scope over its class and at least one other
class of that scope: `classes_summed`, at the line's place, or at `report` and `totals`;
3. a line or the totals whose `unmetered` for a scope is below the unmetered markers of its
records (§5.4): `unmetered_summed`, at the same places;
4. a line whose `cited` or `cites` is not the records it came from: `citation_mismatch`, at the
line's place;
5. any other difference: the records, the enrolments, a line missing or of another subject or
role, a figure, a row, `unmetered`, or the totals: `report_mismatch`, at `report`.
Steps 6.2 to 6.4 compare each line of the report with the recomputed line of the same node or
room where there is one. A figure that cannot be held in its field is a difference.
### 7.3 Outcome
- **refuse**: the reason and where.
- **accept**: the report's digest; the organisation; the period; the number of records; each
enrolment with its node, role, label, span and digest, the number of records it signed, and
whether it is **silent** (it signed none in the period); each node line with its label and
figures; each room line with its figures; the totals; for each line and for the totals, each
scope's rows by class label with their figures, joules and `max_ppm`, its unmetered figures and
its coverage; and each line's cited records, by kind and identity.
A silent node is not an unmetered one. An unmetered node signed records whose energy nobody
measured; a silent node signed no record in the period, and the report states nothing of what it
did.
## 8. With the receipt ledger
The fleet and the receipt ledger read the same records and compute different things from them,
and neither recomputes the other's:
- A per-hop record is named in a report as receipt-ledger §5.2 names it, kind 2 and its digest, so
a room line's citations and an invoice's `records` name the same records alike.
- A per-hop record is carried with its ledger, in the members a ledger's `room` source holds in
`hops` (receipt-ledger §5.1: each `record` and `ledger`). The records a room line cites are given
to a ledger as they are, and the reference service serves them by room (§9).
- A report states no price, tariff or amount, and an invoice no fleet figure.
## 9. The reference service and its operations
`wai/wai-fleet` runs a fleet as an HTTP service over a directory it owns, under the organisation
key:
| request | does |
|---|---|
| `GET /v1/fleet` | the service, its organisation key, its limits |
| `GET /v1/fleet/health` | `ok` |
| `POST /v1/fleet/enrolments` | `{"enrolment": hex}`: an enrolment the organisation key signed, read and verified (§3), refused for a node already enrolled (`enrolment_duplicate`), kept |
| `GET /v1/fleet/enrolments` | every enrolment held, with its node, role, label, span, digest and the records held of it |
| `POST /v1/fleet/records` | `{"records": [...]}`: each record (§4.1), in order, verified as §7.2 steps 3 to 5 verify it against the enrolments and the records held, with no period, a descriptor held before it or earlier in the request; each held record is kept, and the answer gives each record's kind and identity or its refusal |
| `GET /v1/fleet/rooms/{room}/hops` | the per-hop records held of a room, each `record` and `ledger` (§8) |
| `POST /v1/fleet/reports` | `{"start", "end"}`: the report of the period over the enrolments whose span meets it and the records held in it, with the descriptors those name, signed, verified as §7 verifies it, and kept with its bundle |
| `GET /v1/fleet/reports` | every report kept, with its period |
| `GET /v1/fleet/reports/{digest}` | the report's bundle and its outcome |
| `POST /v1/fleet/verify` | a bundle: its outcome (§7.3) |
Each refusal names its reason and place. The service writes every file whole, appends records and
never rewrites one, holds a lock on its directory so that one process writes it, and on start reads
every enrolment, record and report back and verifies each again. It has no accounts: an enrolment
is accepted only when the organisation key signed it, and a record only when it verifies under an
enrolled node's key, whoever submits either. The service listens on a loopback address unless it
is told otherwise; who may reach it is decided in front of it.
`wai-fleet collect` reads a node's published summary file, a local file or an `http` or `https`
address answering with its lines, and submits each line to a running service as a `summary`
record.
The operations, named by the toolchain's rule (an operation `noun.verb`, its command line the words
in order):
| operation | command line | route | effect |
|---|---|---|---|
| `enrolment.create` | `wai enrolment create <node> --role <role> --key <file> [--label L] [--from µs] [--until µs] [--out F] [--target URL]` | `POST /v1/fleet/enrolments` with `--target` | writes a file; with `--target`, the service |
| `enrolment.list` | `wai enrolment list --target URL` | `GET /v1/fleet/enrolments` | reads |
| `report.create` | `wai report create --target URL --start µs --end µs` | `POST /v1/fleet/reports` | the service |
| `report.export` | `wai report export <digest> --target URL [--out F]` | `GET /v1/fleet/reports/{digest}` | writes a file |
| `receipt.verify` | `wai verify <bundle>` | `POST /v1/fleet/verify` | reads |
Each takes `--json`, and writes its JSON envelope when standard output is not a terminal; each that
writes takes `--dry-run`, which writes nothing and says what it would write; one that changes the
service waits for `--confirm`, and without it exits with the command that confirms.
## 10. Reasons
The codes this extension adds, each with where it is raised:
| reason | raised at |
|---|---|
| `enrolment_invalid` | §3.3, §7.2 step 2 |
| `enrolment_duplicate` | §3.4, §7.2 step 2 |
| `wrong_organisation` | §7.2 step 2 |
| `report_invalid` | §6.4, §7.2 step 1 |
| `signer_not_enrolled` | §7.2 step 4 |
| `role_mismatch` | §3.4, §7.2 step 4 |
| `descriptor_missing` | §4.5, §7.2 step 4 |
| `outside_enrolment` | §4.5, §7.2 step 4 |
| `line_unsourced` | §5.5, §7.2 step 6 |
| `unmetered_summed` | §5.4, §7.2 step 6 |
| `report_mismatch` | §7.2 step 6 |
A verifier of this extension also raises, with the meaning this text gives them: `outside_period`
(§4.5, §7.2 steps 2 and 4), `record_duplicate` and `record_overlap` (§4.6, §7.2 step 5) and
`citation_mismatch` (§5.5, §7.2 step 6), which receipt-ledger §10 adds; `classes_summed` (§5.4,
§6.4, §7.2 steps 1 and 6), `energy_invalid` and `record_invalid` (§4.2, §4.3), `ledger_invalid`
and `ledger_mismatch` (§4.3), which live-metering §9 adds; and `truncated`, `malformed` and
`unknown_version` for what does not read, and `bad_key` and `bad_signature` for its signature.
## 11. Conformance
[`live-conformance/fleet/`](../live-conformance/fleet/) holds the corpus, in one file,
`vectors.json`, written by `wai_fleet_vectors gen` from fixed seeds and from records taken from
[`live-conformance/metering/`](../live-conformance/metering/): the lesson room's descriptor and its
per-hop records, of three classes, with their ledgers; and the relay room's descriptor with the
per-hop record the reference relay signed, which states the unmetered marker. The summaries are
written by the generator in the reference relay's form, and `wai-fleet`'s tests read each one with
the relay's own types and require its bytes, re-serialised, to be the line's. Each vector is a
bundle with the outcome §7.3 gives it:
- **Accepted**: a week of a fleet of two relays cascading, a compositor and an ingest gateway, with
a room service enrolled and silent, whose report states an unmetered scope of sessions, a room
whose hops are of three classes, one row each, and a room whose relay's figure is the unmetered
marker, so that the fleet's hops are partial; a fleet whose every figure is metered, whole; a
fleet of two relays whose every figure is the unmetered marker; and a fleet enrolled and silent,
with no line.
- **Refused**: each reason of §10 at least once, each vector a control of one defect, among them a
report that drops an unmetered figure and one that counts it as a metered figure of zero joules;
and each reason it reuses.
`wai_fleet_vectors verify` and the test `tests/fleet.rs` check that the file regenerates byte for
byte, that `wai-rs` gives every vector its outcome, that every reason of §10 and every reused code
refuses a vector, and that §10 lists exactly the reference's codes. `live-conformance/fleet_verify.py`
is the second implementation, written from this text: it shares no code with `wai-rs`, verifies
signatures with WAI's strict rule written from RFC 8032 (`claim-conformance/_ed25519.py`), reads
per-hop records and their ledgers with the second implementation of live metering
(`live_metering_verify.py`), recomputes every line, row and citation itself, gives every vector the
same outcome, requires every mutated copy of the corpus to fail, and drops each of its own checks
in turn and requires the corpus to catch the weakened verifier.
## 12. Security and privacy considerations
- **Who signs what.** Each node signs its own records, and the organisation signs only who its
nodes are and what their records add up to. An organisation cannot report a session no relay
summarised, a byte no record states, or a figure in a class its node did not sign: a verifier
recomputes every line from the records.
- **What a report rests on.** A report covers the records its bundle holds, and nothing proves a
bundle holds every record a node signed: a node that does not contribute, or an organisation
that leaves a record out, makes a report that is smaller and still verifies. The enrolments the
report lists show which nodes said nothing in the period (silent, §7.3), and a buyer or an
auditor who holds a node's own summary file checks it against the report's citations.
- **Energy.** A row states figures as their nodes signed them, with their class: attributable, and
its sum recomputable, not true (energy-measurement §5). `max_ppm` bounds the declared part of a
sum's uncertainty and says nothing of a class's undeclared systematic error. An unmetered figure
is counted, never summed as zero, so a fleet that meters some of its nodes reports that it does.
- **Keys.** The service holds the organisation key, since it signs reports. An enrolment signed with
that key elsewhere can be held by any copy of the service; one who holds the key can enrol any
node, so it is kept as the ledger's seller key is, readable by its owner alone.
- **What a bundle reveals.** A summary records its session's remote address and every track it
carried, and a bundle holds summaries whole, since their signatures cover every byte. A bundle
therefore reveals the network addresses of the fleet's clients as its relays recorded them, and it
travels only to those the organisation would show its relays' records to. The report itself names
nodes, rooms and record identities, and no client.

View file

@ -0,0 +1,67 @@
# live-conformance/fleet — the fleet
The corpus of [`extensions/fleet.md`](../../extensions/fleet.md) §11, in one file,
`vectors.json`:
- `vectors`: bundles (§7.1), each a `report`, its `enrolments` and its `records` in their
carriage (§4.1), with the `outcome` §7.3 gives it;
- `keys`: the organisation's and the nodes' public keys, and those of another organisation and
of a relay of no fleet, which the controls use.
**Fleet equivalence.** For each vector, a conforming verifier given its bundle reaches the outcome
the vector states:
- `refuse`: the `reason` and where, `at`: `["bundle"]`, `["report"]`, `["enrolment", i]`,
`["record", i]`, `["report", "nodes", j]` or `["report", "rooms", j]` for a line of the report,
or `["report", "totals"]`;
- `accept`: the report's `digest`, its `organisation`, `start` and `end`, the number of
`records`; each enrolment (its `node`, `role`, `label`, `from`, `until`, `digest`, the
`records` it signed and whether it is `silent`); each node line (its `node`, `label`, `role`,
`sessions`, `links`, `bytes_in`, `bytes_out`, `link_bytes_in`, `link_bytes_out`, `hops`,
`hop_bytes_in`, `hop_bytes_out`); each room line (its `room`, `t0`, `hops`, `sessions`,
`subscriber_us`, `bytes_in`, `bytes_out`); the `totals`; for each line and the totals, the
`energy` of each scope, `sessions` and `hops`: its rows (`class`, `figures`, `joules_micro`,
`max_ppm`), its `unmetered` figures and its `coverage`; and each line's `cites`, each
`[kind, identity]`.
The accepted vectors:
- `a_week`: a week of a fleet of two relays cascading (the edge relay's upstream link to the home
relay is one session at each end), a compositor and an ingest gateway, with a room service
enrolled and silent. Every session's figure is the unmetered marker, so the sessions are
`unmetered`; the lesson room's hops are of three classes, one row each, `whole`; the relay
room's only hop is the reference relay's recorded record, the unmetered marker; and the fleet's
hops are `partial`.
- `measured_only`: a day of a relay and a compositor, both OnChipCounter readings: one row of two
figures with the larger declared uncertainty, `whole`.
- `unmetered_only`: a day of two relays with summaries alone: sessions `unmetered`, hops `none`.
- `enrolled_and_silent`: two relays enrolled, no record: no line, both nodes silent.
Every other vector is refused, a control of one defect: each reason of §10 and each code it reuses,
among them a report that drops an unmetered figure, one that states it as a metered figure of zero
joules, and totals that would state partial hops whole.
**Provenance.** Generated here: `wai_fleet_vectors gen` writes `vectors.json`. The per-hop records,
their ledgers and the room descriptors are taken unchanged from [`metering/`](../metering/): the
lesson room's descriptor and the per-hop records of its relay (OnChipCounter), its compositor
(OnChipCounter in `lesson`, ModelBased in `mixed_classes`) and its ingest gateway (Estimator), and
the relay room's descriptor with `relay-record.wmh`, which the reference relay signed in its own
test and which states the unmetered marker. Their joule figures are those that corpus states. The
home relay here is the relay that signed those records, under the same key. The session summaries
are written by the generator in the reference relay's form, `joule-moq-relay/session-summary/2`,
from sessions authored in code: their counts were measured by nothing, their remote addresses are
documentation addresses (RFC 5737), and every relay states the unmetered marker for a session, as
the reference relay does. `wai-fleet`'s tests read every summary of the accepted vectors with the
reference relay's own types, require its text re-serialised by them to be the line's byte for
byte, and verify its signature with the relay's own check. The organisation's and the other keys
are SHA-256 of a fixed label; every time is a constant; Ed25519 signing is deterministic (RFC 8032);
so the file is the same bytes on every machine. When the metering corpus is regenerated, so is
this one.
Regenerate and check:
cd wai/wai-rs
cargo run --no-default-features --features fleet --bin wai_fleet_vectors -- gen ../live-conformance/fleet
cargo run --no-default-features --features fleet --bin wai_fleet_vectors -- verify ../live-conformance/fleet
cargo test --no-default-features --features fleet --test fleet
python3 -I ../live-conformance/fleet_verify.py ../live-conformance/fleet

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,855 @@
#!/usr/bin/env python3
"""Independent verifier of the fleet corpus (extensions/fleet.md).
Written from the extension's text. It shares no code with wai-rs, which generates the corpus,
and never runs it. It checks Ed25519 signatures with ../claim-conformance/_ed25519.py, WAI's
strict rule (SPEC §7 *Signatures*) written from RFC 8032 with the standard library, and reads
per-hop records and their ledgers with live_metering_verify.py, the second implementation of
live metering, written from that text alone (§4.3). Everything else is here:
- the enrolment (§3): reading, rules, signature;
- the records and their carriage (§4.1): the relay's summary line and its text read strictly
(§4.2), the room descriptor as far as the fleet reads it (§4.4);
- placement in time and enrolment (§4.5), and one thing once (§4.6);
- the report (§6): reading, rules, signature;
- every line, row, unmetered count and citation recomputed (§5), compared in §7.2 step 6's
order, and the outcome of §7.3.
For every vector it requires the outcome the corpus states; of the corpus, that every reason of
§10 and every code it reuses appears in it, that §10 names every reason it raises, and that no other
file is there. Then it mutates
copies of the corpus, one defect each, and requires every copy to fail; and it drops each of its
own checks in turn and requires the corpus to catch the weakened verifier.
python3 -I fleet_verify.py <live-conformance/fleet>
python3 -I fleet_verify.py --bundle <bundle.wai-fleet.json>
Exit status 0 only if every vector verifies, every mutated copy fails and every weakened verifier
fails. Python 3.8 or later; standard library only.
"""
import copy
import hashlib
import json
import os
import re
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
import _ed25519 as ED # noqa: E402
import live_metering_verify as LMV # noqa: E402
EXTENSION = os.path.join(HERE, "..", "extensions", "fleet.md")
ENROL_MAGIC, REPORT_MAGIC, DESCRIPTOR_MAGIC = b"WFE1", b"WFR1", b"WRD1" # §3.1, §6.1, §4.4
ENROL_DOMAIN = b"wai:fleet-enrol\x01" # §3.2
REPORT_DOMAIN = b"wai:fleet-report\x01" # §6.3
CITES_DOMAIN = b"wai:fleet-cites\x01" # §5.5
SUMMARY_DOMAIN = b"joule-moq-relay:session-summary\x01" # §4.2
DESCRIPTOR_DOMAIN = b"wai:live-room\x01" # §4.4
SUMMARY_FORMAT = "joule-moq-relay/session-summary/2"
LINE_HEAD, LINE_SIG, LINE_TAIL = b'{"summary":', b',"signature":"', b'"}'
BUNDLE_KIND = "wai-fleet-bundle" # §7.1
HOP, SUMMARY, DESCRIPTOR = 2, 10, 11 # §4.1
ROLES = {1: "relay", 2: "compositor", 3: "ingest", 4: "room", 5: "ledger", 6: "audit"} # §2
LABELS = {2: "ModelBased", 3: "Estimator", 4: "OnChipCounter", 5: "CalibratedInstrument"}
SCOPES = {1: "sessions", 2: "hops"}
U64, U32, U16 = 2 ** 64 - 1, 2 ** 32 - 1, 2 ** 16 - 1
HEX = re.compile(r"\A(?:[0-9a-f]{2})*\Z")
SKIP = set()
class Refuse(Exception):
def __init__(self, reason, at=None):
super().__init__(reason)
self.reason, self.at = reason, at
def sha(*parts):
h = hashlib.sha256()
for p in parts:
h.update(p)
return h.digest()
_VERIFIED = {}
def ed_verify(key, msg, sig):
"""ED.verify, remembered: the corpus is verified many times over (its mutated copies and its
weakened verifiers), and a signature's verdict depends on its bytes alone."""
k = (key, sha(msg), sig)
if k not in _VERIFIED:
_VERIFIED[k] = ED.verify(key, msg, sig)
return _VERIFIED[k]
def check_sig(name, key, domain, body, sig):
"""SPEC §7 *Signatures*: bad_key or bad_signature."""
if name in SKIP:
return
bad = ed_verify(key, domain + body, sig)
if bad:
raise Refuse(bad)
class R:
"""A cursor; running out is `truncated`."""
def __init__(self, b, p=0):
self.b, self.p = b, p
def take(self, n):
if self.p + n > len(self.b):
raise Refuse("truncated")
s = self.b[self.p:self.p + n]
self.p += n
return s
def u8(self):
return self.take(1)[0]
def u16(self):
return struct.unpack("<H", self.take(2))[0]
def u32(self):
return struct.unpack("<I", self.take(4))[0]
def u64(self):
return struct.unpack("<Q", self.take(8))[0]
def read_head(b, magic):
"""live-events §3.3's first two steps, with `magic` and version 1."""
if len(b) < 4:
raise Refuse("truncated")
if b[:4] != magic:
raise Refuse("malformed")
if len(b) < 5:
raise Refuse("truncated")
if b[4] != 1:
raise Refuse("unknown_version")
def hexb(s):
if not isinstance(s, str) or not HEX.match(s):
raise Refuse("malformed")
return bytes.fromhex(s)
# ---------------------------------------------------------------- §3 the enrolment
def read_enrolment(b):
read_head(b, ENROL_MAGIC)
r = R(b, 5)
e = {"organisation": r.take(32), "node": r.take(32), "role": r.u8(), "from": r.u64(), "until": r.u64()}
e["label"] = r.take(r.u8())
sig = r.take(64)
if r.p != len(b):
raise Refuse("malformed")
try:
text = e["label"].decode("utf-8")
label_ok = len(e["label"]) <= 64 and not any(ord(c) < 0x20 or ord(c) == 0x7F for c in text)
except UnicodeDecodeError:
label_ok = False
rules = 1 <= e["role"] <= 6 and e["from"] < e["until"] and label_ok and e["node"] != e["organisation"]
if not rules and "enrolment-rules" not in SKIP:
raise Refuse("enrolment_invalid")
check_sig("enrolment-signature", e["organisation"], ENROL_DOMAIN, b[:-64], sig)
e["digest"] = sha(b[:-64])
return e
# ---------------------------------------------------------------- §4 the records
class Int(int):
"""A number spelled as a plain non-negative decimal integer (§4.2)."""
class NotInt:
"""Any other number."""
def __init__(self, spelling):
self.spelling = spelling
def no_duplicates(pairs):
names = [k for k, _ in pairs]
if len(set(names)) != len(names) and "summary-duplicates" not in SKIP:
raise ValueError("a member named twice")
return dict(pairs)
def no_constant(s):
raise ValueError("not JSON: %s" % s)
def depth_ok(v, d=0):
"""No value inside more than 63 objects and arrays."""
if d >= 64:
return False
if isinstance(v, dict):
return all(isinstance(k, str) for k in v) and all(depth_ok(x, d + 1) for x in v.values())
if isinstance(v, list):
return all(depth_ok(x, d + 1) for x in v)
return True
def surrogate_free(v):
if isinstance(v, str):
return not any(0xD800 <= ord(c) <= 0xDFFF for c in v)
if isinstance(v, dict):
return all(surrogate_free(k) and surrogate_free(x) for k, x in v.items())
if isinstance(v, list):
return all(surrogate_free(x) for x in v)
return True
def strict_json(b):
"""§4.2: one JSON text read strictly, or Refuse("malformed")."""
try:
text = b.decode("utf-8")
v = json.loads(text, object_pairs_hook=no_duplicates, parse_constant=no_constant,
parse_int=lambda s: Int(s) if not s.startswith("-") else NotInt(s), parse_float=NotInt)
except (UnicodeDecodeError, ValueError, RecursionError):
raise Refuse("malformed")
if not depth_ok(v) or not surrogate_free(v):
raise Refuse("malformed")
return v
def integer(o, k):
v = o.get(k) if isinstance(o, dict) else None
if not isinstance(v, Int) or int(v) > U64:
raise Refuse("malformed")
return int(v)
def key_hex(v):
if not isinstance(v, str) or len(v) != 64 or not HEX.match(v):
raise Refuse("malformed")
return bytes.fromhex(v)
def obj(v):
if not isinstance(v, dict):
raise Refuse("malformed")
return v
def split_line(line):
"""§4.2: a summary's text and its signature, from its line in the relay's form."""
b = line.encode("utf-8") if isinstance(line, str) else None
if b is None:
raise Refuse("malformed")
if "summary-form" in SKIP:
b = b.rstrip()
sig_len = len(LINE_SIG) + 128
if not (b.startswith(LINE_HEAD) and b.endswith(LINE_TAIL)) or len(b) < len(LINE_HEAD) + sig_len + len(LINE_TAIL):
raise Refuse("malformed")
tail = b[len(b) - len(LINE_TAIL) - sig_len:len(b) - len(LINE_TAIL)]
if not tail.startswith(LINE_SIG) or not HEX.match(tail[len(LINE_SIG):].decode("latin-1")):
raise Refuse("malformed")
return b[len(LINE_HEAD):len(b) - len(LINE_TAIL) - sig_len], bytes.fromhex(tail[len(LINE_SIG):].decode("ascii"))
def read_summary(line):
"""§4.2: the line, the text, its members in the table's order, then its energy, its rules and
its signature."""
text, sig = split_line(line)
s = obj(strict_json(text))
if not isinstance(s.get("format"), str):
raise Refuse("malformed")
if s["format"] != SUMMARY_FORMAT and "summary-format" not in SKIP:
raise Refuse("unknown_version")
x = {"kind": SUMMARY, "relay": key_hex(s.get("relay_key")), "session": integer(s, "session"),
"opened": integer(s, "opened_unix_ms"), "closed": integer(s, "closed_unix_ms")}
energy = obj(s.get("relay_energy"))
if not isinstance(energy.get("acquisition"), str):
raise Refuse("malformed")
acquisition, joules = energy["acquisition"], integer(energy, "joules_micro")
tally = obj(s.get("tally"))
x["peer"] = key_hex(tally["peer"]) if "peer" in tally else None
x["bytes_in"] = integer(obj(tally.get("publisher")), "object_bytes_received")
sub = obj(tally.get("subscriber"))
forwarded = integer(sub, "object_bytes_forwarded")
fetched = 0
for t in obj(sub.get("tracks")).values():
fetched += integer(t, "fetch_bytes") if "fetch_bytes" in obj(t) else 0
two = len(energy) == 2
if acquisition == "unmetered" and joules == 0 and two:
x["figure"] = None
elif acquisition in ("ModelBased", "Estimator") and joules > 0 and two:
x["figure"] = (2 if acquisition == "ModelBased" else 3, joules, 0)
elif "summary-energy" in SKIP:
x["figure"] = None
else:
raise Refuse("energy_invalid")
x["bytes_out"] = forwarded + fetched
if (x["opened"] > x["closed"] or x["closed"] * 1000 > U64 or x["bytes_out"] > U64) and "summary-rules" not in SKIP:
raise Refuse("record_invalid")
check_sig("summary-signature", x["relay"], SUMMARY_DOMAIN, text, sig)
x["id"] = sha(text)
x["signer"] = x["relay"]
return x
_HOPS = {}
def lmv_read(rec, led):
"""live_metering_verify.py's reading of a record and its ledger, remembered by their bytes: the
reason it refuses them, or what it reads."""
if (rec, led) not in _HOPS:
try:
_HOPS[rec, led] = (None, LMV.read_hop(rec, rec[5:37]), LMV.read_ledger(led))
except LMV.Refuse as e:
_HOPS[rec, led] = (e.reason, None, None)
return _HOPS[rec, led]
def read_hop(rec, led):
"""§4.3: live metering's reading of the record and its ledger, and the ledger's binding."""
reason, h, entries = lmv_read(rec, led)
if reason:
raise Refuse(reason)
if (len(entries) != h["grants"] or LMV.ledger_root(entries) != h["ledger"]) and "ledger-binding" not in SKIP:
raise Refuse("ledger_mismatch")
cls = h["class"]
figure = None if cls is None else (cls[0], h["joules"], cls[1] if cls[0] in (4, 5) else 0)
return {"kind": HOP, "id": h["digest"], "signer": h["hop"], "room": h["room"], "role": h["role"], "start": h["start"],
"end": h["end"], "figure": figure, "sessions": h["sessions"], "sub": sum(h["sub"]),
"bytes_in": sum(e["bytes"] for e in h["tracks"] if e["dir"] == 1),
"bytes_out": sum(e["bytes"] for e in h["tracks"] if e["dir"] == 2)}
def read_descriptor(b):
"""§4.4: magic, version, authority, t0 and the signature; the digest is the room id."""
read_head(b, DESCRIPTOR_MAGIC)
if len(b) < 77 + 64:
raise Refuse("truncated")
check_sig("descriptor-signature", b[5:37], DESCRIPTOR_DOMAIN, b[:-64], b[-64:])
return {"kind": DESCRIPTOR, "id": sha(b[:-64]), "t0": struct.unpack("<Q", b[69:77])[0], "signer": None}
def read_record(c):
"""§4.1: a carriage, read as its kind."""
if not isinstance(c, dict):
raise Refuse("malformed")
k = c.get("kind")
if k == "hop" and set(c) == {"kind", "record", "ledger"}:
rec, led = hexb(c["record"]), hexb(c["ledger"])
return read_hop(rec, led)
if k == "summary" and set(c) == {"kind", "line"}:
return read_summary(c["line"])
if k == "descriptor" and set(c) == {"kind", "descriptor"}:
return read_descriptor(hexb(c["descriptor"]))
raise Refuse("malformed")
def instant(x, t0s):
"""§4.5: a summary's or a per-hop record's instant; None for one in no period."""
if x["kind"] == SUMMARY:
return x["closed"] * 1000
if x["room"] not in t0s:
if "descriptor" in SKIP:
return x["end"] - 1
raise Refuse("descriptor_missing")
t = t0s[x["room"]] + x["end"] - 1
return t if t <= U64 else None
def place(x, enrolments, t0s, period):
"""§7.2 step 4."""
e = next((e for e in enrolments if e["node"] == x["signer"]), None)
if e is None:
if "enrolled" in SKIP:
return
raise Refuse("signer_not_enrolled")
admits = e["role"] == 1 if x["kind"] == SUMMARY else (1 <= e["role"] <= 3 and x["role"] == e["role"])
if not admits and "role" not in SKIP:
raise Refuse("role_mismatch")
t = instant(x, t0s)
if not (t is not None and period[0] <= t < period[1]) and "period" not in SKIP:
raise Refuse("outside_period")
if not (t is not None and e["from"] <= t < e["until"]) and "enrolment-window" not in SKIP:
raise Refuse("outside_enrolment")
def once(records):
"""§4.6: the index of the first record that repeats one before it, and why."""
heads, sessions, spans = set(), set(), {}
for i, x in enumerate(records):
head = (x["kind"], x["id"])
if head in heads and "duplicate" not in SKIP:
raise Refuse("record_duplicate", ["record", i])
if "overlap" not in SKIP:
if x["kind"] == SUMMARY and (x["relay"], x["session"], x["opened"]) in sessions:
raise Refuse("record_overlap", ["record", i])
if x["kind"] == HOP and any(s < x["end"] and x["start"] < e for s, e in spans.get((x["signer"], x["room"]), [])):
raise Refuse("record_overlap", ["record", i])
heads.add(head)
if x["kind"] == SUMMARY:
sessions.add((x["relay"], x["session"], x["opened"]))
if x["kind"] == HOP:
spans.setdefault((x["signer"], x["room"]), []).append((x["start"], x["end"]))
# ---------------------------------------------------------------- §6 the report
def read_block(r):
rows = [{"scope": r.u8(), "tag": r.u8(), "figures": r.u32(), "joules": r.u64(), "max_ppm": r.u32()} for _ in range(r.u8())]
return {"rows": rows, "unmetered": [r.u32(), r.u32()]}
def block_ok(bl, room):
keys = [(x["scope"], x["tag"]) for x in bl["rows"]]
return (all(a < b for a, b in zip(keys, keys[1:]))
and all(x["scope"] in (1, 2) and 2 <= x["tag"] <= 5 and x["figures"] > 0 and (x["tag"] >= 4 or x["max_ppm"] == 0)
for x in bl["rows"])
and (not room or (all(x["scope"] != 1 for x in bl["rows"]) and bl["unmetered"][0] == 0)))
def read_report(b):
"""§6.4: the report, read, its rules, its signature."""
read_head(b, REPORT_MAGIC)
r = R(b, 5)
rep = {"organisation": r.take(32), "start": r.u64(), "end": r.u64()}
rep["records"] = [(r.u8(), r.take(32)) for _ in range(r.u32())]
rep["enrolments"] = [(r.take(32), r.take(32)) for _ in range(r.u16())]
nodes = []
for _ in range(r.u16()):
l = {"node": r.take(32), "role": r.u8()}
for k in ("sessions", "links", "bytes_in", "bytes_out", "link_bytes_in", "link_bytes_out"):
l[k] = r.u64()
l["hops"], l["hop_bytes_in"], l["hop_bytes_out"] = r.u32(), r.u64(), r.u64()
l["energy"], l["cited"], l["cites"] = read_block(r), r.u32(), r.take(32)
nodes.append(l)
rooms = []
for _ in range(r.u16()):
l = {"room": r.take(32), "t0": r.u64(), "hops": r.u32()}
for k in ("sessions", "subscriber_us", "bytes_in", "bytes_out"):
l[k] = r.u64()
l["energy"], l["cited"], l["cites"] = read_block(r), r.u32(), r.take(32)
rooms.append(l)
t = {}
for k in ("sessions", "links", "bytes_in", "bytes_out", "link_bytes_in", "link_bytes_out"):
t[k] = r.u64()
t["hops"], t["hop_bytes_in"], t["hop_bytes_out"] = r.u32(), r.u64(), r.u64()
t["energy"] = read_block(r)
sig = r.take(64)
if r.p != len(b):
raise Refuse("malformed")
rep["nodes"], rep["rooms"], rep["totals"] = nodes, rooms, t
blocks = [l["energy"] for l in nodes + rooms] + [t["energy"]]
if any(x["tag"] == 0 for bl in blocks for x in bl["rows"]) and "row-of-no-class" not in SKIP:
raise Refuse("classes_summed")
asc = lambda xs: all(a < b for a, b in zip(xs, xs[1:])) # noqa: E731
rules = (rep["start"] < rep["end"] and asc(rep["records"]) and all(k in (HOP, SUMMARY, DESCRIPTOR) for k, _ in rep["records"])
and asc([n for n, _ in rep["enrolments"]]) and asc([l["node"] for l in nodes])
and all(1 <= l["role"] <= 6 and block_ok(l["energy"], False) for l in nodes)
and asc([l["room"] for l in rooms]) and all(block_ok(l["energy"], True) for l in rooms)
and block_ok(t["energy"], False))
if not rules and "report-rules" not in SKIP:
raise Refuse("report_invalid")
check_sig("report-signature", rep["organisation"], REPORT_DOMAIN, b[:-64], sig)
rep["digest"] = sha(b[:-64])
return rep
# ---------------------------------------------------------------- §5 the figures
class Line:
"""A line's sums, before they are stated."""
def __init__(self):
self.n = [0] * 9
self.rows = {} # (scope, tag) -> [figures, joules, max_ppm]
self.unmetered = [0, 0]
self.cites = set()
self.role, self.t0 = 0, 0
def add(self, scope, figure):
if figure is None:
self.unmetered[scope - 1] += 1
return
tag, joules, ppm = figure
row = self.rows.setdefault((scope, tag), [0, 0, 0])
row[0] += 1
row[1] += joules
if tag >= 4:
row[2] = max(row[2], ppm)
def block(self):
rows = [{"scope": s, "tag": t, "figures": v[0], "joules": v[1], "max_ppm": v[2]} for (s, t), v in sorted(self.rows.items())]
if len(rows) > 255 or any(x["figures"] > U32 or x["joules"] > U64 for x in rows) or max(self.unmetered) > U32:
return None
return {"rows": rows, "unmetered": list(self.unmetered)}
def class_sums(self, scope):
return {t: v[1] for (s, t), v in self.rows.items() if s == scope}
def cites_digest(c):
return sha(CITES_DOMAIN, b"".join(bytes([k]) + i for k, i in sorted(c)))
def compute(enrolments, records):
"""§5: every line and the totals, from the records."""
roles = {e["node"]: e["role"] for e in enrolments}
t0s = {x["id"]: x["t0"] for x in records if x["kind"] == DESCRIPTOR}
nodes, rooms, total = {}, {}, Line()
for x in records:
if x["kind"] == SUMMARY:
l = nodes.setdefault(x["relay"], Line())
count, b_in, b_out = (1, 4, 5) if x["peer"] is not None else (0, 2, 3)
for y in (l, total):
y.n[count] += 1
y.n[b_in] += x["bytes_in"]
y.n[b_out] += x["bytes_out"]
l.add(1, x["figure"])
total.add(1, x["figure"])
l.cites.add((SUMMARY, x["id"]))
elif x["kind"] == HOP:
l = nodes.setdefault(x["signer"], Line())
for y in (l, total):
y.n[6] += 1
y.n[7] += x["bytes_in"]
y.n[8] += x["bytes_out"]
l.add(2, x["figure"])
total.add(2, x["figure"])
l.cites.add((HOP, x["id"]))
room = rooms.setdefault(x["room"], Line())
room.t0 = t0s.get(x["room"], 0)
for k, v in enumerate((1, x["sessions"], x["sub"], x["bytes_in"], x["bytes_out"])):
room.n[k] += v
room.add(2, x["figure"])
room.cites |= {(HOP, x["id"]), (DESCRIPTOR, x["room"])}
for node, l in nodes.items():
l.role = roles.get(node, 0)
return {"records": sorted((x["kind"], x["id"]) for x in records), "enrolments": sorted((e["node"], e["digest"]) for e in enrolments),
"nodes": nodes, "rooms": rooms, "total": total}
def stated(c):
"""The lines and totals as a report states them, or None for a figure no field holds."""
def fits(vals, top):
return all(v <= top for v in vals)
nodes = []
for node in sorted(c["nodes"]):
x = c["nodes"][node]
bl = x.block()
if bl is None or not fits(x.n[:6] + x.n[7:9], U64) or x.n[6] > U32 or len(x.cites) > U32:
return None
nodes.append({"node": node, "role": x.role, "sessions": x.n[0], "links": x.n[1], "bytes_in": x.n[2], "bytes_out": x.n[3],
"link_bytes_in": x.n[4], "link_bytes_out": x.n[5], "hops": x.n[6], "hop_bytes_in": x.n[7],
"hop_bytes_out": x.n[8], "energy": bl, "cited": len(x.cites), "cites": cites_digest(x.cites)})
rooms = []
for room in sorted(c["rooms"]):
x = c["rooms"][room]
bl = x.block()
if bl is None or not fits(x.n[1:5], U64) or x.n[0] > U32 or len(x.cites) > U32:
return None
rooms.append({"room": room, "t0": x.t0, "hops": x.n[0], "sessions": x.n[1], "subscriber_us": x.n[2], "bytes_in": x.n[3],
"bytes_out": x.n[4], "energy": bl, "cited": len(x.cites), "cites": cites_digest(x.cites)})
t = c["total"]
bl = t.block()
if bl is None or not fits(t.n[:6] + t.n[7:9], U64) or t.n[6] > U32:
return None
totals = {"sessions": t.n[0], "links": t.n[1], "bytes_in": t.n[2], "bytes_out": t.n[3], "link_bytes_in": t.n[4],
"link_bytes_out": t.n[5], "hops": t.n[6], "hop_bytes_in": t.n[7], "hop_bytes_out": t.n[8], "energy": bl}
return nodes, rooms, totals
def summed_across(row, sums):
"""§7.2 step 6.2: a row whose joules is its class's with at least one other class's of its scope."""
own = sums.get(row["tag"], 0)
if row["joules"] == own:
return False
others = [j for t, j in sums.items() if t != row["tag"]]
for m in range(1, 1 << len(others)):
if own + sum(j for i, j in enumerate(others) if m >> i & 1) == row["joules"]:
return True
return False
def compare(rep, c):
"""§7.2 step 6, in its order."""
for j, l in enumerate(rep["nodes"]):
if (l["node"] not in c["nodes"] or l["cited"] == 0) and "unsourced" not in SKIP:
raise Refuse("line_unsourced", ["report", "nodes", j])
for j, l in enumerate(rep["rooms"]):
if (l["room"] not in c["rooms"] or l["cited"] == 0) and "unsourced" not in SKIP:
raise Refuse("line_unsourced", ["report", "rooms", j])
blocks = [(l["energy"], c["nodes"].get(l["node"]), ["report", "nodes", j]) for j, l in enumerate(rep["nodes"])]
blocks += [(l["energy"], c["rooms"].get(l["room"]), ["report", "rooms", j]) for j, l in enumerate(rep["rooms"])]
blocks += [(rep["totals"]["energy"], c["total"], ["report", "totals"])]
blocks = [b for b in blocks if b[1] is not None]
if "summed" not in SKIP:
for bl, x, at in blocks:
if any(summed_across(row, x.class_sums(row["scope"])) for row in bl["rows"]):
raise Refuse("classes_summed", at)
if "unmetered" not in SKIP:
for bl, x, at in blocks:
if any(bl["unmetered"][s] < x.unmetered[s] for s in (0, 1)):
raise Refuse("unmetered_summed", at)
if "citations" not in SKIP:
for which, key in (("nodes", "node"), ("rooms", "room")):
for j, l in enumerate(rep[which]):
x = c[which].get(l[key])
if x is not None and (l["cited"] != len(x.cites) or l["cites"] != cites_digest(x.cites)):
raise Refuse("citation_mismatch", ["report", which, j])
s = stated(c)
same = s is not None and rep["records"] == c["records"] and rep["enrolments"] == c["enrolments"] \
and rep["nodes"] == s[0] and rep["rooms"] == s[1] and rep["totals"] == s[2]
if not same and "mismatch" not in SKIP:
raise Refuse("report_mismatch", ["report"])
# ---------------------------------------------------------------- §7 the bundle
def energy_json(bl):
out = {}
for s, name in SCOPES.items():
rows = [{"class": LABELS[x["tag"]], "figures": x["figures"], "joules_micro": x["joules"], "max_ppm": x["max_ppm"]}
for x in bl["rows"] if x["scope"] == s]
un = bl["unmetered"][s - 1]
cov = {(True, False): "whole", (True, True): "partial", (False, True): "unmetered", (False, False): "none"}[(bool(rows), un > 0)]
out[name] = {"rows": rows, "unmetered": un, "coverage": cov}
return out
def cites_json(c):
return [[k, i.hex()] for k, i in sorted(c)]
def outcome(rep, enrolments, records, c):
labels = {e["node"]: e["label"].decode("utf-8") for e in enrolments}
signed = {}
for x in records:
if x["signer"] is not None:
signed[x["signer"]] = signed.get(x["signer"], 0) + 1
t = rep["totals"]
return {
"outcome": "accept", "digest": rep["digest"].hex(), "organisation": rep["organisation"].hex(),
"start": rep["start"], "end": rep["end"], "records": len(rep["records"]),
"enrolments": [{"node": e["node"].hex(), "role": ROLES[e["role"]], "label": labels[e["node"]], "from": e["from"],
"until": e["until"], "digest": e["digest"].hex(), "records": signed.get(e["node"], 0),
"silent": signed.get(e["node"], 0) == 0} for e in sorted(enrolments, key=lambda e: e["node"])],
"nodes": [{"node": l["node"].hex(), "label": labels.get(l["node"]), "role": ROLES.get(l["role"]),
**{k: l[k] for k in ("sessions", "links", "bytes_in", "bytes_out", "link_bytes_in", "link_bytes_out", "hops",
"hop_bytes_in", "hop_bytes_out")},
"energy": energy_json(l["energy"]), "cites": cites_json(c["nodes"][l["node"]].cites)} for l in rep["nodes"]],
"rooms": [{"room": l["room"].hex(), **{k: l[k] for k in ("t0", "hops", "sessions", "subscriber_us", "bytes_in", "bytes_out")},
"energy": energy_json(l["energy"]), "cites": cites_json(c["rooms"][l["room"]].cites)} for l in rep["rooms"]],
"totals": {**{k: t[k] for k in ("sessions", "links", "bytes_in", "bytes_out", "link_bytes_in", "link_bytes_out", "hops",
"hop_bytes_in", "hop_bytes_out")}, "energy": energy_json(t["energy"])},
}
def verify(bundle):
"""§7.2, stopping at the first refusal; the outcome of §7.3."""
try:
return verify_steps(bundle)
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason, "at": e.at}
def at(place):
"""Run a step's reading, its refusal placed."""
class Ctx:
def __enter__(self):
return self
def __exit__(self, kind, e, tb):
if isinstance(e, Refuse) and e.at is None:
e.at = place
return False
return Ctx()
def verify_steps(b):
with at(["bundle"]):
if not isinstance(b, dict) or set(b) != {"kind", "report", "enrolments", "records"} or b["kind"] != BUNDLE_KIND:
raise Refuse("malformed")
rb = hexb(b["report"])
if not isinstance(b["enrolments"], list) or not isinstance(b["records"], list):
raise Refuse("malformed")
eb = [hexb(e) for e in b["enrolments"]]
# 1. The report.
with at(["report"]):
rep = read_report(rb)
period = (rep["start"], rep["end"])
# 2. Each enrolment.
enrolments = []
for i, e in enumerate(eb):
with at(["enrolment", i]):
e = read_enrolment(e)
if e["organisation"] != rep["organisation"] and "organisation" not in SKIP:
raise Refuse("wrong_organisation")
if any(x["node"] == e["node"] for x in enrolments) and "enrolment-duplicate" not in SKIP:
raise Refuse("enrolment_duplicate")
if not (e["from"] < rep["end"] and rep["start"] < e["until"]) and "enrolment-period" not in SKIP:
raise Refuse("outside_period")
enrolments.append(e)
# 3. Each record reads and verifies.
records = []
for i, c in enumerate(b["records"]):
with at(["record", i]):
records.append(read_record(c))
# 4. Each summary and per-hop record is enrolled, of its role, placed, in the period and within
# its enrolment.
t0s = {x["id"]: x["t0"] for x in records if x["kind"] == DESCRIPTOR}
for i, x in enumerate(records):
if x["signer"] is not None:
with at(["record", i]):
place(x, enrolments, t0s, period)
# 5. Across records.
once(records)
# 6. The figures.
c = compute(enrolments, records)
compare(rep, c)
return outcome(rep, enrolments, records, c)
# ---------------------------------------------------------------- the corpus
def check(doc, quiet=False):
"""The failures: every vector whose stated outcome is not the one this verifier reaches."""
fails = []
for v in doc.get("vectors", []):
try:
got = verify(v["bundle"])
except Exception as e: # a crash on a mutated copy is a failure to verify it
got = {"outcome": "crash", "error": "%s: %s" % (type(e).__name__, e)}
ok = got == v.get("outcome")
if not quiet:
what = got["outcome"] + (" %s at %s" % (got["reason"], got["at"]) if got["outcome"] == "refuse" else "")
print(("PASS" if ok else "FAIL") + " %s: %s" % (v.get("name"), what))
if not ok:
fails.append(v.get("name"))
return fails
def reasons_listed():
"""§10's table, in order, and the codes its paragraph names as reused."""
text = open(EXTENSION, encoding="utf-8").read()
start = text.index("\n## 10. Reasons")
section = text[start:text.find("\n## ", start + 1)]
added = [m.group(1) for m in re.finditer(r"^\| `([a-z_]+)` \|", section, re.M)]
reused = [r for r in re.findall(r"`([a-z_]+)`", section.split("also raises", 1)[1]) if r not in added]
return added, reused
def flip_hex(h, at):
b = bytearray(bytes.fromhex(h))
b[at % len(b)] ^= 0x01
return b.hex()
def mutations(doc):
"""Copies of the corpus, one defect each, that a verifier must reject."""
out = []
def copy_with(name, k, edit):
m = copy.deepcopy(doc)
edit(m["vectors"][k])
out.append(("%s: %s" % (doc["vectors"][k]["name"], name), m))
for k, v in enumerate(doc["vectors"]):
if v["outcome"]["outcome"] != "accept":
continue
b = v["bundle"]
for at_ in (6, 50, -100, -1):
copy_with("report byte %d flipped" % at_, k, lambda x, a=at_: x["bundle"].__setitem__("report", flip_hex(b["report"], a)))
if b["enrolments"]:
copy_with("an enrolment's byte flipped", k, lambda x: x["bundle"]["enrolments"].__setitem__(0, flip_hex(b["enrolments"][0], 70)))
copy_with("its last enrolment dropped", k, lambda x: x["bundle"]["enrolments"].pop())
for i, r in enumerate(b["records"]):
if r["kind"] == "summary":
copy_with("record %d's line changed by a digit" % i, k,
lambda x, i=i: x["bundle"]["records"][i].__setitem__("line", re.sub(r'"session":(\d)', lambda m: '"session":%d' % ((int(m.group(1)) + 1) % 10), b["records"][i]["line"], count=1)))
elif r["kind"] == "hop":
copy_with("record %d's byte flipped" % i, k, lambda x, i=i: x["bundle"]["records"][i].__setitem__("record", flip_hex(b["records"][i]["record"], 120)))
if b["records"]:
copy_with("its last record dropped", k, lambda x: x["bundle"]["records"].pop())
copy_with("its first record given twice", k, lambda x: x["bundle"]["records"].append(b["records"][0]))
o = v["outcome"]
copy_with("its stated totals' bytes in raised", k, lambda x: x["outcome"]["totals"].__setitem__("bytes_in", o["totals"]["bytes_in"] + 1))
copy_with("its stated hops' coverage changed", k, lambda x: x["outcome"]["totals"]["energy"]["hops"].__setitem__(
"coverage", "whole" if o["totals"]["energy"]["hops"]["coverage"] != "whole" else "partial"))
copy_with("its stated sessions' unmetered figures set to zero", k, lambda x: x["outcome"]["totals"]["energy"]["sessions"].__setitem__(
"unmetered", 0 if o["totals"]["energy"]["sessions"]["unmetered"] else 1))
if o["enrolments"]:
copy_with("an enrolment stated silent the other way", k, lambda x: x["outcome"]["enrolments"][0].__setitem__("silent", not o["enrolments"][0]["silent"]))
if o["nodes"]:
copy_with("its first node line's citations dropped", k, lambda x: x["outcome"]["nodes"][0].__setitem__("cites", []))
for k, v in enumerate(doc["vectors"]):
if v["outcome"]["outcome"] == "refuse":
r = v["outcome"]["reason"]
copy_with("its stated reason changed", k, lambda x, r=r: x["outcome"].__setitem__("reason", "report_mismatch" if r != "report_mismatch" else "malformed"))
copy_with("its stated place changed", k, lambda x: x["outcome"].__setitem__("at", ["bundle"] if x["outcome"]["at"] != ["bundle"] else ["report"]))
return out
CHECKS = ["report-signature", "enrolment-signature", "summary-signature", "descriptor-signature", "report-rules", "row-of-no-class",
"enrolment-rules", "organisation", "enrolment-duplicate", "enrolment-period", "summary-form", "summary-duplicates",
"summary-format", "summary-energy", "summary-rules", "ledger-binding", "enrolled", "role", "descriptor", "period",
"enrolment-window", "duplicate", "overlap", "unsourced", "summed", "unmetered", "citations", "mismatch"]
def main(corpus_dir):
doc = json.load(open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8"))
fails = check(doc)
files = sorted(os.listdir(corpus_dir))
if files != ["README.md", "vectors.json"]:
fails.append("the corpus holds other files: %s" % files)
seen = {v["outcome"].get("reason") for v in doc["vectors"]}
added, reused = reasons_listed()
missing = [r for r in added + reused if r not in seen]
if missing:
fails.append("no vector is refused %s" % missing)
print("%s every reason of §10 and every code it reuses refuses a vector (%d added, %d reused)"
% ("PASS" if not missing else "FAIL", len(added), len(reused)))
unlisted = sorted(r for r in seen if r is not None and r not in added + reused)
if unlisted:
fails.append("§10 does not name %s, which the corpus raises" % unlisted)
print("%s §10 names every reason the corpus raises" % ("PASS" if not unlisted else "FAIL"))
copies = mutations(doc)
weak = [name for name, m in copies if not check(m, quiet=True)]
print("%s every mutated copy of the corpus fails (%d copies)" % ("PASS" if not weak else "FAIL", len(copies)))
fails += ["the mutated copy %r verifies" % w for w in weak]
undetected = []
for c in CHECKS:
SKIP.add(c)
try:
if not check(doc, quiet=True):
undetected.append(c)
finally:
SKIP.discard(c)
print("%s the corpus catches the verifier with each of its checks dropped (%d checks)" % ("PASS" if not undetected else "FAIL", len(CHECKS)))
fails += ["dropping the check %r goes unnoticed" % c for c in undetected]
for f in fails:
print("FAIL", f)
return 1 if fails else 0
def check_bundle(path):
"""One bundle, such as a fleet service exports with a report: its outcome, printed. Status 0
only when it is accepted."""
got = verify(json.load(open(path, encoding="utf-8")))
print(json.dumps(got, indent=1))
return 0 if got["outcome"] == "accept" else 1
if __name__ == "__main__":
if len(sys.argv) == 3 and sys.argv[1] == "--bundle":
sys.exit(check_bundle(sys.argv[2]))
if len(sys.argv) != 2:
print("usage: fleet_verify.py <live-conformance/fleet> | --bundle FILE", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))

View file

@ -376,6 +376,20 @@
"edition": null,
"registered": "2026-10-25"
},
{
"capability": "wai.delivery.fleet",
"kind": "record",
"status": "registered",
"media": null,
"role": null,
"tier": null,
"licence": null,
"replicate": null,
"sets": [],
"spec_section": "§5 Interactive, temporal & computed content",
"edition": null,
"registered": "2026-10-27"
},
{
"capability": "wai.delivery.ledger",
"kind": "record",
@ -2485,6 +2499,13 @@
"section": "§5.2",
"status": "defined"
},
{
"name": ".wai-fleet.json",
"kind": "path",
"document": "extensions/fleet.md",
"section": "§7.1",
"status": "defined"
},
{
"name": ".wai.hp",
"kind": "path",
@ -2821,6 +2842,20 @@
"section": "§3.1",
"status": "defined"
},
{
"name": "WFE1",
"kind": "magic",
"document": "extensions/fleet.md",
"section": "§3.1",
"status": "defined"
},
{
"name": "WFR1",
"kind": "magic",
"document": "extensions/fleet.md",
"section": "§6.1",
"status": "defined"
},
{
"name": "WGM1",
"kind": "magic",
@ -3661,6 +3696,13 @@
"section": "§6.3",
"status": "defined"
},
{
"name": "wai-fleet-bundle",
"kind": "document-kind",
"document": "extensions/fleet.md",
"section": "§7.1",
"status": "defined"
},
{
"name": "wai-influence-report",
"kind": "document-kind",

View file

@ -359,6 +359,13 @@ wai:wai.delivery.audit a wai:Capability ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-25"^^xsd:date .
wai:wai.delivery.fleet a wai:Capability ;
rdfs:label "wai.delivery.fleet" ;
wai:kind wai:kind-record ;
wai:status wai:status-registered ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-27"^^xsd:date .
wai:wai.delivery.ledger a wai:Capability ;
rdfs:label "wai.delivery.ledger" ;
wai:kind wai:kind-record ;

2958
wai/wai-fleet/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

41
wai/wai-fleet/Cargo.toml Normal file
View file

@ -0,0 +1,41 @@
[package]
name = "wai-fleet"
version = "0.1.0"
edition = "2024"
rust-version = "1.97"
license = "Apache-2.0"
description = "A WAI fleet as a service: an organisation's nodes enrolled by their signing keys, the session summaries and per-hop records they sign verified and kept append-only, and the fleet report the organisation signs for a period, exported as a bundle that verifies offline."
repository = "https://git.transaction.science/Transaction-Science/open-standards"
publish = false
# Its own workspace and Cargo.lock, like the other crates beside wai-rs.
[workspace]
# The relay's wtransport, vendored with its WebTransport session close, as the relay's own
# workspace patches it (joule-code/joule-code-rs/vendor/wtransport/VENDORED.md): the relay is a
# dev-dependency here, for its own types in the tests.
[patch.crates-io]
wtransport = { path = "../../joule-code/joule-code-rs/vendor/wtransport" }
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "signal", "io-util", "time"] }
serde_json = { version = "1", features = ["preserve_order"] }
ed25519-dalek = "2"
getrandom = "0.4"
# `wai-fleet collect` reads a node's published summary file over https: rustls with ring, as the
# relay's own upstream does, and the platform's roots.
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
rustls-native-certs = "0.8"
wai = { path = "../wai-rs", default-features = false, features = ["fleet"] }
[profile.release]
lto = "thin"
[dev-dependencies]
sha2 = "0.10"
# The reference relay, for its own types: the corpus's summaries read and re-serialised by them,
# and summaries and per-hop records signed by its own code, collected from a file it would write.
joule-moq-relay = { path = "../../joule-code/joule-code-rs/crates/joule-moq-relay" }
hex = "0.4"

100
wai/wai-fleet/README.md Normal file
View file

@ -0,0 +1,100 @@
# wai-fleet
A WAI fleet ([`extensions/fleet.md`](../extensions/fleet.md)) as a service. An organisation enrols
the nodes it runs by their signing keys: relays, compositors, ingest gateways, room services,
ledgers and audit collectors. Each node contributes the records it already signs, as it signs
them: a relay's session summaries, and every hop's per-hop records with their ledgers, with the
room descriptors that place those in time. The service verifies each record against its node's
enrolled key, keeps it append-only, and for a period signs the **fleet report** with the
organisation key. The report has one line per node and one per room, with sessions, bytes and
energy, and each line cites the records it came from. Energy keeps its acquisition class: one row
per scope and class, never summed across either. An unmetered figure is counted as unmetered,
never as zero joules, and a total over measured and unmetered figures says it is partial.
Every report is kept with its **bundle**: the report, its enrolments and its records. Any verifier
of the extension checks a bundle without this service, the corpus's second implementation among
them:
```sh
python3 -I ../live-conformance/fleet_verify.py --bundle week.wai-fleet.json
wai verify week.wai-fleet.json
```
Accounts, members, sign-on, quotas and billing are not here.
## Running it
```sh
cargo build --locked --release
WAI_FLEET_DIR=/path/to/fleet WAI_FLEET_KEY=/path/to/organisation.key WAI_FLEET_ADDR=127.0.0.1:8252 \
./target/release/wai-fleet serve
```
| variable | meaning |
|---|---|
| `WAI_FLEET_DIR` | the directory the service owns (required); created when absent |
| `WAI_FLEET_KEY` | the organisation key's file: 32 bytes, the Ed25519 seed; made with mode 0600 when absent |
| `WAI_FLEET_ADDR` | the address it listens on (default `127.0.0.1:8252`) |
`wai-fleet key` prints the organisation key of `WAI_FLEET_KEY`. `wai-fleet verify FILE` prints a
bundle's outcome. Enrolments are signed with the organisation key, by `wai enrolment create`, and
given to the service; the service signs none itself.
`wai-fleet collect URL SOURCE` reads a node's summary file and gives each line to the service at
`URL` as a summary record: `SOURCE` is a path (a relay's `summary.out_path`) or an `http` or
`https` address that answers with the file. It prints how many lines were held and how many were
refused, by reason. Collecting the same file again holds nothing twice: a line held already is
refused `record_duplicate`.
The directory:
```text
LOCK held exclusively while a process serves the directory
enrolments.jsonl {"enrolment": hex}, one a line, in the order admitted
records.jsonl each record's carriage, one a line, in the order admitted
reports/<digest>.wfr a report
reports/<digest>.wai-fleet.json its bundle
```
The two logs are append-only: a line is written whole and the log synced before the request that
admitted it is answered, and a line a crash cut short, never answered, is cut off on start. Reports
are written whole (to a temporary name, synced, renamed into place). On start the service takes the
lock (a second process on the same directory is refused), then reads every enrolment, record and
report back and verifies each again, and refuses to start on any that does not.
## Endpoints
| | |
|---|---|
| `GET /v1/fleet` | the service, its organisation key and its limits |
| `GET /v1/fleet/health` | `ok` |
| `POST /v1/fleet/enrolments` | `{"enrolment": <hex>}`: an enrolment the organisation key signed, read and verified, for a node not enrolled yet; `201` with its node, role and digest |
| `GET /v1/fleet/enrolments` | every enrolment, with its node, role, label, span, digest and the records held of it |
| `POST /v1/fleet/records` | `{"records": [...]}`: each record in its carriage, in order, verified against its node's enrolment and every record held; `200` with how many were held and refused, and each one's kind and identity or its reason |
| `GET /v1/fleet/rooms/{room}/hops` | the per-hop records held of a room, each `record` and `ledger`, as a receipt ledger's room source holds them |
| `POST /v1/fleet/reports` | `{"start": <µs>, "end": <µs>}`: the report of the period over the enrolments whose span meets it and the records held in it, signed, verified and kept; `201`, its digest, its outcome and its bundle |
| `GET /v1/fleet/reports` | every report kept, with its period |
| `GET /v1/fleet/reports/{digest}` | a report's bundle and outcome |
| `POST /v1/fleet/verify` | a bundle: its outcome |
A refusal is `422` with `{"outcome": "refuse", "reason"}`, the extension's reason; a record refused
in a batch is named in the batch's answer. A body that is not what the endpoint takes is `400`; an
unknown report is `404`. A request body is at most 32 MiB and 10 000 records. Requests that change
the fleet run one at a time; `POST /v1/fleet/verify` verifies at most two bundles at a time.
The service has no accounts. It holds the organisation key because it signs reports. It accepts an
enrolment only when the organisation key signed it and a record only when it verifies under an
enrolled node's key, whoever submits either. It listens on a loopback address unless told
otherwise: who may reach it is decided in front of it.
## Checks
```sh
cargo test --locked # the corpus's report signed byte for byte, the directory
# recovered after a restart, a line cut short cut off, one
# process per directory, the corpus's summaries read and
# written again by the relay's own types, and records the
# relay's own code signs collected from a file over HTTP
bash smoke.sh http://127.0.0.1:18252 # over HTTP, against an instance started with the corpus's
# organisation key (see .github/workflows/wai-fleet.yml)
```

99
wai/wai-fleet/smoke.sh Executable file
View file

@ -0,0 +1,99 @@
#!/usr/bin/env bash
# smoke.sh BASE_URL: the fleet service's promises, checked over HTTP against a running instance
# started with the corpus's organisation key (CI starts one locally; see README.md).
#
# bash smoke.sh http://127.0.0.1:18252
#
# From the committed corpus (../live-conformance/fleet/vectors.json): it enrols the week's nodes,
# gives the service the week's records, asks for the week's report and requires the corpus's report
# byte for byte with the corpus's outcome; checks the report's bundle with the independent verifier,
# fleet_verify.py; requires a record held already, and a record of a node no enrolment names, to be
# refused; requires the lesson room's per-hop records back in the ledger's source form; and requires
# an altered bundle to be refused by POST /v1/fleet/verify. Each check prints one line and the script
# fails on the first that does not hold. Needs curl and python3.
set -euo pipefail
U=${1:?usage: smoke.sh BASE_URL}
U=${U%/}
HERE=$(cd "$(dirname "$0")" && pwd)
CORPUS="$HERE/../live-conformance/fleet/vectors.json"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
post() { curl -sS --max-time 300 -o "$3" -w '%{http_code}' -X POST "$U$1" -H 'content-type: application/json' --data-binary "@$2"; }
py() { python3 -I -c "$@"; }
curl -sS --max-time 30 "$U/v1/fleet" > "$tmp/describe.json"
py 'import json,sys
d=json.load(open(sys.argv[1])); c=json.load(open(sys.argv[2]))
assert d["service"]=="wai-fleet" and d["capability"]=="wai.delivery.fleet", d
assert d["organisation_key"]==c["keys"]["organisation"], "start the service with the corpus organisation key (README.md)"
print("describe: ok, organisation", d["organisation_key"][:16]+"…")' "$tmp/describe.json" "$CORPUS"
# The week's enrolments, then its records in one request.
py 'import json,sys
c=json.load(open(sys.argv[1])); v=next(x for x in c["vectors"] if x["name"]=="a_week")
for k,e in enumerate(v["bundle"]["enrolments"]):
json.dump({"enrolment": e}, open(sys.argv[2]+"/enrol-%02d.json" % k,"w"))
json.dump({"records": v["bundle"]["records"]}, open(sys.argv[2]+"/records.json","w"))
json.dump({"records": v["bundle"]["records"][:1]}, open(sys.argv[2]+"/again.json","w"))
s=next(x for x in c["vectors"] if x["name"]=="signer_not_enrolled")
json.dump({"records": [s["bundle"]["records"][8]]}, open(sys.argv[2]+"/outsider.json","w"))
json.dump({"start": v["outcome"]["start"], "end": v["outcome"]["end"]}, open(sys.argv[2]+"/period.json","w"))
print(v["outcome"]["rooms"][0]["room"])' "$CORPUS" "$tmp" > "$tmp/lesson"
for f in "$tmp"/enrol-*.json; do
code=$(post /v1/fleet/enrolments "$f" "$f.out")
[ "$code" = 201 ] || { echo "enrolment $(basename "$f"): HTTP $code: $(cat "$f.out")"; exit 1; }
done
echo "enrolments: ok, $(ls "$tmp"/enrol-*.json | wc -l | tr -d ' ') enrolled"
code=$(post /v1/fleet/records "$tmp/records.json" "$tmp/records.out")
[ "$code" = 200 ] || { echo "records: HTTP $code: $(cat "$tmp/records.out")"; exit 1; }
py 'import json,sys
a=json.load(open(sys.argv[1])); assert a["refused"]==0 and a["held"]>0, a
print("records: ok,", a["held"], "held")' "$tmp/records.out"
# A record held already, and one of a node no enrolment names, are refused.
post /v1/fleet/records "$tmp/again.json" "$tmp/again.out" > /dev/null
post /v1/fleet/records "$tmp/outsider.json" "$tmp/outsider.out" > /dev/null
py 'import json,sys
a=json.load(open(sys.argv[1])); b=json.load(open(sys.argv[2]))
assert a["results"]==[{"refused":"record_duplicate"}], a
assert b["results"]==[{"refused":"signer_not_enrolled"}], b
print("refusals: ok, record_duplicate and signer_not_enrolled")' "$tmp/again.out" "$tmp/outsider.out"
# The week's report: the corpus's, byte for byte, with its outcome.
code=$(post /v1/fleet/reports "$tmp/period.json" "$tmp/report.out")
[ "$code" = 201 ] || { echo "report: HTTP $code: $(cat "$tmp/report.out")"; exit 1; }
py 'import json,sys
c=json.load(open(sys.argv[1])); v=next(x for x in c["vectors"] if x["name"]=="a_week"); r=json.load(open(sys.argv[2]))
assert r["bundle"]["report"]==v["bundle"]["report"], "the report differs from the corpus"
assert r["outcome"]==v["outcome"], "the outcome differs from the corpus"
json.dump(r["bundle"], open(sys.argv[3]+"/week.wai-fleet.json","w"))
print("report: ok, the corpus report, digest", r["report"][:16]+"…")' "$CORPUS" "$tmp/report.out" "$tmp"
D=$(py 'import json,sys; print(json.load(open(sys.argv[1]))["report"])' "$tmp/report.out")
curl -sS --max-time 60 "$U/v1/fleet/reports/$D" > "$tmp/served.json"
py 'import json,sys
a=json.load(open(sys.argv[1])); b=json.load(open(sys.argv[2]))
assert a["bundle"]==b["bundle"] and a["outcome"]==b["outcome"]
print("report served: ok")' "$tmp/served.json" "$tmp/report.out"
# The bundle, checked by the second implementation, which shares no code with the service.
python3 -I "$HERE/../live-conformance/fleet_verify.py" --bundle "$tmp/week.wai-fleet.json" > "$tmp/independent.out" \
|| { echo "fleet_verify.py refused the bundle:"; cat "$tmp/independent.out"; exit 1; }
echo "independent verifier: ok, the bundle verifies"
# The lesson room's per-hop records, in the ledger's source form.
curl -sS --max-time 60 "$U/v1/fleet/rooms/$(cat "$tmp/lesson")/hops" > "$tmp/hops.json"
py 'import json,sys
h=json.load(open(sys.argv[1]))["hops"]
assert len(h)==3 and all(sorted(x)==["ledger","record"] for x in h), h
print("room hops: ok, 3 records with their ledgers")' "$tmp/hops.json"
# An altered bundle is refused.
py 'import json,sys
b=json.load(open(sys.argv[1])); r=bytearray(bytes.fromhex(b["report"])); r[-1]^=1; b["report"]=r.hex()
json.dump(b, open(sys.argv[2],"w"))' "$tmp/week.wai-fleet.json" "$tmp/altered.json"
post /v1/fleet/verify "$tmp/altered.json" "$tmp/altered.out" > /dev/null
py 'import json,sys
o=json.load(open(sys.argv[1])); assert o["outcome"]=="refuse" and o["reason"]=="bad_signature", o
print("verify: ok, an altered bundle is refused", o["reason"])' "$tmp/altered.out"
echo "smoke: all checks held"

109
wai/wai-fleet/src/client.rs Normal file
View file

@ -0,0 +1,109 @@
//! An HTTP/1.0 client of the least `wai-fleet collect` needs: a GET of a node's published summary
//! file and a POST of records to a fleet service, over `http` or `https` (rustls with ring and
//! the platform's roots), each answered whole and closed. A request to HTTP/1.0 is never answered
//! in chunks, so the body is what follows the head, to the close.
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
/// The longest answer read.
pub const MAX_ANSWER: usize = 256 << 20;
const TIMEOUT: Duration = Duration::from_secs(60);
/// An `http` or `https` address, split.
#[derive(Debug, PartialEq, Eq)]
pub struct Url {
pub tls: bool,
pub host: String,
pub port: u16,
pub path: String,
}
pub fn parse(url: &str) -> Result<Url, String> {
let (tls, rest) = if let Some(r) = url.strip_prefix("https://") {
(true, r)
} else if let Some(r) = url.strip_prefix("http://") {
(false, r)
} else {
return Err(format!("{url}: an http:// or https:// address"));
};
let (authority, path) = match rest.find('/') {
Some(i) => (&rest[..i], rest[i..].to_string()),
None => (rest, "/".to_string()),
};
if authority.is_empty() || authority.contains('@') {
return Err(format!("{url}: no host, or credentials in the address"));
}
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) if !h.ends_with(']') || h.starts_with('[') => (h.to_string(), p.parse::<u16>().map_err(|_| format!("{url}: a port"))?),
_ => (authority.to_string(), if tls { 443 } else { 80 }),
};
Ok(Url { tls, host: host.trim_start_matches('[').trim_end_matches(']').to_string(), port, path })
}
trait Stream: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Stream for T {}
async fn connect(u: &Url) -> Result<Box<dyn Stream>, String> {
let tcp = tokio::time::timeout(TIMEOUT, TcpStream::connect((u.host.as_str(), u.port))).await.map_err(|_| format!("{}: no answer", u.host))?.map_err(|e| format!("{}:{}: {e}", u.host, u.port))?;
if !u.tls {
return Ok(Box::new(tcp));
}
let mut roots = rustls::RootCertStore::empty();
for c in rustls_native_certs::load_native_certs().certs {
let _ = roots.add(c);
}
let cfg = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.map_err(|e| e.to_string())?
.with_root_certificates(roots)
.with_no_client_auth();
let name = rustls::pki_types::ServerName::try_from(u.host.clone()).map_err(|e| format!("{}: {e}", u.host))?;
let tls = tokio_rustls::TlsConnector::from(Arc::new(cfg)).connect(name, tcp).await.map_err(|e| format!("{}: TLS: {e}", u.host))?;
Ok(Box::new(tls))
}
/// One request and its whole answer: the status and the body.
pub async fn request(method: &str, url: &str, body: Option<&[u8]>) -> Result<(u16, Vec<u8>), String> {
let u = parse(url)?;
let mut s = connect(&u).await?;
let mut head = format!("{method} {} HTTP/1.0\r\nHost: {}\r\nUser-Agent: wai-fleet\r\nConnection: close\r\n", u.path, u.host);
if let Some(b) = body {
head.push_str(&format!("Content-Type: application/json\r\nContent-Length: {}\r\n", b.len()));
}
head.push_str("\r\n");
let mut out = head.into_bytes();
out.extend_from_slice(body.unwrap_or_default());
let go = async {
s.write_all(&out).await?;
s.flush().await?;
let mut all = Vec::new();
(&mut s).take(MAX_ANSWER as u64 + 1).read_to_end(&mut all).await?;
Ok::<_, std::io::Error>(all)
};
let all = tokio::time::timeout(TIMEOUT * 5, go).await.map_err(|_| format!("{url}: the answer did not end in time"))?.map_err(|e| format!("{url}: {e}"))?;
if all.len() > MAX_ANSWER {
return Err(format!("{url}: an answer longer than {MAX_ANSWER} bytes"));
}
let at = all.windows(4).position(|w| w == b"\r\n\r\n").ok_or(format!("{url}: no HTTP head in the answer"))?;
let status_line = String::from_utf8_lossy(&all[..all[..at].iter().position(|&c| c == b'\r').unwrap_or(at)]).into_owned();
let status = status_line.split(' ').nth(1).and_then(|c| c.parse::<u16>().ok()).ok_or(format!("{url}: no status in {status_line:?}"))?;
Ok((status, all[at + 4..].to_vec()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn addresses_split() {
assert_eq!(parse("https://relay.example/sessions.jsonl").unwrap(), Url { tls: true, host: "relay.example".into(), port: 443, path: "/sessions.jsonl".into() });
assert_eq!(parse("http://127.0.0.1:8252").unwrap(), Url { tls: false, host: "127.0.0.1".into(), port: 8252, path: "/".into() });
assert_eq!(parse("http://[::1]:8252/v1").unwrap().host, "::1");
assert!(parse("ftp://x/").is_err());
assert!(parse("https://user:pw@x/").is_err(), "no credentials in an address");
}
}

145
wai/wai-fleet/src/fleet.rs Normal file
View file

@ -0,0 +1,145 @@
//! The fleet a service holds (extensions/fleet.md §9): its enrolments and records, admitted as
//! the extension admits them and kept in the directory before they are answered for, and its
//! reports, signed with the organisation key, each verified before it is kept.
use std::collections::BTreeMap;
use std::path::Path;
use ed25519_dalek::SigningKey;
use serde_json::{json, Value};
use wai::fleet::{self as f, Held, Reason};
use crate::store::{write_whole, Dir, Log};
/// Why a request was not done: a refusal the extension names, or the directory failing.
#[derive(Debug)]
pub enum Error {
Refused(Reason),
Store(String),
}
impl From<Reason> for Error {
fn from(r: Reason) -> Error {
Error::Refused(r)
}
}
pub struct Fleet {
pub held: Held,
pub key: SigningKey,
dir: Dir,
/// Each report kept, by digest: its bundle and its outcome.
pub reports: BTreeMap<[u8; 32], (Value, Value)>,
}
/// What became of one record given: its kind and identity, held; or the refusal.
pub type Admitted = Result<(u8, [u8; 32]), Reason>;
/// A report made (§9): its digest, its bundle and its outcome.
pub struct Made {
pub digest: [u8; 32],
pub bundle: Value,
pub outcome: Value,
}
impl Fleet {
/// Open the directory and read back every enrolment, record and report, each admitted or
/// verified again: a directory that does not is refused, and the service does not start.
pub fn open(root: &Path, key: SigningKey) -> Result<Fleet, String> {
let mut dir = Dir::open(root)?;
let mut held = Held::new(key.verifying_key().to_bytes());
for (n, line) in dir.lines(Log::Enrolments)?.iter().enumerate() {
let v: Value = serde_json::from_str(line).map_err(|e| format!("enrolment {n}: {e}"))?;
let b = v["enrolment"].as_str().and_then(f::unhex).ok_or(format!("enrolment {n}: not {{\"enrolment\": hex}}"))?;
held.enrol(&b).map_err(|e| format!("enrolment {n} is refused: {e}"))?;
}
for (n, line) in dir.lines(Log::Records)?.iter().enumerate() {
let v: Value = serde_json::from_str(line).map_err(|e| format!("record {n}: {e}"))?;
held.admit(&v).map_err(|e| format!("record {n} is refused: {e}"))?;
}
let mut reports = BTreeMap::new();
let mut paths: Vec<_> = std::fs::read_dir(dir.reports()).map_err(|e| e.to_string())?.flatten().map(|e| e.path()).collect();
paths.sort();
for p in paths.into_iter().filter(|p| p.to_string_lossy().ends_with(f::BUNDLE_SUFFIX)) {
let text = std::fs::read_to_string(&p).map_err(|e| format!("{}: {e}", p.display()))?;
let bundle: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", p.display()))?;
let checked = f::verify(&bundle);
let outcome = f::outcome_json(&checked);
let a = checked.map_err(|(r, _)| format!("{}: the report is refused {r}", p.display()))?;
if a.report.organisation != held.organisation {
return Err(format!("{}: a report of another organisation", p.display()));
}
reports.insert(a.digest, (bundle, outcome));
}
Ok(Fleet { held, key, dir, reports })
}
/// An enrolment the organisation key signed (§3), checked, kept, and synced.
pub fn enrol(&mut self, b: &[u8]) -> Result<f::Enrolment, Error> {
let e = self.held.check_enrolment(b)?;
self.dir.append(Log::Enrolments, &json!({"enrolment": f::hex(b)}).to_string()).map_err(Error::Store)?;
self.dir.sync(Log::Enrolments).map_err(Error::Store)?;
Ok(self.held.hold_enrolment(e).clone())
}
/// Records (§9): each checked against what is held and what this request kept before it,
/// kept in order, and the log synced once before the answer.
pub fn records(&mut self, carriages: Vec<Value>) -> Result<Vec<Admitted>, Error> {
let mut out = Vec::with_capacity(carriages.len());
let mut kept = 0;
for c in carriages {
match self.held.check(&c) {
Ok(r) => {
self.dir.append(Log::Records, &c.to_string()).map_err(Error::Store)?;
out.push(Ok(self.held.hold(r, c)));
kept += 1;
}
Err(e) => out.push(Err(e)),
}
}
if kept > 0 {
self.dir.sync(Log::Records).map_err(Error::Store)?;
}
Ok(out)
}
/// The report of a period (§9): signed, verified as a verifier would, and kept with its
/// bundle.
pub fn report(&mut self, start: u64, end: u64) -> Result<Made, Error> {
let (report, bundle) = self.held.report(&self.key, start, end).map_err(Error::Store)?;
let checked = f::verify(&bundle);
let outcome = f::outcome_json(&checked);
if let Err((r, _)) = checked {
return Err(Error::Store(format!("the report signed does not verify: {r}")));
}
let digest = report.digest();
let name = f::hex(&digest);
write_whole(&self.dir.reports().join(format!("{name}.wfr")), &report.to_bytes()).map_err(Error::Store)?;
let text = serde_json::to_string_pretty(&bundle).expect("JSON") + "\n";
write_whole(&self.dir.reports().join(format!("{name}{}", f::BUNDLE_SUFFIX)), text.as_bytes()).map_err(Error::Store)?;
self.reports.insert(digest, (bundle.clone(), outcome.clone()));
Ok(Made { digest, bundle, outcome })
}
/// Each enrolment held, with what is held of it.
pub fn enrolments(&self) -> Value {
let mut count: BTreeMap<[u8; 32], u64> = BTreeMap::new();
for (r, _) in &self.held.records {
if let Some(s) = r.signer() {
*count.entry(s).or_default() += 1;
}
}
Value::Array(
self.held
.enrolments
.iter()
.map(|e| {
json!({
"node": f::hex(&e.node), "role": f::role::name(e.role), "label": e.label_text(), "from": e.from, "until": e.until,
"digest": f::hex(&e.digest()), "records": count.get(&e.node).copied().unwrap_or(0),
})
})
.collect(),
)
}
}

176
wai/wai-fleet/src/http.rs Normal file
View file

@ -0,0 +1,176 @@
//! The HTTP API (extensions/fleet.md §9). Every request that changes the fleet runs under one
//! lock, on a blocking thread: verifying records is CPU work, and the directory has one writer.
//! `POST /v1/fleet/verify` holds no lock and runs at most two at a time.
use std::sync::Arc;
use axum::extract::{DefaultBodyLimit, Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{json, Value};
use tokio::sync::{Mutex, Semaphore};
use wai::fleet as f;
use crate::fleet::{Error, Fleet};
/// The largest request body: a batch of a relay's summary lines.
pub const MAX_BODY: usize = 32 << 20;
/// The most records one request may carry.
pub const MAX_RECORDS: usize = 10_000;
#[derive(Clone)]
pub struct Shared {
pub fleet: Arc<Mutex<Fleet>>,
pub verifiers: Arc<Semaphore>,
pub organisation: [u8; 32],
}
fn refusal(reason: f::Reason) -> Response {
(StatusCode::UNPROCESSABLE_ENTITY, Json(json!({"outcome": "refuse", "reason": reason.code()}))).into_response()
}
fn bad_request(why: &str) -> Response {
(StatusCode::BAD_REQUEST, Json(json!({"error": why}))).into_response()
}
fn not_found(why: &str) -> Response {
(StatusCode::NOT_FOUND, Json(json!({"error": why}))).into_response()
}
fn error(e: Error) -> Response {
match e {
Error::Refused(r) => refusal(r),
Error::Store(why) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": why}))).into_response(),
}
}
fn digest_of(s: &str) -> Option<[u8; 32]> {
f::unhex(s)?.try_into().ok()
}
const ENDPOINTS: [&str; 10] = [
"GET /v1/fleet",
"GET /v1/fleet/health",
"POST /v1/fleet/enrolments",
"GET /v1/fleet/enrolments",
"POST /v1/fleet/records",
"GET /v1/fleet/rooms/{room}/hops",
"POST /v1/fleet/reports",
"GET /v1/fleet/reports",
"GET /v1/fleet/reports/{digest}",
"POST /v1/fleet/verify",
];
async fn describe(State(s): State<Shared>) -> Json<Value> {
Json(json!({
"service": "wai-fleet",
"version": env!("CARGO_PKG_VERSION"),
"capability": f::CAPABILITY,
"organisation_key": f::hex(&s.organisation),
"limits": {"max_body_bytes": MAX_BODY, "max_records_per_request": MAX_RECORDS, "concurrent_verifications": 2},
"endpoints": ENDPOINTS,
}))
}
async fn health() -> &'static str {
"ok"
}
/// Run `g` on the fleet, under its lock, on a blocking thread.
async fn with_fleet<T: Send + 'static>(s: &Shared, g: impl FnOnce(&mut Fleet) -> T + Send + 'static) -> T {
let mut guard = s.fleet.clone().lock_owned().await;
tokio::task::spawn_blocking(move || g(&mut guard)).await.expect("a fleet task does not panic")
}
async fn enrol(State(s): State<Shared>, Json(body): Json<Value>) -> Response {
let Some(b) = body.get("enrolment").and_then(Value::as_str).and_then(f::unhex) else {
return bad_request("the body is {\"enrolment\": <hex>}, an enrolment the organisation key signed");
};
match with_fleet(&s, move |fl| fl.enrol(&b)).await {
Ok(e) => (StatusCode::CREATED, Json(json!({"node": f::hex(&e.node), "role": f::role::name(e.role), "digest": f::hex(&e.digest())}))).into_response(),
Err(e) => error(e),
}
}
async fn enrolments(State(s): State<Shared>) -> Json<Value> {
Json(json!({"enrolments": with_fleet(&s, |fl| fl.enrolments()).await}))
}
async fn records(State(s): State<Shared>, Json(mut body): Json<Value>) -> Response {
let Some(list) = body.get_mut("records").and_then(Value::as_array_mut).map(std::mem::take) else {
return bad_request("the body is {\"records\": [...]}, each a record in its carriage (extensions/fleet.md §4.1)");
};
if list.len() > MAX_RECORDS {
return bad_request("too many records in one request");
}
match with_fleet(&s, move |fl| fl.records(list)).await {
Ok(results) => {
let held = results.iter().filter(|r| r.is_ok()).count();
let out: Vec<Value> = results
.iter()
.map(|r| match r {
Ok((k, id)) => json!({"held": [k, f::hex(id)]}),
Err(e) => json!({"refused": e.code()}),
})
.collect();
Json(json!({"held": held, "refused": out.len() - held, "results": out})).into_response()
}
Err(e) => error(e),
}
}
async fn hops(State(s): State<Shared>, Path(room): Path<String>) -> Response {
let Some(room) = digest_of(&room) else { return bad_request("a room is named by its id, 64 hexadecimal digits") };
Json(json!({"hops": with_fleet(&s, move |fl| fl.held.hops_of(&room)).await})).into_response()
}
async fn report(State(s): State<Shared>, Json(body): Json<Value>) -> Response {
let (Some(start), Some(end)) = (body.get("start").and_then(Value::as_u64), body.get("end").and_then(Value::as_u64)) else {
return bad_request("the body is {\"start\": <µs>, \"end\": <µs>}");
};
if start >= end {
return bad_request("a period's start is below its end");
}
match with_fleet(&s, move |fl| fl.report(start, end)).await {
Ok(m) => (StatusCode::CREATED, Json(json!({"report": f::hex(&m.digest), "outcome": m.outcome, "bundle": m.bundle}))).into_response(),
Err(e) => error(e),
}
}
async fn reports(State(s): State<Shared>) -> Json<Value> {
let list = with_fleet(&s, |fl| {
fl.reports.iter().map(|(d, (_, o))| json!({"report": f::hex(d), "start": o["start"], "end": o["end"]})).collect::<Vec<_>>()
})
.await;
Json(json!({"reports": list}))
}
async fn report_of(State(s): State<Shared>, Path(d): Path<String>) -> Response {
let Some(d) = digest_of(&d) else { return bad_request("a report is named by its digest") };
match with_fleet(&s, move |fl| fl.reports.get(&d).cloned()).await {
Some((bundle, outcome)) => Json(json!({"report": f::hex(&d), "outcome": outcome, "bundle": bundle})).into_response(),
None => not_found("no such report"),
}
}
async fn verify(State(s): State<Shared>, Json(bundle): Json<Value>) -> Response {
let Ok(_permit) = s.verifiers.clone().acquire_owned().await else { return bad_request("shutting down") };
let outcome = tokio::task::spawn_blocking(move || f::outcome_json(&f::verify(&bundle))).await.expect("a verification does not panic");
Json(outcome).into_response()
}
pub fn router(s: Shared) -> Router {
Router::new()
.route("/v1/fleet", get(describe))
.route("/v1/fleet/health", get(health))
.route("/v1/fleet/enrolments", post(enrol).get(enrolments))
.route("/v1/fleet/records", post(records))
.route("/v1/fleet/rooms/{room}/hops", get(hops))
.route("/v1/fleet/reports", post(report).get(reports))
.route("/v1/fleet/reports/{digest}", get(report_of))
.route("/v1/fleet/verify", post(verify))
.layer(DefaultBodyLimit::max(MAX_BODY))
.with_state(s)
}

185
wai/wai-fleet/src/main.rs Normal file
View file

@ -0,0 +1,185 @@
//! wai-fleet: a WAI fleet as a service (extensions/fleet.md §9).
//!
//! An organisation enrols its nodes by their signing keys; the service verifies each record a
//! node contributes as it signs it (a relay's session summaries, every hop's per-hop records with
//! their ledgers, the room descriptors that place them) against the enrolled key, keeps it
//! append-only, and for a period signs the fleet report with the organisation key: one line per
//! node and per room, with sessions, bytes and energy per scope and class, each line citing its
//! records. Every report is kept with its bundle, which any verifier of the extension checks
//! without this service.
//!
//! ```text
//! wai-fleet serve the HTTP API: WAI_FLEET_ADDR (127.0.0.1:8252), WAI_FLEET_DIR (its
//! directory), WAI_FLEET_KEY (the organisation key's file, 32 bytes,
//! made with mode 0600 when absent)
//! wai-fleet key the organisation key of WAI_FLEET_KEY, in hexadecimal
//! wai-fleet verify FILE a bundle's outcome, here, printed
//! wai-fleet collect URL SOURCE each line of a node's summary file (a path, or an http or https
//! address) given to the fleet service at URL as a summary record
//! ```
mod client;
mod fleet;
mod http;
mod store;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use ed25519_dalek::SigningKey;
use serde_json::{json, Value};
const USAGE: &str = "usage: wai-fleet [serve | key | verify FILE | collect URL SOURCE]";
fn main() {
let args: Vec<String> = std::env::args().collect();
let code = match args.get(1).map(String::as_str) {
Some("serve") | None => serve().map(|_| 0),
Some("key") => key().map(|k| {
println!("{}", wai::fleet::hex(&k.verifying_key().to_bytes()));
0
}),
Some("verify") => match args.get(2) {
Some(p) => verify_file(p),
None => Err(USAGE.into()),
},
Some("collect") => match (args.get(2), args.get(3)) {
(Some(u), Some(s)) => collect(u, s),
_ => Err(USAGE.into()),
},
_ => Err(USAGE.into()),
};
match code {
Ok(c) => std::process::exit(c),
Err(e) => {
eprintln!("wai-fleet: {e}");
std::process::exit(if e == USAGE { 2 } else { 1 });
}
}
}
fn env_path(name: &str) -> Result<PathBuf, String> {
std::env::var_os(name).map(PathBuf::from).ok_or_else(|| format!("{name} is not set"))
}
/// The organisation key: read from WAI_FLEET_KEY, or made there, readable by its owner alone.
fn key() -> Result<SigningKey, String> {
let p = env_path("WAI_FLEET_KEY")?;
match std::fs::read(&p) {
Ok(b) => {
let seed: [u8; 32] = b.try_into().map_err(|_| format!("{}: a key file holds 32 bytes", p.display()))?;
Ok(SigningKey::from_bytes(&seed))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let mut seed = [0u8; 32];
getrandom::fill(&mut seed).map_err(|e| format!("no randomness for a key: {e}"))?;
write_key(&p, &seed)?;
Ok(SigningKey::from_bytes(&seed))
}
Err(e) => Err(format!("{}: {e}", p.display())),
}
}
#[cfg(unix)]
fn write_key(p: &std::path::Path, seed: &[u8; 32]) -> Result<(), String> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(p).map_err(|e| format!("{}: {e}", p.display()))?;
f.write_all(seed).and_then(|_| f.sync_all()).map_err(|e| format!("{}: {e}", p.display()))
}
#[cfg(not(unix))]
fn write_key(p: &std::path::Path, seed: &[u8; 32]) -> Result<(), String> {
std::fs::write(p, seed).map_err(|e| format!("{}: {e}", p.display()))
}
fn verify_file(p: &str) -> Result<i32, String> {
let text = std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?;
let bundle: Value = serde_json::from_str(&text).map_err(|e| format!("{p}: {e}"))?;
let out = wai::fleet::outcome_json(&wai::fleet::verify(&bundle));
println!("{}", serde_json::to_string_pretty(&out).expect("JSON"));
Ok(if out["outcome"] == "accept" { 0 } else { 1 })
}
fn runtime() -> Result<tokio::runtime::Runtime, String> {
tokio::runtime::Builder::new_multi_thread().enable_all().build().map_err(|e| e.to_string())
}
/// The lines of a node's summary file: read from a path, or fetched from an address.
fn source_lines(source: &str) -> Result<Vec<String>, String> {
let bytes = if source.starts_with("http://") || source.starts_with("https://") {
let (status, body) = runtime()?.block_on(client::request("GET", source, None))?;
if status != 200 {
return Err(format!("{source}: HTTP {status}"));
}
body
} else {
std::fs::read(source).map_err(|e| format!("{source}: {e}"))?
};
let text = String::from_utf8(bytes).map_err(|_| format!("{source}: not UTF-8"))?;
Ok(text.split('\n').filter(|l| !l.is_empty()).map(str::to_string).collect())
}
/// `wai-fleet collect URL SOURCE`: each line given as a summary record, in batches; what was held
/// and what was refused, by reason, printed as JSON.
fn collect(url: &str, source: &str) -> Result<i32, String> {
let lines = source_lines(source)?;
let rt = runtime()?;
let endpoint = format!("{}/v1/fleet/records", url.trim_end_matches('/'));
let (mut held, mut refused) = (0u64, BTreeMap::<String, u64>::new());
for batch in lines.chunks(512) {
let body = json!({"records": batch.iter().map(|l| json!({"kind": "summary", "line": l})).collect::<Vec<_>>()});
let (status, answer) = rt.block_on(client::request("POST", &endpoint, Some(body.to_string().as_bytes())))?;
if status != 200 {
return Err(format!("{endpoint}: HTTP {status}: {}", String::from_utf8_lossy(&answer)));
}
let a: Value = serde_json::from_slice(&answer).map_err(|e| format!("{endpoint}: {e}"))?;
for r in a["results"].as_array().ok_or(format!("{endpoint}: no results"))? {
match r["refused"].as_str() {
Some(reason) => *refused.entry(reason.to_string()).or_default() += 1,
None => held += 1,
}
}
}
println!("{}", serde_json::to_string_pretty(&json!({"source": source, "lines": lines.len(), "held": held, "refused": refused})).expect("JSON"));
Ok(0)
}
fn serve() -> Result<(), String> {
let addr = std::env::var("WAI_FLEET_ADDR").unwrap_or_else(|_| "127.0.0.1:8252".into());
let dir = env_path("WAI_FLEET_DIR")?;
let key = key()?;
let organisation = key.verifying_key().to_bytes();
let fl = fleet::Fleet::open(&dir, key)?;
eprintln!(
"wai-fleet: {} enrolment(s), {} record(s) and {} report(s) recovered from {}; organisation key {}",
fl.held.enrolments.len(),
fl.held.records.len(),
fl.reports.len(),
dir.display(),
wai::fleet::hex(&organisation)
);
let shared = http::Shared { fleet: Arc::new(tokio::sync::Mutex::new(fl)), verifiers: Arc::new(tokio::sync::Semaphore::new(2)), organisation };
runtime()?.block_on(async move {
let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| format!("{addr}: {e}"))?;
eprintln!("wai-fleet: listening on {addr}");
axum::serve(listener, http::router(shared))
.with_graceful_shutdown(async {
let term = async {
#[cfg(unix)]
if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
s.recv().await;
return;
}
std::future::pending::<()>().await
};
tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = term => {} }
})
.await
.map_err(|e| e.to_string())
})
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,94 @@
//! The service's directory (extensions/fleet.md §9): the enrolments and the records as two
//! append-only logs, one JSON object a line, and each report with its bundle in files written
//! whole.
//!
//! ```text
//! <dir>/LOCK held exclusively while a process serves the directory
//! <dir>/enrolments.jsonl {"enrolment": hex}, one a line, in the order admitted
//! <dir>/records.jsonl each record's carriage, one a line, in the order admitted
//! <dir>/reports/<digest>.wfr a report
//! <dir>/reports/<digest>.wai-fleet.json its bundle
//! ```
//!
//! A line is appended whole and the log synced before the request that admitted it is answered.
//! A line a crash cut short was never answered: on start it is cut off.
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
pub const ENROLMENTS: &str = "enrolments.jsonl";
pub const RECORDS: &str = "records.jsonl";
pub const REPORTS: &str = "reports";
/// The directory, locked for this process, with its two logs open for appending.
pub struct Dir {
pub root: PathBuf,
enrolments: File,
records: File,
// Held for the life of the process: a second process on the same directory is refused.
_lock: File,
}
/// Which log.
#[derive(Clone, Copy)]
pub enum Log {
Enrolments,
Records,
}
impl Dir {
/// Open (and create) the directory and take its lock, before anything is read or written.
pub fn open(root: &Path) -> Result<Dir, String> {
fs::create_dir_all(root.join(REPORTS)).map_err(|e| format!("{}: {e}", root.display()))?;
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(root.join("LOCK")).map_err(|e| format!("LOCK: {e}"))?;
lock.try_lock().map_err(|e| format!("{}: another process holds the fleet's directory ({e})", root.display()))?;
let log = |name: &str| OpenOptions::new().create(true).read(true).append(true).open(root.join(name)).map_err(|e| format!("{name}: {e}"));
Ok(Dir { root: root.to_path_buf(), enrolments: log(ENROLMENTS)?, records: log(RECORDS)?, _lock: lock })
}
fn file(&mut self, l: Log) -> &mut File {
match l {
Log::Enrolments => &mut self.enrolments,
Log::Records => &mut self.records,
}
}
/// Every whole line of a log, a line a crash cut short cut off.
pub fn lines(&mut self, l: Log) -> Result<Vec<String>, String> {
let f = self.file(l);
let mut text = String::new();
f.seek(SeekFrom::Start(0)).and_then(|_| f.read_to_string(&mut text)).map_err(|e| format!("a log: {e}"))?;
let whole = text.rfind('\n').map_or(0, |i| i + 1);
if whole != text.len() {
f.set_len(whole as u64).and_then(|_| f.sync_all()).map_err(|e| format!("a log: {e}"))?;
}
Ok(text[..whole].lines().filter(|x| !x.is_empty()).map(str::to_string).collect())
}
/// Append one line to a log; [`Dir::sync`] makes it last.
pub fn append(&mut self, l: Log, line: &str) -> Result<(), String> {
let mut b = line.as_bytes().to_vec();
b.push(b'\n');
self.file(l).write_all(&b).map_err(|e| format!("a log: {e}"))
}
pub fn sync(&mut self, l: Log) -> Result<(), String> {
self.file(l).sync_data().map_err(|e| format!("a log: {e}"))
}
pub fn reports(&self) -> PathBuf {
self.root.join(REPORTS)
}
}
/// Write `bytes` to `path` whole: to a temporary name beside it, synced, renamed into place, and
/// the directory synced, so a crash leaves either no file or the whole one.
pub fn write_whole(path: &Path, bytes: &[u8]) -> Result<(), String> {
let dir = path.parent().ok_or("a file has a directory")?;
let tmp = dir.join(format!(".{}.tmp-{}", path.file_name().and_then(|n| n.to_str()).unwrap_or("file"), std::process::id()));
let mut f = File::create(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
f.write_all(bytes).and_then(|_| f.sync_all()).map_err(|e| format!("{}: {e}", tmp.display()))?;
fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display()))?;
File::open(dir).and_then(|d| d.sync_all()).map_err(|e| format!("{}: {e}", dir.display()))
}

248
wai/wai-fleet/src/tests.rs Normal file
View file

@ -0,0 +1,248 @@
//! The service's fleet against the committed corpus (`live-conformance/fleet/`) and against the
//! reference relay's own code: given the corpus's enrolments and records, it signs the corpus's
//! report byte for byte and recovers everything from its directory after a restart; a record cut
//! short by a crash is cut off; one process holds the directory; every summary of the corpus is
//! what the relay's own types write and verify; and summaries and a per-hop record the relay's own
//! code signs, written to a summary file as the relay writes it, are collected from that file over
//! HTTP and reported.
use std::io::{Read, Write};
use std::path::PathBuf;
use ed25519_dalek::SigningKey;
use joule_moq_relay::summary::{LayerSpan, RelayEnergy, SessionSummary, SignedSessionSummary, Tally, FORMAT};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use wai::fleet::{self as f, Reason};
use crate::fleet::{Error, Fleet};
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn corpus() -> Value {
serde_json::from_str(&std::fs::read_to_string(wai_dir().join("live-conformance/fleet/vectors.json")).expect("the corpus")).expect("JSON")
}
fn vector<'a>(doc: &'a Value, name: &str) -> &'a Value {
doc["vectors"].as_array().unwrap().iter().find(|v| v["name"] == name).unwrap_or_else(|| panic!("no vector {name}"))
}
/// The corpus's organisation key, from its fixed seed.
fn organisation() -> SigningKey {
let seed: [u8; 32] = Sha256::new().chain_update(b"wai:fleet/corpus/key\x00").chain_update(b"organisation").finalize().into();
SigningKey::from_bytes(&seed)
}
fn scratch(name: &str) -> PathBuf {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let d = std::env::temp_dir().join(format!("wai-fleet-test-{name}-{}-{nanos}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
fn enrol_all(fl: &mut Fleet, b: &Value) {
for e in b["enrolments"].as_array().unwrap() {
fl.enrol(&f::unhex(e.as_str().unwrap()).unwrap()).unwrap();
}
}
fn reason(e: Error) -> Reason {
match e {
Error::Refused(r) => r,
Error::Store(s) => panic!("a store failure: {s}"),
}
}
#[test]
fn the_fleet_signs_the_corpus_report_and_recovers_its_directory() {
let doc = corpus();
let week = vector(&doc, "a_week");
let b = &week["bundle"];
let (start, end) = (week["outcome"]["start"].as_u64().unwrap(), week["outcome"]["end"].as_u64().unwrap());
let dir = scratch("week");
let digest = {
let mut fl = Fleet::open(&dir, organisation()).unwrap();
enrol_all(&mut fl, b);
assert_eq!(reason(fl.enrol(&f::unhex(b["enrolments"][0].as_str().unwrap()).unwrap()).unwrap_err()), Reason::EnrolmentDuplicate);
let results = fl.records(b["records"].as_array().unwrap().clone()).unwrap();
assert!(results.iter().all(Result::is_ok), "{results:?}");
let again = fl.records(b["records"].as_array().unwrap()[..2].to_vec()).unwrap();
assert!(again.iter().all(|r| *r == Err(Reason::RecordDuplicate)), "a record is kept once");
let m = fl.report(start, end).unwrap();
assert_eq!(m.bundle["report"], b["report"], "the corpus's report, byte for byte");
assert_eq!(m.outcome, week["outcome"]);
m.digest
};
// A restart: the enrolments, the records and the report come back from the directory, each
// verified again, and the same period signs the same report.
let mut fl = Fleet::open(&dir, organisation()).unwrap();
assert_eq!((fl.held.enrolments.len(), fl.held.records.len(), fl.reports.len()), (5, b["records"].as_array().unwrap().len(), 1));
assert_eq!(fl.reports[&digest].1, week["outcome"]);
assert_eq!(fl.report(start, end).unwrap().digest, digest);
// A record of a node no enrolment names, and an enrolment another organisation signed, are
// refused and not kept.
let outsider = vector(&doc, "signer_not_enrolled")["bundle"]["records"][8].clone();
assert_eq!(fl.records(vec![outsider]).unwrap(), vec![Err(Reason::SignerNotEnrolled)]);
let other = SigningKey::from_bytes(&[9; 32]);
let e = f::Enrolment::new(other.verifying_key().to_bytes(), [7; 32], f::role::RELAY, 0, f::NO_END, "").sign(&other);
assert_eq!(reason(fl.enrol(&e.to_bytes()).unwrap_err()), Reason::WrongOrganisation);
drop(fl);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_record_cut_short_is_cut_off_and_one_process_holds_the_directory() {
let doc = corpus();
let b = &vector(&doc, "unmetered_only")["bundle"];
let dir = scratch("torn");
{
let mut fl = Fleet::open(&dir, organisation()).unwrap();
assert!(Fleet::open(&dir, organisation()).is_err(), "a second process is refused the directory");
enrol_all(&mut fl, b);
fl.records(b["records"].as_array().unwrap()[..2].to_vec()).unwrap();
}
// A crash in the middle of an append: half a line after the last whole one.
let mut log = std::fs::OpenOptions::new().append(true).open(dir.join("records.jsonl")).unwrap();
let half = b["records"][2].to_string();
log.write_all(&half.as_bytes()[..half.len() / 2]).unwrap();
drop(log);
let mut fl = Fleet::open(&dir, organisation()).unwrap();
assert_eq!(fl.held.records.len(), 2, "the cut line is cut off");
assert!(fl.records(vec![b["records"][2].clone()]).unwrap()[0].is_ok(), "and the record it held can be given again");
drop(fl);
let fl = Fleet::open(&dir, organisation()).unwrap();
assert_eq!(fl.held.records.len(), 3);
drop(fl);
let _ = std::fs::remove_dir_all(&dir);
}
/// Every summary of the corpus's accepted vectors is what the reference relay's own types write:
/// its text, read into them and serialised again, is the line's byte for byte, and the relay's
/// own check verifies its signature.
#[test]
fn the_corpus_summaries_are_the_relays_own_form() {
let text = std::fs::read_to_string(wai_dir().join("live-conformance/fleet/vectors.json")).unwrap();
let lines = f::corpus::summary_lines(&text);
assert!(lines.len() >= 7, "the week's summaries");
for line in &lines {
let signed: SignedSessionSummary = serde_json::from_str(line).unwrap_or_else(|e| panic!("{e}: {line}"));
signed.verify().unwrap_or_else(|e| panic!("the relay's check: {e}"));
assert_eq!(&serde_json::to_string(&signed).unwrap(), line, "the relay writes the line byte for byte");
let (s, _) = f::split_line(line).unwrap();
let summary: SessionSummary = serde_json::from_str(s).unwrap();
assert_eq!(serde_json::to_string(&summary).unwrap(), s);
}
}
/// A tiny HTTP server answering every request with `body`, `n` times, on a thread of its own.
fn serve_file(body: Vec<u8>, n: usize) -> String {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap();
std::thread::spawn(move || {
for s in l.incoming().take(n) {
let mut s = s.unwrap();
let mut req = [0u8; 4096];
let _ = s.read(&mut req);
let _ = s.write_all(format!("HTTP/1.0 200 OK\r\nContent-Type: application/x-ndjson\r\nContent-Length: {}\r\n\r\n", body.len()).as_bytes());
let _ = s.write_all(&body);
}
});
format!("http://{addr}/var/log/sessions.jsonl")
}
/// Summaries and a per-hop record signed by the reference relay's own code, from a session of
/// the corpus's lesson room: the summaries written to a file as the relay writes its summary file
/// and collected from it over HTTP by `wai-fleet collect` into a running service, the record
/// given with its ledger and the room's descriptor; the report states what they state.
#[test]
fn records_the_relay_signs_are_collected_and_reported() {
let doc = corpus();
let measured = vector(&doc, "measured_only");
let descriptor = measured["bundle"]["records"].as_array().unwrap().iter().find(|r| r["kind"] == "descriptor").unwrap().clone();
let d = match f::read_record(&descriptor).unwrap() {
f::Record::Descriptor(d) => d,
_ => unreachable!(),
};
let room = hex::encode(d.room);
let relay = SigningKey::from_bytes(&Sha256::digest(b"wai-fleet test relay").into());
let relay_hex = hex::encode(relay.verifying_key().to_bytes());
let t0_ms = d.t0 / 1000;
let video = format!("{room}-{}-video--0", "ab".repeat(32));
let summary = |session: u64, opened: u64, closed: u64, tally: Tally| SessionSummary {
format: FORMAT.into(),
relay_key: relay_hex.clone(),
session,
transport: "quic".into(),
remote: "198.51.100.1:5000".into(),
draft: "moqt-22".into(),
opened_unix_ms: t0_ms + opened,
closed_unix_ms: t0_ms + closed,
close_code: Some(0),
relay_energy: RelayEnergy::unmetered(),
tally,
};
let mut publisher = Tally::default();
publisher.publisher.objects_received = 50;
publisher.publisher.object_bytes_received = 64_000;
publisher.published(&video).objects_received = 50;
publisher.published(&video).object_bytes_received = 64_000;
let mut viewer = Tally::default();
viewer.subscriber.objects_forwarded = 48;
viewer.subscriber.object_bytes_forwarded = 61_440;
viewer.track(&video).objects_forwarded = 48;
viewer.track(&video).object_bytes_forwarded = 61_440;
viewer.track(&video).fetch_objects = 2;
viewer.track(&video).fetch_bytes = 2_560;
viewer.subscriber.layer_spans.push(LayerSpan { room: room.clone(), class: 0, from_unix_us: (t0_ms + 1_000) * 1000, to_unix_us: (t0_ms + 9_000) * 1000 });
let summaries = [summary(1, 0, 10_000, publisher), summary(2, 1_000, 9_000, viewer)];
// The summary file, one line each, as the relay appends it (its session.rs).
let file: String = summaries.iter().map(|s| serde_json::to_string(&SignedSessionSummary::sign(s.clone(), &relay)).unwrap() + "\n").collect();
let rec = joule_moq_relay::metering::hop_record(&summaries, d.room, d.t0, &relay, None);
rec.verify().unwrap();
let dir = scratch("relay");
let org = organisation();
let mut fl = Fleet::open(&dir, org.clone()).unwrap();
let e = f::Enrolment::new(org.verifying_key().to_bytes(), relay.verifying_key().to_bytes(), f::role::RELAY, d.t0, f::NO_END, "test relay").sign(&org);
fl.enrol(&e.to_bytes()).unwrap();
let hop = json!({"kind": "hop", "record": hex::encode(rec.to_bytes()), "ledger": hex::encode(rec.ledger_bytes())});
assert_eq!(fl.records(vec![hop.clone()]).unwrap(), vec![Err(Reason::DescriptorMissing)], "a record before its room's descriptor");
let results = fl.records(vec![descriptor, hop]).unwrap();
assert!(results.iter().all(Result::is_ok), "{results:?}");
// A running service, and `wai-fleet collect` reading the summary file from an address.
let shared = crate::http::Shared { fleet: std::sync::Arc::new(tokio::sync::Mutex::new(fl)), verifiers: std::sync::Arc::new(tokio::sync::Semaphore::new(2)), organisation: org.verifying_key().to_bytes() };
let fleet = shared.fleet.clone();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
listener.set_nonblocking(true).unwrap();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap();
rt.block_on(async move {
let l = tokio::net::TcpListener::from_std(listener).unwrap();
axum::serve(l, crate::http::router(shared)).await.unwrap();
})
});
let source = serve_file(file.into_bytes(), 2);
assert_eq!(crate::collect(&base, &source).unwrap(), 0);
// Collected twice: the second time, each line is refused as held already.
assert_eq!(crate::collect(&base, &source).unwrap(), 0);
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
let mut fl = rt.block_on(fleet.lock_owned());
assert_eq!(fl.held.records.len(), 4, "the descriptor, the per-hop record and the two summaries, each once");
let m = fl.report(d.t0, d.t0 + 86_400_000_000).unwrap();
let o = &m.outcome;
assert_eq!(o["outcome"], "accept", "{o}");
let node = &o["nodes"][0];
assert_eq!((node["sessions"].as_u64(), node["bytes_in"].as_u64(), node["bytes_out"].as_u64()), (Some(2), Some(64_000), Some(61_440 + 2_560)));
assert_eq!((node["hop_bytes_in"].as_u64(), node["hop_bytes_out"].as_u64()), (Some(64_000), Some(61_440 + 2_560)), "the relay's record recomputes from its summaries");
assert_eq!(node["energy"]["sessions"]["coverage"], "unmetered");
assert_eq!(node["energy"]["hops"]["coverage"], "unmetered", "the relay's meter is off here");
let r = &o["rooms"][0];
assert_eq!((r["room"].as_str(), r["sessions"].as_u64(), r["t0"].as_u64()), (Some(room.as_str()), Some(1), Some(d.t0)));
assert_eq!(r["subscriber_us"].as_u64(), Some(8_000_000));
drop(fl);
let _ = std::fs::remove_dir_all(&dir);
}

View file

@ -155,10 +155,11 @@ ffi = []
# The quickstart's `wai` is built with it alone (`--no-default-features --features floor`).
floor = ["dep:image", "dep:claxon", "dep:zstd"]
# What the quickstart's `wai` holds: the floor, `wai hello` and `wai verify` (a receipt
# sealed and checked as http-delivery §8 lays receipts out), and `wai mcp`, the server
# sealed and checked as http-delivery §8 lays receipts out, and a fleet report's bundle as
# extensions/fleet.md §7 verifies it), and `wai mcp`, the server
# `wai setup-agent` registers. Pure Rust and the floor's zstd: no system library. `wai create`,
# `wai dev` and `wai setup-agent` need no feature.
quickstart = ["floor", "http", "receipt_claims", "mcp", "dep:getrandom"]
quickstart = ["floor", "http", "receipt_claims", "mcp", "fleet", "dep:getrandom"]
# Pulls in the ONNX runtime + zstd unpack so the native sink can
# decode `wai.neural.<...>` envelopes. Off by default — the bare
# container + zeroth codecs build with no ML dep chain.
@ -510,6 +511,16 @@ receipt_ledger = ["live_metering", "settlement", "live_ads", "video_package", "s
# per role and class; the bundle a collector verifies; and the channel report it signs and any holder
# of the bundles recomputes. SHA-256, BLAKE3, Ed25519 and the modules it reads; no new dependency.
stream_audit = ["settlement", "live_ads", "telemetry"]
# Fleet (extensions/fleet.md): `wai.delivery.fleet` — an organisation's nodes enrolled by their
# signing keys (WFE1: node key, role, label, span, signed by the organisation key); the records a node
# contributes as it signs them (a relay's session summaries, every hop's per-hop records with their
# ledgers, and the room descriptors that place those in time), verified and counted once; and the
# fleet report (WFR1) the organisation signs for a period: one line per node and per room with their
# sessions, bytes and energy per scope and class, never summed across either, every unmetered figure
# counted as unmetered, each line citing its records; the bundle a verifier recomputes it from. It reads
# per-hop records through live_metering and summaries through the strict JSON reader. SHA-256 and
# Ed25519; no new dependency.
fleet = ["live_metering", "ed25519"]
# Native sink (extensions/native-sink.md): the core a native host's player integration runs, and its C
# ABI (`ffi_sink`, header `include/wai.h`): a media playlist verified against its playlist receipt and
# the chain of its live revisions (http-delivery §8.6), objects held to the content hash their names
@ -1287,6 +1298,11 @@ name = "wai_receipt_ledger_vectors"
path = "src/bin/wai_receipt_ledger_vectors.rs"
required-features = ["receipt_ledger"]
[[bin]]
name = "wai_fleet_vectors"
path = "src/bin/wai_fleet_vectors.rs"
required-features = ["fleet"]
[[bin]]
name = "wai_stream_audit_vectors"
path = "src/bin/wai_stream_audit_vectors.rs"
@ -1483,6 +1499,11 @@ name = "cli_dx"
path = "tests/cli_dx.rs"
required-features = ["quickstart"]
[[test]]
name = "cli_fleet"
path = "tests/cli_fleet.rs"
required-features = ["quickstart"]
[[test]]
name = "jpegai_decode_symbols"
path = "tests/jpegai_decode_symbols.rs"
@ -2259,6 +2280,11 @@ name = "receipt_ledger"
path = "tests/receipt_ledger.rs"
required-features = ["receipt_ledger"]
[[test]]
name = "fleet"
path = "tests/fleet.rs"
required-features = ["fleet"]
[[test]]
name = "stream_audit"
path = "tests/stream_audit.rs"

View file

@ -0,0 +1,408 @@
//! The fleet's operations (extensions/fleet.md §9), on the toolchain's rules: `wai enrolment
//! create` (`enrolment.create`), `wai enrolment list` (`enrolment.list`), `wai report create`
//! (`report.create`) and `wai report export` (`report.export`). A bundle is checked by `wai verify`.
//!
//! - Output: text on a terminal; the JSON envelope (`{"wai": "envelope/1", "op", "ok", ...}`) with
//! `--json` or when standard output is not a terminal. Data to standard output.
//! - Errors: `{code, message, hint, retryable}`; a refusal by the fleet's text has that text's
//! reason as its code (`enrolment_duplicate`, `signer_not_enrolled`, ...).
//! - Exit statuses: 0 done, 1 refused, 2 usage, 3 permission, 4 confirmation required, 5 not
//! found, 6 the service unavailable (retry), 7 internal.
//! - Every operation that writes takes `--dry-run`, which writes and sends nothing and says what
//! it would. One that changes a service (`--target`), or replaces a file, waits for
//! `--confirm` (asked for on a terminal); without it, it exits 4 with the confirming command.
//!
//! A target is a fleet service's `http://` address (`wai-fleet serve`, which listens on a
//! loopback address unless told otherwise).
use serde_json::{json, Value};
use std::io::{IsTerminal, Read, Write};
pub const ENROLMENT_USAGE: &str = "wai enrolment create <node-key> --role <role> --key <file> [--label L] [--from µs] [--until µs] [--out F] [--target URL] [--dry-run] [--confirm] [--json]\n a node's enrolment, signed with the organisation key; with --target, given to a fleet service\n (enrolment.create). wai enrolment list --target URL [--json]: the fleet's nodes (enrolment.list)";
pub const REPORT_USAGE: &str = "wai report create --target URL --start µs --end µs [--dry-run] [--confirm] [--json]\n the fleet report of a period, signed by a fleet service (report.create).\n wai report export <digest> --target URL [--out F] [--dry-run] [--confirm] [--json]: its bundle,\n verified here and written to a file (report.export)";
const DONE: u8 = 0;
const REFUSED: u8 = 1;
const USAGE: u8 = 2;
#[cfg_attr(not(feature = "fleet"), allow(dead_code))]
const PERMISSION: u8 = 3;
const CONFIRM: u8 = 4;
const NOT_FOUND: u8 = 5;
const UNAVAILABLE: u8 = 6;
const INTERNAL: u8 = 7;
/// How one operation reports.
struct Out {
op: &'static str,
json: bool,
}
/// Why an operation stops.
struct Fail {
exit: u8,
code: String,
message: String,
hint: String,
retryable: bool,
}
fn fail(exit: u8, code: &str, message: impl Into<String>, hint: impl Into<String>) -> Fail {
Fail { exit, code: code.into(), message: message.into(), hint: hint.into(), retryable: exit == UNAVAILABLE }
}
fn usage(message: impl Into<String>, hint: &str) -> Fail {
fail(USAGE, "usage", message, hint)
}
impl Out {
fn new(op: &'static str, args: &[String]) -> Out {
Out { op, json: super::flag(args, "--json") || !std::io::stdout().is_terminal() }
}
fn envelope(&self, ok: bool) -> serde_json::Map<String, Value> {
let mut m = serde_json::Map::new();
m.insert("wai".into(), json!("envelope/1"));
m.insert("op".into(), json!(self.op));
m.insert("ok".into(), json!(ok));
m
}
fn done(&self, data: Value, text: &str) -> u8 {
if self.json {
let mut m = self.envelope(true);
m.insert("data".into(), data);
println!("{}", Value::Object(m));
} else {
println!("{text}");
}
DONE
}
fn fail(&self, f: Fail) -> u8 {
if self.json {
let mut m = self.envelope(false);
m.insert("error".into(), json!({"code": f.code, "message": f.message, "hint": f.hint, "retryable": f.retryable}));
println!("{}", Value::Object(m));
} else {
eprintln!("wai: {} ({})\n {}", f.message, f.code, f.hint);
}
f.exit
}
/// The proposed changes, and the command that confirms them: status 4, nothing changed.
fn confirmation(&self, proposed: Value, confirm: String) -> u8 {
if self.json {
let mut m = self.envelope(false);
m.insert("error".into(), json!({"code": "confirmation-required", "message": "a change waits for confirmation", "hint": "re-run with --confirm", "retryable": false}));
m.insert("proposed".into(), proposed);
m.insert("confirm".into(), json!(confirm));
println!("{}", Value::Object(m));
} else {
eprintln!("wai: a change waits for confirmation:\n {proposed}\n to make it: {confirm}");
}
CONFIRM
}
/// A dry run: what would be done, and nothing done.
fn dry_run(&self, proposed: Value) -> u8 {
let text = format!("dry run, nothing written or sent: {proposed}");
self.done(json!({"dry_run": true, "proposed": proposed}), &text)
}
}
/// Whether a change is confirmed: by `--confirm`, or by a person answering `y` on a terminal.
fn confirmed(args: &[String], out: &Out, what: &str) -> bool {
if super::flag(args, "--confirm") {
return true;
}
if out.json || !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return false;
}
eprint!("{what} [y/N] ");
let _ = std::io::stderr().flush();
let mut line = String::new();
std::io::stdin().read_line(&mut line).is_ok() && line.trim().eq_ignore_ascii_case("y")
}
/// The command line again, with `--confirm`.
fn confirm_command(words: &str, args: &[String]) -> String {
let rest: Vec<String> = args.iter().filter(|a| *a != "--json").map(|a| if a.chars().all(|c| c.is_ascii_alphanumeric() || "-_./:".contains(c)) { a.clone() } else { format!("'{}'", a.replace('\'', "'\\''")) }).collect();
format!("wai {words} {} --confirm", rest.join(" "))
}
// ---------------------------------------------------------------------------------------
// A fleet service, over HTTP/1.0
// ---------------------------------------------------------------------------------------
/// One request to a fleet service at `target` (`http://host:port`), and its answer: the
/// status and the body read as JSON.
fn call(target: &str, method: &str, path: &str, body: Option<&Value>) -> Result<(u16, Value), Fail> {
let Some(rest) = target.strip_prefix("http://") else {
return Err(usage(format!("{target}: a fleet service's http:// address"), "give --target http://HOST:PORT, the address wai-fleet serve listens on"));
};
let authority = rest.split('/').next().unwrap_or("");
let host = authority.rsplit_once(':').map_or(authority, |(h, _)| h);
let addr = if authority.contains(':') { authority.to_string() } else { format!("{authority}:80") };
let unavailable = |e: String| fail(UNAVAILABLE, "target-unavailable", format!("{target}: {e}"), "start the fleet service, or check its address");
use std::net::ToSocketAddrs;
let sock = addr.to_socket_addrs().map_err(|e| unavailable(e.to_string()))?.next().ok_or_else(|| unavailable("no address".into()))?;
let mut s = std::net::TcpStream::connect_timeout(&sock, std::time::Duration::from_secs(10)).map_err(|e| unavailable(e.to_string()))?;
let _ = s.set_read_timeout(Some(std::time::Duration::from_secs(300)));
let payload = body.map(|b| b.to_string()).unwrap_or_default();
let mut req = format!("{method} {path} HTTP/1.0\r\nHost: {host}\r\nUser-Agent: wai\r\nConnection: close\r\n");
if body.is_some() {
req.push_str(&format!("Content-Type: application/json\r\nContent-Length: {}\r\n", payload.len()));
}
req.push_str("\r\n");
req.push_str(&payload);
s.write_all(req.as_bytes()).map_err(|e| unavailable(e.to_string()))?;
let mut all = Vec::new();
s.take(512 << 20).read_to_end(&mut all).map_err(|e| unavailable(e.to_string()))?;
let at = all.windows(4).position(|w| w == b"\r\n\r\n").ok_or_else(|| unavailable("no HTTP answer".into()))?;
let head = String::from_utf8_lossy(&all[..at]);
let status = head.split(' ').nth(1).and_then(|c| c.parse::<u16>().ok()).ok_or_else(|| unavailable("no status".into()))?;
let v = serde_json::from_slice(&all[at + 4..]).unwrap_or_else(|_| json!({"text": String::from_utf8_lossy(&all[at + 4..])}));
Ok((status, v))
}
/// An answer that is not a success, as the toolchain's failure.
fn answer_fail(status: u16, v: &Value) -> Fail {
match status {
422 => {
let r = v["reason"].as_str().unwrap_or("refused");
fail(REFUSED, r, format!("the fleet service refused it: {r}"), "see extensions/fleet.md §10 for the reason")
}
404 => fail(NOT_FOUND, "not-found", v["error"].as_str().unwrap_or("not found").to_string(), "list what the service holds"),
400 => usage(v["error"].as_str().unwrap_or("a bad request").to_string(), "see the operation's usage"),
_ => fail(INTERNAL, "service-error", format!("HTTP {status}: {v}"), "see the fleet service's log"),
}
}
#[cfg_attr(not(feature = "fleet"), allow(dead_code))]
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[cfg_attr(not(feature = "fleet"), allow(dead_code))]
fn unhex(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()
}
fn number(args: &[String], flag: &str, default: u64) -> Result<u64, Fail> {
match super::opt(args, flag) {
None => Ok(default),
Some(v) => v.parse().map_err(|_| usage(format!("{flag} {v}: microseconds since 1970, an integer"), "give a number of microseconds")),
}
}
// ---------------------------------------------------------------------------------------
// enrolment.create, enrolment.list
// ---------------------------------------------------------------------------------------
/// `wai enrolment create|list`.
pub fn enrolment(args: &[String]) -> u8 {
match args.first().map(String::as_str) {
Some("create") => {
let out = Out::new("enrolment.create", args);
match enrolment_create(&args[1..], &out) {
Ok(c) => c,
Err(f) => out.fail(f),
}
}
Some("list") => {
let out = Out::new("enrolment.list", args);
match enrolment_list(&args[1..], &out) {
Ok(c) => c,
Err(f) => out.fail(f),
}
}
_ => Out::new("enrolment.create", args).fail(usage(ENROLMENT_USAGE, "wai enrolment create … or wai enrolment list …")),
}
}
#[cfg(not(feature = "fleet"))]
fn enrolment_create(_: &[String], _: &Out) -> Result<u8, Fail> {
Err(fail(USAGE, "feature-missing", "`wai enrolment create` needs a build with the feature `fleet`", "cargo install --path wai/wai-rs --no-default-features --features quickstart"))
}
#[cfg(feature = "fleet")]
fn enrolment_create(args: &[String], out: &Out) -> Result<u8, Fail> {
use wai::fleet::{role, Enrolment, NO_END};
let with_value = ["--role", "--key", "--label", "--from", "--until", "--out", "--target"];
let pos = super::positional(args, &with_value);
let node: [u8; 32] = pos.first().and_then(|s| unhex(s)).and_then(|b| b.try_into().ok()).ok_or_else(|| usage("the node's key: 64 hexadecimal digits", ENROLMENT_USAGE))?;
let role_arg = super::opt(args, "--role").ok_or_else(|| usage("--role: relay, compositor, ingest, room, ledger or audit", ENROLMENT_USAGE))?;
let r = role::code(role_arg).or_else(|| role_arg.parse::<u8>().ok().filter(|r| role::name(*r).is_some())).ok_or_else(|| usage(format!("--role {role_arg}: relay, compositor, ingest, room, ledger or audit"), ENROLMENT_USAGE))?;
let key_path = super::opt(args, "--key").ok_or_else(|| usage("--key: the organisation key's file (32 bytes, or 64 hexadecimal digits)", ENROLMENT_USAGE))?;
let raw = std::fs::read(key_path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => fail(NOT_FOUND, "not-found", format!("{key_path}: {e}"), "give the organisation key's file"),
std::io::ErrorKind::PermissionDenied => fail(PERMISSION, "permission-denied", format!("{key_path}: {e}"), "the key's owner runs this"),
_ => fail(INTERNAL, "io-error", format!("{key_path}: {e}"), "check the file"),
})?;
let seed: [u8; 32] = if raw.len() == 32 { raw.try_into().expect("32") } else { std::str::from_utf8(&raw).ok().and_then(|s| unhex(s.trim())).and_then(|b| b.try_into().ok()).ok_or_else(|| usage(format!("{key_path}: 32 bytes, or 64 hexadecimal digits"), "the organisation key's seed"))? };
let key = ed25519_dalek::SigningKey::from_bytes(&seed);
let label = super::opt(args, "--label").unwrap_or("");
let (from, until) = (number(args, "--from", 0)?, number(args, "--until", NO_END)?);
let e = Enrolment::new(key.verifying_key().to_bytes(), node, r, from, until, label).sign(&key);
let bytes = e.to_bytes();
// Its own rules, before anything is written or sent.
Enrolment::parse(&bytes).map_err(|x| fail(USAGE, x.code(), format!("the enrolment is refused: {x}"), "extensions/fleet.md §3.3: from below until, a label of at most 64 bytes, a node that is not the organisation"))?;
let target = super::opt(args, "--target");
let file = super::opt(args, "--out").map(str::to_string).or_else(|| target.is_none().then(|| format!("{}.wfe", &hex(&node)[..16])));
let data = json!({
"node": hex(&node), "role": role::name(r), "label": label, "from": from, "until": until,
"organisation": hex(&key.verifying_key().to_bytes()), "digest": hex(&e.digest()), "enrolment": hex(&bytes), "file": file, "target": target,
});
let mut proposed = Vec::new();
if let Some(f) = &file {
proposed.push(json!({"action": "write", "file": f, "bytes": bytes.len(), "replaces": std::path::Path::new(f).exists()}));
}
if let Some(t) = target {
proposed.push(json!({"action": "enrol", "target": t, "node": hex(&node), "role": role::name(r)}));
}
if super::flag(args, "--dry-run") {
return Ok(out.dry_run(Value::Array(proposed)));
}
let replaces = file.as_ref().is_some_and(|f| std::path::Path::new(f).exists());
if (target.is_some() || replaces) && !confirmed(args, out, &format!("{}?", proposed.iter().map(Value::to_string).collect::<Vec<_>>().join("; "))) {
return Ok(out.confirmation(Value::Array(proposed), confirm_command("enrolment create", args)));
}
if let Some(f) = &file {
std::fs::write(f, &bytes).map_err(|e| fail(if e.kind() == std::io::ErrorKind::PermissionDenied { PERMISSION } else { INTERNAL }, "io-error", format!("{f}: {e}"), "check the directory"))?;
}
if let Some(t) = target {
let (status, v) = call(t, "POST", "/v1/fleet/enrolments", Some(&json!({"enrolment": hex(&bytes)})))?;
if status != 201 {
return Err(answer_fail(status, &v));
}
}
let text = format!(
"enrolled {} as {} ({:?}), digest {}{}{}",
&hex(&node)[..16],
role::name(r).unwrap_or("?"),
label,
&hex(&e.digest())[..16],
file.as_ref().map(|f| format!("; written to {f}")).unwrap_or_default(),
target.map(|t| format!("; held by {t}")).unwrap_or_default()
);
Ok(out.done(data, &text))
}
fn enrolment_list(args: &[String], out: &Out) -> Result<u8, Fail> {
let target = super::opt(args, "--target").ok_or_else(|| usage("--target: the fleet service's http:// address", ENROLMENT_USAGE))?;
let (status, v) = call(target, "GET", "/v1/fleet/enrolments", None)?;
if status != 200 {
return Err(answer_fail(status, &v));
}
let list = v["enrolments"].as_array().cloned().unwrap_or_default();
let mut text = format!("{} node(s) enrolled at {target}", list.len());
for e in &list {
text.push_str(&format!(
"\n {}… {:<10} {:<16} records {}",
&e["node"].as_str().unwrap_or("")[..16.min(e["node"].as_str().unwrap_or("").len())],
e["role"].as_str().unwrap_or("?"),
e["label"].as_str().unwrap_or(""),
e["records"]
));
}
Ok(out.done(json!({"enrolments": list}), &text))
}
// ---------------------------------------------------------------------------------------
// report.create, report.export
// ---------------------------------------------------------------------------------------
/// `wai report create|export`.
pub fn report(args: &[String]) -> u8 {
match args.first().map(String::as_str) {
Some("create") => {
let out = Out::new("report.create", args);
match report_create(&args[1..], &out) {
Ok(c) => c,
Err(f) => out.fail(f),
}
}
Some("export") => {
let out = Out::new("report.export", args);
match report_export(&args[1..], &out) {
Ok(c) => c,
Err(f) => out.fail(f),
}
}
_ => Out::new("report.create", args).fail(usage(REPORT_USAGE, "wai report create … or wai report export …")),
}
}
/// A report's outcome, for a person: its period, its lines and each scope's coverage.
fn summarise(o: &Value) -> String {
let cov = |e: &Value| format!("sessions {}, hops {}", e["sessions"]["coverage"].as_str().unwrap_or("?"), e["hops"]["coverage"].as_str().unwrap_or("?"));
let mut t = format!("report {} of [{}, {}): {} node line(s), {} room line(s), {} record(s)", &o["digest"].as_str().unwrap_or("")[..16.min(o["digest"].as_str().unwrap_or("").len())], o["start"], o["end"], o["nodes"].as_array().map_or(0, Vec::len), o["rooms"].as_array().map_or(0, Vec::len), o["records"]);
for n in o["nodes"].as_array().into_iter().flatten() {
t.push_str(&format!("\n node {:<16} {:<10} sessions {} links {} bytes in {} out {}; energy: {}", n["label"].as_str().unwrap_or(""), n["role"].as_str().unwrap_or(""), n["sessions"], n["links"], n["bytes_in"], n["bytes_out"], cov(&n["energy"])));
}
for e in o["enrolments"].as_array().into_iter().flatten().filter(|e| e["silent"] == true) {
t.push_str(&format!("\n node {:<16} {:<10} silent: no record in the period", e["label"].as_str().unwrap_or(""), e["role"].as_str().unwrap_or("")));
}
t.push_str(&format!("\n fleet energy: {} (a partial total is not the fleet's energy)", cov(&o["totals"]["energy"])));
t
}
fn report_create(args: &[String], out: &Out) -> Result<u8, Fail> {
let target = super::opt(args, "--target").ok_or_else(|| usage("--target: the fleet service's http:// address", REPORT_USAGE))?;
let start = number(args, "--start", 0)?;
let end = number(args, "--end", 0)?;
if super::opt(args, "--start").is_none() || super::opt(args, "--end").is_none() || start >= end {
return Err(usage("--start and --end: the period, in microseconds since 1970, start below end", REPORT_USAGE));
}
let proposed = json!([{"action": "sign a report", "target": target, "start": start, "end": end}]);
if super::flag(args, "--dry-run") {
return Ok(out.dry_run(proposed));
}
if !confirmed(args, out, &format!("have {target} sign the report of [{start}, {end})?")) {
return Ok(out.confirmation(proposed, confirm_command("report create", args)));
}
let (status, v) = call(target, "POST", "/v1/fleet/reports", Some(&json!({"start": start, "end": end})))?;
if status != 201 {
return Err(answer_fail(status, &v));
}
let o = &v["outcome"];
let text = summarise(o);
Ok(out.done(json!({"report": v["report"], "outcome": o}), &text))
}
#[cfg(not(feature = "fleet"))]
fn report_export(_: &[String], _: &Out) -> Result<u8, Fail> {
Err(fail(USAGE, "feature-missing", "`wai report export` needs a build with the feature `fleet`", "cargo install --path wai/wai-rs --no-default-features --features quickstart"))
}
#[cfg(feature = "fleet")]
fn report_export(args: &[String], out: &Out) -> Result<u8, Fail> {
let target = super::opt(args, "--target").ok_or_else(|| usage("--target: the fleet service's http:// address", REPORT_USAGE))?;
let pos = super::positional(args, &["--target", "--out"]);
let digest = pos.first().filter(|d| d.len() == 64 && unhex(d).is_some()).ok_or_else(|| usage("the report's digest: 64 hexadecimal digits", REPORT_USAGE))?;
let file = super::opt(args, "--out").map(str::to_string).unwrap_or_else(|| format!("{}{}", &digest[..16], wai::fleet::BUNDLE_SUFFIX));
let replaces = std::path::Path::new(&file).exists();
let proposed = json!([{"action": "write", "file": file, "replaces": replaces}]);
if super::flag(args, "--dry-run") {
return Ok(out.dry_run(proposed));
}
let (status, v) = call(target, "GET", &format!("/v1/fleet/reports/{digest}"), None)?;
if status != 200 {
return Err(answer_fail(status, &v));
}
// The bundle verified here, by this build, before it is written: the service is not trusted.
let o = wai::fleet::outcome_json(&wai::fleet::verify(&v["bundle"]));
if o["outcome"] != "accept" || o["digest"] != json!(digest) {
return Err(fail(REFUSED, o["reason"].as_str().unwrap_or("report_mismatch"), format!("the service's bundle does not verify: {o}"), "report it to the fleet's operator"));
}
if replaces && !confirmed(args, out, &format!("replace {file}?")) {
return Ok(out.confirmation(proposed, confirm_command("report export", args)));
}
let text_out = serde_json::to_string_pretty(&v["bundle"]).expect("JSON") + "\n";
std::fs::write(&file, text_out).map_err(|e| fail(if e.kind() == std::io::ErrorKind::PermissionDenied { PERMISSION } else { INTERNAL }, "io-error", format!("{file}: {e}"), "check the directory"))?;
let text = format!("{}\nwritten to {file}; check it anywhere with `wai verify {file}`", summarise(&o));
Ok(out.done(json!({"file": file, "outcome": o}), &text))
}

View file

@ -4,12 +4,15 @@
//! - `create`: a new project from one of the examples (`wai/examples/`), or from an app made
//! with Create (`--from app.oui`, a build with `app`);
//! - `dev`: a relay, a room service and the room page on one origin, on this machine;
//! - `verify`: the receipts under a directory, checked offline;
//! - `verify`: the receipts under a directory, and fleet report bundles, checked offline;
//! - `app` (`app.guide`, `app.check`; a build with `app`): an app made with Create, its guide
//! and its check;
//! - `mcp` (`agent.mcp`): WAI's operations as tools for a coding agent, a Model Context
//! Protocol server on standard input and output, in toolsets (`../mcp/`);
//! - `setup-agent` (`agent.setup`): a project made ready for coding agents (`../setup_agent.rs`).
//! - `setup-agent` (`agent.setup`): a project made ready for coding agents (`../setup_agent.rs`);
//! - `enrolment` (`enrolment.create`, `enrolment.list`) and `report` (`report.create`,
//! `report.export`): a fleet's nodes enrolled and its reports, against a fleet service
//! (extensions/fleet.md §9; `fleet.rs`).
//!
//! A verb is named in this table and nowhere else: renaming or regrouping one is a line
//! here. The project file a template writes (`wai.json`) is read by [`project`] alone.
@ -19,6 +22,7 @@ pub mod project;
mod app;
mod create;
mod dev;
mod fleet;
mod hello;
mod verify;
@ -57,6 +61,8 @@ pub const VERBS: &[Verb] = &[
Verb { name: "app", usage: app::USAGE, run: Run::Checks(app::main) },
Verb { name: MCP, usage: MCP_USAGE, run: Run::Checks(mcp_main) },
Verb { name: "setup-agent", usage: setup_agent::USAGE, run: Run::Status(setup_agent::main) },
Verb { name: "enrolment", usage: fleet::ENROLMENT_USAGE, run: Run::Status(fleet::enrolment) },
Verb { name: "report", usage: fleet::REPORT_USAGE, run: Run::Status(fleet::report) },
];
const MCP_USAGE: &str = "wai mcp [--toolset core,dev,app] [--read-only] [--project DIR] [--spec-root DIR] [--room-bin BIN]\n WAI's operations as tools for a coding agent: a Model Context Protocol server on standard\n input and output (a build with `mcp`)";

View file

@ -7,8 +7,11 @@
//!
//! A signature proves the bytes were signed by the key the receipt names. Which party holds
//! that key is a key log's to say (http-delivery §8.4); this command names the key.
//!
//! A fleet report's bundle (`*.wai-fleet.json`, extensions/fleet.md §7) under a path is verified
//! too, in a build with `fleet`: the report recomputed from its enrolments and records.
pub const USAGE: &str = "wai verify <path>... the receipts under each path (receipt indexes *.wai.receipts), checked offline";
pub const USAGE: &str = "wai verify <path>... the receipts under each path (receipt indexes *.wai.receipts) and fleet report\n bundles (*.wai-fleet.json), checked offline";
#[cfg(not(feature = "quickstart"))]
pub fn main(_: &[String]) -> Result<bool, String> {
@ -25,10 +28,12 @@ pub fn main(args: &[String]) -> Result<bool, String> {
if paths.is_empty() {
return Err(USAGE.into());
}
fn walk(p: &Path, out: &mut Vec<PathBuf>, depth: usize) -> Result<(), String> {
fn walk(p: &Path, out: &mut Vec<PathBuf>, bundles: &mut Vec<PathBuf>, depth: usize) -> Result<(), String> {
if p.is_file() {
if p.to_string_lossy().ends_with(".wai.receipts") {
out.push(p.to_path_buf());
} else if p.to_string_lossy().ends_with(".wai-fleet.json") {
bundles.push(p.to_path_buf());
}
return Ok(());
}
@ -44,16 +49,16 @@ pub fn main(args: &[String]) -> Result<bool, String> {
if e.is_dir() && e.file_name().is_some_and(|n| n == "rcpt") {
continue;
}
walk(&e, out, depth + 1)?;
walk(&e, out, bundles, depth + 1)?;
}
Ok(())
}
let mut indexes = Vec::new();
let (mut indexes, mut bundles) = (Vec::new(), Vec::new());
for p in &paths {
walk(Path::new(p), &mut indexes, 0)?;
walk(Path::new(p), &mut indexes, &mut bundles, 0)?;
}
if indexes.is_empty() {
return Err(format!("no receipt index (*.wai.receipts) under {}", paths.join(", ")));
if indexes.is_empty() && bundles.is_empty() {
return Err(format!("no receipt index (*.wai.receipts) and no fleet bundle (*.wai-fleet.json) under {}", paths.join(", ")));
}
let mut all = true;
for ix in &indexes {
@ -64,11 +69,67 @@ pub fn main(args: &[String]) -> Result<bool, String> {
}
all &= ok;
}
let n = indexes.len();
println!("wai verify: {} receipt index{} {}", n, if n == 1 { "" } else { "es" }, if all { "verified" } else { "with a refusal (above)" });
for b in &bundles {
println!("{}", b.display());
let (ok, lines) = check_bundle_file(b)?;
for l in lines {
println!(" {l}");
}
all &= ok;
}
let (n, m) = (indexes.len(), bundles.len());
let mut what = Vec::new();
if n > 0 {
what.push(format!("{n} receipt index{}", if n == 1 { "" } else { "es" }));
}
if m > 0 {
what.push(format!("{m} fleet bundle{}", if m == 1 { "" } else { "s" }));
}
println!("wai verify: {} {}", what.join(" and "), if all { "verified" } else { "with a refusal (above)" });
Ok(all)
}
/// One fleet report bundle (extensions/fleet.md §7): whether it verified, and a line for its
/// report and for each node, room and scope of energy.
#[cfg(all(feature = "quickstart", feature = "fleet"))]
pub(crate) fn check_bundle_file(path: &std::path::Path) -> Result<(bool, Vec<String>), String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
let bundle: serde_json::Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(e) => return Ok((false, vec![format!("FAIL the bundle is not JSON: {e}")])),
};
let o = wai::fleet::outcome_json(&wai::fleet::verify(&bundle));
if o["outcome"] != "accept" {
return Ok((false, vec![format!("FAIL fleet report: {} at {}", o["reason"].as_str().unwrap_or("?"), o["at"])]));
}
let short = |v: &serde_json::Value| v.as_str().map(|s| s[..16.min(s.len())].to_string()).unwrap_or_default();
let cov = |e: &serde_json::Value| format!("sessions {}, hops {}", e["sessions"]["coverage"].as_str().unwrap_or("?"), e["hops"]["coverage"].as_str().unwrap_or("?"));
let mut lines = vec![format!(
"ok fleet report {}… of organisation {}…, [{}, {}): {} record(s), every line recomputed",
short(&o["digest"]),
short(&o["organisation"]),
o["start"],
o["end"],
o["records"]
)];
for n in o["nodes"].as_array().into_iter().flatten() {
lines.push(format!("ok node {} ({}): {} session(s), {} link(s); energy {}", n["label"].as_str().unwrap_or(""), n["role"].as_str().unwrap_or(""), n["sessions"], n["links"], cov(&n["energy"])));
}
for e in o["enrolments"].as_array().into_iter().flatten().filter(|e| e["silent"] == true) {
lines.push(format!("ok node {} ({}): silent, no record in the period", e["label"].as_str().unwrap_or(""), e["role"].as_str().unwrap_or("")));
}
for r in o["rooms"].as_array().into_iter().flatten() {
lines.push(format!("ok room {}…: {} hop record(s); energy {}", short(&r["room"]), r["hops"], cov(&r["energy"])));
}
lines.push(format!("ok fleet energy {} (a partial total is not the fleet's energy)", cov(&o["totals"]["energy"])));
Ok((true, lines))
}
#[cfg(all(feature = "quickstart", not(feature = "fleet")))]
pub(crate) fn check_bundle_file(path: &std::path::Path) -> Result<(bool, Vec<String>), String> {
Ok((false, vec![format!("FAIL {}: a fleet bundle is verified by a build with the feature `fleet`", path.display())]))
}
/// One receipt index, checked: whether every item and claim it lists held, and a line for
/// each.
#[cfg(feature = "quickstart")]

View file

@ -0,0 +1,67 @@
//! Conformance corpus for the fleet (`live-conformance/fleet/`,
//! extensions/fleet.md §11).
//!
//! wai_fleet_vectors gen <corpus-dir> write vectors.json, from fixed seeds and the
//! records of the metering corpus
//! wai_fleet_vectors verify <corpus-dir> vectors.json regenerates byte for byte, and
//! every vector gets the outcome it states
//!
//! The corpus directory is `wai/live-conformance/fleet`; the metering corpus it reads is found
//! from it. The generator and this verifier are one implementation. The corpus's second
//! implementation is `live-conformance/fleet_verify.py`, written from the extension's text and
//! sharing no code with this binary.
use std::path::{Path, PathBuf};
use wai::fleet::corpus;
fn wai_dir(corpus_dir: &Path) -> PathBuf {
corpus_dir.join("..").join("..")
}
fn main() {
let a: Vec<String> = std::env::args().collect();
match (a.get(1).map(String::as_str), a.get(2)) {
(Some("gen"), Some(d)) => {
let p = Path::new(d).join("vectors.json");
let text = match corpus::generate(&wai_dir(Path::new(d))) {
Ok(t) => t,
Err(e) => {
eprintln!("generate: {e}");
std::process::exit(1);
}
};
if let Err(e) = std::fs::create_dir_all(d).and_then(|_| std::fs::write(&p, text)) {
eprintln!("write {}: {e}", p.display());
std::process::exit(1);
}
println!("wrote {}", p.display());
}
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
_ => {
eprintln!("usage: wai_fleet_vectors gen|verify <corpus-dir>");
std::process::exit(2);
}
}
}
fn verify(dir: &Path) -> i32 {
let p = dir.join("vectors.json");
let doc = match std::fs::read_to_string(&p) {
Ok(s) => s,
Err(e) => {
eprintln!("read {}: {e}", p.display());
return 1;
}
};
let mut fail = 0;
let same = corpus::generate(&wai_dir(dir)).is_ok_and(|g| g == doc);
println!("{} vectors.json regenerates byte for byte from its seeds and the metering corpus", if same { "PASS" } else { "FAIL" });
fail += (!same) as i32;
let fails = corpus::verify(&doc);
for f in &fails {
println!("FAIL {f}");
}
println!("{} every vector gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
fail += (!fails.is_empty()) as i32;
(fail != 0) as i32
}

View file

@ -238,6 +238,7 @@ pub const CAP_MEDIA_CONTRIBUTION: &str = "wai.media.contribution";
pub const CAP_DELIVERY_SETTLEMENT: &str = "wai.delivery.settlement";
pub const CAP_DELIVERY_LEDGER: &str = "wai.delivery.ledger";
pub const CAP_DELIVERY_AUDIT: &str = "wai.delivery.audit";
pub const CAP_DELIVERY_FLEET: &str = "wai.delivery.fleet";
pub const CAP_MEDIA_TAKEDOWN: &str = "wai.media.takedown";
pub const CAP_LIVE_COPERFORMANCE: &str = "wai.live.coperformance";
pub const CAP_MEDIA_INFLUENCE: &str = "wai.media.influence";
@ -363,6 +364,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
(CAP_DELIVERY_SETTLEMENT, S_INTERACTIVE, None, "2026-10-21"),
(CAP_DELIVERY_LEDGER, S_INTERACTIVE, None, "2026-10-25"),
(CAP_DELIVERY_AUDIT, S_INTERACTIVE, None, "2026-10-25"),
(CAP_DELIVERY_FLEET, S_INTERACTIVE, None, "2026-10-27"),
(CAP_MEDIA_TAKEDOWN, S_INTERACTIVE, None, "2026-10-21"),
(CAP_LIVE_COPERFORMANCE, S_INTERACTIVE, None, "2026-10-21"),
(CAP_MEDIA_INFLUENCE, S_INTERACTIVE, None, "2026-10-21"),
@ -455,6 +457,7 @@ const RECORDS: &[&str] = &[
CAP_DELIVERY_SETTLEMENT,
CAP_DELIVERY_LEDGER,
CAP_DELIVERY_AUDIT,
CAP_DELIVERY_FLEET,
CAP_MEDIA_TAKEDOWN,
CAP_LIVE_COPERFORMANCE,
CAP_MEDIA_INFLUENCE,
@ -652,6 +655,7 @@ const CONTRIBUTION: &str = "extensions/contribution.md";
const SETTLEMENT: &str = "extensions/settlement.md";
const LEDGER: &str = "extensions/receipt-ledger.md";
const STREAM_AUDIT: &str = "extensions/stream-audit.md";
const FLEET: &str = "extensions/fleet.md";
const BROWSER_SINK: &str = "extensions/browser-sink.md";
const TAKEDOWN: &str = "extensions/takedown.md";
const INFLUENCE: &str = "extensions/influence.md";
@ -822,6 +826,10 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("WLT1", WireKind::Magic, LEDGER, "§4.1", WireStatus::Defined),
wire("WLI1", WireKind::Magic, LEDGER, "§6.1", WireStatus::Defined),
wire("WAS1", WireKind::Magic, STREAM_AUDIT, "§5.1", WireStatus::Defined),
wire("WFE1", WireKind::Magic, FLEET, "§3.1", WireStatus::Defined),
wire("WFR1", WireKind::Magic, FLEET, "§6.1", WireStatus::Defined),
wire("wai-fleet-bundle", WireKind::DocumentKind, FLEET, "§7.1", WireStatus::Defined),
wire(".wai-fleet.json", WireKind::Path, FLEET, "§7.1", WireStatus::Defined),
wire("contentBinding", WireKind::FrameMetadata, BROWSER_SINK, "§7.1", WireStatus::Proposed),
wire("wai-takedown-clocks", WireKind::DocumentKind, TAKEDOWN, "§4", WireStatus::Defined),
wire("wai-takedown-policy", WireKind::DocumentKind, TAKEDOWN, "§5.1", WireStatus::Defined),

1632
wai/wai-rs/src/fleet.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,652 @@
//! The fleet corpus (`live-conformance/fleet/`, extensions/fleet.md §11).
//!
//! Its per-hop records and room descriptors come from `live-conformance/metering/`, taken as
//! that corpus states them: the lesson room's descriptor, its relay's, compositor's and ingest
//! gateway's records of three classes with their ledgers, and the relay room's descriptor with
//! the record the reference relay signed, which states the unmetered marker. The relay that
//! signed those is the fleet's home relay here, under the same key. The session summaries are
//! written here in the reference relay's form (§4.2), by the relays' keys, from sessions
//! authored in code; the organisation, the edge relay and the other keys are SHA-256 of a fixed
//! label; and every time is a constant. [`generate`] reads the metering corpus from a `wai/`
//! directory and writes `vectors.json`; [`verify`] checks every vector against the outcome it
//! states.
use std::path::Path;
use ed25519_dalek::SigningKey;
use serde_json::{json, Value};
use super::*;
use crate::capture_take::corpus::{key as principal, AUTHORITY, RELAY};
use crate::energy_class::{CounterFiltering, EnergyProvenance, Uncertainty};
use crate::live_metering::corpus::{COMPOSITOR, INGEST};
/// 2026-10-16T00:00:00Z: the lesson room's `t0`, and the day most vectors report.
const LESSON_T0: u64 = 1_792_108_800_000_000;
/// 2026-10-18T00:00:00Z: the relay room's `t0`.
const RELAY_T0: u64 = LESSON_T0 + 2 * DAY_US;
const DAY_US: u64 = 86_400_000_000;
/// The day of the lesson.
const DAY: (u64, u64) = (LESSON_T0, LESSON_T0 + DAY_US);
/// 2026-10-12T00:00:00Z to 2026-10-19T00:00:00Z: a week, Monday to Monday.
const WEEK: (u64, u64) = (LESSON_T0 - 4 * DAY_US, LESSON_T0 + 3 * DAY_US);
/// The lesson day and the relay room's day in milliseconds, for the summaries.
const LESSON_MS: u64 = LESSON_T0 / 1000;
const RELAY_MS: u64 = RELAY_T0 / 1000;
fn key(label: &str) -> SigningKey {
SigningKey::from_bytes(&sha256(&[b"wai:fleet/corpus/key\x00", label.as_bytes()]))
}
fn pk(k: &SigningKey) -> [u8; 32] {
k.verifying_key().to_bytes()
}
/// The keys of the corpus, by name.
struct Keys {
org: SigningKey,
stranger: SigningKey,
home: SigningKey,
edge: SigningKey,
compositor: SigningKey,
ingest: SigningKey,
room_service: SigningKey,
outsider: SigningKey,
publisher: SigningKey,
}
fn keys() -> Keys {
Keys {
org: key("organisation"),
stranger: key("another organisation"),
home: principal(RELAY),
edge: key("edge relay"),
compositor: principal(COMPOSITOR),
ingest: principal(INGEST),
room_service: principal(AUTHORITY),
outsider: key("a relay of no fleet"),
publisher: key("a publisher"),
}
}
// ---------------------------------------------------------------------------------------
// The records taken from the metering corpus
// ---------------------------------------------------------------------------------------
fn read_json(wai: &Path, rel: &str) -> Result<Value, String> {
let t = std::fs::read_to_string(wai.join(rel)).map_err(|e| format!("{rel}: {e}"))?;
serde_json::from_str(&t).map_err(|e| format!("{rel}: {e}"))
}
/// The records this corpus takes from the metering corpus, as carriages.
struct Taken {
lesson: Value,
relay_room: Value,
lesson_id: [u8; 32],
/// The lesson's relay (OnChipCounter), compositor (ModelBased and OnChipCounter) and ingest
/// gateway (Estimator); the reference relay's recorded record (the unmetered marker).
relay: Value,
compositor_model: Value,
compositor_counter: Value,
ingest: Value,
recorded: Value,
}
fn hop_by_role(m: &Value, vector: &str, role_: u8) -> Result<Value, String> {
let v = m["vectors"].as_array().and_then(|l| l.iter().find(|v| v["name"] == vector)).ok_or_else(|| format!("no metering vector {vector}"))?;
let h = v["hops"]
.as_array()
.and_then(|l| l.iter().find(|h| h["record"].as_str().and_then(unhex).is_some_and(|b| b.get(69) == Some(&role_))))
.ok_or_else(|| format!("{vector}: no hop of role {role_}"))?;
Ok(json!({"kind": "hop", "record": h["record"], "ledger": h["ledger"]}))
}
fn taken(wai: &Path) -> Result<Taken, String> {
let m = read_json(wai, "live-conformance/metering/vectors.json")?;
let descriptor = |room: &str| json!({"kind": "descriptor", "descriptor": m["rooms"][room]["descriptor"]});
let lesson = descriptor("lesson");
let lesson_id = match read_record(&lesson) {
Ok(Record::Descriptor(d)) if d.t0 == LESSON_T0 => d.room,
other => return Err(format!("the lesson room's descriptor: {other:?}")),
};
let relay_room = descriptor("relay");
match read_record(&relay_room) {
Ok(Record::Descriptor(d)) if d.t0 == RELAY_T0 => {}
other => return Err(format!("the relay room's descriptor: {other:?}")),
}
Ok(Taken {
lesson,
relay_room,
lesson_id,
relay: hop_by_role(&m, "mixed_classes", role::RELAY)?,
compositor_model: hop_by_role(&m, "mixed_classes", role::COMPOSITOR)?,
compositor_counter: hop_by_role(&m, "lesson", role::COMPOSITOR)?,
ingest: hop_by_role(&m, "mixed_classes", role::INGEST)?,
recorded: hop_by_role(&m, "relay_recorded", role::RELAY)?,
})
}
// ---------------------------------------------------------------------------------------
// Session summaries, in the reference relay's form (§4.2)
// ---------------------------------------------------------------------------------------
/// One session as a relay summarises it.
#[derive(Clone)]
struct Session {
session: u64,
transport: &'static str,
remote: &'static str,
opened_ms: u64,
closed_ms: u64,
close_code: Option<u64>,
peer: Option<[u8; 32]>,
/// Tracks received as a subscriber: name, objects forwarded, their bytes, objects fetched,
/// their bytes.
out: Vec<(String, u64, u64, u64, u64)>,
/// Tracks published: name, objects, bytes.
inn: Vec<(String, u64, u64)>,
/// Layer spans: room, class, from, to.
spans: Vec<(String, u8, u64, u64)>,
energy: (&'static str, u64),
/// Members the summary's text holds beyond the relay's own, written after `tally`.
extra: Option<(&'static str, Value)>,
}
impl Session {
fn new(session: u64, remote: &'static str, opened_ms: u64, closed_ms: u64) -> Session {
Session { session, transport: "quic", remote, opened_ms, closed_ms, close_code: Some(0), peer: None, out: vec![], inn: vec![], spans: vec![], energy: ("unmetered", 0), extra: None }
}
}
/// The summary's text as the reference relay serialises it: its members in the order the
/// relay declares them, maps in key order, and no whitespace.
fn summary_text(relay: &[u8; 32], s: &Session) -> String {
let mut out = s.out.clone();
out.sort_by(|a, b| a.0.cmp(&b.0));
let mut inn = s.inn.clone();
inn.sort_by(|a, b| a.0.cmp(&b.0));
let sub_tracks: serde_json::Map<String, Value> = out
.iter()
.map(|(n, o, b, fo, fb)| {
(n.clone(), json!({
"objects_forwarded": o, "object_bytes_forwarded": b, "dropped_delivery_timeout": 0, "dropped_receipts_track_subscribed": 0,
"subgroups_reset_after_fin": 0, "fetch_objects": fo, "fetch_bytes": fb, "undelivered_at_close": 0, "incremental_objects": 0,
"dvr_objects": 0, "dvr_bytes": 0,
}))
})
.collect();
let pub_tracks: serde_json::Map<String, Value> =
inn.iter().map(|(n, o, b)| (n.clone(), json!({"objects_received": o, "object_bytes_received": b, "refused_at_door": 0, "deduplicated": 0}))).collect();
let objects_out: u64 = out.iter().map(|t| t.1).sum();
let bytes_out: u64 = out.iter().map(|t| t.2).sum();
let fetch_objects: u64 = out.iter().map(|t| t.3).sum();
let all_bytes: u64 = out.iter().map(|t| t.2 + t.4).sum();
// Every byte written to the session's streams: the payloads and, per object, its headers.
let wire = all_bytes + 9 * (objects_out + fetch_objects);
let mut tally = json!({
"subscriber": {
"objects_forwarded": objects_out, "object_bytes_forwarded": bytes_out, "wire_bytes_sent": wire,
"dropped_delivery_timeout": 0, "dropped_receipts_track_subscribed": 0, "fetch_objects": fetch_objects,
"undelivered_at_close": 0, "tracks": sub_tracks,
"layer_spans": s.spans.iter().map(|(r, c, f, t)| json!({"room": r, "class": c, "from_unix_us": f, "to_unix_us": t})).collect::<Vec<_>>(),
},
"publisher": {
"objects_received": inn.iter().map(|t| t.1).sum::<u64>(), "object_bytes_received": inn.iter().map(|t| t.2).sum::<u64>(),
"refused_at_door": 0, "deduplicated": 0, "tracks": pub_tracks,
},
"publisher_signed": {},
});
if let Some(p) = s.peer {
tally["peer"] = json!(hex(&p));
}
let mut v = json!({
"format": SUMMARY_FORMAT, "relay_key": hex(relay), "session": s.session, "transport": s.transport, "remote": s.remote,
"draft": "moqt-22", "opened_unix_ms": s.opened_ms, "closed_unix_ms": s.closed_ms, "close_code": s.close_code,
"relay_energy": {"acquisition": s.energy.0, "joules_micro": s.energy.1}, "tally": tally,
});
if let Some((k, x)) = &s.extra {
v[*k] = x.clone();
}
serde_json::to_string(&v).expect("JSON")
}
/// A line of the relay's summary file: `text` signed by `k`, in the relay's form.
fn line_of(k: &SigningKey, text: &str) -> String {
let sig = sign(k, SUMMARY_DOMAIN, text.as_bytes());
format!("{LINE_HEAD}{text}{LINE_SIG}{}\"}}", hex(&sig))
}
fn summary(k: &SigningKey, s: &Session) -> Value {
json!({"kind": "summary", "line": line_of(k, &summary_text(&pk(k), s))})
}
/// The sessions of the corpus: the home relay's and the edge relay's on the lesson day (the
/// edge's upstream link to home is one session at each end), and one of home's on the relay
/// room's day.
struct Sessions {
home: Vec<Session>,
edge: Vec<Session>,
home_later: Session,
}
fn sessions(k: &Keys, lesson: &[u8; 32]) -> Sessions {
let room = hex(lesson);
let video = format!("{room}-{}-video--0", hex(&pk(&k.publisher)));
let audio = format!("{room}-{}-audio--0", hex(&pk(&k.publisher)));
let span = |from_ms: u64, to_ms: u64| (room.clone(), 0u8, (LESSON_MS + from_ms) * 1000, (LESSON_MS + to_ms) * 1000);
// The publisher's session at home.
let mut publisher = Session::new(1, "203.0.113.10:50112", LESSON_MS, LESSON_MS + 24_000);
publisher.inn = vec![(video.clone(), 720, 9_216_000), (audio.clone(), 1_200, 192_000)];
// A viewer at home, which fetched the first group it missed.
let mut viewer = Session::new(2, "198.51.100.23:61004", LESSON_MS + 1_000, LESSON_MS + 23_000);
viewer.transport = "webtransport";
viewer.out = vec![(video.clone(), 660, 8_448_000, 30, 384_000), (audio.clone(), 1_100, 176_000, 0, 0)];
viewer.spans = vec![span(1_000, 23_000)];
// The edge relay's upstream link, as home accepted it: home sends the room to the edge.
let mut link_home = Session::new(3, "192.0.2.44:4433", LESSON_MS + 400, LESSON_MS + 24_600);
link_home.peer = Some(pk(&k.edge));
link_home.out = vec![(video.clone(), 715, 9_152_000, 0, 0), (audio.clone(), 1_190, 190_400, 0, 0)];
// The same link at the edge, which dialed it: the edge receives what home sent.
let mut link_edge = Session::new(1, "198.51.100.7:443", LESSON_MS + 400, LESSON_MS + 24_600);
link_edge.transport = "websocket";
link_edge.peer = Some(pk(&k.home));
link_edge.inn = vec![(video.clone(), 715, 9_152_000), (audio.clone(), 1_190, 190_400)];
// Two viewers at the edge.
let mut e2 = Session::new(2, "203.0.113.81:53311", LESSON_MS + 2_000, LESSON_MS + 24_000);
e2.out = vec![(video.clone(), 660, 8_448_000, 0, 0), (audio.clone(), 1_100, 176_000, 0, 0)];
e2.spans = vec![span(2_000, 24_000)];
let mut e3 = Session::new(3, "192.0.2.150:40022", LESSON_MS + 5_000, LESSON_MS + 12_000);
e3.out = vec![(audio.clone(), 350, 56_000, 0, 0)];
e3.spans = vec![(room.clone(), 8, (LESSON_MS + 5_000) * 1000, (LESSON_MS + 12_000) * 1000)];
e3.close_code = None;
// Home, two days later: a viewer of the relay room.
let mut later = Session::new(7, "198.51.100.99:52000", RELAY_MS + 42_335_000, RELAY_MS + 42_339_000);
later.out = vec![("live-a--cam".into(), 40, 512_000, 0, 0)];
Sessions { home: vec![publisher, viewer, link_home], edge: vec![link_edge, e2, e3], home_later: later }
}
// ---------------------------------------------------------------------------------------
// Cases and vectors
// ---------------------------------------------------------------------------------------
/// A bundle's parts before its report is signed.
#[derive(Clone)]
struct Case {
period: (u64, u64),
enrolments: Vec<Enrolment>,
records: Vec<Value>,
}
impl Case {
/// The report the organisation signs of the case: computed from its records as given.
fn report(&self, org: &SigningKey) -> Report {
let recs: Vec<Record> = self.records.iter().map(|c| read_record(c).expect("a corpus record reads")).collect();
sign_report(org, self.period.0, self.period.1, &self.enrolments, &recs).expect("the report's figures are stated")
}
fn bundle(&self, org: &SigningKey) -> Value {
bundle(&self.report(org), &self.enrolments, &self.records)
}
/// The bundle with its report edited and signed again.
fn edited(&self, org: &SigningKey, edit: impl FnOnce(&mut Report)) -> Value {
let mut r = self.report(org);
edit(&mut r);
bundle(&r.sign(org), &self.enrolments, &self.records)
}
/// The bundle with its report's bytes edited, not signed again.
fn raw(&self, org: &SigningKey, edit: impl FnOnce(&mut Vec<u8>)) -> Value {
let mut b = self.bundle(org);
let mut r = unhex(b["report"].as_str().expect("hex")).expect("hex");
edit(&mut r);
b["report"] = json!(hex(&r));
b
}
}
fn enrol(org: &SigningKey, node: &SigningKey, r: u8, label: &str) -> Enrolment {
Enrolment::new(pk(org), pk(node), r, WEEK.0 - 30 * DAY_US, NO_END, label).sign(org)
}
/// A hop carriage's record, edited and signed again by `k`.
fn resigned_hop(c: &Value, k: &SigningKey, edit: impl FnOnce(&mut HopRecord)) -> Value {
let b = unhex(c["record"].as_str().expect("hex")).expect("hex");
let room: [u8; 32] = b[5..37].try_into().expect("32");
let mut r = HopRecord::parse(&b, &room).expect("a corpus record");
edit(&mut r);
json!({"kind": "hop", "record": hex(&r.sign(k).to_bytes()), "ledger": c["ledger"]})
}
/// A carriage with one of its hexadecimal members' bytes edited.
fn bytes_edited(c: &Value, member: &str, edit: impl FnOnce(&mut Vec<u8>)) -> Value {
let mut b = unhex(c[member].as_str().expect("hex")).expect("hex");
edit(&mut b);
let mut c = c.clone();
c[member] = json!(hex(&b));
c
}
struct Vector {
name: &'static str,
about: &'static str,
bundle: Value,
/// For a refusal, the reason and place it was written to reach.
intent: Option<(Reason, Place)>,
}
fn accept(name: &'static str, about: &'static str, bundle: Value) -> Vector {
Vector { name, about, bundle, intent: None }
}
fn refuse(name: &'static str, about: &'static str, bundle: Value, reason: Reason, at: Place) -> Vector {
Vector { name, about, bundle, intent: Some((reason, at)) }
}
fn vectors(t: &Taken) -> Vec<Vector> {
let k = keys();
let s = sessions(&k, &t.lesson_id);
let org = &k.org;
let home_e = enrol(org, &k.home, role::RELAY, "home");
let edge_e = enrol(org, &k.edge, role::RELAY, "edge");
let comp_e = enrol(org, &k.compositor, role::COMPOSITOR, "compositor");
let ingest_e = enrol(org, &k.ingest, role::INGEST, "ingest");
let room_e = enrol(org, &k.room_service, role::ROOM, "room service");
let home_s: Vec<Value> = s.home.iter().map(|x| summary(&k.home, x)).collect();
let edge_s: Vec<Value> = s.edge.iter().map(|x| summary(&k.edge, x)).collect();
let week = Case {
period: WEEK,
enrolments: vec![home_e.clone(), edge_e.clone(), comp_e.clone(), ingest_e.clone(), room_e.clone()],
records: [
vec![t.lesson.clone(), t.relay_room.clone(), t.relay.clone(), t.compositor_model.clone(), t.ingest.clone(), t.recorded.clone()],
home_s.clone(),
vec![summary(&k.home, &s.home_later)],
edge_s.clone(),
]
.concat(),
};
let measured = Case { period: DAY, enrolments: vec![home_e.clone(), comp_e.clone()], records: vec![t.lesson.clone(), t.relay.clone(), t.compositor_counter.clone()] };
let unmetered = Case { period: DAY, enrolments: vec![home_e.clone(), edge_e.clone()], records: [home_s.clone(), edge_s.clone()].concat() };
let silent = Case { period: DAY, enrolments: vec![home_e.clone(), edge_e.clone()], records: vec![] };
// The base of most controls: a day of two relays and a compositor.
let base = Case {
period: DAY,
enrolments: vec![home_e.clone(), edge_e.clone(), comp_e.clone()],
records: [vec![t.lesson.clone(), t.relay.clone(), t.compositor_counter.clone()], home_s.clone(), vec![edge_s[0].clone(), edge_s[1].clone()]].concat(),
};
let with_records = |c: &Case, records: Vec<Value>| Case { records, ..c.clone() };
let with_enrolments = |c: &Case, enrolments: Vec<Enrolment>| Case { enrolments, ..c.clone() };
let replaced = |c: &Case, i: usize, v: Value| {
let mut r = c.records.clone();
r[i] = v;
Case { records: r, ..c.clone() }
};
let plus = |c: &Case, v: Value| {
let mut r = c.records.clone();
r.push(v);
Case { records: r, ..c.clone() }
};
// Base's records, by index: 0 the descriptor, 1 the relay's record, 2 the compositor's,
// 3 to 5 home's summaries (5 its link), 6 the edge's link, 7 an edge viewer.
let home_summary = |edit: &dyn Fn(&mut Session)| {
let mut x = s.home[1].clone();
edit(&mut x);
summary(&k.home, &x)
};
let home_text = |edit: &dyn Fn(&mut Session)| {
let mut x = s.home[1].clone();
edit(&mut x);
summary_text(&pk(&k.home), &x)
};
let line = |text: String| json!({"kind": "summary", "line": line_of(&k.home, &text)});
let node_of = |r: &Report, key: &SigningKey| r.nodes.iter().position(|l| l.node == pk(key)).expect("a line of the node");
let (home_at, edge_at) = {
let r = base.report(org);
(node_of(&r, &k.home), node_of(&r, &k.edge))
};
// A silent ingest gateway enrolled beside the base's nodes, and where a line of it would go.
let silent_line = with_enrolments(&base, vec![home_e.clone(), edge_e.clone(), comp_e.clone(), ingest_e.clone()]);
let silent_at = silent_line.report(org).nodes.partition_point(|l| l.node < pk(&k.ingest));
vec![
accept("a_week", "a week of two relays cascading (the edge's upstream link is one session at each end), a compositor and an ingest gateway, with a room service enrolled and silent: every session's figure is the unmetered marker; the lesson room's hops are of three classes, one row each; the relay room's only hop states the unmetered marker, so the fleet's hops are partial", week.bundle(org)),
accept("measured_only", "a day of a relay and a compositor whose every figure is an OnChipCounter reading with its declared uncertainty: one row, whole", measured.bundle(org)),
accept("unmetered_only", "a day of two relays whose every figure is the unmetered marker, and no per-hop record: the sessions are unmetered and the hops have no figure", unmetered.bundle(org)),
accept("enrolled_and_silent", "two relays enrolled and silent: no line, every scope none", silent.bundle(org)),
// The bundle and the report.
refuse("bundle_malformed", "a bundle with no kind", {
let mut b = base.bundle(org);
b.as_object_mut().expect("an object").remove("kind");
b
}, Reason::Malformed, Place::Bundle),
refuse("report_truncated", "a report one byte short", base.raw(org, |b| { b.pop(); }), Reason::Truncated, Place::Report),
refuse("report_malformed", "a report whose magic is not WFR1", base.raw(org, |b| b[3] = b'2'), Reason::Malformed, Place::Report),
refuse("report_unknown_version", "a report of version 2", base.raw(org, |b| b[4] = 2), Reason::UnknownVersion, Place::Report),
refuse("report_invalid", "a report whose period ends where it starts", base.edited(org, |r| r.end = r.start), Reason::ReportInvalid, Place::Report),
refuse("report_row_of_no_class", "a report with a row of tag 0, a sum over no one class", base.edited(org, |r| r.totals.energy.rows[0].tag = 0), Reason::ClassesSummed, Place::Report),
refuse("report_bad_signature", "a report whose signature does not verify", base.raw(org, |b| { let n = b.len() - 1; b[n] ^= 1; }), Reason::BadSignature, Place::Report),
refuse("report_bad_key", "a report naming as its organisation a key of small order", base.edited(org, |r| r.organisation = crate::sig::IDENTITY), Reason::BadKey, Place::Report),
// Enrolments.
refuse("enrolment_truncated", "an enrolment one byte short", base.bundle(org).pipe(|mut b| {
let e = b["enrolments"][1].as_str().expect("hex").to_string();
b["enrolments"][1] = json!(e[..e.len() - 2]);
b
}), Reason::Truncated, Place::Enrolment(1)),
refuse("enrolment_invalid", "an enrolment of role 7", {
let mut e = edge_e.clone();
e.role = 7;
let e = e.sign(org);
bundle(&base.report(org), &[home_e.clone(), e, comp_e.clone()], &base.records)
}, Reason::EnrolmentInvalid, Place::Enrolment(1)),
refuse("enrolment_bad_signature", "an enrolment whose signature does not verify", {
let mut b = base.bundle(org);
let mut e = unhex(b["enrolments"][2].as_str().expect("hex")).expect("hex");
let n = e.len() - 1;
e[n] ^= 1;
b["enrolments"][2] = json!(hex(&e));
b
}, Reason::BadSignature, Place::Enrolment(2)),
refuse("wrong_organisation", "an enrolment another organisation signed", {
let mut e = edge_e.clone();
e.organisation = pk(&k.stranger);
let e = e.sign(&k.stranger);
bundle(&base.report(org), &[home_e.clone(), e, comp_e.clone()], &base.records)
}, Reason::WrongOrganisation, Place::Enrolment(1)),
refuse("enrolment_duplicate", "a node enrolled twice", {
let e = Enrolment::new(pk(org), pk(&k.home), role::RELAY, home_e.from, NO_END, "home again").sign(org);
bundle(&base.report(org), &[home_e.clone(), edge_e.clone(), comp_e.clone(), e], &base.records)
}, Reason::EnrolmentDuplicate, Place::Enrolment(3)),
refuse("enrolment_outside_period", "an enrolment whose span ended before the period", {
let e = Enrolment::new(pk(org), pk(&k.ingest), role::INGEST, WEEK.0, DAY.0, "ingest, retired").sign(org);
bundle(&base.report(org), &[home_e.clone(), edge_e.clone(), comp_e.clone(), e], &base.records)
}, Reason::OutsidePeriod, Place::Enrolment(3)),
// Records that do not read or verify.
refuse("carriage_malformed", "a carriage with a member its kind does not have", base.bundle(org).pipe(|mut b| {
b["records"][0]["room"] = json!("lesson");
b
}), Reason::Malformed, Place::Record(0)),
refuse("summary_line_malformed", "a summary line given with its line end, which is not the relay's form", base.bundle(org).pipe(|mut b| {
let l = format!("{}\n", b["records"][4]["line"].as_str().expect("a line"));
b["records"][4]["line"] = json!(l);
b
}), Reason::Malformed, Place::Record(4)),
refuse("summary_text_malformed", "a summary's text naming a member twice", replaced(&base, 4, line(home_text(&|_| {}).replacen("\"session\":2,", "\"session\":2,\"session\":2,", 1))).bundle_unchecked(org), Reason::Malformed, Place::Record(4)),
refuse("summary_member_missing", "a summary with no relay_key", replaced(&base, 4, line(home_text(&|_| {}).replacen(&format!("\"relay_key\":\"{}\",", hex(&pk(&k.home))), "", 1))).bundle_unchecked(org), Reason::Malformed, Place::Record(4)),
refuse("summary_unknown_version", "a summary of format session-summary/3", replaced(&base, 4, line(home_text(&|_| {}).replacen("session-summary/2", "session-summary/3", 1))).bundle_unchecked(org), Reason::UnknownVersion, Place::Record(4)),
refuse("summary_class_without_evidence", "a summary stating its session's energy as OnChipCounter, with no member to carry the class's evidence", replaced(&base, 4, home_summary(&|x| x.energy = ("OnChipCounter", 5_000))).bundle_unchecked(org), Reason::EnergyInvalid, Place::Record(4)),
refuse("summary_figure_beside_unmetered", "a summary stating a figure beside the unmetered label", replaced(&base, 4, home_summary(&|x| x.energy = ("unmetered", 7))).bundle_unchecked(org), Reason::EnergyInvalid, Place::Record(4)),
refuse("summary_record_invalid", "a summary of a session that closed before it opened", replaced(&base, 4, home_summary(&|x| x.closed_ms = x.opened_ms - 1)).bundle_unchecked(org), Reason::RecordInvalid, Place::Record(4)),
refuse("summary_bad_signature", "a summary whose bytes out were raised after it was signed", base.bundle(org).pipe(|mut b| {
let l = b["records"][4]["line"].as_str().expect("a line").replacen("\"object_bytes_forwarded\":8624000", "\"object_bytes_forwarded\":8624001", 1);
b["records"][4]["line"] = json!(l);
b
}), Reason::BadSignature, Place::Record(4)),
refuse("summary_bad_key", "a summary naming as its relay a key of small order", replaced(&base, 4, line(home_text(&|_| {}).replacen(&hex(&pk(&k.home)), &hex(&crate::sig::IDENTITY), 1))).bundle_unchecked(org), Reason::BadKey, Place::Record(4)),
refuse("hop_truncated", "a per-hop record one byte short", replaced(&base, 1, bytes_edited(&t.relay, "record", |b| { b.pop(); })).bundle_unchecked(org), Reason::Truncated, Place::Record(1)),
refuse("hop_energy_invalid", "a per-hop record whose OnChipCounter class declares no uncertainty", replaced(&base, 1, resigned_hop(&t.relay, &k.home, |r| {
r.class = Some(EnergyProvenance::OnChipCounter { uncertainty: Uncertainty { relative_ppm: 0, window_us: 24_000_000 }, filtering: CounterFiltering::Unknown });
})).bundle_unchecked(org), Reason::EnergyInvalid, Place::Record(1)),
refuse("hop_record_invalid", "a per-hop record whose span ends where it starts", replaced(&base, 1, resigned_hop(&t.relay, &k.home, |r| r.end = r.start)).bundle_unchecked(org), Reason::RecordInvalid, Place::Record(1)),
refuse("hop_bad_signature", "a per-hop record whose signature does not verify", replaced(&base, 1, bytes_edited(&t.relay, "record", |b| { let n = b.len() - 1; b[n] ^= 1; })).bundle_unchecked(org), Reason::BadSignature, Place::Record(1)),
refuse("hop_ledger_invalid", "a per-hop record whose ledger is not a whole number of entries", replaced(&base, 1, bytes_edited(&t.relay, "ledger", |b| { b.pop(); })).bundle_unchecked(org), Reason::LedgerInvalid, Place::Record(1)),
refuse("hop_ledger_mismatch", "a per-hop record beside another record's ledger", replaced(&base, 1, {
let mut c = t.relay.clone();
c["ledger"] = t.compositor_counter["ledger"].clone();
c
}).bundle_unchecked(org), Reason::LedgerMismatch, Place::Record(1)),
refuse("descriptor_truncated", "a room descriptor cut short of its t0", replaced(&base, 0, bytes_edited(&t.lesson, "descriptor", |b| b.truncate(120))).bundle_unchecked(org), Reason::Truncated, Place::Record(0)),
refuse("descriptor_bad_signature", "a room descriptor whose signature does not verify", replaced(&base, 0, bytes_edited(&t.lesson, "descriptor", |b| { let n = b.len() - 1; b[n] ^= 1; })).bundle_unchecked(org), Reason::BadSignature, Place::Record(0)),
// Records of the wrong signer, role, place or time.
refuse("signer_not_enrolled", "a summary signed by a relay no enrolment names", plus(&base, summary(&k.outsider, &s.edge[1])).bundle_unchecked(org), Reason::SignerNotEnrolled, Place::Record(8)),
refuse("hop_signer_not_enrolled", "a per-hop record of an ingest gateway no enrolment names", plus(&base, t.ingest.clone()).bundle_unchecked(org), Reason::SignerNotEnrolled, Place::Record(8)),
refuse("summary_role_mismatch", "a summary signed by a node enrolled as an ingest gateway", with_enrolments(&base, vec![home_e.clone(), enrol(org, &k.edge, role::INGEST, "edge"), comp_e.clone()]).bundle_unchecked(org), Reason::RoleMismatch, Place::Record(6)),
refuse("hop_role_mismatch", "a compositor's per-hop record, its node enrolled as a relay", with_enrolments(&base, vec![home_e.clone(), edge_e.clone(), enrol(org, &k.compositor, role::RELAY, "compositor")]).bundle_unchecked(org), Reason::RoleMismatch, Place::Record(2)),
refuse("descriptor_missing", "a per-hop record whose room's descriptor the bundle does not hold", with_records(&base, base.records[1..].to_vec()).bundle_unchecked(org), Reason::DescriptorMissing, Place::Record(0)),
refuse("summary_outside_period", "a summary of a session that ended after the period", plus(&base, summary(&k.home, &s.home_later)).bundle_unchecked(org), Reason::OutsidePeriod, Place::Record(8)),
refuse("hop_outside_period", "the relay room's record, two days after the period, with its descriptor", with_records(&base, [base.records.clone(), vec![t.relay_room.clone(), t.recorded.clone()]].concat()).bundle_unchecked(org), Reason::OutsidePeriod, Place::Record(9)),
refuse("outside_enrolment", "a per-hop record of a relay enrolled from after the record's span", {
let late = Enrolment::new(pk(org), pk(&k.home), role::RELAY, LESSON_T0 + 30_000_000, NO_END, "home").sign(org);
with_enrolments(&with_records(&base, vec![t.lesson.clone(), t.relay.clone()]), vec![late]).bundle_unchecked(org)
}, Reason::OutsideEnrolment, Place::Record(1)),
// One thing once.
refuse("record_duplicate", "a summary given twice", plus(&base, base.records[4].clone()).bundle_unchecked(org), Reason::RecordDuplicate, Place::Record(8)),
refuse("descriptor_duplicate", "a room descriptor given twice", plus(&base, t.lesson.clone()).bundle_unchecked(org), Reason::RecordDuplicate, Place::Record(8)),
refuse("session_summarised_twice", "two summaries of one session of one relay, which differ in when it closed", plus(&base, home_summary(&|x| x.closed_ms += 1)).bundle_unchecked(org), Reason::RecordOverlap, Place::Record(8)),
refuse("hop_records_overlap", "two per-hop records of one compositor and one room whose spans overlap: one OnChipCounter, one ModelBased", plus(&base, t.compositor_model.clone()).bundle_unchecked(org), Reason::RecordOverlap, Place::Record(8)),
// The figures.
refuse("line_unsourced", "a node line for an enrolled node that signed nothing in the period", silent_line.edited(org, |r| {
r.nodes.insert(silent_at, NodeLine { node: pk(&k.ingest), role: role::INGEST, cites: cites_digest(&BTreeSet::new()), ..NodeLine::default() });
}), Reason::LineUnsourced, Place::Node(silent_at)),
refuse("room_line_unsourced", "a room line for a room no per-hop record names, citing the record of another room", base.edited(org, |r| {
let mut l = r.rooms[0].clone();
l.room = [0xee; 32];
r.rooms.push(l);
}), Reason::LineUnsourced, Place::Room(1)),
refuse("classes_summed", "the lesson room's OnChipCounter row stated as the sum of its own figure and the ModelBased figure", week.edited(org, |r| {
let x = &mut r.rooms[0].energy.rows;
let model = x.iter().find(|w| w.tag == 2).expect("a ModelBased row").joules;
let counter = x.iter_mut().find(|w| w.tag == 4).expect("an OnChipCounter row");
counter.joules += model;
}), Reason::ClassesSummed, Place::Room(0)),
refuse("unmetered_dropped", "the home relay's line with its sessions' unmetered figures dropped", base.edited(org, |r| r.nodes[home_at].energy.unmetered[0] = 0), Reason::UnmeteredSummed, Place::Node(home_at)),
refuse("unmetered_as_zero", "the relay room's unmetered hop stated as an OnChipCounter figure of zero joules", week.edited(org, |r| {
let l = &mut r.rooms[1];
l.energy.unmetered[1] = 0;
l.energy.rows.push(Row { scope: scope::HOPS, tag: 4, figures: 1, joules: 0, max_ppm: 1 });
}), Reason::UnmeteredSummed, Place::Room(1)),
refuse("totals_unmetered_dropped", "totals whose hops leave out the relay room's unmetered figure, which would state them whole", week.edited(org, |r| r.totals.energy.unmetered[1] = 0), Reason::UnmeteredSummed, Place::Totals),
refuse("citation_mismatch", "a node line citing one record fewer than it came from", base.edited(org, |r| r.nodes[edge_at].cited -= 1), Reason::CitationMismatch, Place::Node(edge_at)),
refuse("cites_mismatch", "a room line whose citation digest leaves out its descriptor", base.edited(org, |r| {
let hops: BTreeSet<(u8, [u8; 32])> = r.records.iter().filter(|(x, _)| *x == kind::HOP).copied().collect();
r.rooms[0].cites = cites_digest(&hops);
}), Reason::CitationMismatch, Place::Room(0)),
refuse("bytes_mismatch", "a node line stating one byte more out than its summaries", base.edited(org, |r| r.nodes[home_at].bytes_out += 1), Reason::ReportMismatch, Place::Report),
refuse("link_bytes_counted_as_sessions", "the edge's line counting its upstream link's bytes as its clients'", base.edited(org, |r| {
let l = &mut r.nodes[edge_at];
l.bytes_in += l.link_bytes_in;
l.link_bytes_in = 0;
}), Reason::ReportMismatch, Place::Report),
refuse("role_misstated", "a relay's node line stating the role of an ingest gateway", base.edited(org, |r| r.nodes[edge_at].role = role::INGEST), Reason::ReportMismatch, Place::Report),
refuse("max_ppm_mismatch", "a row stating a smaller declared uncertainty than its figures", measured.edited(org, |r| r.rooms[0].energy.rows[0].max_ppm -= 1), Reason::ReportMismatch, Place::Report),
refuse("record_not_reported", "a bundle holding a descriptor the report does not list", plus(&base, t.relay_room.clone()).bundle_with_report_of(org, &base), Reason::ReportMismatch, Place::Report),
refuse("enrolment_not_reported", "a report listing another digest for an enrolment", base.edited(org, |r| r.enrolments[0].1 = [0; 32]), Reason::ReportMismatch, Place::Report),
refuse("totals_mismatch", "totals stating one session more than the node lines", base.edited(org, |r| r.totals.sessions += 1), Reason::ReportMismatch, Place::Report),
]
}
/// Pipe a value through a function, for building a vector in one expression.
trait Pipe: Sized {
fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
f(self)
}
}
impl Pipe for Value {}
impl Case {
/// The bundle whose report is computed from the records that read and verify, are placed in
/// the period within their enrolments, and are counted once (a control's defective record
/// left out): the bundle is refused for that record, and a verifier that let the record
/// through would find a report that does not recompute.
fn bundle_unchecked(&self, org: &SigningKey) -> Value {
let read: Vec<Record> = self.records.iter().filter_map(|c| read_record(c).ok()).collect();
let t0s: BTreeMap<[u8; 32], u64> = read.iter().filter_map(|r| if let Record::Descriptor(d) = r { Some((d.room, d.t0)) } else { None }).collect();
let mut seen = Seen::default();
let mut kept = Vec::new();
for r in read {
let placed = r.signer().is_none_or(|s| place(&r, s, &self.enrolments, &t0s, Some(self.period)).is_ok());
if placed && seen.check(&r).is_ok() {
seen.add(&r);
kept.push(r);
}
}
let report = sign_report(org, self.period.0, self.period.1, &self.enrolments, &kept).expect("stated");
bundle(&report, &self.enrolments, &self.records)
}
/// The bundle of these records beside the report of another case's.
fn bundle_with_report_of(&self, org: &SigningKey, other: &Case) -> Value {
bundle(&other.report(org), &self.enrolments, &self.records)
}
}
/// The corpus, as `vectors.json` holds it: from the metering corpus under `wai`.
pub fn generate(wai: &Path) -> Result<String, String> {
let t = taken(wai)?;
let k = keys();
let mut out = Vec::new();
for v in vectors(&t) {
let got = outcome_json(&super::verify(&v.bundle));
match v.intent {
None if got["outcome"] == "accept" => {}
Some((r, at)) if got["reason"] == r.code() && got["at"] == at.json() => {}
_ => return Err(format!("{}: the outcome is {got}, not the one it was written to have", v.name)),
}
out.push(json!({"name": v.name, "about": v.about, "bundle": v.bundle, "outcome": got}));
}
let doc = json!({
"corpus": "live-conformance/fleet",
"extension": "extensions/fleet.md",
"keys": {
"organisation": hex(&pk(&k.org)), "another_organisation": hex(&pk(&k.stranger)),
"home": hex(&pk(&k.home)), "edge": hex(&pk(&k.edge)), "compositor": hex(&pk(&k.compositor)),
"ingest": hex(&pk(&k.ingest)), "room_service": hex(&pk(&k.room_service)), "outsider": hex(&pk(&k.outsider)),
},
"vectors": out,
});
Ok(serde_json::to_string_pretty(&doc).expect("JSON") + "\n")
}
/// Every vector against the outcome it states: the failures.
pub fn verify(text: &str) -> Vec<String> {
let doc: Value = match serde_json::from_str(text) {
Ok(d) => d,
Err(e) => return vec![format!("vectors.json: {e}")],
};
let mut fails = Vec::new();
for v in doc["vectors"].as_array().into_iter().flatten() {
let name = v["name"].as_str().unwrap_or("?");
let got = outcome_json(&super::verify(&v["bundle"]));
if got != v["outcome"] {
fails.push(format!("{name}: the outcome is {got}, the corpus states {}", v["outcome"]));
}
}
fails
}
/// The summary lines of the corpus's accepted vectors, each with the relay key it names: what
/// `wai-fleet`'s tests read with the reference relay's own types (§11).
pub fn summary_lines(text: &str) -> Vec<String> {
let doc: Value = serde_json::from_str(text).unwrap_or(Value::Null);
let mut out = Vec::new();
for v in doc["vectors"].as_array().into_iter().flatten().filter(|v| v["outcome"]["outcome"] == "accept") {
for r in v["bundle"]["records"].as_array().into_iter().flatten() {
if let Some(l) = r["line"].as_str()
&& !out.iter().any(|x: &String| x == l)
{
out.push(l.to_string());
}
}
}
out
}

View file

@ -738,6 +738,14 @@ pub mod receipt_ledger;
/// cbindgen:ignore
#[cfg(feature = "stream_audit")]
pub mod stream_audit;
// Fleet (extensions/fleet.md): `wai.delivery.fleet` — an organisation's nodes enrolled by their
// signing keys, the records each contributes as it signs them (a relay's session summaries, every
// hop's per-hop records, the room descriptors that place them), and the fleet report the organisation
// signs for a period and anyone holding the bundle recomputes. Rust-only, like the other record
// modules.
/// cbindgen:ignore
#[cfg(feature = "fleet")]
pub mod fleet;
// Native sink (extensions/native-sink.md): the core a native host's player integration runs —
// playlists verified against their receipts, objects held to the content hashes their names state,
// stream-audit's records signed under a fresh session key, the bundle, and the termination rule. Its C
@ -787,9 +795,10 @@ pub mod prior_wire;
#[cfg(feature = "scene_map")]
pub mod scene_map;
// The strict JSON reader the two mappings above share. Rust-only.
// The strict JSON reader the two mappings above share, and the fleet's reader of a relay's session
// summaries. Rust-only.
/// cbindgen:ignore
#[cfg(any(feature = "prior_wire", feature = "scene_map"))]
#[cfg(any(feature = "prior_wire", feature = "scene_map", feature = "fleet"))]
pub(crate) mod strict_json;
// Bearer receipts (extensions/bearer-receipts.md): `wai.bearer.receipts` — one receipt on every
// bearer; the verdict over a group from its transmission and reconciliation claims, the upper

View file

@ -39,6 +39,7 @@ impl Json {
_ => None,
}
}
#[cfg_attr(not(any(feature = "prior_wire", feature = "scene_map")), allow(dead_code))]
pub fn as_arr(&self) -> Option<&[Json]> {
match self {
Json::Arr(a) => Some(a),
@ -286,6 +287,7 @@ impl P<'_> {
/// `s` as a JSON string, escaped as RFC 8785 §3.2.2.2 escapes it: `"` and `\` with a
/// backslash, U+0008, U+0009, U+000A, U+000C and U+000D by their short escapes, every other
/// control character as `\u00xx` with lowercase hexadecimal digits, and nothing else.
#[cfg_attr(not(any(feature = "prior_wire", feature = "scene_map")), allow(dead_code))]
pub fn quote(s: &str) -> String {
let mut o = String::with_capacity(s.len() + 2);
o.push('"');

View file

@ -0,0 +1,182 @@
//! The fleet's operations through the `wai` binary (extensions/fleet.md §9), as an agent runs
//! them, on the toolchain's rules: the JSON envelope when standard output is not a terminal, the
//! exit statuses, `--dry-run` that writes and sends nothing, and `--confirm` before a fleet
//! service is changed or a file replaced; a refusal of the service named by the fleet's reason;
//! a report's bundle verified here before it is written; and `wai verify` checking a bundle
//! offline and refusing it altered. The fleet service is played by a server in the test that
//! answers as `wai-fleet` does.
#![cfg(feature = "quickstart")]
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::mpsc;
use serde_json::{json, Value};
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("wai-cli-fleet-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn wai(dir: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(dir).args(args).output().expect("wai runs")
}
/// The envelope a run printed (its standard output is not a terminal here).
fn envelope(o: &Output) -> Value {
serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{e}: {}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)))
}
fn corpus() -> Value {
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../live-conformance/fleet/vectors.json");
serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap()
}
fn vector(name: &str) -> Value {
corpus()["vectors"].as_array().unwrap().iter().find(|v| v["name"] == name).unwrap().clone()
}
/// A server answering each request with the next of `answers` (status and JSON body), and
/// passing on each request's first line and body.
fn service(answers: Vec<(u16, Value)>) -> (String, mpsc::Receiver<(String, String)>) {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = format!("http://{}", l.local_addr().unwrap());
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for (status, body) in answers {
let (mut s, _) = l.accept().unwrap();
let mut buf = Vec::new();
let mut chunk = [0u8; 65536];
// The head, then as much body as it states.
loop {
let n = s.read(&mut chunk).unwrap();
buf.extend_from_slice(&chunk[..n]);
if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
let head = String::from_utf8_lossy(&buf[..at]).to_string();
let len = head.lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length: ").and_then(|v| v.parse::<usize>().ok())).unwrap_or(0);
while buf.len() < at + 4 + len {
let n = s.read(&mut chunk).unwrap();
buf.extend_from_slice(&chunk[..n]);
}
let first = head.lines().next().unwrap_or("").to_string();
tx.send((first, String::from_utf8_lossy(&buf[at + 4..]).to_string())).unwrap();
break;
}
if n == 0 {
break;
}
}
let b = body.to_string();
let _ = s.write_all(format!("HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{b}", b.len()).as_bytes());
}
});
(addr, rx)
}
const NODE: &str = "1111111111111111111111111111111111111111111111111111111111111111";
#[test]
fn enrolment_create_signs_writes_and_waits_for_confirmation() {
let d = tmp("enrol");
std::fs::write(d.join("org.key"), "07".repeat(32)).unwrap();
// A dry run writes nothing.
let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--label", "home", "--out", "n.wfe", "--dry-run"]);
assert_eq!(o.status.code(), Some(0));
let e = envelope(&o);
assert_eq!((e["op"].as_str(), e["ok"].as_bool(), e["data"]["dry_run"].as_bool()), (Some("enrolment.create"), Some(true), Some(true)));
assert!(!d.join("n.wfe").exists(), "a dry run writes nothing");
// The enrolment, written: it reads and its signature verifies.
let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--label", "home", "--out", "n.wfe"]);
assert_eq!(o.status.code(), Some(0), "{}", String::from_utf8_lossy(&o.stderr));
let e = envelope(&o);
let b = std::fs::read(d.join("n.wfe")).unwrap();
let parsed = wai::fleet::Enrolment::parse(&b).unwrap();
assert_eq!((wai::fleet::hex(&parsed.node), parsed.role, parsed.label_text()), (NODE.to_string(), 1, "home".to_string()));
assert_eq!(e["data"]["digest"], json!(wai::fleet::hex(&parsed.digest())));
// Replacing it waits for --confirm: exit 4, with the command that confirms.
let o = wai(&d, &["enrolment", "create", NODE, "--role", "ingest", "--key", "org.key", "--out", "n.wfe"]);
assert_eq!(o.status.code(), Some(4));
let e = envelope(&o);
assert_eq!(e["error"]["code"], "confirmation-required");
assert!(e["confirm"].as_str().unwrap().ends_with("--confirm"));
assert_eq!(std::fs::read(d.join("n.wfe")).unwrap(), b, "nothing changed");
// Its own rules refuse it before anything is written: a node that is the organisation.
let org = wai::fleet::hex(&ed25519_dalek::SigningKey::from_bytes(&[7; 32]).verifying_key().to_bytes());
let o = wai(&d, &["enrolment", "create", &org, "--role", "relay", "--key", "org.key", "--out", "self.wfe"]);
assert_eq!((o.status.code(), envelope(&o)["error"]["code"].as_str()), (Some(2), Some("enrolment_invalid")));
// Usage.
assert_eq!(wai(&d, &["enrolment", "create", NODE, "--key", "org.key"]).status.code(), Some(2), "no role");
assert_eq!(wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "absent.key"]).status.code(), Some(5), "no key file");
}
#[test]
fn enrolment_create_against_a_service() {
let d = tmp("enrol-target");
std::fs::write(d.join("org.key"), [7u8; 32]).unwrap();
let (url, rx) = service(vec![(201, json!({"node": NODE, "role": "relay", "digest": "00"})), (422, json!({"outcome": "refuse", "reason": "enrolment_duplicate"}))]);
// A service is not changed without --confirm.
let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--target", &url]);
assert_eq!(o.status.code(), Some(4));
let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--target", &url, "--confirm"]);
assert_eq!(o.status.code(), Some(0), "{}", String::from_utf8_lossy(&o.stdout));
let (line, body) = rx.recv().unwrap();
assert_eq!(line, "POST /v1/fleet/enrolments HTTP/1.0");
let sent = wai::fleet::unhex(serde_json::from_str::<Value>(&body).unwrap()["enrolment"].as_str().unwrap()).unwrap();
assert!(wai::fleet::Enrolment::parse(&sent).is_ok(), "the service is given a signed enrolment");
assert!(!d.join(format!("{}.wfe", &NODE[..16])).exists(), "with --target, a file only with --out");
// The service's refusal is named by the fleet's reason.
let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--target", &url, "--confirm"]);
assert_eq!((o.status.code(), envelope(&o)["error"]["code"].as_str()), (Some(1), Some("enrolment_duplicate")));
// No service there: unavailable, and worth a retry.
let o = wai(&d, &["enrolment", "list", "--target", "http://127.0.0.1:9"]);
let e = envelope(&o);
assert_eq!((o.status.code(), e["error"]["code"].as_str(), e["error"]["retryable"].as_bool()), (Some(6), Some("target-unavailable"), Some(true)));
}
#[test]
fn report_create_and_export_and_verify() {
let d = tmp("report");
let week = vector("a_week");
let digest = week["outcome"]["digest"].as_str().unwrap().to_string();
let (start, end) = (week["outcome"]["start"].to_string(), week["outcome"]["end"].to_string());
let mut altered = week["bundle"].clone();
let mut r = wai::fleet::unhex(altered["report"].as_str().unwrap()).unwrap();
let n = r.len() - 1;
r[n] ^= 1;
altered["report"] = json!(wai::fleet::hex(&r));
let (url, rx) = service(vec![
(201, json!({"report": digest, "outcome": week["outcome"], "bundle": week["bundle"]})),
(200, json!({"report": digest, "outcome": week["outcome"], "bundle": week["bundle"]})),
(200, json!({"report": digest, "outcome": week["outcome"], "bundle": altered})),
]);
assert_eq!(wai(&d, &["report", "create", "--target", &url, "--start", &start]).status.code(), Some(2), "no end");
let o = wai(&d, &["report", "create", "--target", &url, "--start", &start, "--end", &end, "--dry-run"]);
assert_eq!((o.status.code(), envelope(&o)["data"]["dry_run"].as_bool()), (Some(0), Some(true)));
assert_eq!(wai(&d, &["report", "create", "--target", &url, "--start", &start, "--end", &end]).status.code(), Some(4));
let o = wai(&d, &["report", "create", "--target", &url, "--start", &start, "--end", &end, "--confirm"]);
assert_eq!(o.status.code(), Some(0));
assert_eq!(envelope(&o)["data"]["outcome"]["totals"]["energy"]["hops"]["coverage"], "partial");
assert_eq!(rx.recv().unwrap().0, "POST /v1/fleet/reports HTTP/1.0");
// Exported: verified here, then written.
let o = wai(&d, &["report", "export", &digest, "--target", &url, "--out", "week.wai-fleet.json"]);
assert_eq!(o.status.code(), Some(0), "{}", String::from_utf8_lossy(&o.stdout));
assert_eq!(rx.recv().unwrap().0, format!("GET /v1/fleet/reports/{digest} HTTP/1.0"));
let written: Value = serde_json::from_str(&std::fs::read_to_string(d.join("week.wai-fleet.json")).unwrap()).unwrap();
assert_eq!(written, week["bundle"]);
// A bundle the service altered is not written.
let o = wai(&d, &["report", "export", &digest, "--target", &url, "--out", "altered.wai-fleet.json"]);
assert_eq!((o.status.code(), envelope(&o)["error"]["code"].as_str()), (Some(1), Some("bad_signature")));
assert!(!d.join("altered.wai-fleet.json").exists());
// `wai verify` checks the bundle offline, and refuses it altered.
let o = wai(&d, &["verify", "week.wai-fleet.json"]);
let t = String::from_utf8_lossy(&o.stdout);
assert!(o.status.success(), "{t}");
assert!(t.contains("every line recomputed") && t.contains("silent") && t.contains("hops partial"), "{t}");
std::fs::write(d.join("bad.wai-fleet.json"), altered.to_string()).unwrap();
let o = wai(&d, &["verify", "bad.wai-fleet.json"]);
assert_eq!(o.status.code(), Some(1));
assert!(String::from_utf8_lossy(&o.stdout).contains("FAIL fleet report: bad_signature"));
}

168
wai/wai-rs/tests/fleet.rs Normal file
View file

@ -0,0 +1,168 @@
//! `wai.delivery.fleet` (extensions/fleet.md): the committed corpus
//! `live-conformance/fleet/vectors.json` regenerates byte for byte from its seeds and the metering
//! corpus, and is the only file there besides its README; every vector gets the outcome it states;
//! every reason the extension adds and every code it reuses refuses a vector; the accepted vectors
//! are the cases §11 names, with measured, unmetered and partial energy stated as §5.4 states
//! them; a fleet's records held as a service holds them sign the report the corpus holds; and §10
//! lists exactly the reasons the reference reports.
#![cfg(feature = "fleet")]
use std::collections::BTreeSet;
use std::path::PathBuf;
use serde_json::{json, Value};
use wai::fleet::{self, corpus, Held, Reason};
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn read(p: &str) -> String {
std::fs::read_to_string(wai_dir().join(p)).unwrap_or_else(|e| panic!("{p}: {e}"))
}
fn committed() -> String {
read("live-conformance/fleet/vectors.json")
}
fn doc() -> Value {
serde_json::from_str(&committed()).unwrap()
}
fn vector(d: &Value, name: &str) -> Value {
d["vectors"].as_array().unwrap().iter().find(|v| v["name"] == name).unwrap_or_else(|| panic!("no vector {name}")).clone()
}
#[test]
fn the_committed_corpus_regenerates_from_its_seeds_and_the_metering_corpus() {
let generated = corpus::generate(&wai_dir()).expect("the corpus generates");
assert!(committed() == generated, "live-conformance/fleet/vectors.json differs from `wai_fleet_vectors gen`");
let mut files: Vec<String> = std::fs::read_dir(wai_dir().join("live-conformance/fleet")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
files.sort();
assert_eq!(files, ["README.md", "vectors.json"]);
}
#[test]
fn every_vector_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// Every reason this extension adds and every code it reuses refuses a vector.
#[test]
fn the_corpus_refuses_every_reason() {
let d = doc();
let seen: BTreeSet<&str> = d["vectors"].as_array().unwrap().iter().filter_map(|v| v["outcome"]["reason"].as_str()).collect();
for r in Reason::ALL.iter().chain(Reason::REUSED.iter()) {
assert!(seen.contains(r.code()), "nothing in the corpus is refused {r}");
}
}
fn coverage(e: &Value, scope: &str) -> String {
e[scope]["coverage"].as_str().unwrap().to_string()
}
fn classes(e: &Value, scope: &str) -> Vec<String> {
e[scope]["rows"].as_array().unwrap().iter().map(|r| r["class"].as_str().unwrap().to_string()).collect()
}
/// The accepted vectors are §11's, and state energy as §5.4 states it: measured with its class,
/// unmetered as unmetered, a mix as partial, and a silent node as silent.
#[test]
fn the_accepted_vectors_state_measured_unmetered_and_partial_energy() {
let d = doc();
let week = vector(&d, "a_week")["outcome"].clone();
assert_eq!(week["outcome"], "accept");
let lesson = &week["rooms"][0];
assert_eq!(classes(&lesson["energy"], "hops"), ["ModelBased", "Estimator", "OnChipCounter"], "one row per class, never one across them");
assert_eq!(coverage(&lesson["energy"], "hops"), "whole");
assert_eq!(coverage(&week["rooms"][1]["energy"], "hops"), "unmetered", "the reference relay's recorded hop");
assert_eq!(coverage(&week["totals"]["energy"], "hops"), "partial", "measured and unmetered hops together");
assert_eq!(coverage(&week["totals"]["energy"], "sessions"), "unmetered", "every session's figure is the marker");
assert_eq!(week["totals"]["energy"]["sessions"]["unmetered"], 7);
assert_eq!(week["totals"]["energy"]["hops"]["unmetered"], 1);
let silent: Vec<&str> = week["enrolments"].as_array().unwrap().iter().filter(|e| e["silent"] == true).map(|e| e["label"].as_str().unwrap()).collect();
assert_eq!(silent, ["room service"], "enrolled and silent: listed, with no line");
assert!(week["nodes"].as_array().unwrap().iter().all(|n| n["label"] != "room service"));
let edge = week["nodes"].as_array().unwrap().iter().find(|n| n["label"] == "edge").unwrap();
let home = week["nodes"].as_array().unwrap().iter().find(|n| n["label"] == "home").unwrap();
assert_eq!((edge["links"].as_u64(), home["links"].as_u64()), (Some(1), Some(1)), "the edge's upstream link, one session at each end");
assert_eq!(edge["link_bytes_in"], home["link_bytes_out"], "what home sent over the link, the edge received");
assert!(week["nodes"].as_array().unwrap().iter().chain(week["rooms"].as_array().unwrap()).all(|l| !l["cites"].as_array().unwrap().is_empty()), "every line cites its records");
let measured = vector(&d, "measured_only")["outcome"].clone();
assert_eq!(coverage(&measured["totals"]["energy"], "hops"), "whole");
assert_eq!(coverage(&measured["totals"]["energy"], "sessions"), "none");
let row = &measured["totals"]["energy"]["hops"]["rows"][0];
assert_eq!((row["class"].as_str(), row["figures"].as_u64()), (Some("OnChipCounter"), Some(2)));
assert!(row["max_ppm"].as_u64().unwrap() > 0, "the largest declared uncertainty of the two");
let unmetered = vector(&d, "unmetered_only")["outcome"].clone();
assert_eq!(coverage(&unmetered["totals"]["energy"], "sessions"), "unmetered");
assert_eq!(coverage(&unmetered["totals"]["energy"], "hops"), "none");
assert!(unmetered["totals"]["energy"]["sessions"]["rows"].as_array().unwrap().is_empty(), "no row, never a figure of zero joules");
let silent = vector(&d, "enrolled_and_silent")["outcome"].clone();
assert_eq!((silent["nodes"].as_array().unwrap().len(), silent["rooms"].as_array().unwrap().len()), (0, 0));
assert!(silent["enrolments"].as_array().unwrap().iter().all(|e| e["silent"] == true));
assert_eq!(coverage(&silent["totals"]["energy"], "hops"), "none");
}
/// A fleet held as the service holds it (§9): the week's enrolments and records admitted one by
/// one, in the corpus's order, sign the corpus's report byte for byte; each record is admitted
/// once; and the per-hop records of a room come back in the ledger's source form (§8).
#[test]
fn a_held_fleet_signs_the_corpus_report() {
let d = doc();
let week = vector(&d, "a_week");
let b = &week["bundle"];
let org_seed: [u8; 32] = {
use sha2::{Digest, Sha256};
Sha256::new().chain_update(b"wai:fleet/corpus/key\x00").chain_update(b"organisation").finalize().into()
};
let org = ed25519_dalek::SigningKey::from_bytes(&org_seed);
let mut held = Held::new(org.verifying_key().to_bytes());
for e in b["enrolments"].as_array().unwrap() {
held.enrol(&fleet::unhex(e.as_str().unwrap()).unwrap()).unwrap();
}
assert_eq!(held.enrol(&fleet::unhex(b["enrolments"][0].as_str().unwrap()).unwrap()).unwrap_err(), Reason::EnrolmentDuplicate);
for r in b["records"].as_array().unwrap() {
held.admit(r).unwrap_or_else(|e| panic!("{r}: {e}"));
}
for r in b["records"].as_array().unwrap().iter().take(3) {
assert_eq!(held.admit(r).unwrap_err(), Reason::RecordDuplicate, "a record is held once");
}
let start = week["outcome"]["start"].as_u64().unwrap();
let end = week["outcome"]["end"].as_u64().unwrap();
let (report, bundle) = held.report(&org, start, end).unwrap();
assert_eq!(Value::String(fleet::hex(&report.to_bytes())), b["report"], "the corpus's report, byte for byte");
assert_eq!(fleet::outcome_json(&fleet::verify(&bundle)), week["outcome"]);
let lesson = week["outcome"]["rooms"][0]["room"].as_str().unwrap();
let hops = held.hops_of(&fleet::unhex(lesson).unwrap().try_into().unwrap());
assert_eq!(hops.len(), 3);
assert!(hops.iter().all(|h| h.as_object().unwrap().keys().cloned().collect::<Vec<_>>() == ["record", "ledger"]));
// A day of the week holds the lesson's records and none of the relay room's.
let (_, day) = held.of_period(1_792_108_800_000_000, 1_792_195_200_000_000);
assert!(day.iter().all(|(r, _)| !matches!(r, fleet::Record::Hop(h) if fleet::hex(&h.record.room) != lesson)));
// A record of an unenrolled node, or before its descriptor, is refused.
let mut empty = Held::new(org.verifying_key().to_bytes());
let hop = b["records"].as_array().unwrap().iter().find(|r| r["kind"] == "hop").unwrap();
assert_eq!(empty.admit(hop).unwrap_err(), Reason::SignerNotEnrolled);
empty.enrol(&fleet::unhex(b["enrolments"][0].as_str().unwrap()).unwrap()).unwrap();
let e = empty.admit(&json!({"kind": "descriptor", "descriptor": "00"}));
assert_eq!(e.unwrap_err(), Reason::Truncated);
}
/// §10 of the extension lists exactly the codes `Reason::ALL` has, each once, in order, and
/// names every code `Reason::REUSED` has.
#[test]
fn the_extension_lists_the_reasons() {
let text = read("extensions/fleet.md");
let section = text.split("\n## 10. Reasons").nth(1).unwrap().split("\n## 11.").next().unwrap();
let listed: Vec<&str> = section.lines().filter_map(|l| l.strip_prefix("| `")).filter_map(|l| l.split('`').next()).collect();
let ours: Vec<&str> = Reason::ALL.iter().map(|r| r.code()).collect();
assert_eq!(listed, ours);
for r in Reason::REUSED {
assert!(section.contains(&format!("`{}`", r.code())), "§10 does not name the reused code {r}");
}
}