From c6433368c9e054614a8e43dc721fc166b6f8f5b9 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 27 Oct 2026 04:52:19 -0400 Subject: [PATCH 01/21] wai: the operation catalogue, every operation once as noun.verb (wai::catalogue) One Rust table in the wai crate holds every operation the toolchain offers: its id (noun.verb), summary, CLI spelling and aliases, placement, input and output JSON Schemas, stream, effect, route, toolset, stability, since/deprecated, whether it writes a receipt, and the build features it needs. It lists only operations that work today. Its tests hold the naming rules (noun.verb, no near-synonym verb, singular nouns, one command line per operation, a route exactly when it runs on a service) and break each rule to show it is refused. wai/catalogue/ops.json is its export (wai.ops/1). Co-Authored-By: Claude Opus 5.5 --- wai/catalogue/ops.json | 2713 +++++++++++++++++++++++++++++++++++ wai/wai-rs/src/catalogue.rs | 1283 +++++++++++++++++ wai/wai-rs/src/lib.rs | 4 + 3 files changed, 4000 insertions(+) create mode 100644 wai/catalogue/ops.json create mode 100644 wai/wai-rs/src/catalogue.rs diff --git a/wai/catalogue/ops.json b/wai/catalogue/ops.json new file mode 100644 index 000000000..e0bcf191c --- /dev/null +++ b/wai/catalogue/ops.json @@ -0,0 +1,2713 @@ +{ + "format": "wai.ops/1", + "toolchain": "0.3.0", + "envelope": "envelope/1", + "exit_codes": [ + { + "exit": 0, + "meaning": "done; every check held" + }, + { + "exit": 1, + "meaning": "ran, and a check failed" + }, + { + "exit": 2, + "meaning": "usage error or invalid input" + }, + { + "exit": 3, + "meaning": "authentication or permission" + }, + { + "exit": 4, + "meaning": "confirmation required; nothing changed" + }, + { + "exit": 5, + "meaning": "not found (file, target, room, operation)" + }, + { + "exit": 6, + "meaning": "target unavailable; safe to retry" + }, + { + "exit": 7, + "meaning": "internal error" + } + ], + "operations": [ + { + "id": "toolchain.hello", + "summary": "WAI's four steps (name, dispatch, rebuild, receipt) on a picture made here, its receipt checked offline", + "cli": "wai hello", + "aliases": [], + "usage": [ + "wai hello [] WAI's four steps (name, dispatch, rebuild, receipt) on a sample, written to (wai-hello)" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "dir": { + "type": "string", + "format": "path", + "description": "Where the object and its receipts are written (wai-hello).", + "x-cli": { + "positional": true + } + } + }, + "required": [], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": true, + "needs": { + "all": [ + "quickstart" + ] + }, + "names": { + "cli": "wai toolchain hello", + "mcp": "toolchain_hello", + "c": "wai_toolchain_hello", + "rust": "wai::toolchain::hello", + "sdk": "wai.toolchain.hello" + } + }, + { + "id": "project.create", + "summary": "A new project from one of the examples, written into a new or empty directory; --list names the templates", + "cli": "wai create", + "aliases": [], + "usage": [ + "wai create --template | create --list a new project from one of the examples" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "dir": { + "type": "string", + "format": "path", + "description": "A directory that does not exist or is empty.", + "x-cli": { + "positional": true + } + }, + "template": { + "type": "string", + "description": "The template, one of `wai create --list`.", + "x-cli": { + "flag": "--template" + } + }, + "list": { + "type": "boolean", + "description": "List the templates instead.", + "x-cli": { + "flag": "--list" + } + } + }, + "required": [], + "additionalProperties": false + }, + "output": { + "type": "array", + "items": { + "type": "object", + "required": [ + "template", + "title", + "files" + ] + } + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": null, + "names": { + "cli": "wai project create", + "mcp": "project_create", + "c": "wai_project_create", + "rust": "wai::project::create", + "sdk": "wai.project.create" + } + }, + { + "id": "dev.start", + "summary": "A relay, a room service and the room page on one origin on this machine, for development; --check starts, checks every piece and stops", + "cli": "wai dev", + "aliases": [], + "usage": [ + "wai dev [--port P] [--dir D] [--site DIR] [--watch FILE [--watch-ms MS]] [--call] [--remote]", + " [--messaging] [--e2ee] [--publishers N] [--check] [--for S] [--web DIR] [--relay BIN] [--room BIN]", + " --port P the origin's port on 127.0.0.1 (8080; 0 picks a free one)", + " --dir D keys, configuration and logs (.wai/dev)", + " --site DIR your page: DIR is served at /, the room client beside it at /room/", + " --publishers N places in the room (4)", + " --watch FILE a watch room: everyone in it watches FILE together (.wav, .webm, .mp4,", + " .ogg, .mp3); --watch-ms MS its duration, read from a WAV file's header", + " --call the room is a call: /room/call.html places one, rings, answers", + " --remote the room is one host's shared screen; prints the host's invitation", + " --messaging a conversation beside the room, at /msg-api/: /room/messages.html", + " --e2ee every room end-to-end encrypted", + " --check start, check every piece through the origin, report, stop", + " --for S stop after S seconds", + " --web DIR the room client's files (beside this binary: ../share/wai/web; $WAI_WEB)", + " --relay BIN wai-relay (beside this binary, then $PATH; $WAI_RELAY_BIN)", + " --room BIN wai-room (beside this binary, then $PATH; $WAI_ROOM_BIN)", + " A wai.json in the current directory (`wai create` writes one) gives the defaults; a flag", + " wins over it. Ctrl-C stops everything. With --json and without --check, the first line", + " is {\"ready\": true, ...}." + ], + "placement": "local", + "effect": "runs", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "dev", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "port": { + "type": "integer", + "description": "The origin's port on 127.0.0.1 (8080; 0 picks a free one).", + "x-cli": { + "flag": "--port" + } + }, + "dir": { + "type": "string", + "format": "path", + "description": "Keys, configuration and logs (.wai/dev).", + "x-cli": { + "flag": "--dir" + } + }, + "site": { + "type": "string", + "format": "path", + "description": "A directory served at /.", + "x-cli": { + "flag": "--site" + } + }, + "watch": { + "type": "string", + "format": "path", + "description": "A file everyone in a watch room watches together.", + "x-cli": { + "flag": "--watch" + } + }, + "watch_ms": { + "type": "integer", + "description": "The watch file's duration in milliseconds.", + "x-cli": { + "flag": "--watch-ms" + } + }, + "call": { + "type": "boolean", + "description": "The room is a call.", + "x-cli": { + "flag": "--call" + } + }, + "remote": { + "type": "boolean", + "description": "The room is one host's shared screen.", + "x-cli": { + "flag": "--remote" + } + }, + "messaging": { + "type": "boolean", + "description": "A conversation beside the room.", + "x-cli": { + "flag": "--messaging" + } + }, + "e2ee": { + "type": "boolean", + "description": "Every room end-to-end encrypted.", + "x-cli": { + "flag": "--e2ee" + } + }, + "publishers": { + "type": "integer", + "description": "Places in the room (4).", + "x-cli": { + "flag": "--publishers" + } + }, + "check": { + "type": "boolean", + "description": "Start, check every piece, report and stop.", + "x-cli": { + "flag": "--check" + } + }, + "for_s": { + "type": "integer", + "description": "Stop after this many seconds.", + "x-cli": { + "flag": "--for" + } + }, + "web": { + "type": "string", + "format": "path", + "description": "The room client's files.", + "x-cli": { + "flag": "--web" + } + }, + "relay": { + "type": "string", + "format": "path", + "description": "The relay's binary.", + "x-cli": { + "flag": "--relay" + } + }, + "room": { + "type": "string", + "format": "path", + "description": "The room service's binary.", + "x-cli": { + "flag": "--room" + } + } + }, + "required": [], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "ready", + "seconds", + "steps", + "checks", + "origin" + ] + }, + "stream": { + "type": "object", + "required": [ + "ready" + ], + "properties": { + "ready": { + "type": "boolean" + }, + "origin": { + "type": "string" + } + } + }, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": null, + "names": { + "cli": "wai dev start", + "mcp": "dev_start", + "c": "wai_dev_start", + "rust": "wai::dev::start", + "sdk": "wai.dev.start" + } + }, + { + "id": "receipt.verify", + "summary": "Every receipt index (*.wai.receipts) under each path, its group receipts and claims checked offline", + "cli": "wai verify", + "aliases": [], + "usage": [ + "wai verify ... the receipts under each path (receipt indexes *.wai.receipts), checked offline" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Files or directories holding receipt indexes.", + "x-cli": { + "positional": true + } + } + }, + "required": [ + "paths" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "quickstart" + ] + }, + "names": { + "cli": "wai receipt verify", + "mcp": "receipt_verify", + "c": "wai_receipt_verify", + "rust": "wai::receipt::verify", + "sdk": "wai.receipt.verify" + } + }, + { + "id": "envelope.inspect", + "summary": "What an envelope holds and whether this build decodes it: media, intent, pin, capability, fallback, the dispatch verdict and the conformance checks", + "cli": "wai inspect", + "aliases": [], + "usage": [ + "wai inspect what a WAI1 or WAI2 envelope holds, and the verdict of SPEC §4's dispatch here" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "path": { + "type": "string", + "format": "path", + "description": "A .wai envelope.", + "x-cli": { + "positional": true + } + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": null, + "names": { + "cli": "wai envelope inspect", + "mcp": "envelope_inspect", + "c": "wai_envelope_inspect", + "rust": "wai::envelope::inspect", + "sdk": "wai.envelope.inspect" + } + }, + { + "id": "envelope.wrap", + "summary": "Carry a registered capability's bytes in an envelope, byte for byte, under SPEC's intent, role and candidate rules", + "cli": "wai wrap", + "aliases": [], + "usage": [ + "wai wrap [-o out.wai] [--fallback ] [--intent ]", + " [--companion ]... WAI2: companions bound to the payload" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "capability": { + "type": "string", + "description": "A registered capability (`wai capabilities`).", + "x-cli": { + "positional": true + } + }, + "payload": { + "type": "string", + "format": "path", + "description": "The bytes to carry.", + "x-cli": { + "positional": true + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The envelope (out.wai).", + "x-cli": { + "flag": "-o" + } + }, + "fallback": { + "type": "string", + "description": "A WAI1 fallback capability.", + "x-cli": { + "flag": "--fallback" + } + }, + "intent": { + "type": "string", + "description": "The envelope's intent (replicate where SPEC §7 allows it).", + "x-cli": { + "flag": "--intent" + } + }, + "companion": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A companion capability and its payload, bound to the payload.", + "x-cli": { + "flag": "--companion" + } + } + }, + "required": [ + "capability", + "payload" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": null, + "names": { + "cli": "wai envelope wrap", + "mcp": "envelope_wrap", + "c": "wai_envelope_wrap", + "rust": "wai::envelope::wrap", + "sdk": "wai.envelope.wrap" + } + }, + { + "id": "envelope.unwrap", + "summary": "An envelope's capability and its checks, and with -o its payload or one component, exactly as wrapped", + "cli": "wai unwrap", + "aliases": [], + "usage": [ + "wai unwrap [-o payload] [--component ] extract any registered capability's bytes" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "path": { + "type": "string", + "format": "path", + "description": "A .wai envelope.", + "x-cli": { + "positional": true + } + }, + "out": { + "type": "string", + "format": "path", + "description": "Where the payload is written.", + "x-cli": { + "flag": "-o" + } + }, + "component": { + "type": "integer", + "description": "A WAI2 component's index.", + "x-cli": { + "flag": "--component" + } + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": null, + "names": { + "cli": "wai envelope unwrap", + "mcp": "envelope_unwrap", + "c": "wai_envelope_unwrap", + "rust": "wai::envelope::unwrap", + "sdk": "wai.envelope.unwrap" + } + }, + { + "id": "media.encode", + "summary": "Encode a file with a codec this build holds, in an envelope; AVC and HEVC through the device's own encoders, with a signed record", + "cli": "wai encode", + "aliases": [], + "usage": [ + "wai encode [-o out.wai] [-q quality] codecs: png jpeg avif jxl jxl-lossless opus flac av1 av1-lossless zstd xz", + " a build with `floor` alone encodes png and zstd; the others need `codecs`", + " image codecs: .rgb (with .meta holding 'H W') or a PNG/JPEG file; audio: .f32 (raw mono float32)", + " with .meta holding the sample rate; video: packed RGB frames with .meta 'N H W fps_num fps_den';", + " text codecs: any file", + "wai encode avc|hevc [-o out.wai] [--encoder auto|platform|va|gpu-driver|software] [--library ]", + " [--device ] [--hardware yes|no] [--kbps ] [--size WxH] [--idr-every |", + " --idr-plan ] [--es ] [--record --key ] [--meter [--rounds ]]", + " (a build with `platform_encode`)" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "codec": { + "type": "string", + "description": "png, jpeg, avif, jxl, jxl-lossless, opus, flac, av1, av1-lossless, zstd, xz; or avc, hevc.", + "x-cli": { + "positional": true + } + }, + "input": { + "type": "string", + "format": "path", + "description": "The file to encode.", + "x-cli": { + "positional": true + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The envelope (out.wai).", + "x-cli": { + "flag": "-o" + } + }, + "quality": { + "type": "integer", + "description": "Quality, 1 to 100 (75).", + "x-cli": { + "flag": "-q" + } + } + }, + "required": [ + "codec", + "input" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": true, + "needs": { + "any": [ + "floor", + "codecs", + "platform_encode" + ] + }, + "names": { + "cli": "wai media encode", + "mcp": "media_encode", + "c": "wai_media_encode", + "rust": "wai::media::encode", + "sdk": "wai.media.encode" + } + }, + { + "id": "media.decode", + "summary": "Decode an envelope through SPEC §4's dispatch to raw samples, with a .meta file beside them", + "cli": "wai decode", + "aliases": [], + "usage": [ + "wai decode -o a build with `floor` decodes png, flac and zstd; `codecs` the rest" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "path": { + "type": "string", + "format": "path", + "description": "A .wai envelope.", + "x-cli": { + "positional": true + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The decoded samples.", + "x-cli": { + "flag": "-o" + } + } + }, + "required": [ + "path", + "out" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "any": [ + "floor", + "codecs" + ] + }, + "names": { + "cli": "wai media decode", + "mcp": "media_decode", + "c": "wai_media_decode", + "rust": "wai::media::decode", + "sdk": "wai.media.decode" + } + }, + { + "id": "media.generate", + "summary": "Make a wai.generate.spectral picture natively and print its SHA-256, or write its instruction object", + "cli": "wai generate", + "aliases": [], + "usage": [ + "wai generate --model (--prompt --seed --steps | --instr ) --size WxH -o ", + " make a wai.generate.spectral picture (RGB8) and print its SHA-256; --sizes WxH,... --write-instr", + " writes the instruction object of form v0 (SPEC §5)" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "model": { + "type": "string", + "format": "path", + "description": "The model file.", + "x-cli": { + "flag": "--model" + } + }, + "prompt": { + "type": "string", + "description": "The prompt.", + "x-cli": { + "flag": "--prompt" + } + }, + "seed": { + "type": "integer", + "description": "The seed.", + "x-cli": { + "flag": "--seed" + } + }, + "steps": { + "type": "integer", + "description": "The steps.", + "x-cli": { + "flag": "--steps" + } + }, + "instr": { + "type": "string", + "format": "path", + "description": "An instruction object of form v0.", + "x-cli": { + "flag": "--instr" + } + }, + "size": { + "type": "string", + "description": "WxH.", + "x-cli": { + "flag": "--size" + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The picture, RGB8.", + "x-cli": { + "flag": "-o" + } + } + }, + "required": [ + "model" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "description": "The picture's or instruction object's digests, as `wai generate --json` prints them" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "generate" + ] + }, + "names": { + "cli": "wai media generate", + "mcp": "media_generate", + "c": "wai_media_generate", + "rust": "wai::media::generate", + "sdk": "wai.media.generate" + } + }, + { + "id": "score.build", + "summary": "A WSCR score from integer note fields", + "cli": "wai score build", + "aliases": [], + "usage": [ + "wai score build -o a WSCR from integer note fields" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "notes": { + "type": "string", + "format": "path", + "description": "The note fields, JSON.", + "x-cli": { + "positional": true + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The score.", + "x-cli": { + "flag": "-o" + } + } + }, + "required": [ + "notes", + "out" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "description": "The score's digests, as `wai score build|render --json` print them" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "score" + ] + }, + "names": { + "cli": "wai score build", + "mcp": "score_build", + "c": "wai_score_build", + "rust": "wai::score::build", + "sdk": "wai.score.build" + } + }, + { + "id": "score.render", + "summary": "A score's reference mixdown and its digests (pcm_sha256, mixdown_blake3, score_hash)", + "cli": "wai score render", + "aliases": [], + "usage": [ + "wai score render [--pcm ] the reference mixdown and its digests" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "score": { + "type": "string", + "format": "path", + "description": "A WSCR score.", + "x-cli": { + "positional": true + } + }, + "pcm": { + "type": "string", + "format": "path", + "description": "Where the mixdown is written.", + "x-cli": { + "flag": "--pcm" + } + } + }, + "required": [ + "score" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "description": "The score's digests, as `wai score build|render --json` print them" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "score" + ] + }, + "names": { + "cli": "wai score render", + "mcp": "score_render", + "c": "wai_score_render", + "rust": "wai::score::render", + "sdk": "wai.score.render" + } + }, + { + "id": "encoder.list", + "summary": "What each encoder interface offers on this device, for AVC and HEVC", + "cli": "wai encoders", + "aliases": [], + "usage": [ + "wai encoders [--library ] what each encoder interface offers here" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "library": { + "type": "string", + "format": "path", + "description": "A software encoder library.", + "x-cli": { + "flag": "--library" + } + } + }, + "required": [], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "platform_encode" + ] + }, + "names": { + "cli": "wai encoder list", + "mcp": "encoder_list", + "c": "wai_encoder_list", + "rust": "wai::encoder::list", + "sdk": "wai.encoder.list" + } + }, + { + "id": "encoderecord.verify", + "summary": "A signed encode record checked, against the stream and its source where given", + "cli": "wai encode-record verify", + "aliases": [], + "usage": [ + "wai encode-record verify [--stream ] [--source ]" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "record": { + "type": "string", + "format": "path", + "description": "The signed record.", + "x-cli": { + "positional": true + } + }, + "stream": { + "type": "string", + "format": "path", + "description": "The elementary stream it names.", + "x-cli": { + "flag": "--stream" + } + }, + "source": { + "type": "string", + "format": "path", + "description": "The source it names.", + "x-cli": { + "flag": "--source" + } + } + }, + "required": [ + "record" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "platform_encode" + ] + }, + "names": { + "cli": "wai encoderecord verify", + "mcp": "encoderecord_verify", + "c": "wai_encoderecord_verify", + "rust": "wai::encoderecord::verify", + "sdk": "wai.encoderecord.verify" + } + }, + { + "id": "registry.list", + "summary": "Every registered capability with its fidelity, licence class, replicate class and role, and whether this build decodes it", + "cli": "wai capabilities", + "aliases": [], + "usage": [ + "wai capabilities the registered capabilities, their tier, licence, replicate class and role" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": null, + "names": { + "cli": "wai registry list", + "mcp": "registry_list", + "c": "wai_registry_list", + "rust": "wai::registry::list", + "sdk": "wai.registry.list" + } + }, + { + "id": "registry.export", + "summary": "Write, or with --check compare, the registry's machine-readable export and the generated table of REGISTRY.md", + "cli": "wai registry export", + "aliases": [], + "usage": [ + "wai registry export [--root ] [--check] write (or compare) registry/capabilities.json, its", + " schema, vocab/v2.* and the generated table of REGISTRY.md" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "maintainer", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "root": { + "type": "string", + "format": "path", + "description": "The wai/ directory.", + "x-cli": { + "flag": "--root" + } + }, + "check": { + "type": "boolean", + "description": "Compare, write nothing.", + "x-cli": { + "flag": "--check" + } + } + }, + "required": [], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": null, + "names": { + "cli": "wai registry export", + "mcp": "registry_export", + "c": "wai_registry_export", + "rust": "wai::registry::export", + "sdk": "wai.registry.export" + } + }, + { + "id": "prior.list", + "summary": "The listing of a parameter set (wai.prior.set) on stdout, and its pin on stderr", + "cli": "wai prior-set", + "aliases": [], + "usage": [ + "wai prior-set print the listing of a parameter set (wai.prior.set) and its pin" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "root": { + "type": "string", + "format": "path", + "description": "The parameter set's directory.", + "x-cli": { + "positional": true + } + } + }, + "required": [ + "root" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": null, + "names": { + "cli": "wai prior list", + "mcp": "prior_list", + "c": "wai_prior_list", + "rust": "wai::prior::list", + "sdk": "wai.prior.list" + } + }, + { + "id": "stage.refine", + "summary": "A refinement layer over a base and the layers below it, toward a target", + "cli": "wai stage refine", + "aliases": [], + "usage": [ + "wai stage refine --base [--on ]... --target --q N [--predictor identity|up2|rate:NUM/DEN]", + " [--span F:C] [--out-dims D0,D1,D2] [--max-symbols N] [--table-per-unit] -o L.wai" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "base": { + "type": "string", + "format": "path", + "description": "The base envelope.", + "x-cli": { + "flag": "--base" + } + }, + "target": { + "type": "string", + "format": "path", + "description": "The target samples.", + "x-cli": { + "flag": "--target" + } + }, + "q": { + "type": "integer", + "description": "The quantiser.", + "x-cli": { + "flag": "--q" + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The layer.", + "x-cli": { + "flag": "-o" + } + } + }, + "required": [ + "base", + "target", + "q", + "out" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "staged", + "video_int", + "audio_int", + "splat_int" + ] + }, + "names": { + "cli": "wai stage refine", + "mcp": "stage_refine", + "c": "wai_stage_refine", + "rust": "wai::stage::refine", + "sdk": "wai.stage.refine" + } + }, + { + "id": "stage.apply", + "summary": "Envelopes taken in order: each layer offered, each base decoded and refined, with each report and the units", + "cli": "wai stage apply", + "aliases": [], + "usage": [ + "wai stage apply ... each layer offered, a base decoded (SPEC §4) and refined; the reports and units, or a JSON trace" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "envelopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The base, then its layers.", + "x-cli": { + "positional": true + } + } + }, + "required": [ + "envelopes" + ], + "additionalProperties": false + }, + "output": { + "description": "The trace of every stage reached, as `wai stage apply --json` prints it" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "staged", + "video_int", + "audio_int", + "splat_int" + ] + }, + "names": { + "cli": "wai stage apply", + "mcp": "stage_apply", + "c": "wai_stage_apply", + "rust": "wai::stage::apply", + "sdk": "wai.stage.apply" + } + }, + { + "id": "stage.base", + "summary": "A wai.video.int_motion base of N frames of RGB8 (WIV1, keyframe 0, zero motion)", + "cli": "wai stage base", + "aliases": [], + "usage": [ + "wai stage base --frames --dims N,H,W --q Q -o base.wai" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "frames": { + "type": "string", + "format": "path", + "description": "The frames, packed RGB8.", + "x-cli": { + "flag": "--frames" + } + }, + "dims": { + "type": "string", + "description": "N,H,W.", + "x-cli": { + "flag": "--dims" + } + }, + "q": { + "type": "integer", + "description": "The quantiser.", + "x-cli": { + "flag": "--q" + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The base envelope.", + "x-cli": { + "flag": "-o" + } + } + }, + "required": [ + "frames", + "dims", + "q", + "out" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "staged", + "video_int", + "audio_int", + "splat_int" + ] + }, + "names": { + "cli": "wai stage base", + "mcp": "stage_base", + "c": "wai_stage_base", + "rust": "wai::stage::base", + "sdk": "wai.stage.base" + } + }, + { + "id": "stage.inspect", + "summary": "A refinement layer's refines, its WIR1 header and its declared cost", + "cli": "wai stage inspect", + "aliases": [], + "usage": [ + "wai stage inspect " + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "layer": { + "type": "string", + "format": "path", + "description": "A layer envelope.", + "x-cli": { + "positional": true + } + } + }, + "required": [ + "layer" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "staged", + "video_int", + "audio_int", + "splat_int" + ] + }, + "names": { + "cli": "wai stage inspect", + "mcp": "stage_inspect", + "c": "wai_stage_inspect", + "rust": "wai::stage::inspect", + "sdk": "wai.stage.inspect" + } + }, + { + "id": "stage.pack", + "summary": "WAI1 envelopes packed into one WAI2, their manifests verbatim", + "cli": "wai stage pack", + "aliases": [], + "usage": [ + "wai stage pack ... -o x.wai" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "envelopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The base, then its layers.", + "x-cli": { + "positional": true + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The WAI2 envelope.", + "x-cli": { + "flag": "-o" + } + } + }, + "required": [ + "envelopes", + "out" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "staged", + "video_int", + "audio_int", + "splat_int" + ] + }, + "names": { + "cli": "wai stage pack", + "mcp": "stage_pack", + "c": "wai_stage_pack", + "rust": "wai::stage::pack", + "sdk": "wai.stage.pack" + } + }, + { + "id": "stage.explode", + "summary": "One component of a WAI2 envelope as its WAI1 envelope", + "cli": "wai stage explode", + "aliases": [], + "usage": [ + "wai stage explode [-o out.wai]" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "path": { + "type": "string", + "format": "path", + "description": "A WAI2 envelope.", + "x-cli": { + "positional": true + } + }, + "index": { + "type": "integer", + "description": "The component's index.", + "x-cli": { + "positional": true + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The WAI1 envelope.", + "x-cli": { + "flag": "-o" + } + } + }, + "required": [ + "path", + "index" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "staged", + "video_int", + "audio_int", + "splat_int" + ] + }, + "names": { + "cli": "wai stage explode", + "mcp": "stage_explode", + "c": "wai_stage_explode", + "rust": "wai::stage::explode", + "sdk": "wai.stage.explode" + } + }, + { + "id": "http.publish", + "summary": "Envelopes named by their content hash, each with its head proof and entity tag, ready for an origin", + "cli": "wai http publish", + "aliases": [], + "usage": [ + "wai http publish ... --out " + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "envelopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The envelopes.", + "x-cli": { + "positional": true + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The directory.", + "x-cli": { + "flag": "--out" + } + } + }, + "required": [ + "envelopes", + "out" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "http" + ] + }, + "names": { + "cli": "wai http publish", + "mcp": "http_publish", + "c": "wai_http_publish", + "rust": "wai::http::publish", + "sdk": "wai.http.publish" + } + }, + { + "id": "http.codecs", + "summary": "The codecs parameter a sequence of envelopes advertises: its union and its floor", + "cli": "wai http codecs", + "aliases": [], + "usage": [ + "wai http codecs ..." + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "envelopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The envelopes.", + "x-cli": { + "positional": true + } + } + }, + "required": [ + "envelopes" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "http" + ] + }, + "names": { + "cli": "wai http codecs", + "mcp": "http_codecs", + "c": "wai_http_codecs", + "rust": "wai::http::codecs", + "sdk": "wai.http.codecs" + } + }, + { + "id": "http.origin", + "summary": "An origin on loopback serving a published directory with http-delivery's headers, ranges and If-Range", + "cli": "wai http origin", + "aliases": [], + "usage": [ + "wai http origin --root [--port P] [--delay-range FIRST-LAST:MS]" + ], + "placement": "local", + "effect": "runs", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "root": { + "type": "string", + "format": "path", + "description": "The published directory.", + "x-cli": { + "flag": "--root" + } + }, + "port": { + "type": "integer", + "description": "The port on 127.0.0.1.", + "x-cli": { + "flag": "--port" + } + } + }, + "required": [ + "root" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "http" + ] + }, + "names": { + "cli": "wai http origin", + "mcp": "http_origin", + "c": "wai_http_origin", + "rust": "wai::http::origin", + "sdk": "wai.http.origin" + } + }, + { + "id": "http.hls", + "summary": "An HLS rendition of published objects", + "cli": "wai http hls", + "aliases": [], + "usage": [ + "wai http hls --objects --out [--master NAME]" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "objects": { + "type": "string", + "format": "path", + "description": "The objects, JSON.", + "x-cli": { + "flag": "--objects" + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The directory.", + "x-cli": { + "flag": "--out" + } + } + }, + "required": [ + "objects", + "out" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "http" + ] + }, + "names": { + "cli": "wai http hls", + "mcp": "http_hls", + "c": "wai_http_hls", + "rust": "wai::http::hls", + "sdk": "wai.http.hls" + } + }, + { + "id": "http.mpd", + "summary": "A DASH manifest of published objects", + "cli": "wai http mpd", + "aliases": [], + "usage": [ + "wai http mpd --objects --out [--period-id ID] [--set-id N]" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "objects": { + "type": "string", + "format": "path", + "description": "The objects, JSON.", + "x-cli": { + "flag": "--objects" + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The manifest.", + "x-cli": { + "flag": "--out" + } + } + }, + "required": [ + "objects", + "out" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "http" + ] + }, + "names": { + "cli": "wai http mpd", + "mcp": "http_mpd", + "c": "wai_http_mpd", + "rust": "wai::http::mpd", + "sdk": "wai.http.mpd" + } + }, + { + "id": "http.sealplaylist", + "summary": "A media playlist sealed with a signed manifest receipt", + "cli": "wai http seal-playlist", + "aliases": [], + "usage": [ + "wai http seal-playlist --key [--signer-id ID] [--content-id ID] [--parent ] [--out ]" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "playlist": { + "type": "string", + "format": "path", + "description": "The playlist.", + "x-cli": { + "positional": true + } + }, + "key": { + "type": "string", + "format": "path", + "description": "The signing seed.", + "x-cli": { + "flag": "--key" + } + } + }, + "required": [ + "playlist", + "key" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": true, + "needs": { + "all": [ + "http", + "video_manifest" + ] + }, + "names": { + "cli": "wai http sealplaylist", + "mcp": "http_sealplaylist", + "c": "wai_http_sealplaylist", + "rust": "wai::http::sealplaylist", + "sdk": "wai.http.sealplaylist" + } + }, + { + "id": "http.checkplaylist", + "summary": "A media playlist checked against its manifest receipt", + "cli": "wai http check-playlist", + "aliases": [], + "usage": [ + "wai http check-playlist --receipt [--parent ] [--classical ]" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "playlist": { + "type": "string", + "format": "path", + "description": "The playlist.", + "x-cli": { + "positional": true + } + }, + "receipt": { + "type": "string", + "format": "path", + "description": "Its manifest receipt.", + "x-cli": { + "flag": "--receipt" + } + } + }, + "required": [ + "playlist", + "receipt" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "http", + "video_manifest" + ] + }, + "names": { + "cli": "wai http checkplaylist", + "mcp": "http_checkplaylist", + "c": "wai_http_checkplaylist", + "rust": "wai::http::checkplaylist", + "sdk": "wai.http.checkplaylist" + } + }, + { + "id": "http.vectors", + "summary": "The HTTP conformance corpus: generated, verified, or its differential and fuzz cases written", + "cli": "wai http vectors", + "aliases": [], + "usage": [ + "wai http vectors gen|verify ", + "wai http vectors diff|fuzz --seed S --count N --out F" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "maintainer", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "description": "gen, verify, diff or fuzz.", + "x-cli": { + "positional": true + } + }, + "dir": { + "type": "string", + "format": "path", + "description": "The corpus directory.", + "x-cli": { + "positional": true + } + } + }, + "required": [ + "mode", + "dir" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "http" + ] + }, + "names": { + "cli": "wai http vectors", + "mcp": "http_vectors", + "c": "wai_http_vectors", + "rust": "wai::http::vectors", + "sdk": "wai.http.vectors" + } + }, + { + "id": "http.bench", + "summary": "An object fetched by the byte-range procedure, repeatedly, and the record of each run", + "cli": "wai http bench", + "aliases": [], + "usage": [ + "wai http bench --url URL --runs N --advertise CAP [--advertise CAP]... --out F" + ], + "placement": "client", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The object's URL.", + "x-cli": { + "flag": "--url" + } + }, + "runs": { + "type": "integer", + "description": "How many runs.", + "x-cli": { + "flag": "--runs" + } + }, + "advertise": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A capability this sink advertises.", + "x-cli": { + "flag": "--advertise" + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The record.", + "x-cli": { + "flag": "--out" + } + } + }, + "required": [ + "url", + "runs", + "advertise", + "out" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "http" + ] + }, + "names": { + "cli": "wai http bench", + "mcp": "http_bench", + "c": "wai_http_bench", + "rust": "wai::http::bench", + "sdk": "wai.http.bench" + } + }, + { + "id": "http.receipts", + "summary": "An object's receipt index fetched over HTTP and checked, its signers confirmed under the origin's key log where named", + "cli": "wai http receipts", + "aliases": [], + "usage": [ + "wai http receipts --url URL [--advertise CAP]... [--held fetch|none] [--join] [--max-walk N] [--fetch-budget N]", + " [--deadline-ms MS] [--key-origin https://HOST[:PORT] [--pin-key-log FILE | --test-plaintext-connect HOST:PORT]]" + ], + "placement": "client", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The object's URL.", + "x-cli": { + "flag": "--url" + } + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "http", + "receipt_claims" + ] + }, + "names": { + "cli": "wai http receipts", + "mcp": "http_receipts", + "c": "wai_http_receipts", + "rust": "wai::http::receipts", + "sdk": "wai.http.receipts" + } + }, + { + "id": "ops.list", + "summary": "Every operation of this catalogue: its id, command line, effect and whether this build holds it", + "cli": "wai ops list", + "aliases": [], + "usage": [ + "wai ops list every operation, its id and command line" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "output": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "summary", + "cli", + "effect", + "placement", + "toolset", + "stability", + "available" + ] + } + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": null, + "names": { + "cli": "wai ops list", + "mcp": "ops_list", + "c": "wai_ops_list", + "rust": "wai::ops::list", + "sdk": "wai.ops.list" + } + }, + { + "id": "ops.search", + "summary": "The operations that match a task, ranked, offline: up to five", + "cli": "wai ops search", + "aliases": [], + "usage": [ + "wai ops search \"\" up to five operations that match, ranked, offline" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "What you want to do, in words.", + "x-cli": { + "positional": true + } + } + }, + "required": [ + "task" + ], + "additionalProperties": false + }, + "output": { + "type": "array", + "maxItems": 5, + "items": { + "type": "object", + "required": [ + "id", + "summary", + "cli", + "score" + ], + "properties": { + "id": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "cli": { + "type": [ + "string", + "null" + ] + }, + "score": { + "type": "integer" + } + } + } + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": null, + "names": { + "cli": "wai ops search", + "mcp": "ops_search", + "c": "wai_ops_search", + "rust": "wai::ops::search", + "sdk": "wai.ops.search" + } + }, + { + "id": "ops.schema", + "summary": "One operation's entry: its input and output schemas, effect, placement and command line", + "cli": "wai ops schema", + "aliases": [], + "usage": [ + "wai ops schema one operation's entry, as JSON" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "An operation id, noun.verb.", + "x-cli": { + "positional": true + } + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "id", + "summary", + "input", + "output", + "effect", + "placement" + ] + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": null, + "names": { + "cli": "wai ops schema", + "mcp": "ops_schema", + "c": "wai_ops_schema", + "rust": "wai::ops::schema", + "sdk": "wai.ops.schema" + } + }, + { + "id": "ops.export", + "summary": "The catalogue as JSON (wai.ops/1), what other tools and CI read; --check compares a file with it", + "cli": "wai ops export", + "aliases": [], + "usage": [ + "wai ops export [--out FILE | --check FILE] the catalogue as JSON, to stdout, to FILE, or compared with FILE" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "out": { + "type": "string", + "format": "path", + "description": "Write the export here.", + "x-cli": { + "flag": "--out" + } + }, + "check": { + "type": "string", + "format": "path", + "description": "Compare this file with the export; exit 1 when they differ.", + "x-cli": { + "flag": "--check" + } + } + }, + "required": [], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "format", + "toolchain", + "envelope", + "operations" + ], + "properties": { + "format": { + "const": "wai.ops/1" + } + } + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": null, + "names": { + "cli": "wai ops export", + "mcp": "ops_export", + "c": "wai_ops_export", + "rust": "wai::ops::export", + "sdk": "wai.ops.export" + } + } + ] +} diff --git a/wai/wai-rs/src/catalogue.rs b/wai/wai-rs/src/catalogue.rs new file mode 100644 index 000000000..6ceb7dc52 --- /dev/null +++ b/wai/wai-rs/src/catalogue.rs @@ -0,0 +1,1283 @@ +//! The operation catalogue: every operation the WAI toolchain offers, once, as data. +//! +//! An operation is named `noun.verb` (`envelope.inspect`, `project.create`). Every door +//! names it by a mechanical transform of that id ([`Op::names`]): +//! +//! | Door | Form | +//! |---|---| +//! | CLI | `wai envelope inspect`, or the spelling the CLI has today (`wai inspect`) | +//! | HTTP API | `operationId` = the id, at the operation's [`Route`] | +//! | Rust | `wai::envelope::inspect` | +//! | C | `wai_envelope_inspect` | +//! | JavaScript, Swift, Kotlin | `wai.envelope.inspect` (an object per noun, a method per verb) | +//! | MCP | `envelope_inspect` | +//! +//! The table lists only what works today: an operation joins it when it is built. One that +//! needs a build feature names it ([`Op::needs`]); [`Op::available`] says whether this build +//! holds it. `wai ops export` writes the table as JSON (`wai/catalogue/ops.json`), which +//! the doors' checks read, and `wai ops search` ranks it against a task, offline. +//! +//! The rules every entry keeps, held by this module's tests: +//! - the id is `noun.verb`, lowercase ASCII letters and digits, the noun singular, and the +//! verb never a near-synonym of another (`get`, `describe` and `info` are `show`; +//! `keygen`, `init` and `new` are `create`; `up` is `dev`) — [`NEAR_SYNONYMS`]; +//! - no two operations answer to the same command line; +//! - an operation has an HTTP route exactly when it runs on a service (`service` or `both`); +//! - an operation whose effect is not `reads` takes `--dry-run`, and one that would change +//! a file the command line did not name, or is `destructive`, or acts on a target that is +//! not local, waits for `--yes` (the `wai` binary's tests hold its doors to these). + +use serde_json::{Map, Value, json}; + +/// The export's format identifier. +pub const FORMAT: &str = "wai.ops/1"; + +/// The version of the JSON envelope `wai --json` prints. +pub const ENVELOPE: &str = "envelope/1"; + +/// Verbs that are another verb's near-synonym, with the verb to use: an id never ends in one. +pub const NEAR_SYNONYMS: &[(&str, &str)] = &[ + ("get", "show"), + ("describe", "show"), + ("info", "show"), + ("keygen", "create"), + ("init", "create"), + ("new", "create"), + ("up", "start (wai dev)"), +]; + +/// Where an operation runs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Placement { + /// On this machine, touching nothing else. + Local, + /// On this machine, against a service it names (a URL, a room). + Client, + /// On a service, reached through its HTTP route. + Service, + /// Either: on this machine or through the service's route. + Both, +} + +/// What an operation does to the world, which decides `--dry-run` and confirmation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Effect { + /// Reads only. + Reads, + /// Runs something (a local server, a development stack) and writes only its own state. + Runs, + /// Writes files on this machine. + WritesProject, + /// Changes a target that is not this machine. + WritesTarget, + /// Removes or replaces what cannot be recovered. + Destructive, +} + +/// The MCP toolset an operation belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Toolset { + Core, + Dev, + App, + Quantum, + Maintainer, +} + +/// How far an operation's name, flags and output are promised. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Stability { + /// Changed only with a deprecation window. + Stable, + /// Published and working; its shape may still change. + Preview, + /// Never published. + Internal, +} + +/// The JSON type of an input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Ty { + String, + /// A path on this machine. + Path, + Integer, + Boolean, + /// Repeated: a list of strings. + Strings, +} + +/// How an input is given on the command line. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Cli { + /// In order, after the command's words. + Positional, + /// `--flag `. + Flag(&'static str), + /// `--switch`, a boolean. + Switch(&'static str), +} + +/// One input of an operation. +#[derive(Clone, Copy, Debug)] +pub struct Arg { + pub name: &'static str, + pub ty: Ty, + pub required: bool, + pub cli: Cli, + pub doc: &'static str, +} + +/// An HTTP route: method and path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Route { + pub method: &'static str, + pub path: &'static str, +} + +/// The build features an operation needs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Needs { + /// Every build holds it. + Nothing, + /// A build with all of these. + All(&'static [&'static str]), + /// A build with any one of these. + Any(&'static [&'static str]), +} + +/// What an operation's `--json` envelope carries in `data`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Output { + /// The text it prints, as `{"text": ...}`. + Text, + /// A JSON value of this schema (JSON Schema text). + Json(&'static str), +} + +/// A renamed operation, kept answering under its old name. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Deprecated { + pub since: &'static str, + pub use_instead: &'static str, +} + +/// One operation. +#[derive(Clone, Copy, Debug)] +pub struct Op { + /// `noun.verb`. + pub id: &'static str, + /// One line, for help, docs, MCP descriptions and the search. + pub summary: &'static str, + /// The command line's words today (after `wai`), when the CLI carries it. Every operation + /// also answers to its mechanical form, `wai `. + pub cli: Option<&'static str>, + /// Further CLI spellings, declared here and nowhere else. + pub aliases: &'static [&'static str], + /// The synopsis: one or more lines, each after `wai `. + pub usage: &'static str, + pub placement: Placement, + pub effect: Effect, + pub toolset: Toolset, + pub stability: Stability, + pub input: &'static [Arg], + pub output: Output, + /// For a streaming operation: the JSON Schema of each JSON Lines line. + pub stream: Option<&'static str>, + /// For an operation that runs on a service: its HTTP route. + pub route: Option, + /// The toolchain version that first carries it in this catalogue. + pub since: &'static str, + pub deprecated: Option, + /// Whether it writes a signed receipt or record that a `verify` operation checks. + pub receipt: bool, + pub needs: Needs, +} + +const fn pos(name: &'static str, ty: Ty, required: bool, doc: &'static str) -> Arg { + Arg { name, ty, required, cli: Cli::Positional, doc } +} +const fn flag(name: &'static str, f: &'static str, ty: Ty, required: bool, doc: &'static str) -> Arg { + Arg { name, ty, required, cli: Cli::Flag(f), doc } +} +const fn switch(name: &'static str, f: &'static str, doc: &'static str) -> Arg { + Arg { name, ty: Ty::Boolean, required: false, cli: Cli::Switch(f), doc } +} + +/// The toolchain version the catalogue's first entries carry. +const V0: &str = "0.3.0"; + +const STAGE: Needs = Needs::All(&["staged", "video_int", "audio_int", "splat_int"]); +const HTTP: Needs = Needs::All(&["http"]); + +/// The default entry every operation below starts from. +const OP: Op = Op { + id: "", + summary: "", + cli: None, + aliases: &[], + usage: "", + placement: Placement::Local, + effect: Effect::Reads, + toolset: Toolset::Core, + stability: Stability::Stable, + input: &[], + output: Output::Text, + stream: None, + route: None, + since: V0, + deprecated: None, + receipt: false, + needs: Needs::Nothing, +}; + +const S_OPS_LIST: &str = r#"{"type":"array","items":{"type":"object","required":["id","summary","cli","effect","placement","toolset","stability","available"]}}"#; +const S_OPS_SEARCH: &str = r#"{"type":"array","maxItems":5,"items":{"type":"object","required":["id","summary","cli","score"],"properties":{"id":{"type":"string"},"summary":{"type":"string"},"cli":{"type":["string","null"]},"score":{"type":"integer"}}}}"#; +const S_OPS_EXPORT: &str = r#"{"type":"object","required":["format","toolchain","envelope","operations"],"properties":{"format":{"const":"wai.ops/1"}}}"#; +const S_OPS_SCHEMA: &str = r#"{"type":"object","required":["id","summary","input","output","effect","placement"]}"#; +const S_TEMPLATES: &str = r#"{"type":"array","items":{"type":"object","required":["template","title","files"]}}"#; +const S_DEV_CHECK: &str = r#"{"type":"object","required":["ready","seconds","steps","checks","origin"]}"#; +const S_DEV_READY: &str = r#"{"type":"object","required":["ready"],"properties":{"ready":{"type":"boolean"},"origin":{"type":"string"}}}"#; +const S_GENERATE: &str = r#"{"type":"object","description":"The picture's or instruction object's digests, as `wai generate --json` prints them"}"#; +const S_SCORE: &str = r#"{"type":"object","description":"The score's digests, as `wai score build|render --json` print them"}"#; +const S_STAGE_APPLY: &str = r#"{"description":"The trace of every stage reached, as `wai stage apply --json` prints it"}"#; + +/// Every operation, in the order help lists them: the developer's path first. +pub const OPS: &[Op] = &[ + // ─── the developer's path ─── + Op { + id: "toolchain.hello", + summary: "WAI's four steps (name, dispatch, rebuild, receipt) on a picture made here, its receipt checked offline", + cli: Some("hello"), + usage: "hello [] WAI's four steps (name, dispatch, rebuild, receipt) on a sample, written to (wai-hello)", + effect: Effect::WritesProject, + input: &[pos("dir", Ty::Path, false, "Where the object and its receipts are written (wai-hello).")], + receipt: true, + needs: Needs::All(&["quickstart"]), + ..OP + }, + Op { + id: "project.create", + summary: "A new project from one of the examples, written into a new or empty directory; --list names the templates", + cli: Some("create"), + usage: "create --template | create --list a new project from one of the examples", + effect: Effect::WritesProject, + input: &[ + pos("dir", Ty::Path, false, "A directory that does not exist or is empty."), + flag("template", "--template", Ty::String, false, "The template, one of `wai create --list`."), + switch("list", "--list", "List the templates instead."), + ], + output: Output::Json(S_TEMPLATES), + ..OP + }, + Op { + id: "dev.start", + summary: "A relay, a room service and the room page on one origin on this machine, for development; --check starts, checks every piece and stops", + cli: Some("dev"), + usage: "dev [--port P] [--dir D] [--site DIR] [--watch FILE [--watch-ms MS]] [--call] [--remote] + [--messaging] [--e2ee] [--publishers N] [--check] [--for S] [--web DIR] [--relay BIN] [--room BIN] + --port P the origin's port on 127.0.0.1 (8080; 0 picks a free one) + --dir D keys, configuration and logs (.wai/dev) + --site DIR your page: DIR is served at /, the room client beside it at /room/ + --publishers N places in the room (4) + --watch FILE a watch room: everyone in it watches FILE together (.wav, .webm, .mp4, + .ogg, .mp3); --watch-ms MS its duration, read from a WAV file's header + --call the room is a call: /room/call.html places one, rings, answers + --remote the room is one host's shared screen; prints the host's invitation + --messaging a conversation beside the room, at /msg-api/: /room/messages.html + --e2ee every room end-to-end encrypted + --check start, check every piece through the origin, report, stop + --for S stop after S seconds + --web DIR the room client's files (beside this binary: ../share/wai/web; $WAI_WEB) + --relay BIN wai-relay (beside this binary, then $PATH; $WAI_RELAY_BIN) + --room BIN wai-room (beside this binary, then $PATH; $WAI_ROOM_BIN) + A wai.json in the current directory (`wai create` writes one) gives the defaults; a flag + wins over it. Ctrl-C stops everything. With --json and without --check, the first line + is {\"ready\": true, ...}.", + effect: Effect::Runs, + toolset: Toolset::Dev, + input: &[ + flag("port", "--port", Ty::Integer, false, "The origin's port on 127.0.0.1 (8080; 0 picks a free one)."), + flag("dir", "--dir", Ty::Path, false, "Keys, configuration and logs (.wai/dev)."), + flag("site", "--site", Ty::Path, false, "A directory served at /."), + flag("watch", "--watch", Ty::Path, false, "A file everyone in a watch room watches together."), + flag("watch_ms", "--watch-ms", Ty::Integer, false, "The watch file's duration in milliseconds."), + switch("call", "--call", "The room is a call."), + switch("remote", "--remote", "The room is one host's shared screen."), + switch("messaging", "--messaging", "A conversation beside the room."), + switch("e2ee", "--e2ee", "Every room end-to-end encrypted."), + flag("publishers", "--publishers", Ty::Integer, false, "Places in the room (4)."), + switch("check", "--check", "Start, check every piece, report and stop."), + flag("for_s", "--for", Ty::Integer, false, "Stop after this many seconds."), + flag("web", "--web", Ty::Path, false, "The room client's files."), + flag("relay", "--relay", Ty::Path, false, "The relay's binary."), + flag("room", "--room", Ty::Path, false, "The room service's binary."), + ], + output: Output::Json(S_DEV_CHECK), + stream: Some(S_DEV_READY), + ..OP + }, + Op { + id: "receipt.verify", + summary: "Every receipt index (*.wai.receipts) under each path, its group receipts and claims checked offline", + cli: Some("verify"), + usage: "verify ... the receipts under each path (receipt indexes *.wai.receipts), checked offline", + input: &[pos("paths", Ty::Strings, true, "Files or directories holding receipt indexes.")], + needs: Needs::All(&["quickstart"]), + ..OP + }, + // ─── envelopes and media ─── + Op { + id: "envelope.inspect", + summary: "What an envelope holds and whether this build decodes it: media, intent, pin, capability, fallback, the dispatch verdict and the conformance checks", + cli: Some("inspect"), + usage: "inspect what a WAI1 or WAI2 envelope holds, and the verdict of SPEC §4's dispatch here", + input: &[pos("path", Ty::Path, true, "A .wai envelope.")], + ..OP + }, + Op { + id: "envelope.wrap", + summary: "Carry a registered capability's bytes in an envelope, byte for byte, under SPEC's intent, role and candidate rules", + cli: Some("wrap"), + usage: "wrap [-o out.wai] [--fallback ] [--intent ] + [--companion ]... WAI2: companions bound to the payload", + effect: Effect::WritesProject, + input: &[ + pos("capability", Ty::String, true, "A registered capability (`wai capabilities`)."), + pos("payload", Ty::Path, true, "The bytes to carry."), + flag("out", "-o", Ty::Path, false, "The envelope (out.wai)."), + flag("fallback", "--fallback", Ty::String, false, "A WAI1 fallback capability."), + flag("intent", "--intent", Ty::String, false, "The envelope's intent (replicate where SPEC §7 allows it)."), + flag("companion", "--companion", Ty::Strings, false, "A companion capability and its payload, bound to the payload."), + ], + ..OP + }, + Op { + id: "envelope.unwrap", + summary: "An envelope's capability and its checks, and with -o its payload or one component, exactly as wrapped", + cli: Some("unwrap"), + usage: "unwrap [-o payload] [--component ] extract any registered capability's bytes", + effect: Effect::WritesProject, + input: &[ + pos("path", Ty::Path, true, "A .wai envelope."), + flag("out", "-o", Ty::Path, false, "Where the payload is written."), + flag("component", "--component", Ty::Integer, false, "A WAI2 component's index."), + ], + ..OP + }, + Op { + id: "media.encode", + summary: "Encode a file with a codec this build holds, in an envelope; AVC and HEVC through the device's own encoders, with a signed record", + cli: Some("encode"), + usage: "encode [-o out.wai] [-q quality] codecs: png jpeg avif jxl jxl-lossless opus flac av1 av1-lossless zstd xz + a build with `floor` alone encodes png and zstd; the others need `codecs` + image codecs: .rgb (with .meta holding 'H W') or a PNG/JPEG file; audio: .f32 (raw mono float32) + with .meta holding the sample rate; video: packed RGB frames with .meta 'N H W fps_num fps_den'; + text codecs: any file +encode avc|hevc [-o out.wai] [--encoder auto|platform|va|gpu-driver|software] [--library ] + [--device ] [--hardware yes|no] [--kbps ] [--size WxH] [--idr-every | + --idr-plan ] [--es ] [--record --key ] [--meter [--rounds ]] + (a build with `platform_encode`)", + effect: Effect::WritesProject, + input: &[ + pos("codec", Ty::String, true, "png, jpeg, avif, jxl, jxl-lossless, opus, flac, av1, av1-lossless, zstd, xz; or avc, hevc."), + pos("input", Ty::Path, true, "The file to encode."), + flag("out", "-o", Ty::Path, false, "The envelope (out.wai)."), + flag("quality", "-q", Ty::Integer, false, "Quality, 1 to 100 (75)."), + ], + receipt: true, + needs: Needs::Any(&["floor", "codecs", "platform_encode"]), + ..OP + }, + Op { + id: "media.decode", + summary: "Decode an envelope through SPEC §4's dispatch to raw samples, with a .meta file beside them", + cli: Some("decode"), + usage: "decode -o a build with `floor` decodes png, flac and zstd; `codecs` the rest", + effect: Effect::WritesProject, + input: &[pos("path", Ty::Path, true, "A .wai envelope."), flag("out", "-o", Ty::Path, true, "The decoded samples.")], + needs: Needs::Any(&["floor", "codecs"]), + ..OP + }, + Op { + id: "media.generate", + summary: "Make a wai.generate.spectral picture natively and print its SHA-256, or write its instruction object", + cli: Some("generate"), + usage: "generate --model (--prompt --seed --steps | --instr ) --size WxH -o + make a wai.generate.spectral picture (RGB8) and print its SHA-256; --sizes WxH,... --write-instr + writes the instruction object of form v0 (SPEC §5)", + effect: Effect::WritesProject, + input: &[ + flag("model", "--model", Ty::Path, true, "The model file."), + flag("prompt", "--prompt", Ty::String, false, "The prompt."), + flag("seed", "--seed", Ty::Integer, false, "The seed."), + flag("steps", "--steps", Ty::Integer, false, "The steps."), + flag("instr", "--instr", Ty::Path, false, "An instruction object of form v0."), + flag("size", "--size", Ty::String, false, "WxH."), + flag("out", "-o", Ty::Path, false, "The picture, RGB8."), + ], + output: Output::Json(S_GENERATE), + needs: Needs::All(&["generate"]), + ..OP + }, + Op { + id: "score.build", + summary: "A WSCR score from integer note fields", + cli: Some("score build"), + usage: "score build -o a WSCR from integer note fields", + effect: Effect::WritesProject, + input: &[pos("notes", Ty::Path, true, "The note fields, JSON."), flag("out", "-o", Ty::Path, true, "The score.")], + output: Output::Json(S_SCORE), + needs: Needs::All(&["score"]), + ..OP + }, + Op { + id: "score.render", + summary: "A score's reference mixdown and its digests (pcm_sha256, mixdown_blake3, score_hash)", + cli: Some("score render"), + usage: "score render [--pcm ] the reference mixdown and its digests", + effect: Effect::WritesProject, + input: &[pos("score", Ty::Path, true, "A WSCR score."), flag("pcm", "--pcm", Ty::Path, false, "Where the mixdown is written.")], + output: Output::Json(S_SCORE), + needs: Needs::All(&["score"]), + ..OP + }, + Op { + id: "encoder.list", + summary: "What each encoder interface offers on this device, for AVC and HEVC", + cli: Some("encoders"), + usage: "encoders [--library ] what each encoder interface offers here", + stability: Stability::Preview, + input: &[flag("library", "--library", Ty::Path, false, "A software encoder library.")], + needs: Needs::All(&["platform_encode"]), + ..OP + }, + Op { + id: "encoderecord.verify", + summary: "A signed encode record checked, against the stream and its source where given", + cli: Some("encode-record verify"), + usage: "encode-record verify [--stream ] [--source ]", + stability: Stability::Preview, + input: &[ + pos("record", Ty::Path, true, "The signed record."), + flag("stream", "--stream", Ty::Path, false, "The elementary stream it names."), + flag("source", "--source", Ty::Path, false, "The source it names."), + ], + needs: Needs::All(&["platform_encode"]), + ..OP + }, + // ─── the registry and parameter sets ─── + Op { + id: "registry.list", + summary: "Every registered capability with its fidelity, licence class, replicate class and role, and whether this build decodes it", + cli: Some("capabilities"), + usage: "capabilities the registered capabilities, their tier, licence, replicate class and role", + ..OP + }, + Op { + id: "registry.export", + summary: "Write, or with --check compare, the registry's machine-readable export and the generated table of REGISTRY.md", + cli: Some("registry export"), + usage: "registry export [--root ] [--check] write (or compare) registry/capabilities.json, its + schema, vocab/v2.* and the generated table of REGISTRY.md", + effect: Effect::WritesProject, + toolset: Toolset::Maintainer, + input: &[flag("root", "--root", Ty::Path, false, "The wai/ directory."), switch("check", "--check", "Compare, write nothing.")], + ..OP + }, + Op { + id: "prior.list", + summary: "The listing of a parameter set (wai.prior.set) on stdout, and its pin on stderr", + cli: Some("prior-set"), + usage: "prior-set print the listing of a parameter set (wai.prior.set) and its pin", + input: &[pos("root", Ty::Path, true, "The parameter set's directory.")], + ..OP + }, + // ─── staged delivery ─── + Op { + id: "stage.refine", + summary: "A refinement layer over a base and the layers below it, toward a target", + cli: Some("stage refine"), + usage: "stage refine --base [--on ]... --target --q N [--predictor identity|up2|rate:NUM/DEN] + [--span F:C] [--out-dims D0,D1,D2] [--max-symbols N] [--table-per-unit] -o L.wai", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + flag("base", "--base", Ty::Path, true, "The base envelope."), + flag("target", "--target", Ty::Path, true, "The target samples."), + flag("q", "--q", Ty::Integer, true, "The quantiser."), + flag("out", "-o", Ty::Path, true, "The layer."), + ], + needs: STAGE, + ..OP + }, + Op { + id: "stage.apply", + summary: "Envelopes taken in order: each layer offered, each base decoded and refined, with each report and the units", + cli: Some("stage apply"), + usage: "stage apply ... each layer offered, a base decoded (SPEC §4) and refined; the reports and units, or a JSON trace", + stability: Stability::Preview, + input: &[pos("envelopes", Ty::Strings, true, "The base, then its layers.")], + output: Output::Json(S_STAGE_APPLY), + needs: STAGE, + ..OP + }, + Op { + id: "stage.base", + summary: "A wai.video.int_motion base of N frames of RGB8 (WIV1, keyframe 0, zero motion)", + cli: Some("stage base"), + usage: "stage base --frames --dims N,H,W --q Q -o base.wai", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + flag("frames", "--frames", Ty::Path, true, "The frames, packed RGB8."), + flag("dims", "--dims", Ty::String, true, "N,H,W."), + flag("q", "--q", Ty::Integer, true, "The quantiser."), + flag("out", "-o", Ty::Path, true, "The base envelope."), + ], + needs: STAGE, + ..OP + }, + Op { + id: "stage.inspect", + summary: "A refinement layer's refines, its WIR1 header and its declared cost", + cli: Some("stage inspect"), + usage: "stage inspect ", + stability: Stability::Preview, + input: &[pos("layer", Ty::Path, true, "A layer envelope.")], + needs: STAGE, + ..OP + }, + Op { + id: "stage.pack", + summary: "WAI1 envelopes packed into one WAI2, their manifests verbatim", + cli: Some("stage pack"), + usage: "stage pack ... -o x.wai", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[pos("envelopes", Ty::Strings, true, "The base, then its layers."), flag("out", "-o", Ty::Path, true, "The WAI2 envelope.")], + needs: STAGE, + ..OP + }, + Op { + id: "stage.explode", + summary: "One component of a WAI2 envelope as its WAI1 envelope", + cli: Some("stage explode"), + usage: "stage explode [-o out.wai]", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + pos("path", Ty::Path, true, "A WAI2 envelope."), + pos("index", Ty::Integer, true, "The component's index."), + flag("out", "-o", Ty::Path, false, "The WAI1 envelope."), + ], + needs: STAGE, + ..OP + }, + // ─── HTTP delivery ─── + Op { + id: "http.publish", + summary: "Envelopes named by their content hash, each with its head proof and entity tag, ready for an origin", + cli: Some("http publish"), + usage: "http publish ... --out ", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[pos("envelopes", Ty::Strings, true, "The envelopes."), flag("out", "--out", Ty::Path, true, "The directory.")], + needs: HTTP, + ..OP + }, + Op { + id: "http.codecs", + summary: "The codecs parameter a sequence of envelopes advertises: its union and its floor", + cli: Some("http codecs"), + usage: "http codecs ...", + stability: Stability::Preview, + input: &[pos("envelopes", Ty::Strings, true, "The envelopes.")], + needs: HTTP, + ..OP + }, + Op { + id: "http.origin", + summary: "An origin on loopback serving a published directory with http-delivery's headers, ranges and If-Range", + cli: Some("http origin"), + usage: "http origin --root [--port P] [--delay-range FIRST-LAST:MS]", + effect: Effect::Runs, + stability: Stability::Preview, + input: &[ + flag("root", "--root", Ty::Path, true, "The published directory."), + flag("port", "--port", Ty::Integer, false, "The port on 127.0.0.1."), + ], + needs: HTTP, + ..OP + }, + Op { + id: "http.hls", + summary: "An HLS rendition of published objects", + cli: Some("http hls"), + usage: "http hls --objects --out [--master NAME]", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[flag("objects", "--objects", Ty::Path, true, "The objects, JSON."), flag("out", "--out", Ty::Path, true, "The directory.")], + needs: HTTP, + ..OP + }, + Op { + id: "http.mpd", + summary: "A DASH manifest of published objects", + cli: Some("http mpd"), + usage: "http mpd --objects --out [--period-id ID] [--set-id N]", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[flag("objects", "--objects", Ty::Path, true, "The objects, JSON."), flag("out", "--out", Ty::Path, true, "The manifest.")], + needs: HTTP, + ..OP + }, + Op { + id: "http.sealplaylist", + summary: "A media playlist sealed with a signed manifest receipt", + cli: Some("http seal-playlist"), + usage: "http seal-playlist --key [--signer-id ID] [--content-id ID] [--parent ] [--out ]", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[pos("playlist", Ty::Path, true, "The playlist."), flag("key", "--key", Ty::Path, true, "The signing seed.")], + receipt: true, + needs: Needs::All(&["http", "video_manifest"]), + ..OP + }, + Op { + id: "http.checkplaylist", + summary: "A media playlist checked against its manifest receipt", + cli: Some("http check-playlist"), + usage: "http check-playlist --receipt [--parent ] [--classical ]", + stability: Stability::Preview, + input: &[pos("playlist", Ty::Path, true, "The playlist."), flag("receipt", "--receipt", Ty::Path, true, "Its manifest receipt.")], + needs: Needs::All(&["http", "video_manifest"]), + ..OP + }, + Op { + id: "http.vectors", + summary: "The HTTP conformance corpus: generated, verified, or its differential and fuzz cases written", + cli: Some("http vectors"), + usage: "http vectors gen|verify +http vectors diff|fuzz --seed S --count N --out F", + effect: Effect::WritesProject, + toolset: Toolset::Maintainer, + stability: Stability::Preview, + input: &[pos("mode", Ty::String, true, "gen, verify, diff or fuzz."), pos("dir", Ty::Path, true, "The corpus directory.")], + needs: HTTP, + ..OP + }, + Op { + id: "http.bench", + summary: "An object fetched by the byte-range procedure, repeatedly, and the record of each run", + cli: Some("http bench"), + usage: "http bench --url URL --runs N --advertise CAP [--advertise CAP]... --out F", + placement: Placement::Client, + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + flag("url", "--url", Ty::String, true, "The object's URL."), + flag("runs", "--runs", Ty::Integer, true, "How many runs."), + flag("advertise", "--advertise", Ty::Strings, true, "A capability this sink advertises."), + flag("out", "--out", Ty::Path, true, "The record."), + ], + needs: HTTP, + ..OP + }, + Op { + id: "http.receipts", + summary: "An object's receipt index fetched over HTTP and checked, its signers confirmed under the origin's key log where named", + cli: Some("http receipts"), + usage: "http receipts --url URL [--advertise CAP]... [--held fetch|none] [--join] [--max-walk N] [--fetch-budget N] + [--deadline-ms MS] [--key-origin https://HOST[:PORT] [--pin-key-log FILE | --test-plaintext-connect HOST:PORT]]", + placement: Placement::Client, + stability: Stability::Preview, + input: &[flag("url", "--url", Ty::String, true, "The object's URL.")], + needs: Needs::All(&["http", "receipt_claims"]), + ..OP + }, + // ─── the catalogue itself ─── + Op { + id: "ops.list", + summary: "Every operation of this catalogue: its id, command line, effect and whether this build holds it", + cli: Some("ops list"), + usage: "ops list every operation, its id and command line", + output: Output::Json(S_OPS_LIST), + ..OP + }, + Op { + id: "ops.search", + summary: "The operations that match a task, ranked, offline: up to five", + cli: Some("ops search"), + usage: "ops search \"\" up to five operations that match, ranked, offline", + input: &[pos("task", Ty::String, true, "What you want to do, in words.")], + output: Output::Json(S_OPS_SEARCH), + ..OP + }, + Op { + id: "ops.schema", + summary: "One operation's entry: its input and output schemas, effect, placement and command line", + cli: Some("ops schema"), + usage: "ops schema one operation's entry, as JSON", + input: &[pos("id", Ty::String, true, "An operation id, noun.verb.")], + output: Output::Json(S_OPS_SCHEMA), + ..OP + }, + Op { + id: "ops.export", + summary: "The catalogue as JSON (wai.ops/1), what other tools and CI read; --check compares a file with it", + cli: Some("ops export"), + usage: "ops export [--out FILE | --check FILE] the catalogue as JSON, to stdout, to FILE, or compared with FILE", + effect: Effect::WritesProject, + input: &[ + flag("out", "--out", Ty::Path, false, "Write the export here."), + flag("check", "--check", Ty::Path, false, "Compare this file with the export; exit 1 when they differ."), + ], + output: Output::Json(S_OPS_EXPORT), + ..OP + }, +]; + +/// The operation `id`. +pub fn op(id: &str) -> Option<&'static Op> { + OPS.iter().find(|o| o.id == id) +} + +/// Whether this build of the crate has `feature`. `None` for a name this function does not +/// know, which the tests refuse in any [`Op::needs`]. +pub fn feature_on(feature: &str) -> Option { + Some(match feature { + "quickstart" => cfg!(feature = "quickstart"), + "floor" => cfg!(feature = "floor"), + "codecs" => cfg!(feature = "codecs"), + "http" => cfg!(feature = "http"), + "receipt_claims" => cfg!(feature = "receipt_claims"), + "video_manifest" => cfg!(feature = "video_manifest"), + "key_discovery" => cfg!(feature = "key_discovery"), + "generate" => cfg!(feature = "generate"), + "score" => cfg!(feature = "score"), + "staged" => cfg!(feature = "staged"), + "video_int" => cfg!(feature = "video_int"), + "audio_int" => cfg!(feature = "audio_int"), + "splat_int" => cfg!(feature = "splat_int"), + "platform_encode" => cfg!(feature = "platform_encode"), + _ => return None, + }) +} + +impl Placement { + pub fn label(self) -> &'static str { + match self { + Placement::Local => "local", + Placement::Client => "client", + Placement::Service => "service", + Placement::Both => "both", + } + } +} + +impl Effect { + pub fn label(self) -> &'static str { + match self { + Effect::Reads => "reads", + Effect::Runs => "runs", + Effect::WritesProject => "writes-project", + Effect::WritesTarget => "writes-target", + Effect::Destructive => "destructive", + } + } +} + +impl Toolset { + pub fn label(self) -> &'static str { + match self { + Toolset::Core => "core", + Toolset::Dev => "dev", + Toolset::App => "app", + Toolset::Quantum => "quantum", + Toolset::Maintainer => "maintainer", + } + } +} + +impl Stability { + pub fn label(self) -> &'static str { + match self { + Stability::Stable => "stable", + Stability::Preview => "preview", + Stability::Internal => "internal", + } + } +} + +impl Ty { + fn schema(self) -> Value { + match self { + Ty::String => json!({"type": "string"}), + Ty::Path => json!({"type": "string", "format": "path"}), + Ty::Integer => json!({"type": "integer"}), + Ty::Boolean => json!({"type": "boolean"}), + Ty::Strings => json!({"type": "array", "items": {"type": "string"}}), + } + } +} + +/// An operation's name in each door, by the mechanical rule. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Names { + /// `wai `. + pub cli: String, + /// `noun_verb`. + pub mcp: String, + /// `wai_noun_verb`. + pub c: String, + /// `wai::noun::verb`. + pub rust: String, + /// `wai.noun.verb`, for JavaScript, Swift and Kotlin. + pub sdk: String, +} + +impl Op { + /// The noun and the verb. + pub fn parts(&self) -> (&'static str, &'static str) { + self.id.split_once('.').unwrap_or((self.id, "")) + } + + /// Its name in every door. + pub fn names(&self) -> Names { + let (n, v) = self.parts(); + Names { + cli: format!("wai {n} {v}"), + mcp: format!("{n}_{v}"), + c: format!("wai_{n}_{v}"), + rust: format!("wai::{n}::{v}"), + sdk: format!("wai.{n}.{v}"), + } + } + + /// Every command line that runs it, each as its words after `wai`: its CLI spelling, its + /// aliases, then its mechanical form. + pub fn cli_forms(&self) -> Vec> { + let (n, v) = self.parts(); + let mut out: Vec> = Vec::new(); + for f in self.cli.iter().chain(self.aliases.iter()) { + out.push(f.split_whitespace().collect()); + } + let mech = vec![n, v]; + if !out.contains(&mech) { + out.push(mech); + } + out + } + + /// The command line help names it by: its CLI spelling, or its mechanical form. + pub fn command(&self) -> String { + match self.cli { + Some(c) => format!("wai {c}"), + None => self.names().cli, + } + } + + /// Whether this build holds it. + pub fn available(&self) -> bool { + let on = |f: &&str| feature_on(f).unwrap_or(false); + match self.needs { + Needs::Nothing => true, + Needs::All(fs) => fs.iter().all(on), + Needs::Any(fs) => fs.iter().any(on), + } + } + + /// Whether it takes `--dry-run`: every operation whose effect is not `reads`. + pub fn dry_run(&self) -> bool { + self.effect != Effect::Reads + } + + /// Whether it waits for confirmation in every case: a destructive operation, or one that + /// changes a target that is not this machine. An operation that writes on this machine + /// waits only when it would replace a file the command line did not name. + pub fn always_confirms(&self) -> bool { + matches!(self.effect, Effect::Destructive | Effect::WritesTarget) + } + + /// Its input as a JSON Schema object. + pub fn input_schema(&self) -> Value { + let mut props = Map::new(); + let mut required = Vec::new(); + for a in self.input { + let mut s = a.ty.schema(); + s["description"] = Value::from(a.doc); + match a.cli { + Cli::Positional => s["x-cli"] = json!({"positional": true}), + Cli::Flag(f) | Cli::Switch(f) => s["x-cli"] = json!({"flag": f}), + } + props.insert(a.name.to_string(), s); + if a.required { + required.push(a.name); + } + } + json!({"type": "object", "properties": props, "required": required, "additionalProperties": false}) + } + + /// Its output, the `data` of its `--json` envelope, as a JSON Schema object. + pub fn output_schema(&self) -> Value { + match self.output { + Output::Text => json!({"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}), + Output::Json(s) => serde_json::from_str(s).expect("an output schema is JSON"), + } + } + + /// The entry as the export writes it. + pub fn to_json(&self) -> Value { + let n = self.names(); + let needs = match self.needs { + Needs::Nothing => Value::Null, + Needs::All(f) => json!({"all": f}), + Needs::Any(f) => json!({"any": f}), + }; + json!({ + "id": self.id, + "summary": self.summary, + "cli": self.cli.map(|c| format!("wai {c}")), + "aliases": self.aliases.iter().map(|a| format!("wai {a}")).collect::>(), + "usage": self.usage.lines().map(|l| if l.starts_with(' ') { l.to_string() } else { format!("wai {l}") }).collect::>(), + "placement": self.placement.label(), + "effect": self.effect.label(), + "dry_run": self.dry_run(), + "confirm": if self.always_confirms() { "always" } else if self.effect == Effect::Reads { "never" } else { "when-replacing-an-unnamed-file" }, + "route": self.route.map(|r| json!({"method": r.method, "path": r.path})), + "toolset": self.toolset.label(), + "stability": self.stability.label(), + "input": self.input_schema(), + "output": self.output_schema(), + "stream": self.stream.map(|s| serde_json::from_str::(s).expect("a stream schema is JSON")), + "since": self.since, + "deprecated": self.deprecated.map(|d| json!({"since": d.since, "use_instead": d.use_instead})), + "receipt": self.receipt, + "needs": needs, + "names": {"cli": n.cli, "mcp": n.mcp, "c": n.c, "rust": n.rust, "sdk": n.sdk}, + }) + } +} + +/// The catalogue as `wai ops export` writes it: the format, the toolchain's version, the +/// envelope's version and every operation, in [`OPS`]' order. The same text for the same +/// table on every platform. +pub fn export() -> Value { + json!({ + "format": FORMAT, + "toolchain": env!("CARGO_PKG_VERSION"), + "envelope": ENVELOPE, + "exit_codes": EXIT_CODES.iter().map(|(c, m)| json!({"exit": c, "meaning": m})).collect::>(), + "operations": OPS.iter().map(Op::to_json).collect::>(), + }) +} + +/// [`export`] as text: pretty JSON with a final newline. +pub fn export_text() -> String { + serde_json::to_string_pretty(&export()).expect("the export is JSON") + "\n" +} + +/// The exit codes every catalogue command uses. +pub const EXIT_CODES: &[(u8, &str)] = &[ + (0, "done; every check held"), + (1, "ran, and a check failed"), + (2, "usage error or invalid input"), + (3, "authentication or permission"), + (4, "confirmation required; nothing changed"), + (5, "not found (file, target, room, operation)"), + (6, "target unavailable; safe to retry"), + (7, "internal error"), +]; + +/// One match of [`search`]. +#[derive(Clone, Copy, Debug)] +pub struct Match { + pub op: &'static Op, + pub score: u32, +} + +/// Words that say nothing about which operation is meant. +const STOP: &[&str] = &[ + "a", "an", "the", "of", "to", "in", "on", "for", "with", "and", "or", "my", "me", "i", "it", "its", "this", "that", + "how", "do", "can", "what", "is", "are", "from", "into", "as", "by", "at", "be", "want", "please", "some", "one", +]; + +/// Words that mean another word, for the search alone. +const SYNONYMS: &[(&str, &str)] = &[ + ("make", "create"), ("new", "create"), ("scaffold", "create"), ("init", "create"), ("start", "start"), + ("run", "start"), ("serve", "start"), ("launch", "start"), ("local", "dev"), ("development", "dev"), + ("check", "verify"), ("validate", "verify"), ("prove", "verify"), ("signature", "receipt"), + ("show", "inspect"), ("read", "inspect"), ("describe", "inspect"), ("look", "inspect"), ("open", "inspect"), + ("extract", "unwrap"), ("pack", "wrap"), ("package", "wrap"), ("compress", "encode"), ("convert", "encode"), + ("capability", "registry"), ("codec", "registry"), ("list", "list"), ("example", "template"), + ("template", "template"), ("project", "project"), ("room", "room"), ("relay", "relay"), + ("picture", "generate"), ("image", "media"), ("audio", "media"), ("video", "media"), ("playlist", "playlist"), + ("operation", "ops"), ("command", "ops"), ("catalogue", "ops"), ("catalog", "ops"), +]; + +fn stem(w: &str) -> String { + let w = w.to_ascii_lowercase(); + if let Some(s) = w.strip_suffix("ies") + && s.len() >= 3 + { + return format!("{s}y"); + } + for suf in ["ing", "ed"] { + if w.len() > suf.len() + 3 + && let Some(s) = w.strip_suffix(suf) + { + return s.to_string(); + } + } + if let Some(s) = w.strip_suffix("es") + && s.len() >= 3 + && (s.ends_with(['s', 'x', 'z']) || s.ends_with("ch") || s.ends_with("sh")) + { + return s.to_string(); + } + if let Some(s) = w.strip_suffix('s') + && s.len() >= 3 + && !s.ends_with('s') + { + return s.to_string(); + } + w +} + +fn words(text: &str) -> Vec { + text.split(|c: char| !c.is_ascii_alphanumeric()).filter(|w| !w.is_empty()).map(|w| w.to_ascii_lowercase()).collect() +} + +/// The operations that match `task`, best first, at most `limit`, offline. A word of the +/// task scores against an operation's noun and verb (most), its command line and aliases, +/// then its summary; a word is compared by its stem, and a few common words stand for the +/// operation word they mean (`make` for `create`, `check` for `verify`). Ties go to the +/// order of [`OPS`]. Operations this build does not hold are ranked as well. +pub fn search(task: &str, limit: usize) -> Vec { + let mut query: Vec = Vec::new(); + for w in words(task) { + if STOP.contains(&w.as_str()) { + continue; + } + let s = stem(&w); + if !query.contains(&s) { + query.push(s.clone()); + } + if let Some((_, to)) = SYNONYMS.iter().find(|(from, _)| stem(from) == s || *from == w) + && !query.contains(&to.to_string()) + { + query.push(to.to_string()); + } + } + let mut out: Vec = Vec::new(); + for o in OPS { + let (n, v) = o.parts(); + let id_words = [stem(n), stem(v)]; + let cli_words: Vec = o.cli.iter().chain(o.aliases.iter()).flat_map(|c| words(c)).map(|w| stem(&w)).collect(); + let summary: Vec = words(o.summary).iter().map(|w| stem(w)).collect(); + let mut score = 0u32; + for q in &query { + if id_words.contains(q) { + score += 6; + } + if cli_words.contains(q) { + score += 4; + } + if summary.contains(q) { + score += 2; + } else if q.len() >= 4 && summary.iter().any(|w| w.starts_with(q.as_str())) { + score += 1; + } + } + if score > 0 { + out.push(Match { op: o, score }); + } + } + out.sort_by(|a, b| b.score.cmp(&a.score)); + out.truncate(limit); + out +} + +/// What is wrong with the table: one line a finding, none when every rule holds. +pub fn findings() -> Vec { + findings_of(OPS) +} + +/// [`findings`] for any table. +pub fn findings_of(ops: &[Op]) -> Vec { + let mut f = Vec::new(); + let mut seen_forms: Vec<(Vec<&str>, &str)> = Vec::new(); + for (i, o) in ops.iter().enumerate() { + let id = o.id; + let ok_word = |w: &str| !w.is_empty() && w.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) && w.as_bytes()[0].is_ascii_lowercase(); + match id.split_once('.') { + Some((n, v)) if ok_word(n) && ok_word(v) && !v.contains('.') => { + if let Some((_, use_instead)) = NEAR_SYNONYMS.iter().find(|(s, _)| *s == v) { + f.push(format!("{id}: the verb {v} is a near-synonym; use {use_instead}")); + } + if n.len() > 3 && n.ends_with('s') && !n.ends_with("ss") && n != "ops" { + f.push(format!("{id}: the noun {n} is plural; nouns are singular")); + } + } + _ => f.push(format!("{id:?}: an id is noun.verb, lowercase ASCII letters and digits")), + } + if ops[..i].iter().any(|p| p.id == id) { + f.push(format!("{id}: listed twice")); + } + if o.summary.is_empty() || o.summary.contains('\n') || o.summary.ends_with('.') { + f.push(format!("{id}: the summary is one line, without a final full stop")); + } + if o.usage.is_empty() { + f.push(format!("{id}: no usage")); + } + if o.since.is_empty() { + f.push(format!("{id}: no since")); + } + let serves = matches!(o.placement, Placement::Service | Placement::Both); + if serves != o.route.is_some() { + f.push(format!("{id}: a route exactly when it runs on a service (placement {}, route {:?})", o.placement.label(), o.route)); + } + match o.needs { + Needs::All(fs) | Needs::Any(fs) => { + for x in fs { + if feature_on(x).is_none() { + f.push(format!("{id}: needs the feature {x}, which catalogue::feature_on does not know")); + } + } + } + Needs::Nothing => {} + } + for a in o.input { + if o.input.iter().filter(|b| b.name == a.name).count() > 1 { + f.push(format!("{id}: the input {} twice", a.name)); + } + } + if let Output::Json(s) = o.output + && serde_json::from_str::(s).is_err() + { + f.push(format!("{id}: the output schema is not JSON")); + } + if let Some(s) = o.stream + && serde_json::from_str::(s).is_err() + { + f.push(format!("{id}: the stream schema is not JSON")); + } + for form in o.cli_forms() { + if let Some((_, other)) = seen_forms.iter().find(|(w, other)| *w == form && *other != id) { + f.push(format!("{id}: `wai {}` already runs {other}", form.join(" "))); + } + seen_forms.push((form, id)); + } + } + // A command line that is a strict prefix of another operation's would take that + // operation's words as its arguments. + for (a, ida) in &seen_forms { + for (b, idb) in &seen_forms { + if ida != idb && a.len() < b.len() && b[..a.len()] == a[..] { + f.push(format!("{ida}: `wai {}` is the start of {idb}'s `wai {}`", a.join(" "), b.join(" "))); + } + } + } + f +} + +/// The operation a command line names, and the words after it: the longest command line of +/// any operation that `args` starts with. +pub fn resolve(args: &[String]) -> Option<(&'static Op, usize)> { + let mut best: Option<(&'static Op, usize)> = None; + for o in OPS { + for form in o.cli_forms() { + if form.len() <= args.len() && form.iter().zip(args).all(|(w, a)| *w == a) && best.is_none_or(|(_, n)| form.len() > n) { + best = Some((o, form.len())); + } + } + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(s: &[&str]) -> Vec { + s.iter().map(|x| x.to_string()).collect() + } + + #[test] + fn every_entry_keeps_the_rules() { + let f = findings(); + assert!(f.is_empty(), "{}", f.join("\n")); + } + + #[test] + fn the_rules_refuse_what_they_name() { + // Each rule, broken in a copy of the table, is a finding that names it. + let broken = |f: &dyn Fn(&mut Vec)| -> String { + let mut t = OPS.to_vec(); + f(&mut t); + findings_of(&t).join("\n") + }; + let at = |t: &mut Vec, id: &str| t.iter().position(|o| o.id == id).unwrap(); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].id = "registry.get"; }).contains("near-synonym")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].id = "Registry.list"; }).contains("noun.verb")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].id = "registries.list"; }).contains("plural")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].id = "envelope.inspect"; }).contains("listed twice")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].placement = Placement::Service; }).contains("route")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].cli = Some("inspect"); }).contains("already runs")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].cli = Some("registry"); }).contains("is the start of")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].needs = Needs::All(&["no_such_feature"]); }).contains("does not know")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].summary = "Two\nlines"; }).contains("one line")); + assert!(broken(&|_| {}).is_empty()); + } + + #[test] + fn a_command_line_resolves_to_its_operation_and_its_mechanical_form_too() { + let r = |s: &[&str]| resolve(&v(s)).map(|(o, n)| (o.id, n)); + assert_eq!(r(&["inspect", "x.wai"]), Some(("envelope.inspect", 1))); + assert_eq!(r(&["envelope", "inspect", "x.wai"]), Some(("envelope.inspect", 2))); + assert_eq!(r(&["registry", "export", "--check"]), Some(("registry.export", 2))); + assert_eq!(r(&["registry", "list"]), Some(("registry.list", 2))); + assert_eq!(r(&["capabilities"]), Some(("registry.list", 1))); + assert_eq!(r(&["dev", "--check"]), Some(("dev.start", 1))); + assert_eq!(r(&["dev", "start", "--check"]), Some(("dev.start", 2))); + assert_eq!(r(&["http", "seal-playlist", "p.m3u8"]), Some(("http.sealplaylist", 2))); + assert_eq!(r(&["encode-record", "verify", "r.json"]), Some(("encoderecord.verify", 2))); + assert_eq!(r(&["frobnicate"]), None); + assert_eq!(r(&["registry"]), None); + } + + #[test] + fn the_names_in_every_door_are_mechanical() { + let n = op("envelope.inspect").unwrap().names(); + assert_eq!((n.cli.as_str(), n.mcp.as_str(), n.c.as_str(), n.rust.as_str(), n.sdk.as_str()), + ("wai envelope inspect", "envelope_inspect", "wai_envelope_inspect", "wai::envelope::inspect", "wai.envelope.inspect")); + } + + #[test] + fn the_search_ranks_the_operation_a_task_means_first() { + let first = |t: &str| search(t, 5).first().map(|m| m.op.id); + assert_eq!(first("verify receipts"), Some("receipt.verify")); + assert_eq!(first("check the receipts under a directory"), Some("receipt.verify")); + assert_eq!(first("make a new project from a template"), Some("project.create")); + assert_eq!(first("start a local room for development"), Some("dev.start")); + assert_eq!(first("what is inside an envelope"), Some("envelope.inspect")); + assert_eq!(first("list capabilities"), Some("registry.list")); + assert_eq!(first("search operations"), Some("ops.search")); + assert!(search("zzzz qqqq", 5).is_empty()); + assert!(search("wai", 5).len() <= 5); + } + + #[test] + fn the_export_is_json_with_every_operation_in_order() { + let e = export(); + assert_eq!(e["format"], FORMAT); + let ids: Vec<&str> = e["operations"].as_array().unwrap().iter().map(|o| o["id"].as_str().unwrap()).collect(); + assert_eq!(ids, OPS.iter().map(|o| o.id).collect::>()); + for o in e["operations"].as_array().unwrap() { + assert_eq!(o["input"]["type"], "object", "{}", o["id"]); + assert!(o["dry_run"].as_bool().unwrap() == (o["effect"] != "reads"), "{}", o["id"]); + } + assert_eq!(export_text(), export_text()); + } +} diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index 23c49b639..ce4887395 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -20,6 +20,10 @@ //! dispatch table knows them; the actual neural model runs in whatever //! ML runtime the sink has installed (PyTorch / Core ML / ONNX / etc.). +// The operation catalogue: every operation the toolchain offers, `noun.verb`, as data every +// door (the CLI, the C ABI, the SDKs, MCP, the HTTP API) is named from and checked against. +// Pure data; no feature. +pub mod catalogue; pub mod codecs; pub mod container; // Platform decode capabilities (extensions/platform-decode.md): the query a sink makes of -- 2.54.0 (Apple Git-157) From aaf0affc430b2105d690b107d3871d6ef9617076 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 27 Oct 2026 04:52:20 -0400 Subject: [PATCH 02/21] wai: the command line through the catalogue, and the agent rules on every command Every command line resolves through the catalogue (wai_cli/door.rs): its CLI spelling, an alias, or its mechanical form wai ; an implementation is found by operation id, and help is written from the catalogue's entries. dx::VERBS is the developer path's view of it. Every command keeps the agent rules (wai_cli/agent.rs): exit codes 0 to 7, --json (a versioned envelope, envelope/1, carrying a command's own JSON or its text in data), --dry-run on every operation that is not read-only (every write goes through wai_cli/out.rs), and --yes where a change waits for confirmation (exit 4 off a terminal). wai ops list, search, schema and export. The tests and the quickstart's packaging check read the JSON forms from the envelope's data. Co-Authored-By: Claude Opus 5.5 --- wai/quickstart/build.sh | 10 +- wai/wai-rs/src/bin/wai.rs | 205 ++---- wai/wai-rs/src/bin/wai_cli/agent.rs | 252 +++++++ wai/wai-rs/src/bin/wai_cli/door.rs | 656 +++++++++++++++++++ wai/wai-rs/src/bin/wai_cli/dx/create.rs | 25 +- wai/wai-rs/src/bin/wai_cli/dx/dev.rs | 111 ++-- wai/wai-rs/src/bin/wai_cli/dx/hello.rs | 48 +- wai/wai-rs/src/bin/wai_cli/dx/mod.rs | 52 +- wai/wai-rs/src/bin/wai_cli/dx/templates.rs | 18 +- wai/wai-rs/src/bin/wai_cli/dx/verify.rs | 15 +- wai/wai-rs/src/bin/wai_cli/encode.rs | 2 +- wai/wai-rs/src/bin/wai_cli/generate.rs | 4 +- wai/wai-rs/src/bin/wai_cli/http.rs | 18 +- wai/wai-rs/src/bin/wai_cli/http_playlists.rs | 4 +- wai/wai-rs/src/bin/wai_cli/http_vectors.rs | 8 +- wai/wai-rs/src/bin/wai_cli/out.rs | 114 ++++ wai/wai-rs/src/bin/wai_cli/score.rs | 4 +- wai/wai-rs/src/bin/wai_cli/stage.rs | 8 +- wai/wai-rs/src/bin/wai_http_vectors.rs | 6 + wai/wai-rs/tests/cli_dx.rs | 6 +- wai/wai-rs/tests/cli_generate_score.rs | 17 +- wai/wai-rs/tests/cli_stage.rs | 24 +- 22 files changed, 1306 insertions(+), 301 deletions(-) create mode 100644 wai/wai-rs/src/bin/wai_cli/agent.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/door.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/out.rs diff --git a/wai/quickstart/build.sh b/wai/quickstart/build.sh index 8f2fc88e1..67a4b7edb 100755 --- a/wai/quickstart/build.sh +++ b/wai/quickstart/build.sh @@ -6,8 +6,8 @@ # Builds, each release and --locked, each from its own workspace: # wai wai/wai-rs, --no-default-features --features quickstart: the mandatory # floor, hello, create, dev and verify; no system codec library -# joule-moq-relay the relay (joule-code/joule-code-rs) -# joule-moq-room the room service +# wai-relay the relay (crate joule-moq-relay, joule-code/joule-code-rs) +# wai-room the room service (crate joule-moq-room) # wai-remote-host a remote desktop room's host agent # wai-pack the packager # and writes /wai--.tar.gz holding bin/ (those five), share/wai/web/ @@ -61,8 +61,8 @@ STAGE=$(mktemp -d) trap 'rm -rf "$STAGE"' EXIT mkdir -p "$STAGE/$NAME/bin" "$STAGE/$NAME/share/wai" need "$ROOT/wai/wai-rs" wai --no-default-features --features quickstart --bin wai -need "$ROOT/joule-code/joule-code-rs" joule-moq-relay -p joule-moq-relay --bin joule-moq-relay -need "$ROOT/joule-code/joule-code-rs/crates/joule-moq-room" joule-moq-room --bin joule-moq-room +need "$ROOT/joule-code/joule-code-rs" wai-relay -p joule-moq-relay --bin wai-relay +need "$ROOT/joule-code/joule-code-rs/crates/joule-moq-room" wai-room --bin wai-room need "$ROOT/joule-code/joule-code-rs/crates/joule-moq-remote" wai-remote-host --bin wai-remote-host need "$ROOT/wai/wai-pack" wai-pack --bin wai-pack cp -R "$WEBDIR" "$STAGE/$NAME/share/wai/web" @@ -78,7 +78,7 @@ if [ "$TRIPLE" = "$HOST" ]; then import json, os, subprocess, sys, tempfile wai, ex = sys.argv[1], sys.argv[2] with tempfile.TemporaryDirectory() as d: - for t in json.loads(subprocess.check_output([wai, "create", "--list", "--json"])): + for t in json.loads(subprocess.check_output([wai, "create", "--list", "--json"]))["data"]: out = os.path.join(d, t["template"]) subprocess.check_output([wai, "create", out, "--template", t["template"]]) for f in t["files"]: diff --git a/wai/wai-rs/src/bin/wai.rs b/wai/wai-rs/src/bin/wai.rs index 3b9c61205..6c30f658b 100644 --- a/wai/wai-rs/src/bin/wai.rs +++ b/wai/wai-rs/src/bin/wai.rs @@ -5,17 +5,23 @@ //! (AVIF/JPEG-XL/PNG/Opus/FLAC/AV1/zstd/XZ). Designed for scripting and quick //! verification; production paths go through the Rust API or the C FFI. //! +//! Every command is an operation of the catalogue (`wai::catalogue`), named `noun.verb`, +//! and the command line reaches it only through the catalogue (`wai_cli/door.rs`): its +//! words, its usage and its help come from there, and so do the agent rules every command +//! keeps (`wai_cli/agent.rs`: the exit codes, `--json`, `--dry-run`, `--yes`). `wai ops` +//! lists, exports and searches the catalogue. +//! //! Every command except `encode` and `decode` builds without the `codecs` //! feature (no system codec libraries), which is how CI runs it. With the `floor` //! feature alone, `encode` writes PNG and zstd and `decode` opens the mandatory floor -//! (PNG, FLAC, zstd), still with no system library. `up` (a local relay, room service +//! (PNG, FLAC, zstd), still with no system library. `dev` (a local relay, room service //! and room page on one origin) and `create` (a new project from an example) need no //! feature. `generate` (make a //! `wai.generate.spectral` picture natively and print its SHA-256) needs the feature //! `generate`, and `score build|render` (write a `WSCR` from integer note fields, //! render one and print its digests) the feature `score`; neither needs a codec library. +//! `wai` sends nothing off the machine unless a command names a remote service. -use std::env; use std::fs; use std::process::ExitCode; @@ -54,11 +60,23 @@ mod score; #[path = "wai_cli/encode.rs"] mod platform_encode_cli; -// The developer's path: `hello`, `create`, `dev`, `verify`, one module a verb, named in one -// table (dx::VERBS). +// The developer's path: `hello`, `create`, `dev`, `verify`, one module an operation, its +// implementations by catalogue id in dx::VERBS. #[path = "wai_cli/dx/mod.rs"] mod dx; +// The command line's door to the catalogue: dispatch, help and `wai ops`. +#[path = "wai_cli/door.rs"] +mod door; + +// The agent rules: exit codes, the JSON envelope, confirmation. +#[path = "wai_cli/agent.rs"] +mod agent; + +// Every file a command writes, through one place, so `--dry-run` writes none. +#[path = "wai_cli/out.rs"] +mod out; + #[cfg(feature = "http")] #[path = "wai_cli/http.rs"] mod http; @@ -69,51 +87,6 @@ mod http_vectors; #[path = "wai_cli/http_playlists.rs"] mod http_playlists; -fn die(msg: impl std::fmt::Display) -> ExitCode { - eprintln!("error: {msg}"); - ExitCode::from(2) -} - -fn usage() { - eprintln!("wai encode [-o out.wai] [-q quality]"); - eprintln!("wai decode -o "); - eprintln!("wai inspect "); - eprintln!("wai wrap [-o out.wai] [--fallback ] [--intent ]"); - eprintln!(" [--companion ]... WAI2: companions bound to the payload"); - eprintln!("wai unwrap [-o payload] [--component ] carry/extract ANY registered codec's bytes"); - eprintln!("wai capabilities list registered capabilities + tier + license + replicate class + role"); - eprintln!("wai registry export [--root ] [--check]"); - eprintln!(" write (or, with --check, compare) registry/capabilities.json, its schema,"); - eprintln!(" vocab/v2.* and the generated table of REGISTRY.md"); - eprintln!("wai prior-set print the listing of a parameter set (wai.prior.set) and its pin"); - eprintln!("wai stage refine|apply|inspect|pack|explode … staged-delivery layers (a build with `staged`,"); - eprintln!(" `video_int`, `audio_int` and `splat_int`); `wai stage` alone prints its usage"); - eprintln!("wai http publish|codecs|origin|vectors|bench … HTTP delivery (a build with `http`);"); - eprintln!(" `wai http` alone prints its usage"); - eprintln!("wai generate --model (--prompt --seed --steps | --instr )"); - eprintln!(" --size WxH -o [--json] make a wai.generate.spectral picture (RGB8) and"); - eprintln!(" print its SHA-256; --sizes WxH,... --write-instr writes the instruction"); - eprintln!(" object of form v0 (SPEC \u{a7}5). A build with `generate`"); - eprintln!("wai score build -o [--json] a WSCR from integer note fields"); - eprintln!("wai score render [--pcm ] [--json] the reference mixdown and its digests"); - eprintln!(" (pcm_sha256, mixdown_blake3, score_hash). A build with `score`"); - #[cfg(feature = "platform_encode")] - platform_encode_cli::usage(); - #[cfg(not(feature = "platform_encode"))] - eprintln!("wai encode avc|hevc …, wai encoders, wai encode-record verify … a build with `platform_encode`"); - eprintln!(); - dx::usage(); - eprintln!(); - eprintln!("encode and decode need a build with the `codecs` feature; a build with `floor` alone"); - eprintln!("encodes png and zstd and decodes png, flac and zstd (the mandatory floor, no system library)."); - eprintln!("codecs: png jpeg avif jxl jxl-lossless opus flac av1 av1-lossless zstd xz"); - eprintln!("inputs:"); - eprintln!(" image codecs: .rgb (with .meta containing 'H W' ASCII) or PNG/JPEG file"); - eprintln!(" audio codecs: .f32 (raw mono float32) with .meta containing sample-rate"); - eprintln!(" video codecs: .rgb-seq directory of frame files + .meta with 'N H W fps_num fps_den'"); - eprintln!(" text codecs: any file"); -} - /// The capabilities this binary decodes natively: the crate's sink set /// (`wai::sink_capabilities`) in a build with the `codecs` feature, the mandatory /// floor (`wai::floor_capabilities`: PNG, FLAC, zstd) in a build with `floor` alone, @@ -293,8 +266,9 @@ fn encode(codec: &str, input: &str, out: &str, q: u8) -> Result<(), String> { }; let manifest = Manifest::new(media_for(cap), "replicate", ModelRequirement::new(cap), Conditioning::new(kind)); - let n = Wai::new(manifest, payload).write(out).map_err(|e| e.to_string())?; - println!("encoded → {out} ({} bytes, capability {})", n, cap); + let bytes = Wai::new(manifest, payload).to_bytes().map_err(|e| e.to_string())?; + out::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?; + println!("encoded → {out} ({} bytes, capability {})", bytes.len(), cap); Ok(()) } @@ -325,48 +299,48 @@ fn decode(input: &str, out: &str) -> Result<(), String> { match cap { c if c == CAP_IMAGE_PNG => { let (rgb, h, w) = image::png_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; - fs::write(out, &rgb).map_err(|e| e.to_string())?; - fs::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; + out::write(out, &rgb).map_err(|e| e.to_string())?; + out::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; } #[cfg(feature = "codecs")] c if c == CAP_IMAGE_JPEG => { let (rgb, h, w) = image::jpeg_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; - fs::write(out, &rgb).map_err(|e| e.to_string())?; - fs::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; + out::write(out, &rgb).map_err(|e| e.to_string())?; + out::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; } #[cfg(feature = "codecs")] c if c == CAP_IMAGE_AVIF => { let (rgb, h, w) = image::avif_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; - fs::write(out, &rgb).map_err(|e| e.to_string())?; - fs::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; + out::write(out, &rgb).map_err(|e| e.to_string())?; + out::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; } #[cfg(feature = "codecs")] c if c == CAP_IMAGE_JXL => { let (rgb, h, w) = image::jxl_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; - fs::write(out, &rgb).map_err(|e| e.to_string())?; - fs::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; + out::write(out, &rgb).map_err(|e| e.to_string())?; + out::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; } #[cfg(feature = "codecs")] c if c == CAP_AUDIO_OPUS => { let (samples, sr) = audio::opus_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; let bytes: Vec = samples.iter().flat_map(|f| f.to_le_bytes()).collect(); - fs::write(out, &bytes).map_err(|e| e.to_string())?; - fs::write(format!("{out}.meta"), format!("{sr}\n")).map_err(|e| e.to_string())?; + out::write(out, &bytes).map_err(|e| e.to_string())?; + out::write(format!("{out}.meta"), format!("{sr}\n")).map_err(|e| e.to_string())?; } c if c == CAP_AUDIO_FLAC => { let (samples, sr) = audio::flac_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; let bytes: Vec = samples.iter().flat_map(|f| f.to_le_bytes()).collect(); - fs::write(out, &bytes).map_err(|e| e.to_string())?; - fs::write(format!("{out}.meta"), format!("{sr}\n")).map_err(|e| e.to_string())?; + out::write(out, &bytes).map_err(|e| e.to_string())?; + out::write(format!("{out}.meta"), format!("{sr}\n")).map_err(|e| e.to_string())?; } c if c == CAP_TEXT_ZSTD => { let d = text::zstd_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; - fs::write(out, &d).map_err(|e| e.to_string())?; + out::write(out, &d).map_err(|e| e.to_string())?; } #[cfg(feature = "codecs")] c if c == CAP_TEXT_XZ => { let d = text::xz_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; - fs::write(out, &d).map_err(|e| e.to_string())?; + out::write(out, &d).map_err(|e| e.to_string())?; } #[cfg(feature = "codecs")] c if c == CAP_VIDEO_AV1 || c == CAP_VIDEO_AV1_LOSSLESS => { @@ -374,7 +348,7 @@ fn decode(input: &str, out: &str) -> Result<(), String> { // decode to far more than fits in memory at once. use std::io::Write as _; let hd = video::av1_header(&wai.payload).map_err(|e| format!("{:?}", e))?; - let mut file = std::io::BufWriter::new(fs::File::create(out).map_err(|e| e.to_string())?); + let mut file = std::io::BufWriter::new(out::create(out).map_err(|e| e.to_string())?); let mut n = 0u32; video::av1_decode_each(&wai.payload, |rgb, w, h| { if (w, h) != (hd.w, hd.h) { @@ -385,7 +359,7 @@ fn decode(input: &str, out: &str) -> Result<(), String> { file.write_all(rgb).map_err(|e| video::VideoError(e.to_string())) }).map_err(|e| format!("{:?}", e))?; file.flush().map_err(|e| e.to_string())?; - fs::write(format!("{out}.meta"), + out::write(format!("{out}.meta"), format!("{n} {} {} {} {}\n", hd.h, hd.w, hd.fps_num, hd.fps_den)) .map_err(|e| e.to_string())?; } @@ -692,7 +666,9 @@ fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>, "{e}: a layer names what it refines; `wai stage refine` writes one (extensions/staged-delivery.md)"), _ => format!("{e} (SPEC \u{a7}5)"), })?; - let n = envelope.write(out).map_err(|e| e.to_string())?; + let bytes = envelope.to_bytes().map_err(|e| e.to_string())?; + out::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?; + let n = bytes.len(); let tier = wai::codecs::capability_tier(cap).map(|t| format!("{t:?}")).unwrap_or_else(|| "?".into()); println!("wrapped → {out} ({n} B envelope, {n_in} B payload)"); println!(" capability {cap} · media {} · {tier} · {} · intent {intent}", @@ -789,7 +765,9 @@ fn wrap_with_companions(cap: &str, payload: Vec, out: &str, fallback: Option envelope.check_int_payload_framing().map_err(|(i, e)| format!("not writing {out}: component {i}: {e}"))?; envelope.check_component_roles() .map_err(|e| format!("{e} (SPEC \u{a7}5, \u{a7}7.1)"))?; - let n = envelope.write(out).map_err(|e| e.to_string())?; + let bytes = envelope.to_bytes().map_err(|e| e.to_string())?; + out::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?; + let n = bytes.len(); println!("wrapped → {out} ({n} B WAI2 envelope, {} components, intent {intent})", envelope.renditions.len()); print_components(&envelope); @@ -849,7 +827,7 @@ fn unwrap(input: &str, out: Option<&str>, component: Option<&str>) -> Result<(), } println!(" payload {} bytes", w.payload.len()); if let Some(out) = out { - std::fs::write(out, &w.payload).map_err(|e| format!("write {out}: {e}"))?; + out::write(out, &w.payload).map_err(|e| format!("write {out}: {e}"))?; println!(" → wrote payload to {out} (byte-identical to the wrapped input)"); } Ok(()) @@ -884,7 +862,7 @@ fn unwrap_component(input: &str, bytes: &[u8], out: Option<&str>, } println!(" component [{idx}] {} · {} bytes", meta.capability, payload.len()); if let Some(out) = out { - std::fs::write(out, payload).map_err(|e| format!("write {out}: {e}"))?; + out::write(out, payload).map_err(|e| format!("write {out}: {e}"))?; println!(" → wrote component {idx} to {out} (byte-identical to the wrapped input)"); } Ok(()) @@ -950,9 +928,9 @@ fn registry(args: &[String]) -> Result { stale.push(rel.clone()); } else { if let Some(dir) = path.parent() { - fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + out::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?; } - fs::write(&path, want).map_err(|e| format!("write {}: {e}", path.display()))?; + out::write(&path, want).map_err(|e| format!("write {}: {e}", path.display()))?; println!("wrote {}", path.display()); } } @@ -968,84 +946,7 @@ fn registry(args: &[String]) -> Result { Ok(true) } +/// Every command line goes through the catalogue's door. fn main() -> ExitCode { - let args: Vec = env::args().skip(1).collect(); - if args.is_empty() { - usage(); - return ExitCode::from(2); - } - if let Some(r) = dx::dispatch(&args) { - return match r { - Ok(true) => ExitCode::SUCCESS, - Ok(false) => ExitCode::from(1), - Err(e) => die(e), - }; - } - let r = match args[0].as_str() { - #[cfg(feature = "platform_encode")] - "encode" if args.len() >= 3 && matches!(args[1].as_str(), "avc" | "hevc") => platform_encode_cli::encode(&args[1..]), - #[cfg(not(feature = "platform_encode"))] - "encode" if args.len() >= 3 && matches!(args[1].as_str(), "avc" | "hevc") => { - Err("built without platform_encode: `wai encode avc|hevc` needs a build with the feature `platform_encode`".into()) - } - #[cfg(feature = "platform_encode")] - "encoders" => platform_encode_cli::encoders(&args[1..]), - #[cfg(feature = "platform_encode")] - "encode-record" => match platform_encode_cli::record_cmd(&args[1..]) { - Ok(true) => Ok(()), - Ok(false) => return ExitCode::from(1), - Err(e) => Err(e), - }, - #[cfg(not(feature = "platform_encode"))] - "encoders" | "encode-record" => Err("built without platform_encode: this command needs a build with the feature `platform_encode`".into()), - "encode" if args.len() >= 3 => { - let out = opt(&args[3..], "-o").map(String::from).unwrap_or("out.wai".into()); - let q: u8 = opt(&args[3..], "-q").and_then(|s| s.parse().ok()).unwrap_or(75); - encode(&args[1], &args[2], &out, q) - } - "decode" if args.len() >= 4 => decode(&args[1], &args[3]), - "wrap" if args.len() >= 3 => { - let out = opt(&args[3..], "-o").map(String::from).unwrap_or("out.wai".into()); - match companion_args(&args[3..]) { - Ok(comps) => wrap(&args[1], &args[2], &out, opt(&args[3..], "--fallback"), - opt(&args[3..], "--intent"), &comps), - Err(e) => Err(e), - } - } - "unwrap" if args.len() >= 2 => unwrap(&args[1], opt(&args[2..], "-o"), - opt(&args[2..], "--component")), - "inspect" if args.len() >= 2 => inspect(&args[1]), - "capabilities" => capabilities(), - "registry" => match registry(&args[1..]) { - Ok(true) => Ok(()), - Ok(false) => return ExitCode::from(1), - Err(e) => Err(e), - }, - "prior-set" if args.len() >= 2 => prior_set(&args[1]), - #[cfg(all(feature = "staged", feature = "video_int", feature = "audio_int", feature = "splat_int"))] - "stage" => stage::main(&args[1..]), - #[cfg(not(all(feature = "staged", feature = "video_int", feature = "audio_int", feature = "splat_int")))] - "stage" => Err("`wai stage` needs a build with the features staged, video_int, audio_int and splat_int".into()), - #[cfg(feature = "http")] - "http" => match http::main(&args[1..]) { - Ok(true) => Ok(()), - Ok(false) => return ExitCode::from(1), - Err(e) => Err(e), - }, - #[cfg(not(feature = "http"))] - "http" => Err("built without http: `wai http` needs a build with the feature `http`".into()), - #[cfg(feature = "generate")] - "generate" => generate::main(&args[1..]), - #[cfg(not(feature = "generate"))] - "generate" => Err("built without generate: `wai generate` needs a build with the feature `generate`".into()), - #[cfg(feature = "score")] - "score" => score::main(&args[1..]), - #[cfg(not(feature = "score"))] - "score" => Err("built without score: `wai score` needs a build with the feature `score`".into()), - _ => { usage(); return ExitCode::from(2); } - }; - match r { - Ok(()) => ExitCode::SUCCESS, - Err(e) => die(e), - } + door::main() } diff --git a/wai/wai-rs/src/bin/wai_cli/agent.rs b/wai/wai-rs/src/bin/wai_cli/agent.rs new file mode 100644 index 000000000..730283f78 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/agent.rs @@ -0,0 +1,252 @@ +//! The rules every catalogue command keeps, for a person and for an agent alike: +//! +//! - **exit codes**, one meaning each (`wai::catalogue::EXIT_CODES`): 0 done, 1 a check +//! failed, 2 usage or invalid input, 3 permission, 4 confirmation required (nothing +//! changed), 5 not found, 6 target unavailable (safe to retry), 7 internal error; +//! - **`--json`**: stdout carries one JSON envelope, `{"version": "envelope/1", "op", "ok", +//! "data" | "error": {"code", "message", "hint", "retryable"}}`, and logs stay on stderr; +//! - **`--dry-run`** on every operation whose effect is not `reads`: nothing is written, and +//! the command says what it would write; +//! - **`--yes`** (also `--confirm`): an operation that would replace a file the command line +//! did not name asks on a terminal, and without one (or with `--no-input`) exits 4 with the +//! proposed change and the command that confirms it. `--yes` is never implied. + +use serde_json::{Value, json}; +use std::io::{BufRead, IsTerminal, Write}; +use std::sync::OnceLock; + +/// The environment variable a parent `wai --json` sets on the `wai` it runs for an envelope. +pub const CHILD_ENV: &str = "WAI_ENVELOPE_CHILD"; +/// The prefix of the line a child writes last on stderr: its failure, as JSON. +pub const TRAILER_ERROR: &str = "wai-envelope-error: "; +/// The prefix of the line a child writes on stderr after a dry run: its plan, as JSON. +pub const TRAILER_PLAN: &str = "wai-envelope-plan: "; + +/// This run's agent flags. +#[derive(Clone, Debug, Default)] +pub struct Ctx { + /// `--json`: one envelope on stdout. + pub json: bool, + /// `--dry-run`. + pub dry_run: bool, + /// `--yes` or `--confirm`. + pub yes: bool, + /// `--no-input`: never ask. + pub no_input: bool, + /// Run by a parent `wai --json` for its envelope. + pub child: bool, + /// The command line after `wai`, without `--json`, for the command that confirms. + pub argv: Vec, +} + +static CTX: OnceLock = OnceLock::new(); + +/// Set this run's flags, once. +pub fn set(c: Ctx) { + let _ = CTX.set(c); +} + +/// This run's flags. +pub fn ctx() -> &'static Ctx { + CTX.get_or_init(Ctx::default) +} + +/// Why a command did not succeed, and the exit code that says so. +#[derive(Clone, Debug, PartialEq)] +pub struct Failure { + pub exit: u8, + /// A stable string: `usage`, `invalid-input`, `check-failed`, `permission-denied`, + /// `confirmation-required`, `not-found`, `operation-not-found`, `operation-not-in-build`, + /// `target-unavailable`, `internal`. + pub code: &'static str, + pub message: String, + pub hint: Option, + pub retryable: bool, + /// With exit 4: the changes that wait for confirmation. + pub proposed: Vec, + /// With exit 4: the command that confirms them. + pub confirm: Option, +} + +impl Failure { + pub fn new(exit: u8, code: &'static str, message: impl Into) -> Failure { + Failure { exit, code, message: message.into(), hint: None, retryable: exit == 6, proposed: Vec::new(), confirm: None } + } + + pub fn hint(mut self, h: impl Into) -> Failure { + self.hint = Some(h.into()); + self + } + + pub fn usage(message: impl Into) -> Failure { + Failure::new(2, "usage", message) + } + + pub fn unavailable(message: impl Into) -> Failure { + Failure::new(6, "target-unavailable", message).hint("start it again; nothing was changed") + } + + pub fn internal(message: impl Into) -> Failure { + Failure::new(7, "internal", message) + } + + /// The envelope's `error` member. + pub fn to_json(&self) -> Value { + json!({"code": self.code, "message": self.message, "hint": self.hint, "retryable": self.retryable}) + } +} + +/// An error a command reports as text, read for its exit code: a refused permission (3), a +/// file or a binary that is not there, or an operation this build does not hold (5), a +/// usage line (2), and any other refusal of the input (2). +impl From for Failure { + fn from(message: String) -> Failure { + let m = message.to_ascii_lowercase(); + let has = |s: &[&str]| s.iter().any(|x| m.contains(x)); + if has(&["(os error 13)", "permission denied", "access is denied"]) { + Failure::new(3, "permission-denied", message) + } else if has(&["(os error 2)", "(os error 3)", "no such file or directory", "cannot find the file", "cannot find the path", "nor on path", "is not a file", "are not found"]) { + Failure::new(5, "not-found", message) + } else if has(&["needs a build with", "built without"]) { + Failure::new(5, "operation-not-in-build", message) + } else if m.starts_with("usage") || m.starts_with("wai ") || has(&["not an option", "takes a value", "is required"]) { + Failure::new(2, "usage", message) + } else { + Failure::new(2, "invalid-input", message) + } + } +} + +impl From<&str> for Failure { + fn from(m: &str) -> Failure { + Failure::from(m.to_string()) + } +} + +/// A word as a POSIX shell reads it back. +fn shell_word(w: &str) -> String { + if !w.is_empty() && w.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_./=:,@%+".contains(&c)) { + w.to_string() + } else { + format!("'{}'", w.replace('\'', "'\\''")) + } +} + +/// The command that runs this one again, confirmed. +pub fn confirm_command() -> String { + let mut words = vec!["wai".to_string()]; + words.extend(ctx().argv.iter().filter(|a| *a != "--yes" && *a != "--confirm").map(|a| shell_word(a))); + words.push("--yes".into()); + words.join(" ") +} + +/// Ask before `proposed` changes are made. Go ahead with `--yes`, in a dry run (which makes +/// none), or when a person at a terminal answers yes; otherwise nothing changes and the +/// command exits 4 with the proposal and the command that confirms it. +pub fn confirm(what: &str, proposed: Vec) -> Result<(), Failure> { + let c = ctx(); + if c.yes || c.dry_run { + return Ok(()); + } + let interactive = !c.json && !c.child && !c.no_input && std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); + if interactive { + eprint!("wai: {what}. Go ahead? [y/N] "); + let _ = std::io::stderr().flush(); + let mut line = String::new(); + if std::io::stdin().lock().read_line(&mut line).is_ok() && matches!(line.trim(), "y" | "Y" | "yes") { + return Ok(()); + } + } + Err(Failure { + exit: 4, + code: "confirmation-required", + message: format!("{what}; nothing was changed"), + hint: Some("read the proposed changes, then run the confirming command".into()), + retryable: false, + proposed, + confirm: Some(confirm_command()), + }) +} + +/// The envelope of a success: `data`, and in a dry run the plan. +pub fn envelope_ok(op: &str, data: Value, plan: Option>) -> Value { + let mut v = json!({"version": wai::catalogue::ENVELOPE, "op": op, "ok": true, "data": data}); + if let Some(p) = plan { + v["dry_run"] = Value::Bool(true); + v["plan"] = Value::Array(p); + } + v +} + +/// The envelope of a failure; with exit 1 (a check failed) it carries the command's `data` +/// as well, the report of what did not hold. +pub fn envelope_err(op: Option<&str>, f: &Failure, data: Option) -> Value { + let mut v = json!({"version": wai::catalogue::ENVELOPE, "op": op, "ok": false, "error": f.to_json()}); + if let Some(d) = data { + v["data"] = d; + } + if f.exit == 4 { + v["proposed"] = Value::Array(f.proposed.clone()); + v["confirm"] = json!(f.confirm); + } + v +} + +/// Print an envelope on stdout, one JSON document. +pub fn print(v: &Value) { + println!("{}", serde_json::to_string_pretty(v).expect("an envelope is JSON")); +} + +/// Report a failure: as text on stderr, with what waits for confirmation; in a child, also +/// as the trailer line its parent reads. +pub fn report(op: Option<&str>, f: &Failure) { + eprintln!("error: {}", f.message); + if f.exit == 4 { + for p in &f.proposed { + eprintln!(" would {}", p); + } + if let Some(c) = &f.confirm { + eprintln!("to go ahead: {c}"); + } + } else if let Some(h) = &f.hint { + eprintln!("hint: {h}"); + } + if ctx().child { + let t = json!({"error": f.to_json(), "proposed": f.proposed, "confirm": f.confirm, "op": op}); + eprintln!("{TRAILER_ERROR}{t}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_text_error_is_read_for_its_exit_code() { + let e = |m: &str| Failure::from(m.to_string()).exit; + assert_eq!(e("x.wai: No such file or directory (os error 2)"), 5); + assert_eq!(e("read x: Permission denied (os error 13)"), 3); + assert_eq!(e("`wai hello` needs a build with the feature `quickstart`"), 5); + assert_eq!(e("built without generate: `wai generate` needs ..."), 5); + assert_eq!(e("wai create: --x: not an option"), 2); + assert_eq!(e("'wai.x' is not a registered capability"), 2); + assert_eq!(e("wai-relay is not beside `wai` nor on PATH"), 5); + } + + #[test] + fn the_confirming_command_quotes_what_a_shell_would_split() { + assert_eq!(shell_word("out.wai"), "out.wai"); + assert_eq!(shell_word("a b"), "'a b'"); + assert_eq!(shell_word("it's"), "'it'\\''s'"); + } + + #[test] + fn a_failure_envelope_names_its_code_and_a_confirmation_its_command() { + let f = Failure { proposed: vec![json!({"replace": "out.wai"})], confirm: Some("wai wrap x y --yes".into()), ..Failure::new(4, "confirmation-required", "m") }; + let v = envelope_err(Some("envelope.wrap"), &f, None); + assert_eq!((v["ok"].as_bool(), v["error"]["code"].as_str(), v["confirm"].as_str()), (Some(false), Some("confirmation-required"), Some("wai wrap x y --yes"))); + assert_eq!(v["version"], wai::catalogue::ENVELOPE); + let ok = envelope_ok("envelope.inspect", json!({"text": "t"}), None); + assert!(ok["ok"] == true && ok.get("error").is_none() && ok.get("dry_run").is_none()); + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/door.rs b/wai/wai-rs/src/bin/wai_cli/door.rs new file mode 100644 index 000000000..3c52d8ee3 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/door.rs @@ -0,0 +1,656 @@ +//! The command line's door to the operation catalogue (`wai::catalogue`). +//! +//! A command line is resolved through the catalogue alone: its words name an operation (its +//! CLI spelling, an alias, or its mechanical form `wai `), and the operation's +//! implementation is found by its id in [`impls`]. A command the catalogue does not hold +//! cannot run, and help is written from the catalogue's entries. This module's tests hold +//! the two tables to each other: every implementation names an operation of the catalogue, +//! and every operation with a command line has exactly one implementation. +//! +//! The agent rules (`agent`) apply to every operation here: the exit codes, `--json`, +//! `--dry-run` and `--yes`. An operation that prints text runs again as a child for +//! `--json`, its output carried in the envelope's `data`; one with a JSON form of its own +//! carries that. + +use crate::agent::{self, Ctx, Failure}; +use crate::out; +use serde_json::{Value, json}; +use std::process::{Command, ExitCode, Stdio}; +use wai::catalogue::{self, Effect, Op, OPS}; + +/// An operation's implementation: its arguments after the command's words, `Ok(true)` when +/// everything it checked held, `Ok(false)` when it ran and a check did not (exit 1). +pub type Run = fn(&[String]) -> Result; + +/// What an operation prints for `--json`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Json { + /// Text: carried as `{"text": ...}`. + Text, + /// A JSON form of its own, asked for with `--json`. + Own, + /// Its own JSON form when this flag is among its arguments; text otherwise. + OwnWith(&'static str), + /// It prints the envelope itself. + Native, + /// It streams JSON Lines itself, unless this flag is among its arguments, when it has a + /// JSON form of its own. + StreamUnless(&'static str), +} + +/// One implementation, by its operation's id. +pub struct Impl { + pub id: &'static str, + pub run: Run, + pub json: Json, +} + +/// Every implementation: the developer's path (`dx::VERBS`), then the rest. +pub fn impls() -> impl Iterator { + crate::dx::VERBS.iter().chain(IMPLS.iter()) +} + +fn impl_of(id: &str) -> Option<&'static Impl> { + impls().find(|i| i.id == id) +} + +/// The flags every operation takes, read wherever they are on the command line. +const GLOBAL: &[&str] = &["--json", "--dry-run", "--yes", "--confirm", "--no-input"]; + +pub fn main() -> ExitCode { + let raw: Vec = std::env::args().skip(1).collect(); + let child = std::env::var_os(agent::CHILD_ENV).is_some(); + let has = |f: &str| raw.iter().any(|a| a == f); + let c = Ctx { + // A child's `--json` is its operation's own flag, given by its parent. + json: !child && has("--json"), + dry_run: has("--dry-run"), + yes: has("--yes") || has("--confirm"), + no_input: has("--no-input"), + child, + argv: raw.iter().filter(|a| *a != "--json").cloned().collect(), + }; + let json = c.json; + out::set_dry_run(c.dry_run); + agent::set(c); + let args: Vec = raw.iter().filter(|a| !GLOBAL.contains(&a.as_str()) || (child && *a == "--json")).cloned().collect(); + + match args.first().map(String::as_str) { + None => { + eprint!("{}", usage_text()); + return fail(None, &Failure::usage("name an operation: `wai ops list` lists them, `wai --help` describes them"), json); + } + Some("--help" | "-h") => { + print!("{}", usage_text()); + return ExitCode::SUCCESS; + } + Some("help") => { + match catalogue::resolve(&args[1..]) { + Some((op, _)) => print!("{}", op_help(op)), + None => print!("{}", usage_text()), + } + return ExitCode::SUCCESS; + } + _ => {} + } + let Some((op, n)) = catalogue::resolve(&args) else { + return fail(None, &unknown(&args), json); + }; + let rest = &args[n..]; + if rest.iter().any(|a| a == "--help" || a == "-h") { + print!("{}", op_help(op)); + return ExitCode::SUCCESS; + } + if !op.available() { + return fail(Some(op.id), ¬_built(op), json); + } + let Some(imp) = impl_of(op.id) else { + return fail(Some(op.id), &Failure::internal(format!("{} has no implementation in this binary", op.id)), json); + }; + if json { + match imp.json { + Json::Native => {} + Json::StreamUnless(f) if !rest.iter().any(|a| a == f) => { + let mut with = rest.to_vec(); + with.push("--json".into()); + return finish(op, run(imp, &with), json); + } + Json::Text | Json::Own | Json::OwnWith(_) | Json::StreamUnless(_) => return enveloped(op, imp, rest), + } + } + finish(op, run(imp, rest), json) +} + +/// Run an implementation, a panic in it an internal error. +fn run(imp: &Impl, args: &[String]) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (imp.run)(args))) + .unwrap_or_else(|_| Err(Failure::internal(format!("{} stopped on an internal error (above)", imp.id)))) +} + +/// The exit of an operation run here, with what a dry run would have done. +fn finish(op: &Op, r: Result, json: bool) -> ExitCode { + match r { + Ok(ok) => { + if out::dry_run() && op.effect != Effect::Reads { + if agent::ctx().child { + eprintln!("{}{}", agent::TRAILER_PLAN, Value::Array(out::plan())); + } else if !json { + let lines = out::plan_lines(); + if lines.is_empty() { + println!("dry run: nothing would be written"); + } else { + println!("dry run: nothing was written. Without --dry-run, wai would:"); + for l in lines { + println!(" {l}"); + } + } + } + } + if ok { ExitCode::SUCCESS } else { ExitCode::from(1) } + } + Err(f) => fail(Some(op.id), &f, json), + } +} + +/// Report a failure (as an envelope with `--json`) and exit with its code. +fn fail(op: Option<&str>, f: &Failure, json: bool) -> ExitCode { + agent::report(op, f); + if json { + agent::print(&agent::envelope_err(op, f, None)); + } + ExitCode::from(f.exit) +} + +/// `--json` for an operation that prints text, or JSON of its own: it runs again as a child +/// of this process, and its output and exit become the envelope. +fn enveloped(op: &Op, imp: &Impl, rest: &[String]) -> ExitCode { + let own = match imp.json { + Json::Own => true, + Json::OwnWith(f) | Json::StreamUnless(f) => rest.iter().any(|a| a == f), + Json::Text | Json::Native => false, + }; + let mut a = agent::ctx().argv.clone(); + if own { + a.push("--json".into()); + } + let exe = match std::env::current_exe() { + Ok(e) => e, + Err(e) => return fail(Some(op.id), &Failure::internal(format!("this binary's path: {e}")), true), + }; + let o = match Command::new(exe).args(&a).env(agent::CHILD_ENV, "1").stdin(Stdio::inherit()).output() { + Ok(o) => o, + Err(e) => return fail(Some(op.id), &Failure::internal(format!("running {}: {e}", op.id)), true), + }; + let code = o.status.code().and_then(|c| u8::try_from(c).ok()).unwrap_or(7); + let (mut trailer, mut plan, mut logs) = (None::, None::, Vec::new()); + for line in String::from_utf8_lossy(&o.stderr).lines() { + if let Some(j) = line.strip_prefix(agent::TRAILER_ERROR) { + trailer = serde_json::from_str(j).ok(); + } else if let Some(j) = line.strip_prefix(agent::TRAILER_PLAN) { + plan = serde_json::from_str(j).ok(); + } else { + logs.push(line.to_string()); + } + } + for l in &logs { + eprintln!("{l}"); + } + let stdout = String::from_utf8_lossy(&o.stdout).into_owned(); + let data = || -> Value { + if own && let Ok(v) = serde_json::from_str::(stdout.trim()) { + return v; + } + json!({"text": stdout}) + }; + let v = match code { + 0 => agent::envelope_ok(op.id, data(), plan.and_then(|p| p.as_array().cloned())), + 1 => { + let msg = logs.iter().rev().find(|l| !l.trim().is_empty()).cloned().unwrap_or_else(|| format!("{}: a check did not hold", op.id)); + agent::envelope_err(Some(op.id), &Failure::new(1, "check-failed", msg), Some(data())) + } + _ => match trailer { + Some(t) => { + let mut v = json!({"version": catalogue::ENVELOPE, "op": op.id, "ok": false, "error": t["error"]}); + if code == 4 { + v["proposed"] = t["proposed"].clone(); + v["confirm"] = t["confirm"].clone(); + } + v + } + None => { + let msg = logs.iter().rev().find(|l| !l.trim().is_empty()).cloned().unwrap_or_else(|| format!("{} exited {code}", op.id)); + let f = if code == 7 || o.status.code().is_none() { Failure::internal(msg) } else { Failure::from(msg) }; + agent::envelope_err(Some(op.id), &f, None) + } + }, + }; + agent::print(&v); + ExitCode::from(code) +} + +/// A command line that names no operation: exit 5, with the closest operations. +fn unknown(args: &[String]) -> Failure { + let words: Vec<&str> = args.iter().take_while(|a| !a.starts_with('-')).map(String::as_str).collect(); + let near: Vec = catalogue::search(&words.join(" "), 3).iter().map(|m| format!("{} ({})", m.op.command(), m.op.id)).collect(); + let f = Failure::new(5, "operation-not-found", format!("`wai {}` names no operation", words.join(" "))); + if near.is_empty() { + f.hint("`wai ops list` lists every operation; `wai ops search \"\"` finds one") + } else { + f.hint(format!("the closest: {}; `wai ops search \"\"` finds others", near.join(", "))) + } +} + +/// An operation this build does not hold: exit 5, naming the build that does. +pub fn not_built(op: &Op) -> Failure { + let need = match op.needs { + catalogue::Needs::Nothing => String::new(), + catalogue::Needs::All(f) => format!("the feature{} {}", if f.len() > 1 { "s" } else { "" }, f.join(", ")), + catalogue::Needs::Any(f) => format!("one of the features {}", f.join(", ")), + }; + Failure::new(5, "operation-not-in-build", format!("`{}` ({}) needs a build with {need}", op.command(), op.id)) + .hint("the release archive's `wai` holds the developer's path; a source build takes --features") +} + +// ─────────────────────────────── help, from the catalogue ─────────────────────────────── + +/// The help `wai --help` prints: every operation, the developer's path first. +pub fn usage_text() -> String { + let mut s = String::new(); + s += "wai — the WAI toolchain. Every operation has an id, noun.verb, and `wai ` runs it\n"; + s += "as well as the command shown.\n\nStart here:\n"; + let line = |o: &Op| -> String { + let cmd = o.command(); + let note = if o.available() { String::new() } else { " (not in this build)".into() }; + format!(" {cmd:<26} {:<20} {}{note}\n", o.id, o.summary) + }; + let start = ["toolchain.hello", "project.create", "dev.start", "receipt.verify"]; + for id in start { + if let Some(o) = catalogue::op(id) { + s += &line(o); + } + } + s += "\nEvery other operation:\n"; + for o in OPS.iter().filter(|o| !start.contains(&o.id)) { + s += &line(o); + } + s += "\nFor every operation:\n"; + s += " --json one JSON envelope on stdout: {\"version\": \"envelope/1\", \"op\", \"ok\", \"data\" | \"error\"}\n"; + s += " --dry-run write nothing; say what would be written (an operation that only reads ignores it)\n"; + s += " --yes confirm a change that waits for confirmation; off a terminal, without it, exit 4\n"; + s += " --help the operation's usage, effect and id\n"; + s += "exit codes: "; + s += &catalogue::EXIT_CODES.iter().map(|(c, m)| format!("{c} {m}")).collect::>().join("; "); + s += "\n\n`wai ops search \"\"` finds an operation; `wai ops schema ` describes one as JSON.\n"; + s += "`wai` sends nothing off your machine unless you name a remote service.\n"; + s +} + +/// One operation's help. +pub fn op_help(o: &Op) -> String { + let mut s = format!("{} — {}\n{}\n\nusage:\n", o.command(), o.id, o.summary); + for l in o.usage.lines() { + if l.starts_with(' ') { + s += &format!(" {l}\n"); + } else { + s += &format!(" wai {l}\n"); + } + } + let forms: Vec = o.cli_forms().iter().map(|f| format!("wai {}", f.join(" "))).filter(|f| *f != o.command()).collect(); + if !forms.is_empty() { + s += &format!("also: {}\n", forms.join(", ")); + } + s += &format!("\neffect {} · runs {} · toolset {} · {}\n", o.effect.label(), o.placement.label(), o.toolset.label(), o.stability.label()); + if o.dry_run() { + s += "--dry-run writes nothing and says what would be written.\n"; + } + if o.always_confirms() { + s += "It waits for confirmation: --yes, or yes at a terminal.\n"; + } else if o.effect != Effect::Reads { + s += "Replacing a file the command line did not name waits for confirmation: --yes, or yes at a terminal.\n"; + } + if !o.available() { + s += &format!("{}\n", not_built(o).message); + } + s +} + +// ─────────────────────────────── wai ops ─────────────────────────────── + +fn ops_list(a: &[String]) -> Result { + if let Some(x) = a.first() { + return Err(Failure::usage(format!("wai ops list: {x}: takes no argument"))); + } + let rows: Vec = OPS + .iter() + .map(|o| json!({"id": o.id, "summary": o.summary, "cli": o.command(), "effect": o.effect.label(), "placement": o.placement.label(), "toolset": o.toolset.label(), "stability": o.stability.label(), "available": o.available()})) + .collect(); + if agent::ctx().json { + agent::print(&agent::envelope_ok("ops.list", Value::Array(rows), None)); + } else { + for o in OPS { + println!("{:<22} {:<30} {:<15}{}", o.id, o.command(), o.effect.label(), if o.available() { "" } else { " (not in this build)" }); + } + } + Ok(true) +} + +fn ops_search(a: &[String]) -> Result { + if a.is_empty() || a.iter().any(|x| x.starts_with("--")) { + return Err(Failure::usage("usage: wai ops search \"\"")); + } + let found = catalogue::search(&a.join(" "), 5); + if agent::ctx().json { + let rows: Vec = found.iter().map(|m| json!({"id": m.op.id, "summary": m.op.summary, "cli": m.op.command(), "score": m.score})).collect(); + agent::print(&agent::envelope_ok("ops.search", Value::Array(rows), None)); + } else if found.is_empty() { + println!("no operation matches; `wai ops list` lists them all"); + } else { + for m in &found { + println!("{:<22} {:<30} {}", m.op.id, m.op.command(), m.op.summary); + } + } + Ok(true) +} + +fn ops_schema(a: &[String]) -> Result { + let [id] = a else { + return Err(Failure::usage("usage: wai ops schema ")); + }; + let o = catalogue::op(id).ok_or_else(|| { + let near: Vec<&str> = catalogue::search(&id.replace(['.', '_'], " "), 3).iter().map(|m| m.op.id).collect(); + Failure::new(5, "operation-not-found", format!("no operation {id}")).hint(format!("the closest: {}", near.join(", "))) + })?; + if agent::ctx().json { + agent::print(&agent::envelope_ok("ops.schema", o.to_json(), None)); + } else { + println!("{}", serde_json::to_string_pretty(&o.to_json()).expect("json")); + } + Ok(true) +} + +fn ops_export(a: &[String]) -> Result { + let (mut out_path, mut check) = (None, None); + let mut i = 0; + while i < a.len() { + match (a[i].as_str(), a.get(i + 1)) { + ("--out", Some(v)) => out_path = Some(v.clone()), + ("--check", Some(v)) => check = Some(v.clone()), + (x, _) => return Err(Failure::usage(format!("wai ops export: {x}: not an option (--out FILE | --check FILE)"))), + } + i += 2; + } + let text = catalogue::export_text(); + let json = agent::ctx().json; + match (out_path, check) { + (Some(_), Some(_)) => Err(Failure::usage("wai ops export: --out or --check, not both")), + (None, Some(f)) => { + let have = std::fs::read_to_string(&f).map_err(|e| Failure::from(format!("{f}: {e}")))?; + let same = have == text; + if json { + let d = json!({"file": f, "up_to_date": same}); + if same { + agent::print(&agent::envelope_ok("ops.export", d, None)); + } else { + agent::print(&agent::envelope_err(Some("ops.export"), &Failure::new(1, "check-failed", format!("{f} differs from the catalogue")), Some(d))); + } + } else if same { + println!("ops export: {f} is up to date"); + } else { + eprintln!("{f} differs from the catalogue in wai-rs/src/catalogue.rs"); + eprintln!("regenerate: cd wai/wai-rs && cargo run -q --no-default-features --bin wai -- ops export --out {f}"); + } + Ok(same) + } + (Some(f), None) => { + out::write(&f, &text).map_err(|e| Failure::from(format!("{f}: {e}")))?; + if json { + agent::print(&agent::envelope_ok("ops.export", json!({"file": f, "bytes": text.len()}), out::dry_run().then(out::plan))); + } else { + println!("wrote {f}"); + } + Ok(true) + } + (None, None) => { + if json { + agent::print(&agent::envelope_ok("ops.export", catalogue::export(), None)); + } else { + print!("{text}"); + } + Ok(true) + } + } +} + +// ─────────────────────────────── the other implementations ─────────────────────────────── + +type R = Result; + +/// The usage failure of operation `id`. +fn usage_of(id: &str) -> Failure { + let o = catalogue::op(id).expect("an operation of the catalogue"); + Failure::usage(format!("usage: {}", op_help(o).split("usage:\n").nth(1).unwrap_or("").split("\n\n").next().unwrap_or("").trim())) +} + +/// The output path a writing command was given with `-o`, or its default; a default that +/// would replace a file waits for confirmation. +fn named_or_default<'a>(rest: &'a [String], default: &'a str) -> Result<&'a str, Failure> { + match crate::opt(rest, "-o") { + Some(o) => Ok(o), + None => { + if std::path::Path::new(default).exists() { + agent::confirm(&format!("{default} exists, and the command line did not name it as the output (-o)"), vec![json!({"replace": default})])?; + } + Ok(default) + } + } +} + +fn envelope_inspect(a: &[String]) -> R { + let [p, ..] = a else { return Err(usage_of("envelope.inspect")) }; + crate::inspect(p)?; + Ok(true) +} + +fn envelope_wrap(a: &[String]) -> R { + let [cap, payload, rest @ ..] = a else { return Err(usage_of("envelope.wrap")) }; + let comps = crate::companion_args(rest)?; + let out = named_or_default(rest, "out.wai")?; + crate::wrap(cap, payload, out, crate::opt(rest, "--fallback"), crate::opt(rest, "--intent"), &comps)?; + Ok(true) +} + +fn envelope_unwrap(a: &[String]) -> R { + let [p, rest @ ..] = a else { return Err(usage_of("envelope.unwrap")) }; + crate::unwrap(p, crate::opt(rest, "-o"), crate::opt(rest, "--component"))?; + Ok(true) +} + +fn media_encode(a: &[String]) -> R { + if matches!(a.first().map(String::as_str), Some("avc" | "hevc")) { + return platform_encode(a); + } + // In a build without `floor` or `codecs`, `crate::encode` says so. + let [codec, input, rest @ ..] = a else { return Err(usage_of("media.encode")) }; + let out = named_or_default(rest, "out.wai")?; + let q: u8 = crate::opt(rest, "-q").and_then(|s| s.parse().ok()).unwrap_or(75); + crate::encode(codec, input, out, q)?; + Ok(true) +} + +fn media_decode(a: &[String]) -> R { + let [p, rest @ ..] = a else { return Err(usage_of("media.decode")) }; + // The output was the third word (`wai decode in.wai -o out`), whatever the second. + let out = crate::opt(rest, "-o").or_else(|| rest.get(1).map(String::as_str)).ok_or_else(|| usage_of("media.decode"))?; + crate::decode(p, out)?; + Ok(true) +} + +fn registry_list(a: &[String]) -> R { + if let Some(x) = a.first() { + return Err(Failure::usage(format!("wai capabilities: {x}: takes no argument"))); + } + crate::capabilities()?; + Ok(true) +} + +fn registry_export(a: &[String]) -> R { + let mut v = vec!["export".to_string()]; + v.extend_from_slice(a); + Ok(crate::registry(&v)?) +} + +fn prior_list(a: &[String]) -> R { + let [root, ..] = a else { return Err(usage_of("prior.list")) }; + crate::prior_set(root)?; + Ok(true) +} + +/// `words` then `a`. +#[allow(dead_code)] +fn with(words: &[&str], a: &[String]) -> Vec { + words.iter().map(|w| w.to_string()).chain(a.iter().cloned()).collect() +} + +/// An implementation that needs build features: compiled with them, and without them a +/// function that says which (the door checks the catalogue's `needs` before either runs). +macro_rules! gated { + ($name:ident, $id:literal, cfg($($cfg:tt)*), |$a:ident| $body:expr) => { + #[cfg($($cfg)*)] + fn $name($a: &[String]) -> R { + $body + } + #[cfg(not($($cfg)*))] + fn $name(_: &[String]) -> R { + Err(not_built(catalogue::op($id).expect("listed"))) + } + }; +} + +gated!(platform_encode, "media.encode", cfg(feature = "platform_encode"), |a| { + crate::platform_encode_cli::encode(a)?; + Ok(true) +}); +gated!(media_generate, "media.generate", cfg(feature = "generate"), |a| { + crate::generate::main(a)?; + Ok(true) +}); +gated!(score_build, "score.build", cfg(feature = "score"), |a| { + crate::score::main(&with(&["build"], a))?; + Ok(true) +}); +gated!(score_render, "score.render", cfg(feature = "score"), |a| { + crate::score::main(&with(&["render"], a))?; + Ok(true) +}); +gated!(encoder_list, "encoder.list", cfg(feature = "platform_encode"), |a| { + crate::platform_encode_cli::encoders(a)?; + Ok(true) +}); +gated!(encoderecord_verify, "encoderecord.verify", cfg(feature = "platform_encode"), |a| Ok(crate::platform_encode_cli::record_cmd(&with(&["verify"], a))?)); + +macro_rules! stage_op { + ($name:ident, $sub:literal, $id:literal) => { + gated!($name, $id, cfg(all(feature = "staged", feature = "video_int", feature = "audio_int", feature = "splat_int")), |a| { + crate::stage::main(&with(&[$sub], a))?; + Ok(true) + }); + }; +} +stage_op!(stage_refine, "refine", "stage.refine"); +stage_op!(stage_apply, "apply", "stage.apply"); +stage_op!(stage_base, "base", "stage.base"); +stage_op!(stage_inspect, "inspect", "stage.inspect"); +stage_op!(stage_pack, "pack", "stage.pack"); +stage_op!(stage_explode, "explode", "stage.explode"); + +macro_rules! http_op { + ($name:ident, $sub:literal, $id:literal) => { + gated!($name, $id, cfg(feature = "http"), |a| Ok(crate::http::main(&with(&[$sub], a))?)); + }; +} +http_op!(http_publish, "publish", "http.publish"); +http_op!(http_codecs, "codecs", "http.codecs"); +http_op!(http_origin, "origin", "http.origin"); +http_op!(http_hls, "hls", "http.hls"); +http_op!(http_mpd, "mpd", "http.mpd"); +http_op!(http_sealplaylist, "seal-playlist", "http.sealplaylist"); +http_op!(http_checkplaylist, "check-playlist", "http.checkplaylist"); +http_op!(http_vectors, "vectors", "http.vectors"); +http_op!(http_bench, "bench", "http.bench"); +http_op!(http_receipts, "receipts", "http.receipts"); + +/// Every implementation but the developer's path (`dx::VERBS`), by its operation's id. +pub const IMPLS: &[Impl] = &[ + Impl { id: "envelope.inspect", run: envelope_inspect, json: Json::Text }, + Impl { id: "envelope.wrap", run: envelope_wrap, json: Json::Text }, + Impl { id: "envelope.unwrap", run: envelope_unwrap, json: Json::Text }, + Impl { id: "media.encode", run: media_encode, json: Json::Text }, + Impl { id: "media.decode", run: media_decode, json: Json::Text }, + Impl { id: "media.generate", run: media_generate, json: Json::Own }, + Impl { id: "score.build", run: score_build, json: Json::Own }, + Impl { id: "score.render", run: score_render, json: Json::Own }, + Impl { id: "encoder.list", run: encoder_list, json: Json::Text }, + Impl { id: "encoderecord.verify", run: encoderecord_verify, json: Json::Text }, + Impl { id: "registry.list", run: registry_list, json: Json::Text }, + Impl { id: "registry.export", run: registry_export, json: Json::Text }, + Impl { id: "prior.list", run: prior_list, json: Json::Text }, + Impl { id: "stage.refine", run: stage_refine, json: Json::Text }, + Impl { id: "stage.apply", run: stage_apply, json: Json::Own }, + Impl { id: "stage.base", run: stage_base, json: Json::Text }, + Impl { id: "stage.inspect", run: stage_inspect, json: Json::Text }, + Impl { id: "stage.pack", run: stage_pack, json: Json::Text }, + Impl { id: "stage.explode", run: stage_explode, json: Json::Text }, + Impl { id: "http.publish", run: http_publish, json: Json::Text }, + Impl { id: "http.codecs", run: http_codecs, json: Json::Text }, + Impl { id: "http.origin", run: http_origin, json: Json::Text }, + Impl { id: "http.hls", run: http_hls, json: Json::Text }, + Impl { id: "http.mpd", run: http_mpd, json: Json::Text }, + Impl { id: "http.sealplaylist", run: http_sealplaylist, json: Json::Text }, + Impl { id: "http.checkplaylist", run: http_checkplaylist, json: Json::Text }, + Impl { id: "http.vectors", run: http_vectors, json: Json::Text }, + Impl { id: "http.bench", run: http_bench, json: Json::Text }, + Impl { id: "http.receipts", run: http_receipts, json: Json::Text }, + Impl { id: "ops.list", run: ops_list, json: Json::Native }, + Impl { id: "ops.search", run: ops_search, json: Json::Native }, + Impl { id: "ops.schema", run: ops_schema, json: Json::Native }, + Impl { id: "ops.export", run: ops_export, json: Json::Native }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_implementation_names_an_operation_of_the_catalogue() { + for i in impls() { + assert!(catalogue::op(i.id).is_some(), "the CLI runs {}, which the catalogue does not hold: add it to wai-rs/src/catalogue.rs", i.id); + } + } + + #[test] + fn every_operation_with_a_command_line_has_exactly_one_implementation() { + for o in OPS.iter().filter(|o| o.cli.is_some()) { + let n = impls().filter(|i| i.id == o.id).count(); + assert_eq!(n, 1, "{} ({}) has {n} implementations in the CLI", o.id, o.command()); + } + } + + #[test] + fn help_is_written_from_the_catalogue_and_names_every_operation() { + let u = usage_text(); + for o in OPS { + assert!(u.contains(o.id) && u.contains(&o.command()), "{} is missing from the help", o.id); + let h = op_help(o); + assert!(h.contains(o.id) && h.contains(o.summary), "{}'s own help", o.id); + assert_eq!(h.contains("--dry-run"), o.effect != Effect::Reads, "{}: the help says --dry-run exactly when it applies", o.id); + } + assert!(u.contains("sends nothing off your machine unless you name a remote service")); + } + + #[test] + fn a_usage_failure_quotes_the_operations_synopsis() { + let f = usage_of("envelope.wrap"); + assert_eq!(f.exit, 2); + assert!(f.message.contains("wai wrap "), "{}", f.message); + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/create.rs b/wai/wai-rs/src/bin/wai_cli/dx/create.rs index ee3b22f3d..260caed57 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/create.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/create.rs @@ -5,16 +5,17 @@ //! It writes into a directory that does not exist or is empty, and never over a file. Each //! example's `test/` directory is the repository's check that the example works, and is not //! part of the project. Where a template names a generated file (a watch room's sample -//! sound), it is made here. +//! sound), it is made here. Every template is Apache-2.0: each carries the licence's text +//! as `LICENSE`, which the project receives with the rest. #[path = "templates.rs"] mod templates; use super::{flag, opt, positional, project}; +use crate::agent::Failure; +use crate::out; use std::path::Path; -pub const USAGE: &str = "wai create --template | wai create --list [--json] a new project from one of the examples"; - /// A file of a template: its path in the project, and its bytes. pub type File = (&'static str, &'static [u8]); /// A file made when the project is created, by a function of this binary. @@ -28,7 +29,7 @@ pub struct Template { pub generated: &'static [Generated], } -pub fn main(args: &[String]) -> Result { +pub fn main(args: &[String]) -> Result { if flag(args, "--list") { if flag(args, "--json") { let v: Vec = templates::TEMPLATES @@ -43,12 +44,13 @@ pub fn main(args: &[String]) -> Result { } return Ok(true); } + let usage = super::usage("project.create"); if let Some(a) = args.iter().find(|a| a.starts_with("--") && *a != "--template") { - return Err(format!("wai create: {a}: not an option ({USAGE})")); + return Err(Failure::usage(format!("wai create: {a}: not an option ({usage})"))); } let pos = positional(args, &["--template"]); let (Some(dir), Some(name), 1) = (pos.first(), opt(args, "--template"), pos.len()) else { - return Err(format!("{USAGE}\ntemplates: {}", names())); + return Err(Failure::usage(format!("{usage}\ntemplates: {}", names()))); }; let t = templates::TEMPLATES.iter().find(|t| t.name == name).ok_or_else(|| format!("no template {name}; the templates: {}", names()))?; let dir = Path::new(dir); @@ -79,19 +81,20 @@ fn write(t: &Template, dir: &Path) -> Result<(), String> { if !files.iter().any(|(p, _)| *p == project::FILE) { files.push((project::FILE, project::minimal(t.name).into_bytes())); } + // The project file must read before anything is written: a template is checked as it is + // made. + let pf = files.iter().find(|(p, _)| *p == project::FILE).map(|(_, b)| String::from_utf8_lossy(b).into_owned()).unwrap_or_default(); + project::parse(dir, &pf).map_err(|e| format!("{}: {e}", dir.join(project::FILE).display()))?; for (rel, bytes) in &files { if rel.split('/').any(|s| s.is_empty() || s == "." || s == "..") { return Err(format!("{rel}: not a path a template may write")); } let p = dir.join(rel); if let Some(parent) = p.parent() { - std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + out::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; } - let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&p).map_err(|e| format!("{}: {e}", p.display()))?; - std::io::Write::write_all(&mut f, bytes).map_err(|e| format!("{}: {e}", p.display()))?; + out::write_new(&p, bytes).map_err(|e| format!("{}: {e}", p.display()))?; } - // The project file the template wrote must read: a template is checked as it is made. - project::load(dir)?.ok_or("the project file is missing")?; Ok(()) } diff --git a/wai/wai-rs/src/bin/wai_cli/dx/dev.rs b/wai/wai-rs/src/bin/wai_cli/dx/dev.rs index 4fbae8a9f..157143191 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/dev.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/dev.rs @@ -1,5 +1,5 @@ -//! `wai dev`: a relay (`joule-moq-relay`), a room service (`joule-moq-room`) and the room -//! page on one origin, on this machine, for development. +//! `wai dev`: a relay (`wai-relay`), a room service (`wai-room`) and the room page on one +//! origin, on this machine, for development. //! //! It makes the room's keys in its directory (`.wai/dev/` by default) once, writes the //! relay's and the service's configuration there, starts both on loopback ports of their @@ -12,9 +12,13 @@ //! WebAssembly, the service and its clock's session on the relay, a viewer's grant, the //! relay's WebSocket upgrade), prints how long it took, and stops: exit 0 when every check //! held. Without it, `wai dev` runs until Ctrl-C (which stops the relay and the service -//! with it) or `--for` seconds. +//! with it) or `--for` seconds; with `--json` its first line on stdout is +//! `{"ready": true, ...}`. `--dry-run` says what it would start and writes nothing. Its +//! usage and help are the catalogue's (`dev.start`). use super::{flag, opt, project}; +use crate::agent::Failure; +use crate::out; use serde_json::{Value, json}; use std::io::{BufRead, BufReader, Read, Write}; use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; @@ -23,30 +27,6 @@ use std::process::{Child, Command, Stdio}; use std::sync::Arc; use std::time::{Duration, Instant}; -pub const USAGE: &str = "wai dev [--port P] [--dir D] [--site DIR] [--watch FILE [--watch-ms MS]] [--call] [--remote]\n [--messaging] [--e2ee] [--publishers N] [--check [--json]] [--for S]\n a local relay, room service and room page on one origin (`wai dev --help`)"; - -const HELP: &str = "wai dev — a relay, a room service and the room page on one origin, on this machine - - --port P the origin's port on 127.0.0.1 (8080; 0 picks a free one) - --dir D keys, configuration and logs (.wai/dev) - --site DIR your page: DIR is served at /, the room client beside it at /room/ - --publishers N places in the room (4) - --watch FILE a watch room: everyone in it watches FILE together (.wav, .webm, .mp4, - .ogg, .mp3); --watch-ms MS its duration, read from a WAV file's header - --call the room is a call: /room/call.html places one, rings, answers - --remote the room is one host's shared screen; prints the host's invitation - --messaging a conversation beside the room, at /msg-api/: /room/messages.html - --e2ee every room end-to-end encrypted - --check start, check every piece through the origin, report, stop - --json with --check, the report as JSON - --for S stop after S seconds - --web DIR the room client's files (beside this binary: ../share/wai/web; $WAI_WEB) - --relay BIN joule-moq-relay (beside this binary, then $PATH; $WAI_RELAY_BIN) - --room BIN joule-moq-room (beside this binary, then $PATH; $WAI_ROOM_BIN) - -A wai.json in the current directory (`wai create` writes one) gives the defaults; a flag -wins over it. Ctrl-C stops everything."; - /// The relay's WebSocket path on the origin. const RELAY_PATH: &str = "/moq"; /// The paths the services are served under. @@ -83,18 +63,55 @@ struct Opts { for_s: Option, } -pub fn main(args: &[String]) -> Result { - if flag(args, "--help") || flag(args, "-h") { - println!("{HELP}"); - return Ok(true); - } +pub fn main(args: &[String]) -> Result { let started = Instant::now(); let o = options(args)?; - run(o, started) + if out::dry_run() { + plan(&o); + return Ok(true); + } + // Past its options, what stops `wai dev` is the stack on this machine not coming up (a + // port held, a service that exits or does not answer): starting it again may succeed. + run(o, started).map_err(Failure::unavailable) +} + +/// What `wai dev` would start, for `--dry-run`: it starts nothing and writes nothing. +fn plan(o: &Opts) { + let mode = match &o.mode { + Mode::Room => "room".to_string(), + Mode::Watch { file, ms } => format!("watch {} ({ms} ms)", file.display()), + Mode::Call => "call".into(), + Mode::Remote => "remote".into(), + }; + let v = json!({ + "dry_run": true, + "origin": format!("http://127.0.0.1:{}", o.port), + "relay": o.relay.display().to_string(), + "room": o.room.display().to_string(), + "web": o.web.display().to_string(), + "site": o.site.as_ref().map(|s| s.display().to_string()), + "dir": o.dir.display().to_string(), + "mode": mode, + "publishers": o.publishers, + "messaging": o.messaging, + "e2ee": o.e2ee, + "check": o.check, + }); + if o.json { + println!("{v}"); + return; + } + println!("wai dev --dry-run: nothing started, nothing written. It would:"); + println!(" start {} (the relay) and {} (the room service){}", o.relay.display(), o.room.display(), if o.messaging { ", and a conversation" } else { "" }); + println!(" serve http://127.0.0.1:{}: {}the room client from {}", o.port, o.site.as_ref().map(|s| format!("your page from {}, ", s.display())).unwrap_or_default(), o.web.display()); + println!(" keep keys, configuration and logs in {}", o.dir.display()); + println!(" run a {mode} room, {} places{}{}", o.publishers, if o.e2ee { ", end-to-end encrypted" } else { "" }, if o.check { "; check every piece, then stop" } else { "" }); } fn options(args: &[String]) -> Result { let known_values = ["--port", "--dir", "--site", "--publishers", "--watch", "--watch-ms", "--for", "--web", "--relay", "--room"]; + // `--json` reaches here as the operation's own flag: with --check, the report as JSON; + // without, the ready line. let known_flags = ["--call", "--remote", "--messaging", "--e2ee", "--check", "--json"]; let mut i = 0; while i < args.len() { @@ -162,11 +179,12 @@ fn options(args: &[String]) -> Result { } let exe_dir = std::env::current_exe().ok().and_then(|e| std::fs::canonicalize(e).ok()).and_then(|e| e.parent().map(Path::to_path_buf)); let web = find_web(opt(args, "--web"), exe_dir.as_deref())?; - let relay = find_bin("joule-moq-relay", opt(args, "--relay"), "WAI_RELAY_BIN", exe_dir.as_deref())?; - let room = find_bin("joule-moq-room", opt(args, "--room"), "WAI_ROOM_BIN", exe_dir.as_deref())?; + let relay = find_bin("wai-relay", opt(args, "--relay"), "WAI_RELAY_BIN", exe_dir.as_deref())?; + let room = find_bin("wai-room", opt(args, "--room"), "WAI_ROOM_BIN", exe_dir.as_deref())?; let dir = opt(args, "--dir").map(PathBuf::from).unwrap_or_else(|| cwd.join(".wai").join("dev")); - std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; - let dir = std::fs::canonicalize(&dir).map_err(|e| e.to_string())?; + out::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + // A dry run makes no directory, so one that is not there yet is named as it would be. + let dir = if out::dry_run() && !dir.exists() { cwd.join(&dir) } else { std::fs::canonicalize(&dir).map_err(|e| e.to_string())? }; let for_s = num("--for")?; Ok(Opts { port, dir, site, page: d.page.clone(), web, relay, room, publishers, mode, messaging, e2ee, check: flag(args, "--check"), json: flag(args, "--json"), for_s }) } @@ -295,9 +313,9 @@ fn free_port() -> Result { /// The relay lines of a room service's keys, made once in `keys`. fn keys(room: &Path, keys: &Path) -> Result { let verb = if keys.exists() { "relay-lines" } else { "keygen" }; - let out = Command::new(room).args([verb, &keys.to_string_lossy()]).output().map_err(|e| format!("joule-moq-room {verb}: {e}"))?; + let out = Command::new(room).args([verb, &keys.to_string_lossy()]).output().map_err(|e| format!("wai-room {verb}: {e}"))?; if !out.status.success() { - return Err(format!("joule-moq-room {verb}: {}", String::from_utf8_lossy(&out.stderr).trim())); + return Err(format!("wai-room {verb}: {}", String::from_utf8_lossy(&out.stderr).trim())); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } @@ -382,10 +400,10 @@ fn run(o: Opts, started: Instant) -> Result { let mut procs: Vec = Vec::new(); let mut times: Vec<(&str, f64)> = Vec::new(); let result = (|| -> Result { - procs.push(Proc::start("joule-moq-relay", &o.relay, &["--config", &relay_cfg.to_string_lossy()], o.dir.join("relay.log"), &[])?); + procs.push(Proc::start("wai-relay", &o.relay, &["--config", &relay_cfg.to_string_lossy()], o.dir.join("relay.log"), &[])?); wait_for("the relay's WebSocket binding", 30_000, &mut procs, || TcpStream::connect(("127.0.0.1", ws)).is_ok())?; times.push(("relay", started.elapsed().as_secs_f64())); - procs.push(Proc::start("joule-moq-room", &o.room, &["--config", &room_cfg.to_string_lossy()], o.dir.join("room.log"), &[])?); + procs.push(Proc::start("wai-room", &o.room, &["--config", &room_cfg.to_string_lossy()], o.dir.join("room.log"), &[])?); let svc_addr: SocketAddr = ([127, 0, 0, 1], svc).into(); wait_for("the room service's clock on the relay", 30_000, &mut procs, || { http(svc_addr, "GET", "/health", &[], b"").is_ok_and(|(s, b)| s == 200 && serde_json::from_slice::(&b).is_ok_and(|v| v["relay"] == true)) @@ -397,7 +415,7 @@ fn run(o: Opts, started: Instant) -> Result { let mport = free_port()?; let mcfg = o.dir.join("conversation.toml"); std::fs::write(&mcfg, conversation_toml(&o, mport)).map_err(|e| e.to_string())?; - procs.push(Proc::start("joule-moq-room (conversation)", &o.room, &["--config", &mcfg.to_string_lossy()], o.dir.join("conversation.log"), &[])?); + procs.push(Proc::start("wai-room (conversation)", &o.room, &["--config", &mcfg.to_string_lossy()], o.dir.join("conversation.log"), &[])?); let maddr: SocketAddr = ([127, 0, 0, 1], mport).into(); wait_for("the conversation's service", 30_000, &mut procs, || http(maddr, "GET", "/msg", &[], b"").is_ok_and(|(s, _)| s == 200))?; times.push(("conversation", started.elapsed().as_secs_f64())); @@ -440,7 +458,12 @@ fn run(o: Opts, started: Instant) -> Result { } passed } else { - announce(&o, &base); + if o.json { + // A streaming operation's first line (JSON Lines): the stack is up. + println!("{}", json!({"ready": true, "origin": base, "room_api": format!("{base}{ROOM_API}"), "relay": format!("ws://{origin}{RELAY_PATH}"), "dir": o.dir.display().to_string()})); + } else { + announce(&o, &base); + } let end = o.for_s.map(|s| Instant::now() + Duration::from_secs(s)); loop { if let Some(e) = procs.iter_mut().find_map(Proc::exited) { @@ -485,7 +508,7 @@ fn announce(o: &Opts, base: &str) { println!(" the host wai-remote-host run --room {base}{ROOM_API} --key host.key --invitation {inv} --relay ws://{}{RELAY_PATH}", base.trim_start_matches("http://")); println!(" (`wai-remote-host key host.key` makes the key; the invitation holds for an hour)"); } - _ => println!(" the host `joule-moq-room invite {}` makes the host's invitation", o.dir.join("room-keys.toml").display()), + _ => println!(" the host `wai-room invite {}` makes the host's invitation", o.dir.join("room-keys.toml").display()), } } Mode::Watch { file, ms } => println!(" watch room {base}/room/?service={ROOM_API} {} ({} s), in sync for everyone in it", file.display(), ms / 1000), diff --git a/wai/wai-rs/src/bin/wai_cli/dx/hello.rs b/wai/wai-rs/src/bin/wai_cli/dx/hello.rs index 7530d431b..f7a49baff 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/hello.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/hello.rs @@ -15,38 +15,42 @@ //! The key's secret is never written: the receipt proves the bytes were signed by the key //! it names, and a later `wai verify` checks that again, offline. -pub const USAGE: &str = "wai hello [] WAI's four steps (name, dispatch, rebuild, receipt) on a sample, written to (wai-hello)"; +use crate::agent::Failure; #[cfg(not(feature = "quickstart"))] -pub fn main(_: &[String]) -> Result { - Err("`wai hello` needs a build with the feature `quickstart`".into()) +pub fn main(_: &[String]) -> Result { + Err(Failure::from("`wai hello` needs a build with the feature `quickstart`")) } #[cfg(feature = "quickstart")] -pub fn main(args: &[String]) -> Result { +pub fn main(args: &[String]) -> Result { use super::hex; + use crate::out; use std::path::PathBuf; use wai::codecs::image; use wai::http_binding::receipts::{IndexItem, RECEIPT_DIR, group_file, index_file}; use wai::jwp_delivery::{GroupBuilder, ObjectLabel, WaiReceiptProperty}; use wai::{CAP_IMAGE_PNG, Conditioning, Manifest, ModelRequirement, Wai}; + let usage = super::usage("toolchain.hello"); if let Some(a) = args.iter().find(|a| a.starts_with("--")) { - return Err(format!("wai hello: {a}: not an option ({USAGE})")); + return Err(Failure::usage(format!("wai hello: {a}: not an option ({usage})"))); } let pos = super::positional(args, &[]); if pos.len() > 1 { - return Err(USAGE.into()); + return Err(Failure::usage(usage)); } let dir = PathBuf::from(pos.first().copied().unwrap_or("wai-hello")); - // Only a directory that is empty, or one an earlier `wai hello` wrote, is written to. + // Only a directory that is empty, or one an earlier `wai hello` wrote, is written to; an + // earlier run's files are replaced only once that is confirmed. + let mut earlier = Vec::new(); if dir.exists() { let theirs = std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?.flatten().any(|e| { let n = e.file_name().to_string_lossy().into_owned(); !(n == "rcpt" || n.ends_with(".wai") || n.ends_with(".wai.receipts")) }); if theirs { - return Err(format!("{}: holds files `wai hello` did not write; name another directory", dir.display())); + return Err(Failure::from(format!("{}: holds files `wai hello` did not write; name another directory", dir.display()))); } // An earlier run's object, index and receipt files, and nothing else. for e in std::fs::read_dir(&dir).map_err(|e| e.to_string())?.flatten() { @@ -54,15 +58,24 @@ pub fn main(args: &[String]) -> Result { if e.file_name() == "rcpt" { for r in std::fs::read_dir(&p).map_err(|e| e.to_string())?.flatten() { if r.file_name().to_string_lossy().ends_with(".json") { - let _ = std::fs::remove_file(r.path()); + earlier.push(r.path()); } } } else { - let _ = std::fs::remove_file(&p); + earlier.push(p); } } } - std::fs::create_dir_all(dir.join(RECEIPT_DIR)).map_err(|e| format!("{}: {e}", dir.display()))?; + if !earlier.is_empty() { + crate::agent::confirm( + &format!("{} holds an earlier `wai hello`'s {} files, which this run replaces", dir.display(), earlier.len()), + earlier.iter().map(|p| serde_json::json!({"remove": p.display().to_string()})).collect(), + )?; + for p in &earlier { + let _ = out::remove_file(p); + } + } + out::create_dir_all(dir.join(RECEIPT_DIR)).map_err(|e| format!("{}: {e}", dir.display()))?; println!("wai hello: WAI's four steps, on this machine\n"); // 1. Name. @@ -79,7 +92,7 @@ pub fn main(args: &[String]) -> Result { let envelope = Wai::new(manifest, png).to_bytes().map_err(|e| e.to_string())?; let cid = wai::jwp_hash::content_hash(&envelope); let object = dir.join(format!("{}.wai", hex(&cid))); - std::fs::write(&object, &envelope).map_err(|e| e.to_string())?; + out::write(&object, &envelope).map_err(|e| e.to_string())?; println!("1 Name a {w}×{h} picture, packed in an envelope that names the capability it needs:"); println!(" {CAP_IMAGE_PNG} (PNG, one of the mandatory floor every sink implements)"); println!(" {} ({} bytes)\n", object.display(), envelope.len()); @@ -111,16 +124,21 @@ pub fn main(args: &[String]) -> Result { b.add(cid, 0, "wai hello", Some(ObjectLabel::Unmetered)); let (group, objects) = b.finish(&key, &wai::signer_id::did_key(&pk), 0, 0, &start, &end, None, true).ok_or("the group would not seal")?; let link = group.link_hash(); - std::fs::write(dir.join(format!("{RECEIPT_DIR}{}.json", hex(&link))), group_file(&group)?).map_err(|e| e.to_string())?; + out::write(dir.join(format!("{RECEIPT_DIR}{}.json", hex(&link))), group_file(&group)?).map_err(|e| e.to_string())?; let item = IndexItem { property: WaiReceiptProperty { content_hash: cid, joules_micro: 0, acquisition: String::new(), acquisition_evidence: Vec::new(), group_receipt: link }, object_receipt: objects[0].clone(), }; - std::fs::write(dir.join(format!("{}.wai.receipts", hex(&cid))), index_file(&cid, &[item], &[])?).map_err(|e| e.to_string())?; + let index = dir.join(format!("{}.wai.receipts", hex(&cid))); + out::write(&index, index_file(&cid, &[item], &[])?).map_err(|e| e.to_string())?; println!("4 Receipt the object's BLAKE3 content hash, in a group receipt signed with a key made for"); println!(" this run (Ed25519 {}…; its secret is not kept). Energy is stated as not", &hex(&pk)[..16]); println!(" measured: this run reads no meter"); - let (ok, lines) = super::verify::check_index_file(&dir.join(format!("{}.wai.receipts", hex(&cid))))?; + if out::dry_run() { + println!(" (a dry run: the receipt is not written, so it is not checked here)"); + return Ok(true); + } + let (ok, lines) = super::verify::check_index_file(&index)?; for l in lines { println!(" {l}"); } diff --git a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs index d12893008..6fc1bfcb0 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs @@ -1,12 +1,16 @@ -//! The developer's path through WAI, one verb a module, every verb in [`VERBS`]: +//! The developer's path through WAI, one operation a module: //! -//! - `hello`: the four steps of WAI (name, dispatch, rebuild, receipt) on a bundled sample; -//! - `create`: a new project from one of the examples (`wai/examples/`); -//! - `dev`: a relay, a room service and the room page on one origin, on this machine; -//! - `verify`: the receipts under a directory, checked offline. +//! - `toolchain.hello` (`wai hello`): the four steps of WAI (name, dispatch, rebuild, +//! receipt) on a bundled sample; +//! - `project.create` (`wai create`): a new project from one of the examples (`wai/examples/`); +//! - `dev.start` (`wai dev`): a relay, a room service and the room page on one origin, on this +//! machine; +//! - `receipt.verify` (`wai verify`): the receipts under a directory, checked offline. //! -//! 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. +//! [`VERBS`] is this module's view of the operation catalogue (`wai::catalogue`): each +//! entry names its operation by id, and the name a person types, its usage and its help come +//! from the catalogue's entry. The project file a template writes (`wai.json`) is read by +//! [`project`] alone. pub mod project; @@ -15,32 +19,20 @@ mod dev; mod hello; mod verify; -/// One verb: its name, its one-line usage, and what runs it. `run` returns `Ok(true)` when -/// everything it checked held, `Ok(false)` when it ran and something did not (exit 1). -pub struct Verb { - pub name: &'static str, - pub usage: &'static str, - pub run: fn(&[String]) -> Result, -} +use crate::door::{Impl, Json}; -pub const VERBS: &[Verb] = &[ - Verb { name: "hello", usage: hello::USAGE, run: hello::main }, - Verb { name: "create", usage: create::USAGE, run: create::main }, - Verb { name: "dev", usage: dev::USAGE, run: dev::main }, - Verb { name: "verify", usage: verify::USAGE, run: verify::main }, +/// The developer's path, by operation id. +pub const VERBS: &[Impl] = &[ + Impl { id: "toolchain.hello", run: hello::main, json: Json::Text }, + Impl { id: "project.create", run: create::main, json: Json::OwnWith("--list") }, + Impl { id: "dev.start", run: dev::main, json: Json::StreamUnless("--check") }, + Impl { id: "receipt.verify", run: verify::main, json: Json::Text }, ]; -/// The verb `args[0]` names, run on the rest; `None` when it names none of these. -pub fn dispatch(args: &[String]) -> Option> { - let v = VERBS.iter().find(|v| Some(v.name) == args.first().map(String::as_str))?; - Some((v.run)(&args[1..])) -} - -/// Each verb's usage line, for `wai`'s own usage. -pub fn usage() { - for v in VERBS { - eprintln!("{}", v.usage); - } +/// An operation's synopsis, from the catalogue, for a usage error. +pub(crate) fn usage(id: &str) -> String { + let o = wai::catalogue::op(id).expect("an operation of the catalogue"); + o.usage.lines().next().map(|l| format!("wai {l}")).unwrap_or_default() } /// The value after `flag` among `args`. diff --git a/wai/wai-rs/src/bin/wai_cli/dx/templates.rs b/wai/wai-rs/src/bin/wai_cli/dx/templates.rs index a7da76137..46136b543 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/templates.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/templates.rs @@ -1,8 +1,13 @@ //! The templates `wai create` writes: each example of `wai/examples/` but its `test/` //! directory, its files held in this binary. `tests/cli_dx.rs` holds this table to the -//! examples on disk, file for file and byte for byte, so the two cannot drift. +//! examples on disk, file for file and byte for byte, so the two cannot drift. Every +//! template is Apache-2.0 and carries the license's text as `LICENSE`: one copy in this +//! binary, `wai/LICENSE`, which each example's `LICENSE` must equal byte for byte. -use super::Template; +use super::{File, Template}; + +/// The Apache-2.0 text every template carries. +const LICENSE: File = ("LICENSE", include_bytes!("../../../../../LICENSE")); macro_rules! ex { ($t:literal, $f:literal) => { @@ -15,6 +20,7 @@ pub const TEMPLATES: &[Template] = &[ name: "video-room", title: "A video room in one page", files: &[ + LICENSE, ex!("video-room", ".gitignore"), ex!("video-room", "README.md"), ex!("video-room", "index.html"), @@ -26,6 +32,7 @@ pub const TEMPLATES: &[Template] = &[ name: "watch-together", title: "Watch together", files: &[ + LICENSE, ex!("watch-together", ".gitignore"), ex!("watch-together", "README.md"), ex!("watch-together", "index.html"), @@ -37,6 +44,7 @@ pub const TEMPLATES: &[Template] = &[ name: "messaging", title: "Messaging and a call", files: &[ + LICENSE, ex!("messaging", ".gitignore"), ex!("messaging", "README.md"), ex!("messaging", "index.html"), @@ -48,6 +56,7 @@ pub const TEMPLATES: &[Template] = &[ name: "encoder-ingest", title: "Stream from an encoder into a room", files: &[ + LICENSE, ex!("encoder-ingest", ".gitignore"), ex!("encoder-ingest", "Cargo.toml"), ex!("encoder-ingest", "README.md"), @@ -60,6 +69,7 @@ pub const TEMPLATES: &[Template] = &[ name: "package-and-deliver", title: "Package a title and play it", files: &[ + LICENSE, ex!("package-and-deliver", ".gitignore"), ex!("package-and-deliver", "README.md"), ex!("package-and-deliver", "floor/v.obu"), @@ -76,6 +86,7 @@ pub const TEMPLATES: &[Template] = &[ name: "verify-receipts", title: "Verify receipts in Rust and in Python", files: &[ + LICENSE, ex!("verify-receipts", ".gitignore"), ex!("verify-receipts", "README.md"), ex!("verify-receipts", "python/_blake3.py"), @@ -91,6 +102,7 @@ pub const TEMPLATES: &[Template] = &[ name: "apple-app", title: "A native app on Apple platforms", files: &[ + LICENSE, ex!("apple-app", ".gitignore"), ex!("apple-app", "Package.swift"), ex!("apple-app", "README.md"), @@ -106,6 +118,7 @@ pub const TEMPLATES: &[Template] = &[ name: "android-app", title: "The native sink in Kotlin, for Android", files: &[ + LICENSE, ex!("android-app", ".gitignore"), ex!("android-app", "README.md"), ex!("android-app", "replay/build.gradle.kts"), @@ -120,6 +133,7 @@ pub const TEMPLATES: &[Template] = &[ name: "remote-desktop", title: "Remote desktop in a room", files: &[ + LICENSE, ex!("remote-desktop", ".gitignore"), ex!("remote-desktop", "README.md"), ex!("remote-desktop", "host.sh"), diff --git a/wai/wai-rs/src/bin/wai_cli/dx/verify.rs b/wai/wai-rs/src/bin/wai_cli/dx/verify.rs index 7264eb4be..114bc27da 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/verify.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/verify.rs @@ -8,22 +8,23 @@ //! 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. -pub const USAGE: &str = "wai verify ... the receipts under each path (receipt indexes *.wai.receipts), checked offline"; +use crate::agent::Failure; #[cfg(not(feature = "quickstart"))] -pub fn main(_: &[String]) -> Result { - Err("`wai verify` needs a build with the feature `quickstart`".into()) +pub fn main(_: &[String]) -> Result { + Err(Failure::from("`wai verify` needs a build with the feature `quickstart`")) } #[cfg(feature = "quickstart")] -pub fn main(args: &[String]) -> Result { +pub fn main(args: &[String]) -> Result { use std::path::{Path, PathBuf}; + let usage = super::usage("receipt.verify"); if let Some(a) = args.iter().find(|a| a.starts_with("--")) { - return Err(format!("wai verify: {a}: not an option ({USAGE})")); + return Err(Failure::usage(format!("wai verify: {a}: not an option ({usage})"))); } let paths = super::positional(args, &[]); if paths.is_empty() { - return Err(USAGE.into()); + return Err(Failure::usage(usage)); } fn walk(p: &Path, out: &mut Vec, depth: usize) -> Result<(), String> { if p.is_file() { @@ -53,7 +54,7 @@ pub fn main(args: &[String]) -> Result { walk(Path::new(p), &mut indexes, 0)?; } if indexes.is_empty() { - return Err(format!("no receipt index (*.wai.receipts) under {}", paths.join(", "))); + return Err(Failure::from(format!("no receipt index (*.wai.receipts) under {}", paths.join(", ")))); } let mut all = true; for ix in &indexes { diff --git a/wai/wai-rs/src/bin/wai_cli/encode.rs b/wai/wai-rs/src/bin/wai_cli/encode.rs index e3d96f444..43de7a1e2 100644 --- a/wai/wai-rs/src/bin/wai_cli/encode.rs +++ b/wai/wai-rs/src/bin/wai_cli/encode.rs @@ -15,7 +15,7 @@ fn read(p: &Path) -> Result, String> { } fn write(p: &str, b: &[u8]) -> Result<(), String> { - std::fs::write(p, b).map_err(|e| format!("{p}: {e}")) + crate::out::write(p, b).map_err(|e| format!("{p}: {e}")) } fn seed(s: &str) -> Result<[u8; 32], String> { diff --git a/wai/wai-rs/src/bin/wai_cli/generate.rs b/wai/wai-rs/src/bin/wai_cli/generate.rs index 02c434302..9dc5bc6d9 100644 --- a/wai/wai-rs/src/bin/wai_cli/generate.rs +++ b/wai/wai-rs/src/bin/wai_cli/generate.rs @@ -86,7 +86,7 @@ fn flags(args: &[String]) -> Result<(String, u32, u32), String> { } fn write_and_report(rgb: &[u8], w: u32, h: u32, out: &str, json: bool) -> Result<(), String> { - std::fs::write(out, rgb).map_err(|e| format!("write {out}: {e}"))?; + crate::out::write(out, rgb).map_err(|e| format!("write {out}: {e}"))?; let sha = wai::pin::sha256_hex(rgb); if json { println!("{}", serde_json::json!({ "w": w, "h": h, "bytes": rgb.len(), "sha256": sha })); @@ -113,7 +113,7 @@ pub fn main(args: &[String]) -> Result<(), String> { let object = i.canonical(); // What is written is what a sink reads. debug_assert_eq!(Instruction::parse(&object).as_ref(), Ok(&i)); - std::fs::write(dest, &object).map_err(|e| format!("write {dest}: {e}"))?; + crate::out::write(dest, &object).map_err(|e| format!("write {dest}: {e}"))?; println!("instruction object → {dest} ({} B)", object.len()); return Ok(()); } diff --git a/wai/wai-rs/src/bin/wai_cli/http.rs b/wai/wai-rs/src/bin/wai_cli/http.rs index 7beb60378..e1439efc1 100644 --- a/wai/wai-rs/src/bin/wai_cli/http.rs +++ b/wai/wai-rs/src/bin/wai_cli/http.rs @@ -133,7 +133,7 @@ fn read(p: &str) -> Result, String> { fn publish(args: &[String]) -> Result<(), String> { let out = PathBuf::from(opt(args, "--out").ok_or("publish needs --out ")?); - std::fs::create_dir_all(&out).map_err(|e| e.to_string())?; + crate::out::create_dir_all(&out).map_err(|e| e.to_string())?; let inputs = positional(args, &["--out"]); if inputs.is_empty() { return Err(usage()); @@ -147,9 +147,9 @@ fn publish(args: &[String]) -> Result<(), String> { let cid = content_hash(&env); let name = object_name(&cid); let proof = HeadProof::build(&env).map_err(|e| format!("{p}: {e}"))?; - std::fs::write(out.join(&name), &env).map_err(|e| e.to_string())?; - std::fs::write(out.join(format!("{name}.hp")), proof.to_bytes()).map_err(|e| e.to_string())?; - std::fs::write(out.join(format!("{name}.etag")), format!("\"{}\"\n", hex(&cid))).map_err(|e| e.to_string())?; + crate::out::write(out.join(&name), &env).map_err(|e| e.to_string())?; + crate::out::write(out.join(format!("{name}.hp")), proof.to_bytes()).map_err(|e| e.to_string())?; + crate::out::write(out.join(format!("{name}.etag")), format!("\"{}\"\n", hex(&cid))).map_err(|e| e.to_string())?; let element = codecs::element_of(&head).map(|e| codecs::render(&e)).unwrap_or_else(|e| format!("({e})")); println!("{}\tn={}\th={}\t{element}\t{p}", hex(&cid), env.len(), head.covered_len()); } @@ -625,6 +625,14 @@ fn origin_cmd(args: &[String]) -> Result<(), String> { None => None, }; let cfg = OriginConfig { root: root.clone(), delay, live, hits, max_connections, max_per_peer, read_timeout, lifetime, active, peers: Default::default(), live_edge }; + // `--dry-run`: say what would be served, and serve nothing. + if crate::out::dry_run() { + if !root.is_dir() { + return Err(format!("--root {}: no such directory", root.display())); + } + println!("wai http origin --dry-run: nothing served. It would serve {} on http://127.0.0.1:{port}/", root.display()); + return Ok(()); + } let listener = TcpListener::bind(("127.0.0.1", port)).map_err(|e| format!("bind 127.0.0.1:{port}: {e}"))?; println!("serving {} on http://127.0.0.1:{}/", root.display(), listener.local_addr().map_err(|e| e.to_string())?.port()); for stream in listener.incoming().flatten() { @@ -861,7 +869,7 @@ fn bench(args: &[String]) -> Result<(), String> { "refinement_deadline_ms": deadline, "energy": "omitted: not measured", }); - std::fs::write(out, serde_json::to_string_pretty(&rec).expect("json") + "\n").map_err(|e| e.to_string())?; + crate::out::write(out, serde_json::to_string_pretty(&rec).expect("json") + "\n").map_err(|e| e.to_string())?; println!("wrote {out}"); Ok(()) } diff --git a/wai/wai-rs/src/bin/wai_cli/http_playlists.rs b/wai/wai-rs/src/bin/wai_cli/http_playlists.rs index ee62960ec..0d448e609 100644 --- a/wai/wai-rs/src/bin/wai_cli/http_playlists.rs +++ b/wai/wai-rs/src/bin/wai_cli/http_playlists.rs @@ -62,9 +62,9 @@ fn read(p: &Path) -> Result, String> { fn write(p: &Path, b: &[u8]) -> Result<(), String> { if let Some(d) = p.parent() { - std::fs::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?; + crate::out::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?; } - std::fs::write(p, b).map_err(|e| format!("{}: {e}", p.display())) + crate::out::write(p, b).map_err(|e| format!("{}: {e}", p.display())) } /// One variant of an objects list, read: its classical playlist and the objects that diff --git a/wai/wai-rs/src/bin/wai_cli/http_vectors.rs b/wai/wai-rs/src/bin/wai_cli/http_vectors.rs index f4ea976aa..d950f2414 100644 --- a/wai/wai-rs/src/bin/wai_cli/http_vectors.rs +++ b/wai/wai-rs/src/bin/wai_cli/http_vectors.rs @@ -67,8 +67,8 @@ pub fn main(args: &[String]) -> Result { let files = generate(&dir)?; for (rel, bytes) in &files { let p = dir.join(rel); - std::fs::create_dir_all(p.parent().expect("a parent")).map_err(|e| e.to_string())?; - std::fs::write(&p, bytes).map_err(|e| format!("{}: {e}", p.display()))?; + crate::out::create_dir_all(p.parent().expect("a parent")).map_err(|e| e.to_string())?; + crate::out::write(&p, bytes).map_err(|e| format!("{}: {e}", p.display()))?; } println!("wrote {} files under {}", files.len(), dir.display()); Ok(true) @@ -79,7 +79,7 @@ pub fn main(args: &[String]) -> Result { let count: usize = opt(args, "--count").and_then(|s| s.parse().ok()).ok_or("fuzz needs --count")?; let out = opt(args, "--out").ok_or("fuzz needs --out")?; let (bytes, n, stats) = fuzz(Path::new(dir), seed, count)?; - std::fs::write(out, bytes).map_err(|e| e.to_string())?; + crate::out::write(out, bytes).map_err(|e| e.to_string())?; println!("wrote {n} cases to {out}"); for (family, (generated, refused)) in &stats { println!(" {family}: {generated} generated, {refused} refused"); @@ -91,7 +91,7 @@ pub fn main(args: &[String]) -> Result { let count: usize = opt(args, "--count").and_then(|s| s.parse().ok()).ok_or("diff needs --count")?; let out = opt(args, "--out").ok_or("diff needs --out")?; let _ = dir; - std::fs::write(out, differential(seed, count)?).map_err(|e| e.to_string())?; + crate::out::write(out, differential(seed, count)?).map_err(|e| e.to_string())?; println!("wrote {count} envelopes' prefixes and proofs to {out}"); Ok(true) } diff --git a/wai/wai-rs/src/bin/wai_cli/out.rs b/wai/wai-rs/src/bin/wai_cli/out.rs new file mode 100644 index 000000000..987ffd0d3 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/out.rs @@ -0,0 +1,114 @@ +//! Every file the `wai` command line writes, makes or removes goes through here, so that +//! `--dry-run` writes nothing: each call is recorded in the plan instead, and the plan is +//! what the command reports it would have done. + +use serde_json::{Value, json}; +use std::io::{self, Write}; +use std::path::Path; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +static DRY_RUN: AtomicBool = AtomicBool::new(false); +static PLAN: Mutex> = Mutex::new(Vec::new()); + +/// Turn dry running on or off for the rest of the process. +pub fn set_dry_run(on: bool) { + DRY_RUN.store(on, Ordering::SeqCst); +} + +/// Whether this run writes nothing. +pub fn dry_run() -> bool { + DRY_RUN.load(Ordering::SeqCst) +} + +fn note(v: Value) { + PLAN.lock().unwrap_or_else(|e| e.into_inner()).push(v); +} + +/// What a dry run would have done, in order: `{"write": path, "bytes": n}`, +/// `{"create-dir": path}`, `{"remove": path}`. +pub fn plan() -> Vec { + PLAN.lock().unwrap_or_else(|e| e.into_inner()).clone() +} + +fn shown(p: &Path) -> String { + p.display().to_string() +} + +/// `std::fs::write`. +pub fn write(path: impl AsRef, bytes: impl AsRef<[u8]>) -> io::Result<()> { + let (p, b) = (path.as_ref(), bytes.as_ref()); + if dry_run() { + note(json!({"write": shown(p), "bytes": b.len(), "replaces": p.exists()})); + return Ok(()); + } + std::fs::write(p, b) +} + +/// A new file that must not exist yet (`create_new`), with its bytes. +pub fn write_new(path: impl AsRef, bytes: impl AsRef<[u8]>) -> io::Result<()> { + let (p, b) = (path.as_ref(), bytes.as_ref()); + if dry_run() { + if p.exists() { + return Err(io::Error::new(io::ErrorKind::AlreadyExists, "exists")); + } + note(json!({"write": shown(p), "bytes": b.len(), "replaces": false})); + return Ok(()); + } + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(p)?; + f.write_all(b) +} + +/// `std::fs::create_dir_all`. +pub fn create_dir_all(path: impl AsRef) -> io::Result<()> { + let p = path.as_ref(); + if dry_run() { + if !p.is_dir() { + note(json!({"create-dir": shown(p)})); + } + return Ok(()); + } + std::fs::create_dir_all(p) +} + +/// `std::fs::remove_file`. +#[cfg_attr(not(feature = "quickstart"), allow(dead_code))] +pub fn remove_file(path: impl AsRef) -> io::Result<()> { + let p = path.as_ref(); + if dry_run() { + note(json!({"remove": shown(p)})); + return Ok(()); + } + std::fs::remove_file(p) +} + +/// `std::fs::File::create`, as a writer: one that discards in a dry run. +#[cfg_attr(not(feature = "codecs"), allow(dead_code))] +pub fn create(path: impl AsRef) -> io::Result> { + let p = path.as_ref(); + if dry_run() { + note(json!({"write": shown(p), "replaces": p.exists()})); + return Ok(Box::new(io::sink())); + } + Ok(Box::new(std::fs::File::create(p)?)) +} + +/// The plan as lines, for a person. +pub fn plan_lines() -> Vec { + plan() + .iter() + .map(|v| { + if let Some(p) = v["write"].as_str() { + let n = v["bytes"].as_u64().map(|n| format!(" ({n} bytes)")).unwrap_or_default(); + let r = if v["replaces"] == true { ", replacing the file there" } else { "" }; + format!("write {p}{n}{r}") + } else if let Some(p) = v["create-dir"].as_str() { + format!("make the directory {p}") + } else if let Some(p) = v["remove"].as_str() { + format!("remove {p}") + } else { + v.to_string() + } + }) + .collect() +} diff --git a/wai/wai-rs/src/bin/wai_cli/score.rs b/wai/wai-rs/src/bin/wai_cli/score.rs index 9f2d4237e..5331e451c 100644 --- a/wai/wai-rs/src/bin/wai_cli/score.rs +++ b/wai/wai-rs/src/bin/wai_cli/score.rs @@ -204,7 +204,7 @@ fn build(args: &[String]) -> Result<(), String> { let bytes = std::fs::read(input).map_err(|e| format!("read {input}: {e}"))?; let s = from_notes_json(&bytes).map_err(|e| format!("{input}: refused: {e}"))?; let wscr = s.to_bytes(); - std::fs::write(out, &wscr).map_err(|e| format!("write {out}: {e}"))?; + crate::out::write(out, &wscr).map_err(|e| format!("write {out}: {e}"))?; let score_hash = hex(&s.score_hash()); if args.iter().any(|a| a == "--json") { println!("{}", serde_json::json!({ @@ -224,7 +224,7 @@ fn render(args: &[String]) -> Result<(), String> { let pcm_sha256 = wai::pin::sha256_hex(&pcm); let (mixdown, score_hash) = (hex(&s.mixdown_hash()), hex(&s.score_hash())); if let Some(p) = opt(args, "--pcm") { - std::fs::write(p, &pcm).map_err(|e| format!("write {p}: {e}"))?; + crate::out::write(p, &pcm).map_err(|e| format!("write {p}: {e}"))?; } if args.iter().any(|a| a == "--json") { println!("{}", serde_json::json!({ diff --git a/wai/wai-rs/src/bin/wai_cli/stage.rs b/wai/wai-rs/src/bin/wai_cli/stage.rs index 46bb4ea23..787ea289f 100644 --- a/wai/wai-rs/src/bin/wai_cli/stage.rs +++ b/wai/wai-rs/src/bin/wai_cli/stage.rs @@ -464,7 +464,7 @@ fn base_cmd(args: &[String]) -> Result<(), String> { if units.len() != recon.len() || units.iter().zip(&recon).any(|(u, r)| u.samples != Samples::U8(r.clone())) { return Err("the base does not decode to the encoder's reconstruction".into()); } - std::fs::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; + crate::out::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; let err = frames.iter().flatten().zip(recon.iter().flatten()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); println!("base → {out} ({} B) {cap} {n} frames of {h}x{w} q {q} tier {} largest error {err}", bytes.len(), tier::token(info.tier)); println!(" content hash {}", hex(&info.content_hash)); @@ -572,7 +572,7 @@ fn refine(args: &[String]) -> Result<(), String> { let env = Wai::new(m, payload); let bytes = env.to_emit_bytes().map_err(|e| e.to_string())?; env.check_component_roles().map_err(|e| e.to_string())?; - std::fs::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; + crate::out::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; let err: u64 = recon.iter().zip(&targets).map(|(r, t)| match (r, t) { (Next::U8(a), Samples::U8(b)) => a.iter().zip(b).map(|(x, y)| (*x as i64 - *y as i64).unsigned_abs()).max().unwrap_or(0), (Next::I16(a), Samples::I16(b)) => a.iter().zip(b).map(|(x, y)| (*x as i64 - *y as i64).unsigned_abs()).max().unwrap_or(0), @@ -637,7 +637,7 @@ fn pack(args: &[String]) -> Result<(), String> { let bytes = WaiMulti::pack(&refs).map_err(|e| e.to_string())?; let w = WaiMulti::from_bytes(&bytes).map_err(|e| e.to_string())?; w.check_component_roles().map_err(|e| e.to_string())?; - std::fs::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; + crate::out::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; println!("packed {} envelopes → {out} ({} B)", files.len(), bytes.len()); Ok(()) } @@ -659,7 +659,7 @@ fn explode(args: &[String]) -> Result<(), String> { let bytes = WaiMulti::explode(&envelope, i).map_err(|e| e.to_string())?; println!("component {i}: {} B content hash {}", bytes.len(), hex(&wai::jwp_hash::content_hash(&bytes))); if let Some(out) = opt(args, "-o") { - std::fs::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; + crate::out::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; } Ok(()) } diff --git a/wai/wai-rs/src/bin/wai_http_vectors.rs b/wai/wai-rs/src/bin/wai_http_vectors.rs index 6c6c39928..727aadf25 100644 --- a/wai/wai-rs/src/bin/wai_http_vectors.rs +++ b/wai/wai-rs/src/bin/wai_http_vectors.rs @@ -14,6 +14,12 @@ use std::process::ExitCode; #[path = "wai_cli/http_vectors.rs"] mod http_vectors; +// The writes `http_vectors` makes go through `out`, as they do in `wai http vectors`, which +// takes `--dry-run`; this tool never sets it. +#[allow(dead_code)] +#[path = "wai_cli/out.rs"] +mod out; + fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); match http_vectors::main(&args) { diff --git a/wai/wai-rs/tests/cli_dx.rs b/wai/wai-rs/tests/cli_dx.rs index 9ead82f19..92602f8e8 100644 --- a/wai/wai-rs/tests/cli_dx.rs +++ b/wai/wai-rs/tests/cli_dx.rs @@ -105,8 +105,10 @@ fn create_writes_every_example_as_it_is_in_the_repository() { let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples"); let o = wai(&d, &["create", "--list", "--json"]); assert!(o.status.success(), "{}", text(&o)); - let list: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); - let list = list.as_array().unwrap(); + // The JSON envelope (envelope/1): the list is its data. + let env: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + assert_eq!((env["ok"].as_bool(), env["op"].as_str()), (Some(true), Some("project.create")), "{env}"); + let list = env["data"].as_array().unwrap(); let names: Vec<&str> = list.iter().map(|t| t["template"].as_str().unwrap()).collect(); // Every example directory is a template, and every template an example. let mut dirs: Vec = std::fs::read_dir(&examples).unwrap().flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().into_owned()).collect(); diff --git a/wai/wai-rs/tests/cli_generate_score.rs b/wai/wai-rs/tests/cli_generate_score.rs index 97c4909d4..13d7e486f 100644 --- a/wai/wai-rs/tests/cli_generate_score.rs +++ b/wai/wai-rs/tests/cli_generate_score.rs @@ -57,9 +57,16 @@ fn refused(run: &Output, out: &Path, what: &str) -> String { stderr } -/// The `--json` line `wai generate` prints, checked against the file it wrote. +/// The `data` of the JSON envelope (envelope/1) that `wai ... --json` prints. +fn data(stdout: &str) -> Value { + let env: Value = serde_json::from_str(stdout.trim()).expect("one JSON envelope"); + assert_eq!((env["version"].as_str(), env["ok"].as_bool()), (Some("envelope/1"), Some(true)), "{env}"); + env["data"].clone() +} + +/// The `--json` data `wai generate` prints, checked against the file it wrote. fn made(stdout: &str, out: &Path, w: u64, h: u64) -> String { - let v: Value = serde_json::from_str(stdout.trim()).unwrap(); + let v: Value = data(stdout); let rgb = std::fs::read(out).unwrap(); assert_eq!((v["w"].as_u64(), v["h"].as_u64()), (Some(w), Some(h))); assert_eq!(v["bytes"].as_u64(), Some(w * h * 3)); @@ -366,13 +373,13 @@ fn score_build_and_render_reproduce_the_corpus() { let notes = score_dir().join("notes").join(format!("{name}.json")); let stdout = ok(&wai(&["score", "build", p(¬es), "-o", p(&built), "--json"])); assert_eq!(std::fs::read(&built).unwrap(), committed, "{name}: built byte for byte"); - let b: Value = serde_json::from_str(stdout.trim()).unwrap(); + let b: Value = data(&stdout); assert_eq!(b["bytes"].as_u64(), Some(committed.len() as u64)); assert_eq!(b["score_hash"], spec["score_hash"], "{name}"); let pcm = scratch(&format!("{name}.s16")); let stdout = ok(&wai(&["score", "render", p(&built), "--pcm", p(&pcm), "--json"])); - let r: Value = serde_json::from_str(stdout.trim()).unwrap(); + let r: Value = data(&stdout); let duration = spec["duration"].as_u64().unwrap(); assert_eq!(r["samples"].as_u64(), Some(duration), "{name}: the contract's duration, whole"); assert_eq!(r["sample_rate"].as_u64(), Some(22050)); @@ -477,7 +484,7 @@ fn score_render_refuses_a_note_count_its_bytes_cannot_hold() { empty[n - 4..].copy_from_slice(&0u32.to_le_bytes()); let input = scratch("empty.wscr"); std::fs::write(&input, empty).unwrap(); - let r: Value = serde_json::from_str(ok(&wai(&["score", "render", p(&input), "--json"])).trim()).unwrap(); + let r: Value = data(&ok(&wai(&["score", "render", p(&input), "--json"]))); assert_eq!((r["samples"].as_u64(), r["notes"].as_u64()), (Some(100), Some(0)), "{r}"); let input = scratch("huge.wscr"); std::fs::write(&input, b).unwrap(); diff --git a/wai/wai-rs/tests/cli_stage.rs b/wai/wai-rs/tests/cli_stage.rs index ae14fb4ed..a8a06d7ad 100644 --- a/wai/wai-rs/tests/cli_stage.rs +++ b/wai/wai-rs/tests/cli_stage.rs @@ -34,6 +34,14 @@ fn text(o: &Output) -> String { format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) } +/// The `data` of the JSON envelope (envelope/1) that `wai ... --json` prints: `wai stage +/// apply`'s trace. +fn envelope_data(stdout: &[u8]) -> serde_json::Value { + let env: serde_json::Value = serde_json::from_slice(stdout).expect("one JSON envelope"); + assert_eq!((env["version"].as_str(), env["op"].as_str()), (Some("envelope/1"), Some("stage.apply")), "{env}"); + env["data"].clone() +} + fn case(name: &str) -> serde_json::Value { serde_json::from_slice(&std::fs::read(corpus(&format!("cases/{name}/case.json"))).unwrap()).unwrap() } @@ -201,7 +209,7 @@ fn base_refine_apply_json_trace() { args.extend(layers.iter().rev().map(|p| p.as_os_str())); let o = wai(&args); assert!(o.status.success(), "{}", text(&o)); - let doc: serde_json::Value = serde_json::from_slice(&o.stdout).expect("one JSON document"); + let doc = envelope_data(&o.stdout); assert_eq!(doc["format"], "wai-stage-apply/1"); let steps = doc["steps"].as_array().unwrap(); assert_eq!(steps.len(), 1 + n); @@ -246,7 +254,7 @@ fn apply_holds_layers_for_a_later_base() { args.extend(files.iter().map(|p| p.as_os_str())); let o = wai(&args); assert!(o.status.success(), "{}", text(&o)); - let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let doc = envelope_data(&o.stdout); let steps = doc["steps"].as_array().unwrap(); let held: usize = steps.iter().filter(|s| s["reports"].as_array().is_some_and(|r| r.iter().any(|r| r["held_for_base"] == true))).count(); assert_eq!(held, 10, "GOPs 1 and 2's layers wait for their bases"); @@ -367,7 +375,7 @@ fn apply_json_replays_the_corpus() { }); if let Some(i) = routed { let o = wai(&[os("stage"), os("apply"), os("--json"), d.join("base.wai").as_os_str(), d.join(steps[i]["file"].as_str().unwrap()).as_os_str()]); - let got: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap_or_default(); + let got = serde_json::from_slice::(&o.stdout).map(|e| e["data"].clone()).unwrap_or_default(); if got["steps"][1]["op"] != "base" { differ.push(format!("{name}: the tool does not route the envelope offered at step {i} as a base: {}", text(&o))); } @@ -408,7 +416,7 @@ fn apply_json_replays_the_corpus() { differ.push(format!("{name}: the tool fails: {}", text(&o))); continue; } - let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let doc = envelope_data(&o.stdout); // A claimed base's chain says so (--stage0); the mark is set aside to compare, // and must be on every report of that chain. let got: Vec = doc["steps"].as_array().unwrap().iter() @@ -529,7 +537,7 @@ fn apply_stage0_marks_the_base_claimed() { let o = wai(&[os("stage"), os("apply"), os("--json"), os("--stage0"), jpath.as_os_str(), os("--units"), os("2"), os("--dims"), os("8,8,3"), d.join("base.wai").as_os_str(), d.join("L1.wai").as_os_str()]); assert!(o.status.success(), "{}", text(&o)); - let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let doc = envelope_data(&o.stdout); let steps = doc["steps"].as_array().unwrap(); assert_eq!(steps[0]["claimed"], true); let s0 = steps[0]["units"].as_array().unwrap(); @@ -546,7 +554,7 @@ fn apply_stage0_marks_the_base_claimed() { assert!(b["units"].as_array().unwrap().iter().all(|u| u["claimed"] == true)); // Without --stage0 the decoded base claims nothing. let o = wai(&[os("stage"), os("apply"), os("--json"), d.join("base.wai").as_os_str(), d.join("L1.wai").as_os_str()]); - let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let doc = envelope_data(&o.stdout); assert!(!o.stdout.windows(9).any(|w| w == b"\"claimed\""), "{doc}"); } @@ -572,7 +580,7 @@ fn apply_records_a_refused_base_and_goes_on() { let t = text(&o); assert!(o.status.success(), "{t}"); if json { - let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let doc = envelope_data(&o.stdout); let steps = doc["steps"].as_array().unwrap(); assert_eq!((steps[0]["op"].as_str(), steps[0]["error"].as_str()), (Some("base"), Some("encrypted"))); assert_eq!(steps[1]["error"], "missing_capability"); @@ -600,7 +608,7 @@ fn apply_holds_a_layer_to_the_stated_limit() { assert!(text(&o).contains("declared cost: 67092480 output bytes"), "the vector declares what its README states: {}", text(&o)); let o = wai(&[os("stage"), os("apply"), os("--json"), b.as_os_str(), l.as_os_str()]); assert!(o.status.success(), "{}", text(&o)); - let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let doc = envelope_data(&o.stdout); let r = &doc["steps"][1]["reports"][0]; assert_eq!((r["envelope"].as_str(), r["detail"].as_str()), (Some("cost-over-limit"), Some("output bytes")), "{r}"); assert!(r["applied"].as_array().unwrap().is_empty()); -- 2.54.0 (Apple Git-157) From e8c8327631bc9647bdc30eb8737921468ca7bd73 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 27 Oct 2026 04:52:31 -0400 Subject: [PATCH 03/21] wai/examples: every template Apache-2.0, its LICENSE carried into the project Each example holds the license's text as LICENSE (wai/LICENSE, byte for byte) and says so in its README; wai create writes it with the rest of the template, from one copy in the binary. Co-Authored-By: Claude Opus 5.5 --- wai/examples/README.md | 3 + wai/examples/android-app/LICENSE | 201 +++++++++++++++++++++ wai/examples/android-app/README.md | 3 + wai/examples/apple-app/LICENSE | 201 +++++++++++++++++++++ wai/examples/apple-app/README.md | 3 + wai/examples/encoder-ingest/LICENSE | 201 +++++++++++++++++++++ wai/examples/encoder-ingest/README.md | 3 + wai/examples/messaging/LICENSE | 201 +++++++++++++++++++++ wai/examples/messaging/README.md | 3 + wai/examples/package-and-deliver/LICENSE | 201 +++++++++++++++++++++ wai/examples/package-and-deliver/README.md | 3 + wai/examples/remote-desktop/LICENSE | 201 +++++++++++++++++++++ wai/examples/remote-desktop/README.md | 3 + wai/examples/verify-receipts/LICENSE | 201 +++++++++++++++++++++ wai/examples/verify-receipts/README.md | 3 + wai/examples/video-room/LICENSE | 201 +++++++++++++++++++++ wai/examples/video-room/README.md | 3 + wai/examples/watch-together/LICENSE | 201 +++++++++++++++++++++ wai/examples/watch-together/README.md | 3 + 19 files changed, 1839 insertions(+) create mode 100644 wai/examples/android-app/LICENSE create mode 100644 wai/examples/apple-app/LICENSE create mode 100644 wai/examples/encoder-ingest/LICENSE create mode 100644 wai/examples/messaging/LICENSE create mode 100644 wai/examples/package-and-deliver/LICENSE create mode 100644 wai/examples/remote-desktop/LICENSE create mode 100644 wai/examples/verify-receipts/LICENSE create mode 100644 wai/examples/video-room/LICENSE create mode 100644 wai/examples/watch-together/LICENSE diff --git a/wai/examples/README.md b/wai/examples/README.md index a39bb308a..1959e4d02 100644 --- a/wai/examples/README.md +++ b/wai/examples/README.md @@ -21,6 +21,9 @@ wai dev | [`android-app`](android-app/README.md) | The native sink's Kotlin API, the one an Android app's player session calls | a JDK 17, the `wai-sink-android` project | | [`remote-desktop`](remote-desktop/README.md) | One machine's screen shared into a room, with control handed over and every input signed | `wai dev`, `wai-remote-host`, a browser | +Every example is Apache-2.0, and carries the license's text as `LICENSE`, which a project +`wai create` makes from it receives with the rest. + Each example's `test/` directory holds the check that it works; it is not part of the project `wai create` writes. `bash ../quickstart/test/run-examples.sh portable|browser|apple|android` runs them, as `.github/workflows/wai-examples.yml` does. diff --git a/wai/examples/android-app/LICENSE b/wai/examples/android-app/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/android-app/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/android-app/README.md b/wai/examples/android-app/README.md index b6aeaf8f2..bd64a6375 100644 --- a/wai/examples/android-app/README.md +++ b/wai/examples/android-app/README.md @@ -27,3 +27,6 @@ README covers it. **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. + +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/apple-app/LICENSE b/wai/examples/apple-app/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/apple-app/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/apple-app/README.md b/wai/examples/apple-app/README.md index 0e79c9a6f..68ea8e26d 100644 --- a/wai/examples/apple-app/README.md +++ b/wai/examples/apple-app/README.md @@ -29,3 +29,6 @@ that the bundle verifies. The player opens a window: give it an HLS address (the **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. + +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/encoder-ingest/LICENSE b/wai/examples/encoder-ingest/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/encoder-ingest/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/encoder-ingest/README.md b/wai/examples/encoder-ingest/README.md index a00c0f2b5..ed72a8a9d 100644 --- a/wai/examples/encoder-ingest/README.md +++ b/wai/examples/encoder-ingest/README.md @@ -28,3 +28,6 @@ first build fetches the repository and compiles the relay and the gateway. **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. + +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/messaging/LICENSE b/wai/examples/messaging/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/messaging/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/messaging/README.md b/wai/examples/messaging/README.md index 8682bb057..661e47059 100644 --- a/wai/examples/messaging/README.md +++ b/wai/examples/messaging/README.md @@ -29,3 +29,6 @@ first tab's inbox rings, and answering opens the call, camera and microphone on **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. + +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/package-and-deliver/LICENSE b/wai/examples/package-and-deliver/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/package-and-deliver/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/package-and-deliver/README.md b/wai/examples/package-and-deliver/README.md index 918dc2502..c0a13a131 100644 --- a/wai/examples/package-and-deliver/README.md +++ b/wai/examples/package-and-deliver/README.md @@ -27,3 +27,6 @@ prints its commands, live packaging among them. **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. + +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/remote-desktop/LICENSE b/wai/examples/remote-desktop/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/remote-desktop/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/remote-desktop/README.md b/wai/examples/remote-desktop/README.md index f77a6bfb6..bf4816434 100644 --- a/wai/examples/remote-desktop/README.md +++ b/wai/examples/remote-desktop/README.md @@ -29,3 +29,6 @@ session's records; the independent verifier in the WAI repository checks them: **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. + +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/verify-receipts/LICENSE b/wai/examples/verify-receipts/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/verify-receipts/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/verify-receipts/README.md b/wai/examples/verify-receipts/README.md index 873e5ac15..9d5750fbd 100644 --- a/wai/examples/verify-receipts/README.md +++ b/wai/examples/verify-receipts/README.md @@ -27,3 +27,6 @@ command line. **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. + +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/video-room/LICENSE b/wai/examples/video-room/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/video-room/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/video-room/README.md b/wai/examples/video-room/README.md index aedfee725..03791a3b8 100644 --- a/wai/examples/video-room/README.md +++ b/wai/examples/video-room/README.md @@ -23,3 +23,6 @@ origin; the API is described at https://wai.transaction.science/docs/api/. **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. + +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/watch-together/LICENSE b/wai/examples/watch-together/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/watch-together/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/watch-together/README.md b/wai/examples/watch-together/README.md index 4ced6afb3..324316522 100644 --- a/wai/examples/watch-together/README.md +++ b/wai/examples/watch-together/README.md @@ -26,3 +26,6 @@ but a WAV file. Or run `wai dev --watch film.webm --watch-ms 90000`. **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. + +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. -- 2.54.0 (Apple Git-157) From c86b75ac5231b5870a26cc5054b07db9272f6f7c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 27 Oct 2026 04:52:31 -0400 Subject: [PATCH 04/21] The service binaries are wai-relay, wai-room and wai-ingest; the crates keep their names WAI toolchain decision 11: joule-moq-relay builds wai-relay, joule-moq-room wai-room, and joule-moq-ingest wai-ingest. wai dev finds them by those names (WAI_RELAY_BIN, WAI_ROOM_BIN), and the quickstart archive, the release workflow, the examples' workflow, the browser tests, the interop scripts, the example host script, the Scoop manifest and the crates' READMEs and example configurations name them so. Wire and record identifiers that carry a crate's name (joule-moq-relay/session-summary/2, joule-moq-ingest/session/1, the relay's MOQT implementation string) and the log variables (JOULE_MOQ_*_LOG) are unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-examples.yml | 4 +-- .github/workflows/wai-release.yml | 6 ++-- .../crates/joule-moq-ingest/Cargo.toml | 3 +- .../crates/joule-moq-ingest/README.md | 6 ++-- .../crates/joule-moq-ingest/src/main.rs | 10 +++---- .../joule-moq-interop/scripts/clients.sh | 2 +- .../crates/joule-moq-interop/scripts/local.sh | 2 +- .../crates/joule-moq-relay/Cargo.toml | 3 +- .../crates/joule-moq-relay/README.md | 6 ++-- .../crates/joule-moq-relay/relay.example.toml | 2 +- .../crates/joule-moq-relay/src/main.rs | 12 ++++---- .../crates/joule-moq-remote/README.md | 2 +- .../crates/joule-moq-room/Cargo.toml | 3 +- .../crates/joule-moq-room/README.md | 17 ++++++----- .../crates/joule-moq-room/room.example.toml | 6 ++-- .../crates/joule-moq-room/src/keys.rs | 2 +- .../crates/joule-moq-room/src/main.rs | 30 +++++++++---------- .../crates/joule-moq-room/src/remote.rs | 2 +- .../crates/joule-moq-web/e2e/lib.mjs | 4 +-- .../joule-moq-web/e2e/messaging.e2e.mjs | 4 +-- .../crates/joule-moq-web/e2e/room.e2e.mjs | 22 +++++++------- .../crates/joule-moq-web/e2e/run.sh | 14 ++++----- wai/examples/remote-desktop/host.sh | 2 +- .../test/remote-desktop.test.sh | 2 +- wai/quickstart/README.md | 4 +-- wai/quickstart/formulae.py | 2 +- wai/quickstart/publish.sh | 2 +- 27 files changed, 89 insertions(+), 85 deletions(-) diff --git a/.github/workflows/wai-examples.yml b/.github/workflows/wai-examples.yml index e2ba01ce1..823acec6f 100644 --- a/.github/workflows/wai-examples.yml +++ b/.github/workflows/wai-examples.yml @@ -93,8 +93,8 @@ jobs: b="$RUNNER_TEMP/bin"; mkdir -p "$b" out() { (cd "$1" && cargo metadata --format-version 1 --no-deps --locked | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])'); } (cd wai/wai-rs && cargo build --locked --release --no-default-features --features quickstart --bin wai) && cp "$(out wai/wai-rs)/release/wai" "$b/" - (cd joule-code/joule-code-rs && cargo build --locked --release -p joule-moq-relay --bin joule-moq-relay) && cp "$(out joule-code/joule-code-rs)/release/joule-moq-relay" "$b/" - (cd joule-code/joule-code-rs/crates/joule-moq-room && cargo build --locked --release --bin joule-moq-room) && cp "$(out joule-code/joule-code-rs/crates/joule-moq-room)/release/joule-moq-room" "$b/" + (cd joule-code/joule-code-rs && cargo build --locked --release -p joule-moq-relay --bin wai-relay) && cp "$(out joule-code/joule-code-rs)/release/wai-relay" "$b/" + (cd joule-code/joule-code-rs/crates/joule-moq-room && cargo build --locked --release --bin wai-room) && cp "$(out joule-code/joule-code-rs/crates/joule-moq-room)/release/wai-room" "$b/" (cd joule-code/joule-code-rs/crates/joule-moq-remote && cargo build --locked --release --bin wai-remote-host) && cp "$(out joule-code/joule-code-rs/crates/joule-moq-remote)/release/wai-remote-host" "$b/" (cd wai/wai-pack && cargo build --locked --release --bin wai-pack) && cp "$(out wai/wai-pack)/release/wai-pack" "$b/" echo "$b" >> "$GITHUB_PATH" diff --git a/.github/workflows/wai-release.yml b/.github/workflows/wai-release.yml index 4ef5da847..e01657a7a 100644 --- a/.github/workflows/wai-release.yml +++ b/.github/workflows/wai-release.yml @@ -3,7 +3,7 @@ name: wai command line release # On a tag wai-v (the version wai/wai-rs/Cargo.toml states), builds the quickstart # archive (wai/quickstart/build.sh) on each runner and attaches it to the forge release of that # tag, each archive with its SHA-256. An archive holds bin/ (wai with the feature quickstart, -# joule-moq-relay, joule-moq-room, wai-remote-host, wai-pack) and share/wai/web/ (the room +# wai-relay, wai-room, wai-remote-host, wai-pack) and share/wai/web/ (the room # client, built once, on the macOS host, which has the WebAssembly toolchain): # macOS aarch64-apple-darwin and x86_64-apple-darwin; # Linux x86_64- and aarch64-unknown-linux-musl, static, so any distribution runs it; @@ -189,8 +189,8 @@ jobs: New-Item -ItemType Directory -Force -Path (Join-Path $stage 'bin'), (Join-Path $stage 'share\wai'), $dist | Out-Null $builds = @( @{ dir = 'wai\wai-rs'; bin = 'wai'; args = @('--no-default-features', '--features', 'quickstart', '--bin', 'wai') }, - @{ dir = 'joule-code\joule-code-rs'; bin = 'joule-moq-relay'; args = @('-p', 'joule-moq-relay', '--bin', 'joule-moq-relay') }, - @{ dir = 'joule-code\joule-code-rs\crates\joule-moq-room'; bin = 'joule-moq-room'; args = @('--bin', 'joule-moq-room') }, + @{ dir = 'joule-code\joule-code-rs'; bin = 'wai-relay'; args = @('-p', 'joule-moq-relay', '--bin', 'wai-relay') }, + @{ dir = 'joule-code\joule-code-rs\crates\joule-moq-room'; bin = 'wai-room'; args = @('--bin', 'wai-room') }, @{ dir = 'joule-code\joule-code-rs\crates\joule-moq-remote'; bin = 'wai-remote-host'; args = @('--bin', 'wai-remote-host') }, @{ dir = 'wai\wai-pack'; bin = 'wai-pack'; args = @('--bin', 'wai-pack') } ) diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-ingest/Cargo.toml index 2986cdb87..220d19dd4 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/Cargo.toml @@ -10,8 +10,9 @@ repository = "https://github.com/transaction-science/open-standards" [lib] path = "src/lib.rs" +# The binary is `wai-ingest`; the crate keeps its name (WAI toolchain decision 11). [[bin]] -name = "joule-moq-ingest" +name = "wai-ingest" path = "src/main.rs" # The link records' conformance corpus, wai/live-conformance/links/. diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/README.md b/joule-code/joule-code-rs/crates/joule-moq-ingest/README.md index 494439cc5..4ec04d1da 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/README.md @@ -13,8 +13,8 @@ cues. It also takes contribution over **SRT** and **RIST** into the same tracks recovery and latency. The mappings it implements are WAI's `extensions/live-ingest.md`, `extensions/cue-injection.md` and `extensions/live-contribution.md`. -It is a library (`GatewayServer`, `Gateway`) and a binary, `joule-moq-ingest`. The relay -is `joule-moq-relay`; the MOQT codec is `joule-moq`'s, `draft-ietf-moq-transport-22`. +It is a library (`GatewayServer`, `Gateway`) and a binary, `wai-ingest` (the crate keeps its +name). The relay is `joule-moq-relay` (binary `wai-relay`); the MOQT codec is `joule-moq`'s, `draft-ietf-moq-transport-22`. ## Resources @@ -187,7 +187,7 @@ Each is a field of `[limits]`, `[groups]`, `[timeline]` or `[whep]` in the confi signing key are files, and a room is its public, signed descriptor. ```text -joule-moq-ingest --config gateway.toml # JOULE_MOQ_INGEST_LOG sets the log level +wai-ingest --config gateway.toml # JOULE_MOQ_INGEST_LOG sets the log level ``` Every listener is loopback by default: put a TLS front before the HTTP resources (RFC 9725 diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/main.rs b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/main.rs index b156bab46..9a50ed297 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/main.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/main.rs @@ -1,7 +1,7 @@ -//! `joule-moq-ingest`: run the gateway. +//! `wai-ingest` (crate `joule-moq-ingest`): run the gateway. //! //! ```text -//! joule-moq-ingest --config gateway.toml +//! wai-ingest --config gateway.toml //! ``` //! //! It logs where it listens and the key it signs receipts and session records with. On @@ -19,19 +19,19 @@ async fn main() -> ExitCode { ["--config", path] => match std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|s| GatewayConfig::from_toml(&s)) { Ok(c) => c, Err(e) => { - eprintln!("joule-moq-ingest: {path}: {e}"); + eprintln!("wai-ingest: {path}: {e}"); return ExitCode::FAILURE; } }, _ => { - eprintln!("usage: joule-moq-ingest --config "); + eprintln!("usage: wai-ingest --config "); return ExitCode::from(2); } }; let server = match GatewayServer::start(cfg).await { Ok(s) => s, Err(e) => { - eprintln!("joule-moq-ingest: {e}"); + eprintln!("wai-ingest: {e}"); return ExitCode::FAILURE; } }; diff --git a/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/clients.sh b/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/clients.sh index e0f5bf73d..65fe9f435 100755 --- a/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/clients.sh +++ b/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/clients.sh @@ -11,7 +11,7 @@ T=$1 OUT=$2 shift 2 mkdir -p "$OUT" -JOULE_MOQ_RELAY_LOG=debug "$T/debug/joule-moq-relay" --dev > "$OUT/relay.log" 2>&1 & +JOULE_MOQ_RELAY_LOG=debug "$T/debug/wai-relay" --dev > "$OUT/relay.log" 2>&1 & R=$! # The relay is gone before this script is: a next run binds the same port. trap 'kill $R 2>/dev/null; wait $R 2>/dev/null' EXIT diff --git a/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/local.sh b/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/local.sh index e4379d22c..66516beb5 100755 --- a/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/local.sh +++ b/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/local.sh @@ -4,7 +4,7 @@ # scripts/local.sh set -u T=${1:-target} -"$T/debug/joule-moq-relay" --dev > /tmp/joule-moq-relay-local.log 2>&1 & +"$T/debug/wai-relay" --dev > /tmp/wai-relay-local.log 2>&1 & R=$! # The relay is gone before this script is: a next run binds the same port. trap 'kill $R 2>/dev/null; wait $R 2>/dev/null' EXIT diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml index 863148244..4c8c21442 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml @@ -10,8 +10,9 @@ repository = "https://github.com/transaction-science/open-standards" [lib] path = "src/lib.rs" +# The binary is `wai-relay`; the crate keeps its name (WAI toolchain decision 11). [[bin]] -name = "joule-moq-relay" +name = "wai-relay" path = "src/main.rs" [dependencies] diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md index 9bfe22f8b..91859b309 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md @@ -7,8 +7,8 @@ draft's priorities give, dropping what passes a subscriber's delivery timeout at Whatever it drops, the object's receipts still reach the subscriber, on the track's receipts track. -It is a library (`Relay`, `RelayServer`, `client::Client`) and a binary, -`joule-moq-relay`. The wire codec is `joule-moq`'s. +It is a library (`Relay`, `RelayServer`, `client::Client`) and a binary, `wai-relay` (the +crate keeps its name; the binary is named for WAI). The wire codec is `joule-moq`'s. ## The drafts it speaks @@ -735,7 +735,7 @@ The store must be kept where the room's region pin allows (live-rooms §3.7). ## Configuration -`joule-moq-relay --config relay.toml`, or `joule-moq-relay --dev` for loopback with a +`wai-relay --config relay.toml`, or `wai-relay --dev` for loopback with a development certificate. `relay.example.toml` shows every field: the listen addresses and path, the certificate files (or the names for a development certificate), the cache's byte budget, the limits (sessions, requests and streams in flight, MAX_REQUEST_UPDATES, the token diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml b/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml index 7ec581975..6dd13c04a 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml @@ -1,4 +1,4 @@ -# joule-moq-relay: an example configuration. Every field is optional; these are the +# wai-relay (crate joule-moq-relay): an example configuration. Every field is optional; these are the # defaults unless a comment says otherwise. Nothing secret belongs here: keys are files # the operator provides, and tokens are named by their SHA-256. diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/main.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/main.rs index 8ea5a73ab..b3d2efe9c 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/main.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/main.rs @@ -1,8 +1,8 @@ -//! `joule-moq-relay`: run the relay. +//! `wai-relay` (crate `joule-moq-relay`): run the relay. //! //! ```text -//! joule-moq-relay --config relay.toml -//! joule-moq-relay --dev # loopback, a development certificate +//! wai-relay --config relay.toml +//! wai-relay --dev # loopback, a development certificate //! ``` //! //! It logs where it listens, the certificate's SHA-256 (for a page's @@ -15,7 +15,7 @@ use joule_moq_relay::{RelayConfig, RelayServer}; use std::process::ExitCode; fn usage() -> ExitCode { - eprintln!("usage: joule-moq-relay (--config | --dev)"); + eprintln!("usage: wai-relay (--config | --dev)"); ExitCode::from(2) } @@ -29,7 +29,7 @@ async fn main() -> ExitCode { ["--config", path] => match std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|s| RelayConfig::from_toml(&s)) { Ok(c) => c, Err(e) => { - eprintln!("joule-moq-relay: {path}: {e}"); + eprintln!("wai-relay: {path}: {e}"); return ExitCode::FAILURE; } }, @@ -38,7 +38,7 @@ async fn main() -> ExitCode { let server = match RelayServer::start(cfg).await { Ok(s) => s, Err(e) => { - eprintln!("joule-moq-relay: {e}"); + eprintln!("wai-relay: {e}"); return ExitCode::FAILURE; } }; diff --git a/joule-code/joule-code-rs/crates/joule-moq-remote/README.md b/joule-code/joule-code-rs/crates/joule-moq-remote/README.md index 299b1e03e..8602aa144 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-remote/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-remote/README.md @@ -31,7 +31,7 @@ takes every principal's records in order. The host makes a key once, and runs in ``` wai-remote-host key ./host.key # mode 0600; prints the public key wai-remote-host run --room https://room.example/room-api --key ./host.key \ - [--invitation ] --relay wss://relay.example/moq \ + [--invitation ] --relay wss://relay.example/moq \ [--ca ./private-ca.pem] --floor floor --give-on-request ``` diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml index 752429920..06133e4a2 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml @@ -20,8 +20,9 @@ wtransport = { path = "../../vendor/wtransport" } [lib] path = "src/lib.rs" +# The binary is `wai-room`; the crate keeps its name (WAI toolchain decision 11). [[bin]] -name = "joule-moq-room" +name = "wai-room" path = "src/main.rs" [dependencies] diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/README.md b/joule-code/joule-code-rs/crates/joule-moq-room/README.md index aa5f39df9..6d308841c 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-room/README.md @@ -1,7 +1,8 @@ # joule-moq-room A room service for WAI live rooms (`wai/extensions/live-rooms.md`) over -`joule-moq-relay`. It holds one room's authority and clock keys and: +`joule-moq-relay`. Its binary is `wai-room` (the crate keeps its name). It holds one room's +authority and clock keys and: - **issues grants.** A visitor's page makes an Ed25519 key and asks for a grant for it (`POST /grant`). The service signs a short-lived `WRG1` grant: a publisher's (its own @@ -27,7 +28,7 @@ A room service for WAI live rooms (`wai/extensions/live-rooms.md`) over permits `meta`, so that each principal sends the extension's statements. The host's grant carries `remote_host`; a controller's the control power, `remote_control` = `control`; a viewer's neither. The host is the key `host` names or, when it names none, the first key that - presents an invitation the room's authority signed (`joule-moq-room invite`), to which the + presents an invitation the room's authority signed (`wai-room invite`), to which the invitation is then bound. Who may ask for a controller's grant is `allow`: `all`, `listed` (the keys `controllers` lists) or `none`. The host hands control over by a grant exchange (`POST /remote/control`, its grant and a proof over the service's challenge), which takes @@ -177,12 +178,12 @@ each room's grants and chain hold one stretch of company. ## Running it ```text -joule-moq-room keygen new keys (mode 0600, never over a file); prints the - relay configuration lines that trust them -joule-moq-room relay-lines those lines again -joule-moq-room invite [] an invitation to host a remote desktop room, signed by - the room's authority, good for seconds (3600) -joule-moq-room --config serve (room.example.toml shows every field) +wai-room keygen new keys (mode 0600, never over a file); prints the + relay configuration lines that trust them +wai-room relay-lines those lines again +wai-room invite [] an invitation to host a remote desktop room, signed by + the room's authority, good for seconds (3600) +wai-room --config serve (room.example.toml shows every field) ``` The keys file must be readable by its owner alone; the service refuses it otherwise. diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml b/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml index d53762e21..f4d802721 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml @@ -1,5 +1,5 @@ -# joule-moq-room: an example configuration. Nothing secret belongs here: the keys are a -# file of their own (`joule-moq-room keygen ` makes it, mode 0600), outside every +# wai-room (crate joule-moq-room): an example configuration. Nothing secret belongs here: the +# keys are a file of their own (`wai-room keygen ` makes it, mode 0600), outside every # repository, and the relay knows the clock's token by its SHA-256 alone. # The HTTP service, on loopback behind the front that serves the room's page. @@ -128,7 +128,7 @@ global_every_s = 10 # Every room is a remote desktop room (WAI remote-desktop): one host's screen shared into it. The # host is the key `host` names, or, empty, the first key that presents an invitation the room's -# authority signed (`joule-moq-room invite `). Not also e2ee, a watch room, a room with +# authority signed (`wai-room invite `). Not also e2ee, a watch room, a room with # co-performance, one admitted by age or a world room. The relay holds the host's screen to its key # with a door: # [[receipts.door]] prefix = ["*", "*", "screen"], trust_field = 1. diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs index f987444a3..4e039039b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs @@ -53,7 +53,7 @@ impl Keys { pub fn to_toml(&self) -> String { format!( - "# joule-moq-room's keys. Secret: mode 0600, outside every repository.\nauthority_seed = \"{}\"\nclock_seed = \"{}\"\nclock_token = \"{}\"\n", + "# wai-room's keys. Secret: mode 0600, outside every repository.\nauthority_seed = \"{}\"\nclock_seed = \"{}\"\nclock_token = \"{}\"\n", hex::encode(self.authority.to_bytes()), hex::encode(self.clock.to_bytes()), hex::encode(self.clock_token) diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs index 720944e73..75745b45c 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs @@ -1,13 +1,13 @@ -//! `joule-moq-room`: run the room service. +//! `wai-room` (crate `joule-moq-room`): run the room service. //! //! ```text -//! joule-moq-room keygen new keys (mode 0600, never over a file), and the +//! wai-room keygen new keys (mode 0600, never over a file), and the //! relay configuration lines that trust them -//! joule-moq-room relay-lines those lines again, for keys that exist -//! joule-moq-room invite [] an invitation to host a remote desktop room +//! wai-room relay-lines those lines again, for keys that exist +//! wai-room invite [] an invitation to host a remote desktop room //! (`[remote]` with no `host`), signed by the //! room's authority, good for seconds (3600) -//! joule-moq-room --config serve +//! wai-room --config serve //! ``` //! //! It stops on Ctrl-C or SIGTERM. With `max_resident_mb` set it exits with status 70 once @@ -19,7 +19,7 @@ use std::path::Path; use std::process::ExitCode; fn usage() -> ExitCode { - eprintln!("usage: joule-moq-room (keygen | relay-lines | invite [] | --config )"); + eprintln!("usage: wai-room (keygen | relay-lines | invite [] | --config )"); ExitCode::from(2) } @@ -32,11 +32,11 @@ async fn main() -> ExitCode { ["keygen", path] => match Keys::create(Path::new(path)) { Ok(k) => { println!("{}", k.relay_lines()); - eprintln!("joule-moq-room: keys written to {path} (mode 0600)"); + eprintln!("wai-room: keys written to {path} (mode 0600)"); ExitCode::SUCCESS } Err(e) => { - eprintln!("joule-moq-room: {e}"); + eprintln!("wai-room: {e}"); ExitCode::FAILURE } }, @@ -46,7 +46,7 @@ async fn main() -> ExitCode { ExitCode::SUCCESS } Err(e) => { - eprintln!("joule-moq-room: {e}"); + eprintln!("wai-room: {e}"); ExitCode::FAILURE } }, @@ -59,11 +59,11 @@ async fn main() -> ExitCode { match Keys::load(Path::new(path)) { Ok(k) => { println!("{}", joule_moq_room::remote::new_invitation(&k, valid_s)); - eprintln!("joule-moq-room: an invitation to host, good for {valid_s} s, bound to the first key that presents it"); + eprintln!("wai-room: an invitation to host, good for {valid_s} s, bound to the first key that presents it"); ExitCode::SUCCESS } Err(e) => { - eprintln!("joule-moq-room: {e}"); + eprintln!("wai-room: {e}"); ExitCode::FAILURE } } @@ -72,14 +72,14 @@ async fn main() -> ExitCode { let cfg = match std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|s| Config::from_toml(&s)) { Ok(c) => c, Err(e) => { - eprintln!("joule-moq-room: {path}: {e}"); + eprintln!("wai-room: {path}: {e}"); return ExitCode::FAILURE; } }; let keys = match Keys::load(&cfg.keys) { Ok(k) => k, Err(e) => { - eprintln!("joule-moq-room: {e}"); + eprintln!("wai-room: {e}"); return ExitCode::FAILURE; } }; @@ -93,14 +93,14 @@ async fn serve(cfg: Config, keys: Keys) -> ExitCode { let listener = match tokio::net::TcpListener::bind(cfg.listen).await { Ok(l) => l, Err(e) => { - eprintln!("joule-moq-room: binding {}: {e}", cfg.listen); + eprintln!("wai-room: binding {}: {e}", cfg.listen); return ExitCode::FAILURE; } }; let svc = match Service::try_new(keys, &cfg) { Ok(s) => s, Err(e) => { - eprintln!("joule-moq-room: {e}"); + eprintln!("wai-room: {e}"); return ExitCode::FAILURE; } }; diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/remote.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/remote.rs index ef55df365..57d4f3b04 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/remote.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/remote.rs @@ -11,7 +11,7 @@ //! `screen` kind; a controller's carries the control power, `remote_control` with the classes //! the configuration gives (`control`); a viewer's carries neither. The host is the key the //! configuration names (`host`), or, when it names none, the first visitor who presents an -//! invitation the room's authority signed (`joule-moq-room invite`); an invitation is bound to +//! invitation the room's authority signed (`wai-room invite`); an invitation is bound to //! the key that presented it first. Who may ask for a controller's grant is the configuration's //! allow rule (`allow`: everyone, the keys listed, or no one); the host hands control over by a //! grant exchange (live-rooms §4.6, §7.1 here), which it asks for with its grant and a proof of diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs index 084d359ca..bb8524818 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs @@ -109,7 +109,7 @@ function relayToml(c) { // A DVR window of every track (WAI live-recording §8). if (c.dvr) t += `[dvr]\npath = ${q(c.dvr.path)}\nroom_keys = ${arr(c.dvr.room_keys || [])}\n\n[[dvr.window]]\nprefix = ["*"]\nmax_age_s = ${c.dvr.max_age_s}\nmax_bytes = ${c.dvr.max_bytes}\n\n`; t += `[summary]\nout_path = ${q(c.summary_out)}\n\n[auth]\nrequired = ${c.auth ? "true" : "false"}\n\n`; - // Lines a room service prints for the relay that trusts it (`joule-moq-room relay-lines`). + // Lines a room service prints for the relay that trusts it (`wai-room relay-lines`). if (c.extra) t += c.extra + "\n"; // A grant that may subscribe may also seek back below the live edge (live-rooms §10.6), // as a late joiner does, unless the test says otherwise. @@ -181,7 +181,7 @@ export function tmpdir(label) { return fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), `moq-web-${label}-`)); } -// A room service (joule-moq-room) for a relay: its keys made in `dir` (mode 0600), the +// A room service (wai-room, crate joule-moq-room) for a relay: its keys made in `dir` (mode 0600), the // relay's lines that trust them, then, once the relay listens, the service itself. export function roomKeys(bin, dir) { const keys = path.join(dir, "room-keys.toml"); diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/messaging.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/messaging.e2e.mjs index 4d8088e79..7bb7004c9 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/messaging.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/messaging.e2e.mjs @@ -1,9 +1,9 @@ // End-to-end tests of the messaging view (web/room/messages.html) and the call flow -// (web/room/call.html) against a room service (joule-moq-room) on loopback, in the host's +// (web/room/call.html) against a room service (wai-room) on loopback, in the host's // installed browser driven by the automation package of e2e/package.json, with a fake camera // and microphone (WAI live-messaging). // -// node e2e/messaging.e2e.mjs --room --relay --web +// node e2e/messaging.e2e.mjs --room --relay --web // [--browser ] [--only messaging,call] [--out ] [--runs N] // // Scenarios: diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs index d30273537..2a6ce025f 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs @@ -2,12 +2,12 @@ // host's installed browser driven by the automation package of e2e/package.json, with a // fake camera and microphone. // -// node e2e/room.e2e.mjs --relay --wai --web +// node e2e/room.e2e.mjs --relay --wai --web // --corpus [--browser ] // [--only compose,webtransport,websocket,goaway,dvr,service,e2ee,age,co,ageco,binding,cowatch, // origin,termination,remote,world,privateworld] // [--runs N] -// [--between ] [--out ] [--room ] [--base ] [--seek on|off] +// [--between ] [--out ] [--room ] [--base ] [--seek on|off] // [--wai-dir ] // // Scenarios: @@ -564,7 +564,7 @@ async function scenarioService(run) { if (BASE) { base = `${BASE}/room/`; } else { - if (!ROOM) throw new Error("--room is needed for the service scenario"); + if (!ROOM) throw new Error("--room is needed for the service scenario"); const k = roomKeys(ROOM, dir); const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); // A DVR window over every track and a cache far smaller than the seconds sought back, as @@ -872,7 +872,7 @@ async function provenGet(url, grantHex, seedHex) { // remote_desktop_verify.py, from the extension's text alone. async function scenarioRemote(run) { const label = "remote"; - if (!REMOTE || !ROOM) throw new Error("--remote and --room are needed for the remote scenario"); + if (!REMOTE || !ROOM) throw new Error("--remote and --room are needed for the remote scenario"); const dir = path.join(OUT, `run${run}-remote`); fs.mkdirSync(dir, { recursive: true }); const k = roomKeys(ROOM, dir); @@ -1051,7 +1051,7 @@ async function scenarioE2ee(run) { const label = "e2ee"; const dir = path.join(OUT, `run${run}-e2ee`); fs.mkdirSync(dir, { recursive: true }); - if (!ROOM) throw new Error("--room is needed for the e2ee scenario"); + if (!ROOM) throw new Error("--room is needed for the e2ee scenario"); const k = roomKeys(ROOM, dir); const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); // A DVR window over every track: the relay keeps nothing of a room whose objects are sealed. @@ -1251,7 +1251,7 @@ async function scenarioAge(run) { const label = "age"; const dir = path.join(OUT, `run${run}-age`); fs.mkdirSync(dir, { recursive: true }); - if (!ROOM) throw new Error("--room is needed for the age scenario"); + if (!ROOM) throw new Error("--room is needed for the age scenario"); const keys = testKeys(path.join(path.dirname(CORPUS), "..", "age", "vectors.json")); const issuer = await startIssuer(keys); const k = roomKeys(ROOM, dir); @@ -1351,7 +1351,7 @@ async function scenarioOrigin(run) { const label = "origin"; const dir = path.join(OUT, `run${run}-origin`); fs.mkdirSync(dir, { recursive: true }); - if (!ROOM) throw new Error("--room is needed for the origin scenario"); + if (!ROOM) throw new Error("--room is needed for the origin scenario"); const k = roomKeys(ROOM, dir); const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); const relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000 } }, dir, "relay"); @@ -1450,7 +1450,7 @@ async function scenarioAgeCo(run) { const label = "ageco"; const dir = path.join(OUT, `run${run}-ageco`); fs.mkdirSync(dir, { recursive: true }); - if (!ROOM) throw new Error("--room is needed for the ageco scenario"); + if (!ROOM) throw new Error("--room is needed for the ageco scenario"); const keys = testKeys(path.join(path.dirname(CORPUS), "..", "age", "vectors.json")); const issuer = await startIssuer(keys); const k = roomKeys(ROOM, dir); @@ -1555,7 +1555,7 @@ async function scenarioCo(run) { const label = "co"; const dir = path.join(OUT, `run${run}-co`); fs.mkdirSync(dir, { recursive: true }); - if (!ROOM) throw new Error("--room is needed for the co scenario"); + if (!ROOM) throw new Error("--room is needed for the co scenario"); const k = roomKeys(ROOM, dir); const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); // The relay holds the floor and the audience's inputs, honouring the clock's statements. @@ -2022,7 +2022,7 @@ async function scenarioCowatch(run) { const label = "cowatch"; const dir = path.join(OUT, `run${run}-cowatch`); fs.mkdirSync(dir, { recursive: true }); - if (!ROOM) throw new Error("--room is needed for the cowatch scenario"); + if (!ROOM) throw new Error("--room is needed for the cowatch scenario"); const k = roomKeys(ROOM, dir); const source = path.join(dir, "programme.wav"); const wav = writeWav(source, 120); @@ -2303,7 +2303,7 @@ const WORLD_TOML = `[world]\nmodel_id = "${"07".repeat(32)}"\nwidth = 64\nheight async function worldRoom(run, label, sealed) { const dir = path.join(OUT, `run${run}-${label}`); fs.mkdirSync(dir, { recursive: true }); - if (!ROOM) throw new Error(`--room is needed for the ${label} scenario`); + if (!ROOM) throw new Error(`--room is needed for the ${label} scenario`); const k = roomKeys(ROOM, dir); // The relay-lines' world door (§4.3), and a DVR window that keeps the room's world tracks for // a page that enters late (§7); it keeps nothing of a private world. diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh index b80825ba9..2fb2016d1 100755 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh @@ -43,9 +43,9 @@ mkdir -p "$PKG" step() { echo "== $*"; } -step "native: joule-moq-relay, joule-moq-room, wai_live_web" -(cd "$JC" && cargo build $LOCKED --release -p joule-moq-relay --bin joule-moq-relay) -(cd "$CRATE/../joule-moq-room" && cargo build $LOCKED --release --bin joule-moq-room) +step "native: wai-relay, wai-room, wai_live_web" +(cd "$JC" && cargo build $LOCKED --release -p joule-moq-relay --bin wai-relay) +(cd "$CRATE/../joule-moq-room" && cargo build $LOCKED --release --bin wai-room) (cd "$CRATE/../joule-moq-remote" && cargo build $LOCKED --release --bin wai-remote-host) (cd "$WLW" && cargo build $LOCKED --release --bin wai_live_web) @@ -93,8 +93,8 @@ while [ $# -gt 0 ]; do done if [ "$only" = 0 ] || [ -n "$room_only" ]; then node "$HERE/room.e2e.mjs" \ - --relay "$TARGET/release/joule-moq-relay" \ - --room "$TARGET/release/joule-moq-room" \ + --relay "$TARGET/release/wai-relay" \ + --room "$TARGET/release/wai-room" \ --wai "$TARGET/release/wai_live_web" \ --remote "$TARGET/release/wai-remote-host" \ --web "$CRATE/web" \ @@ -108,8 +108,8 @@ fi if [ -n "$msg_only" ]; then step "end-to-end: conversations and calls" node "$HERE/messaging.e2e.mjs" \ - --relay "$TARGET/release/joule-moq-relay" \ - --room "$TARGET/release/joule-moq-room" \ + --relay "$TARGET/release/wai-relay" \ + --room "$TARGET/release/wai-room" \ --web "$CRATE/web" \ --out "$OUT/results/messaging" \ --only "$msg_only" \ diff --git a/wai/examples/remote-desktop/host.sh b/wai/examples/remote-desktop/host.sh index 26553835a..8b0966195 100755 --- a/wai/examples/remote-desktop/host.sh +++ b/wai/examples/remote-desktop/host.sh @@ -11,7 +11,7 @@ set -eu ORIGIN=${WAI_DEV_ORIGIN:-http://127.0.0.1:8080} [ -f host.key ] || wai-remote-host key host.key > /dev/null -INVITATION=$(joule-moq-room invite .wai/dev/room-keys.toml 3600) +INVITATION=$(wai-room invite .wai/dev/room-keys.toml 3600) SOURCE=display if [ "${1:-}" = "--pattern" ]; then SOURCE="pattern --encoder ipcm" diff --git a/wai/examples/remote-desktop/test/remote-desktop.test.sh b/wai/examples/remote-desktop/test/remote-desktop.test.sh index 6970374da..938cb3c91 100755 --- a/wai/examples/remote-desktop/test/remote-desktop.test.sh +++ b/wai/examples/remote-desktop/test/remote-desktop.test.sh @@ -5,7 +5,7 @@ # repository's independent verifier must accept the recorded session. # # bash wai/examples/remote-desktop/test/remote-desktop.test.sh -# Needs wai, joule-moq-relay, joule-moq-room and wai-remote-host on PATH (or WAI_RELAY_BIN, +# Needs wai, wai-relay, wai-room and wai-remote-host on PATH (or WAI_RELAY_BIN, # WAI_ROOM_BIN, WAI_WEB as `wai dev` takes them), and python3. # No -e: each check records its own result, and the run goes on to the next. set -uo pipefail diff --git a/wai/quickstart/README.md b/wai/quickstart/README.md index 81927f8f0..04459ed6b 100644 --- a/wai/quickstart/README.md +++ b/wai/quickstart/README.md @@ -7,8 +7,8 @@ of it is open source under Apache-2.0, built from the open-standards repository | File | What it is | |---|---| | `bin/wai` | The command line: `hello`, `create`, `dev`, `verify`, and the envelope tools (`inspect`, `wrap`, `unwrap`, `encode`, `decode` for the mandatory floor, `capabilities`) | -| `bin/joule-moq-relay` | The relay a room's media goes through: MOQT over QUIC, WebTransport and WebSocket | -| `bin/joule-moq-room` | The room service: a room's keys, its grants, its clock | +| `bin/wai-relay` | The relay a room's media goes through: MOQT over QUIC, WebTransport and WebSocket | +| `bin/wai-room` | The room service: a room's keys, its grants, its clock | | `bin/wai-remote-host` | A remote desktop room's host agent | | `bin/wai-pack` | The packager: a title as WAI objects, HTTP playlists and a MoQ catalog | | `share/wai/web/` | The room client a browser loads: its pages and their WebAssembly | diff --git a/wai/quickstart/formulae.py b/wai/quickstart/formulae.py index 7790289aa..8aa9302d5 100644 --- a/wai/quickstart/formulae.py +++ b/wai/quickstart/formulae.py @@ -95,7 +95,7 @@ if win_sha: "homepage": HOME, "license": "Apache-2.0", "architecture": {"64bit": {"url": url(win + ".zip"), "hash": win_sha, "extract_dir": win}}, - "bin": ["bin\\wai.exe", "bin\\joule-moq-relay.exe", "bin\\joule-moq-room.exe", "bin\\wai-remote-host.exe", "bin\\wai-pack.exe"], + "bin": ["bin\\wai.exe", "bin\\wai-relay.exe", "bin\\wai-room.exe", "bin\\wai-remote-host.exe", "bin\\wai-pack.exe"], } pathlib.Path(out, "wai.json").write_text(json.dumps(manifest, indent=2) + "\n") print(f"wrote the formula{' and the manifest' if win_sha else ''} for wai {version}") diff --git a/wai/quickstart/publish.sh b/wai/quickstart/publish.sh index 53bd82c57..61d7bba6f 100755 --- a/wai/quickstart/publish.sh +++ b/wai/quickstart/publish.sh @@ -19,7 +19,7 @@ BASE="$FORGE_API/repos/$FORGE_REPO/releases" release_id() { curl -fsS -H "$AUTH" "$BASE/tags/$TAG" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("id", ""))' 2>/dev/null || true; } ID="$(release_id)" if [ -z "$ID" ]; then - BODY="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "name": sys.argv[2], "body": "The WAI command line and what `wai dev` runs, for macOS, Linux and Windows: each archive holds bin/ (wai, joule-moq-relay, joule-moq-room, wai-remote-host, wai-pack), share/wai/web/ (the room client), a README and the licence; each .sha256 file checks its archive (`shasum -a 256 -c`)."}))' "$TAG" "$TITLE")" + BODY="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "name": sys.argv[2], "body": "The WAI command line and what `wai dev` runs, for macOS, Linux and Windows: each archive holds bin/ (wai, wai-relay, wai-room, wai-remote-host, wai-pack), share/wai/web/ (the room client), a README and the licence; each .sha256 file checks its archive (`shasum -a 256 -c`)."}))' "$TAG" "$TITLE")" curl -fsS -H "$AUTH" -H 'content-type: application/json' -X POST "$BASE" -d "$BODY" > /dev/null 2>&1 || true # Builds run side by side; one of them created it if this did not. for _ in 1 2 3 4 5 6; do ID="$(release_id)"; [ -n "$ID" ] && break; sleep 3; done -- 2.54.0 (Apple Git-157) From 758e00dcdbee85ecead1fca8b4e89947410d86c7 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 27 Oct 2026 04:52:32 -0400 Subject: [PATCH 05/21] scripts: check-wai-ops-doors.py holds every door's names to the operation catalogue MCP tool names (noun_verb), the services' OpenAPI operationIds (the id, placed on a service, at its route), the C header's functions (wai_noun_verb where the noun is the catalogue's and the verb an operation verb) and the Swift and Kotlin sinks' public methods (Wai.) are read and held to wai/catalogue/ops.json; a door with no operation yet says so. The selftest misnames each door's operation and requires the check to fail and say which. Co-Authored-By: Claude Opus 5.5 --- scripts/check-wai-ops-doors-selftest.sh | 142 ++++++++++++ scripts/check-wai-ops-doors.py | 283 ++++++++++++++++++++++++ 2 files changed, 425 insertions(+) create mode 100755 scripts/check-wai-ops-doors-selftest.sh create mode 100755 scripts/check-wai-ops-doors.py diff --git a/scripts/check-wai-ops-doors-selftest.sh b/scripts/check-wai-ops-doors-selftest.sh new file mode 100755 index 000000000..b1640fa9a --- /dev/null +++ b/scripts/check-wai-ops-doors-selftest.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# +# check-wai-ops-doors-selftest.sh — check-wai-ops-doors.py can fail. +# +# A check that cannot fail is decoration. This builds a scratch tree shaped like the +# repository (a catalogue export, an MCP tool table, an OpenAPI document, the C header and a +# Swift and a Kotlin source) whose every door names its operations as the catalogue does, and +# asserts that the check passes it and fails each way a door can misname an operation, +# saying which. +set -uo pipefail + +here=$(cd "$(dirname "$0")" && pwd) +check="$here/check-wai-ops-doors.py" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +mk() { + rm -rf "$tmp/wai" "$tmp/joule-code" + mkdir -p "$tmp/wai/catalogue" "$tmp/wai/wai-rs/src/bin/wai_cli/mcp" "$tmp/wai/wai-rs/include" \ + "$tmp/joule-code/joule-code-rs/crates/svc" "$tmp/wai/wai-sink-swift/Sources/WaiSink" \ + "$tmp/wai/wai-sink-android/core/src/main/kotlin/wai/sink" + cat > "$tmp/wai/catalogue/ops.json" <<'J' +{"format": "wai.ops/1", "operations": [ + {"id": "envelope.inspect", "placement": "local", "route": null}, + {"id": "receipt.verify", "placement": "local", "route": null}, + {"id": "room.grant", "placement": "service", "route": {"method": "POST", "path": "/v1/rooms/{room}/grants"}} +]} +J + cat > "$tmp/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs" <<'R' +pub(crate) const TOOLS: &[Tool] = &[ + Tool { name: "envelope_inspect", title: "Inspect", run: inspect }, + Tool { + name: "room_grant", + title: "Grant", + run: grant, + }, +]; +R + cat > "$tmp/joule-code/joule-code-rs/crates/svc/openapi.json" <<'J' +{"openapi": "3.1.0", "paths": {"/v1/rooms/{room}/grants": {"post": {"operationId": "room.grant"}, "parameters": []}}} +J + cat > "$tmp/wai/wai-rs/include/wai.h" <<'H' +int32_t wai_envelope_inspect(const uint8_t *p, size_t n); +int32_t wai_envelope_pack(const uint8_t *p, size_t n); +void wai_buffer_free(struct WaiBuffer b); +int32_t wai_image_png_decode(const uint8_t *p); +H + cat > "$tmp/wai/wai-sink-swift/Sources/WaiSink/Envelope.swift" <<'S' +public final class WaiEnvelope: @unchecked Sendable { + public init() {} + public func inspect(_ path: String) -> String { "{" } + public func pack() {} + private func verifyQuietly() {} +} +public final class WaiSink { + public func info() -> String { "" } +} +S + cat > "$tmp/wai/wai-sink-android/core/src/main/kotlin/wai/sink/WaiReceipt.kt" <<'K' +package wai.sink + +class WaiReceipt( + val owner: String, + private val pool: Runnable = Runnable { }, +) { + fun verify(bytes: ByteArray): Boolean = bytes.isNotEmpty() + private fun checkQuietly() {} + companion object { + fun verifyAll(list: List) = list.all { it.isNotEmpty() } + } +} +K +} + +fails=0 +expect() { # expect [text the output must hold] + local want=$1 what=$2 needle=${3:-} out rc + out=$(python3 -I "$check" --root "$tmp" 2>&1); rc=$? + if [ "$want" = pass ] && [ $rc -ne 0 ]; then echo "FAIL $what: the check failed"; printf '%s\n' "$out" | sed 's/^/ /'; fails=$((fails+1)); return; fi + if [ "$want" = fail ] && [ $rc -eq 0 ]; then echo "FAIL $what: the check passed"; printf '%s\n' "$out" | sed 's/^/ /'; fails=$((fails+1)); return; fi + if [ -n "$needle" ] && ! grep -qF -- "$needle" <<<"$out"; then echo "FAIL $what: the output does not say '$needle'"; printf '%s\n' "$out" | sed 's/^/ /'; fails=$((fails+1)); return; fi + echo "ok $what" +} +sub() { # sub : one replacement, which must apply + python3 - "$1" "$2" "$3" <<'P' || { echo "FAIL the fixture edit did not apply: $2"; fails=$((fails+1)); } +import sys +p, a, b = sys.argv[1:] +s = open(p).read() +if s.count(a) != 1: + sys.exit(1) +open(p, "w").write(s.replace(a, b)) +P +} + +mk; expect pass "a tree whose every door names its operations as the catalogue does" "MCP: 2 tools, each an operation" +mk; expect pass "the C door's held names and its library API" "C: 1 functions name operations of the catalogue" +mk; expect pass "a Swift and a Kotlin method that name operations" "Kotlin: 2 methods name operations" + +mk; rm "$tmp/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs" +expect pass "a door with no operation yet says so" "no MCP door yet" + +mk; sub "$tmp/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs" '"envelope_inspect"' '"inspect_envelope"' +expect fail "an MCP tool misnamed" "tool 'inspect_envelope' names inspect.envelope, which the catalogue does not hold" + +mk; sub "$tmp/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs" '"room_grant"' '"dev_room_grant"' +expect fail "an MCP tool that is not noun_verb" "tool 'dev_room_grant' is not noun_verb" + +mk; printf 'pub(crate) const TOOLS: &[Tool] = &[];\n' > "$tmp/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs" +expect fail "an MCP tool table the check cannot read" "no tool name could be read" + +mk; sub "$tmp/joule-code/joule-code-rs/crates/svc/openapi.json" '"room.grant"' '"grant"' +expect fail "an OpenAPI operationId misnamed" "operationId 'grant' is not an operation of the catalogue" + +mk; sub "$tmp/joule-code/joule-code-rs/crates/svc/openapi.json" '/v1/rooms/{room}/grants' '/grant' +expect fail "an OpenAPI route that is not the catalogue's" "the catalogue routes room.grant to POST /v1/rooms/{room}/grants" + +mk; sub "$tmp/joule-code/joule-code-rs/crates/svc/openapi.json" '"room.grant"' '"envelope.inspect"' +expect fail "an OpenAPI operation the catalogue places on this machine" "envelope.inspect is placed local, not on a service" + +mk; printf 'int32_t wai_envelope_decode(const uint8_t *p);\n' >> "$tmp/wai/wai-rs/include/wai.h" +expect fail "a C function misnamed" "wai_envelope_decode names envelope.decode, which the catalogue does not hold" + +mk; printf 'int32_t wai_receipt_get(const uint8_t *p);\n' >> "$tmp/wai/wai-rs/include/wai.h" +expect fail "a C function with a near-synonym verb" "wai_receipt_get: the verb get is a near-synonym; the operation verb is show" + +mk; sub "$tmp/wai/wai-sink-swift/Sources/WaiSink/Envelope.swift" 'public func pack() {}' 'public func verify() {}' +expect fail "a Swift method misnamed" "WaiEnvelope.verify names envelope.verify, which the catalogue does not hold" + +mk; sub "$tmp/wai/wai-sink-android/core/src/main/kotlin/wai/sink/WaiReceipt.kt" 'fun verify(bytes' 'fun check(bytes' +expect fail "a Kotlin method misnamed" "WaiReceipt.check names receipt.check, which the catalogue does not hold" + +mk; sub "$tmp/wai/wai-sink-android/core/src/main/kotlin/wai/sink/WaiReceipt.kt" 'fun verifyAll(' 'fun describeAll(' +expect fail "a Kotlin companion method with a near-synonym verb" "WaiReceipt.describeAll: the verb describe is a near-synonym" + +mk; sub "$tmp/wai/wai-sink-android/core/src/main/kotlin/wai/sink/WaiReceipt.kt" 'private fun checkQuietly' 'fun checkQuietly' +expect fail "a private Kotlin method made public under an operation verb" "WaiReceipt.checkQuietly names receipt.check" + +if [ "$fails" -ne 0 ]; then + echo "check-wai-ops-doors-selftest: $fails case(s) failed" + exit 1 +fi +echo "check-wai-ops-doors-selftest: every case held" diff --git a/scripts/check-wai-ops-doors.py b/scripts/check-wai-ops-doors.py new file mode 100755 index 000000000..a3cd727e0 --- /dev/null +++ b/scripts/check-wai-ops-doors.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""check-wai-ops-doors.py: every door of WAI names its operations as the catalogue does. + +WAI's operations are one catalogue, `noun.verb` (wai/wai-rs/src/catalogue.rs), exported as +wai/catalogue/ops.json by `wai ops export` (CI holds the export to the table with +`wai ops export --check`). Each door names an operation by a mechanical transform of its id: + + MCP tool `noun_verb` wai/wai-rs/src/bin/wai_cli/mcp/tools.rs + HTTP API operationId `noun.verb` every openapi.json of the services + C function `wai_noun_verb` wai/wai-rs/include/wai.h + Swift type `Wai`, method `…` wai/wai-sink-swift/Sources + Kotlin class `Wai`, fun `…` wai/wai-sink-android/*/src/main + +The command line is held by the `wai` binary's own tests (wai_cli/door.rs). + +What each door is held to: + +- MCP: every tool's name is `noun_verb` of an operation in the catalogue. +- HTTP API: every operationId is an operation of the catalogue that runs on a service + (placement `service` or `both`), at the route the catalogue gives it. +- C, Swift, Kotlin: a name in a catalogue noun's namespace whose verb is an operation verb + (the closed list below) names an operation, and that operation must be in the catalogue; + a near-synonym verb (`get`, `describe`, `info`, `keygen`, `init`, `new`) in a catalogue + noun's namespace is refused. Any other name is library API the catalogue does not list + (the C ABI's codec and sink functions, the native sinks' classes), and is reported as such. + +A door that has no operation of the catalogue yet is reported as such; that is not a +failure. A wrong name is. + + check-wai-ops-doors.py [--root DIR] [--ops FILE] + +Exit 0 when every door holds, 1 with one line a finding otherwise. Standard library only. +""" +import glob +import json +import os +import re +import sys + +# The operation verbs: the closed list of WAI toolchain v1 (naming rule N3). +VERBS = { + "create", "list", "show", "update", "delete", "check", "verify", "run", "build", "export", + "import", "start", "stop", "join", "leave", "grant", "invite", "revoke", "rotate", "tail", + "search", "test", "load", "encode", "decode", "inspect", "wrap", "unwrap", "fetch", + "generate", "promote", "rollback", "lock", "upgrade", "setup", "guide", "stream", +} +# Near-synonyms of an operation verb, and the verb to use. +NEAR = {"get": "show", "describe": "show", "info": "show", "keygen": "create", "init": "create", "new": "create"} + +MCP_TABLE = "wai/wai-rs/src/bin/wai_cli/mcp/tools.rs" +OPENAPI_GLOBS = ["joule-code/joule-code-rs/crates/*/openapi.json", "wai/*/openapi.json", "wai/wai-rs/openapi.json"] +C_HEADER = "wai/wai-rs/include/wai.h" +SWIFT_GLOB = "wai/wai-sink-swift/Sources/**/*.swift" +KOTLIN_GLOB = "wai/wai-sink-android/*/src/main/**/*.kt" + + +def load_ops(path): + with open(path, encoding="utf-8") as f: + doc = json.load(f) + if doc.get("format") != "wai.ops/1": + raise SystemExit(f"{path}: not a catalogue export (format {doc.get('format')!r})") + return {o["id"]: o for o in doc["operations"]} + + +def split_id(i): + n, _, v = i.partition(".") + return n, v + + +def check_mcp(root, ops, findings, notes): + path = os.path.join(root, MCP_TABLE) + if not os.path.exists(path): + notes.append("MCP: no MCP door yet (no tool table); nothing to hold") + return + with open(path, encoding="utf-8") as f: + src = f.read() + names = re.findall(r'\bTool\s*\{[^{}]*?\bname:\s*"([^"]+)"', src, re.S) + if not names: + findings.append(f"MCP: {MCP_TABLE}: no tool name could be read (`Tool {{ name: \"…\", … }}`)") + return + bad = 0 + for t in names: + if t.count("_") != 1: + findings.append(f"MCP: tool {t!r} is not noun_verb of an operation") + bad += 1 + continue + i = t.replace("_", ".") + if i not in ops: + findings.append(f"MCP: tool {t!r} names {i}, which the catalogue does not hold") + bad += 1 + if not bad: + notes.append(f"MCP: {len(names)} tools, each an operation of the catalogue") + + +def openapi_operations(doc): + for path, item in (doc.get("paths") or {}).items(): + for method, op in item.items(): + if method.lower() in ("get", "put", "post", "delete", "patch", "head", "options", "trace") and isinstance(op, dict): + yield method.upper(), path, op.get("operationId") + + +def check_openapi(root, ops, findings, notes): + files = sorted({f for g in OPENAPI_GLOBS for f in glob.glob(os.path.join(root, g))}) + if not files: + notes.append("HTTP API: no OpenAPI document yet; nothing to hold") + return + for f in files: + rel = os.path.relpath(f, root) + with open(f, encoding="utf-8") as fh: + doc = json.load(fh) + n = bad = 0 + for method, path, oid in openapi_operations(doc): + n += 1 + where = f"HTTP API: {rel}: {method} {path}" + if not oid: + findings.append(f"{where}: no operationId") + bad += 1 + elif oid not in ops: + findings.append(f"{where}: operationId {oid!r} is not an operation of the catalogue") + bad += 1 + else: + o = ops[oid] + if o["placement"] not in ("service", "both"): + findings.append(f"{where}: {oid} is placed {o['placement']}, not on a service") + bad += 1 + r = o.get("route") + if r and (r["method"].upper(), r["path"]) != (method, path): + findings.append(f"{where}: the catalogue routes {oid} to {r['method']} {r['path']}") + bad += 1 + if not bad: + notes.append(f"HTTP API: {rel}: {n} operations, each an operation of the catalogue at its route") + + +def held_name(door, where, noun, verb, nouns, ops, findings): + """A name in `noun`'s namespace with `verb`: a finding when it names an operation the + catalogue does not hold, or uses a near-synonym. True when it names an operation.""" + if noun not in nouns: + return False + if verb in NEAR: + findings.append(f"{door}: {where}: the verb {verb} is a near-synonym; the operation verb is {NEAR[verb]}") + return True + if verb not in VERBS: + return False + i = f"{noun}.{verb}" + if i not in ops: + near = sorted(o for o in ops if split_id(o)[1] == verb) + hint = f" (operations with that verb: {', '.join(near)})" if near else "" + findings.append(f"{door}: {where} names {i}, which the catalogue does not hold{hint}") + return True + + +def check_c(root, ops, nouns, findings, notes): + path = os.path.join(root, C_HEADER) + if not os.path.exists(path): + notes.append("C: no header; nothing to hold") + return + with open(path, encoding="utf-8") as f: + src = f.read() + fns = sorted(set(re.findall(r"\b(wai_[a-z0-9_]+)\s*\(", src))) + mapped = other = 0 + before = len(findings) + for fn in fns: + parts = fn.split("_")[1:] + if len(parts) >= 2 and held_name("C", fn, parts[0], parts[1], nouns, ops, findings): + mapped += 1 + else: + other += 1 + if len(findings) == before: + if mapped: + notes.append(f"C: {mapped} functions name operations of the catalogue, each held; {other} are library API") + else: + notes.append(f"C: no operation of the catalogue in this door yet; its {other} functions are library API (codecs, envelopes, the sink)") + + +def camel_verb(name): + m = re.match(r"[a-z]+", name) + return m.group(0) if m else "" + + +TYPE_RE = { + "Swift": re.compile(r"^\s*(?:@\w+\s+)*(public|open)\s+(?:final\s+)?(?:class|struct|enum|actor)\s+(\w+)|^\s*(?:public\s+)?extension\s+(\w+)"), + "Kotlin": re.compile(r"^\s*(?!private|internal)(?:public\s+)?(?:open\s+|abstract\s+|sealed\s+|data\s+|enum\s+|final\s+)*(?:class|object|interface)\s+(\w+)"), +} +FUNC_RE = { + "Swift": re.compile(r"^\s*(?:@\w+\s+)*(?:public|open)\s+(?:static\s+|class\s+|final\s+|override\s+)*func\s+(\w+)"), + "Kotlin": re.compile(r"^\s*(?!.*\b(?:private|internal|protected)\b)(?:public\s+|override\s+|suspend\s+|inline\s+|operator\s+)*fun\s+(?:<[^>]*>\s*)?(\w+)"), +} + + +def sdk_names(lang, src): + """Every (type, method) the source declares public: a method under the type whose body + holds it, a companion object's under its class. A type's body opens at the first line + after its declaration that ends with `{` (a Kotlin primary constructor can take several + lines, and a default argument can hold a lambda's braces).""" + out = [] + stack = [] # (type name, brace depth inside its body) + depth = 0 + pending = None + for line in src.splitlines(): + code = re.sub(r'"(?:\\.|[^"\\])*"', '""', line.split("//")[0]) + name = None + t = TYPE_RE[lang].match(code) + if t: + name = next(g for g in t.groups() if g and g not in ("public", "open")) + elif lang == "Kotlin" and re.match(r"^\s*companion\s+object\b", code) and stack: + name = stack[-1][0] + f = FUNC_RE[lang].match(code) + if f and stack: + out.append((stack[-1][0], f.group(1))) + if name: + pending = name + if pending and code.rstrip().endswith("{"): + stack.append((pending, depth + 1)) + pending = None + for ch in code: + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + while stack and depth < stack[-1][1]: + stack.pop() + return out + + +def check_sdk(lang, root, pattern, ops, nouns, findings, notes): + files = sorted(glob.glob(os.path.join(root, pattern), recursive=True)) + if not files: + notes.append(f"{lang}: no sources; nothing to hold") + return + mapped = other = 0 + before = len(findings) + for f in files: + with open(f, encoding="utf-8") as fh: + src = fh.read() + for typ, meth in sdk_names(lang, src): + if not typ.startswith("Wai") or len(typ) <= 3: + other += 1 + continue + noun = typ[3:].lower() + where = f"{os.path.relpath(f, root)}: {typ}.{meth}" + if held_name(lang, where, noun, camel_verb(meth), nouns, ops, findings): + mapped += 1 + else: + other += 1 + if len(findings) == before: + if mapped: + notes.append(f"{lang}: {mapped} methods name operations of the catalogue, each held; {other} are library API") + else: + notes.append(f"{lang}: no operation of the catalogue in this door yet; its {other} public names are the native sink's API, whose nouns the catalogue does not hold") + + +def main(argv): + root, ops_path = ".", None + i = 0 + while i < len(argv): + if argv[i] == "--root" and i + 1 < len(argv): + root = argv[i + 1] + i += 2 + elif argv[i] == "--ops" and i + 1 < len(argv): + ops_path = argv[i + 1] + i += 2 + else: + print(__doc__.strip().splitlines()[0], file=sys.stderr) + print("usage: check-wai-ops-doors.py [--root DIR] [--ops FILE]", file=sys.stderr) + return 2 + ops = load_ops(ops_path or os.path.join(root, "wai/catalogue/ops.json")) + nouns = {split_id(i)[0] for i in ops} + findings, notes = [], [] + check_mcp(root, ops, findings, notes) + check_openapi(root, ops, findings, notes) + check_c(root, ops, nouns, findings, notes) + check_sdk("Swift", root, SWIFT_GLOB, ops, nouns, findings, notes) + check_sdk("Kotlin", root, KOTLIN_GLOB, ops, nouns, findings, notes) + for n in notes: + print(f"ok {n}") + for f in findings: + print(f"FAIL {f}") + print(f"check-wai-ops-doors: {len(ops)} operations; {len(findings)} finding(s)") + return 1 if findings else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) -- 2.54.0 (Apple Git-157) From 482244fdced669d529b579736708597ba1145a56 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 27 Oct 2026 05:00:48 -0400 Subject: [PATCH 06/21] wai breaking and wai conform: the compatibility gate, and conformance anyone can run `wai breaking --against ` (operation compat.check) reports what the working tree breaks against the merge base of a ref, in three cumulative categories: wire (the registry export, the media and delivery corpora), record (the signing domains, magics and versions in the WAI crates' sources, the record corpora, the published JSON Schemas) and API (the Rust public API of wai and wai-quantum from rustdoc's JSON, the C header, the room service's OpenAPI document). Each finding names its item, category, what changed and why it breaks; additions are compatible. A break passes only when a line the change adds to wai/CHANGELOG.md, in an entry that says Breaking, names its id. `wai conform --suite --impl ` (operation conformance.run) runs a corpus against another implementation through a command-line or an HTTP adapter: verifiers (claim, jwp, signer), a decoder (score), a packager (policy) and an origin (http). The result is a signed conform-result/1 record that `wai verify` checks. Both follow the agent rules: the envelope/1 JSON output, the exit statuses, --dry-run. .github/workflows/wai-breaking.yml runs the gate on every pull request that touches WAI, the tests of both commands, and the corpora's independent Python verifiers and the reference packager through wai conform. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-breaking.yml | 153 ++++ wai/CHANGELOG.md | 34 + wai/README.md | 14 + wai/wai-rs/Cargo.toml | 11 + .../src/bin/wai_cli/dx/breaking/c_header.rs | 368 ++++++++++ .../src/bin/wai_cli/dx/breaking/corpora.rs | 110 +++ .../src/bin/wai_cli/dx/breaking/domains.rs | 165 +++++ wai/wai-rs/src/bin/wai_cli/dx/breaking/git.rs | 332 +++++++++ .../src/bin/wai_cli/dx/breaking/jsondiff.rs | 158 ++++ wai/wai-rs/src/bin/wai_cli/dx/breaking/mod.rs | 352 +++++++++ .../src/bin/wai_cli/dx/breaking/openapi.rs | 175 +++++ .../src/bin/wai_cli/dx/breaking/registry.rs | 165 +++++ .../src/bin/wai_cli/dx/breaking/rust_api.rs | 682 ++++++++++++++++++ .../src/bin/wai_cli/dx/breaking/schema.rs | 112 +++ .../src/bin/wai_cli/dx/conform/adapter.rs | 196 +++++ wai/wai-rs/src/bin/wai_cli/dx/conform/http.rs | 169 +++++ wai/wai-rs/src/bin/wai_cli/dx/conform/mod.rs | 578 +++++++++++++++ .../src/bin/wai_cli/dx/conform/record.rs | 145 ++++ .../src/bin/wai_cli/dx/conform/suites.rs | 257 +++++++ wai/wai-rs/src/bin/wai_cli/dx/envelope.rs | 135 ++++ wai/wai-rs/src/bin/wai_cli/dx/mod.rs | 28 +- wai/wai-rs/src/bin/wai_cli/dx/verify.rs | 31 +- wai/wai-rs/tests/cli_breaking.rs | 274 +++++++ wai/wai-rs/tests/cli_conform.rs | 252 +++++++ 24 files changed, 4889 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/wai-breaking.yml create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/breaking/c_header.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/breaking/corpora.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/breaking/domains.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/breaking/git.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/breaking/jsondiff.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/breaking/mod.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/breaking/openapi.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/breaking/registry.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/breaking/rust_api.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/breaking/schema.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/conform/adapter.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/conform/http.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/conform/mod.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/conform/record.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/conform/suites.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/envelope.rs create mode 100644 wai/wai-rs/tests/cli_breaking.rs create mode 100644 wai/wai-rs/tests/cli_conform.rs diff --git a/.github/workflows/wai-breaking.yml b/.github/workflows/wai-breaking.yml new file mode 100644 index 000000000..3782365b0 --- /dev/null +++ b/.github/workflows/wai-breaking.yml @@ -0,0 +1,153 @@ +name: WAI breaking changes and the conformance runner + +# The gate WAI's libraries are published behind (toolchain v1, decision 7): every pull request +# that touches WAI runs `wai breaking --against origin/main`, and fails on a breaking change that +# wai/CHANGELOG.md does not name. Two jobs: +# +# - breaking: the check itself, against the merge base of the pull request and main, in all +# three categories: wire (the registry export, the media and delivery corpora), record (the +# signing domains, magics and versions, the record corpora, the published JSON Schemas) and +# API (the Rust public API of the WAI library crates through rustdoc's JSON, the C header, +# the room service's OpenAPI document). +# - runner: the tests of `wai breaking` (each kind of break reported and failing until a +# changelog entry names it) and of `wai conform` (the reference implementations conform, a +# changed verdict or output byte fails, a changed record is refused by `wai verify`); then +# `wai conform` runs the corpora's independent Python verifiers and the reference packager, +# and `wai verify` checks every record those runs wrote. +# +# Push filters match the push's delta, and the `relevant` job gates the work from the pull +# request's own diff (scripts/pr-touches-paths.py, reading the list below), as wai-pack.yml +# explains. + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - 'wai/**' + # The room service's API is part of the API category. + - 'joule-code/joule-code-rs/crates/joule-moq-room/openapi.json' + # The toolchain pin decides which compiler documents both sides of the Rust API. + - 'rust-toolchain.toml' + - 'scripts/pr-touches-paths.py' + - '.github/workflows/wai-breaking.yml' + +jobs: + relevant: + name: WAI breaking · does this change touch WAI + 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-breaking.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches WAI: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + breaking: + name: WAI breaking · wire, record and API against main · linux + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - 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 + rustup default 1.98.0 + SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" + command -v python3 >/dev/null || { $SUDO apt-get update -qq; $SUDO apt-get install -y -qq python3; } + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Build wai + working-directory: wai/wai-rs + shell: bash + run: | + set -euo pipefail + cargo build --locked --release --no-default-features --bin wai + out=$(cargo metadata --format-version 1 --no-deps --locked | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])') + mkdir -p "$RUNNER_TEMP/bin" && cp "$out/release/wai" "$RUNNER_TEMP/bin/" + # The base is main's tip as fetched now; `wai breaking` compares against its merge base + # with this pull request, so what main gained since the branch left it is not read as + # this change removing it. Every finding is printed: each break with its category, what + # changed and why it breaks, and the line that acknowledges it if it is intended. + - name: wai breaking --against origin/main + shell: bash + run: | + set -euo pipefail + git fetch -q --no-tags origin main + "$RUNNER_TEMP/bin/wai" breaking --against origin/main --text --all + + runner: + name: WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux + timeout-minutes: 90 + 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 + rustup default 1.98.0 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + # claim_verify.py, jwp_verify.py and signer_verify.py check Ed25519's equation with it. + - name: Install the Ed25519 library + shell: bash + run: python -m pip install cryptography + # The tests build fixture repositories with git and document a fixture crate with rustdoc. + - name: The tests of wai breaking and wai conform + working-directory: wai/wai-rs + shell: bash + run: | + set -euo pipefail + git config --global user.email ci@example.invalid + git config --global user.name ci + cargo test --locked --no-default-features --features quickstart,score,key_discovery \ + --bin wai --test cli_breaking --test cli_conform + - name: Build wai and the reference packager + shell: bash + run: | + set -euo pipefail + out() { (cd "$1" && cargo metadata --format-version 1 --no-deps --locked | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])'); } + mkdir -p "$RUNNER_TEMP/bin" + (cd wai/wai-rs && cargo build --locked --release --no-default-features --features quickstart --bin wai) && cp "$(out wai/wai-rs)/release/wai" "$RUNNER_TEMP/bin/" + (cd wai/wai-pack && cargo build --locked --release --bin wai-pack) && cp "$(out wai/wai-pack)/release/wai-pack" "$RUNNER_TEMP/bin/" + # The second implementations of the claim, JWP and key-discovery corpora, written from the + # extension texts and sharing no code with wai-rs, run through the conformance runner as + # any other implementation would be; then the reference packager's policy reader. Each + # run must be conformant, and every record it signs must verify. + - name: wai conform with the independent verifiers and the reference packager + shell: bash + run: | + set -euo pipefail + w="$RUNNER_TEMP/bin/wai"; d="$RUNNER_TEMP/conform"; mkdir -p "$d" + for s in claim jwp signer; do + "$w" conform verifier --suite "$s" --text --root wai \ + --impl "python3 -I wai/$s-conformance/${s}_verify.py verdicts {corpus}" --out "$d/$s.wai-conform.json" + done + "$w" conform packager --suite policy --text --root wai \ + --impl "$RUNNER_TEMP/bin/wai-pack policy-verdict {in}" --out "$d/policy.wai-conform.json" + "$w" verify "$d" diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 2241bf598..a0ab2a0a5 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,40 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### `wai breaking` and `wai conform`: the compatibility gate, and conformance anyone can run + +- **`wai breaking --against `** (operation `compat.check`) reports what the working + tree breaks against a ref, in three cumulative categories, strictest by default: **wire** (the + registry export: capabilities and their classes, deterministic identifiers, wire identifiers; + the media and delivery corpora), **record** (the domain-separation prefixes, magics and + versions records are built from; the corpora of signed records and their verdicts; the + published JSON Schemas) and **API** (the Rust public API of `wai` and `wai-quantum`, read from + the compiler's rustdoc JSON; the C header `wai-rs/include/wai.h`; the room service's OpenAPI + document). It compares against the merge base of the ref and `HEAD`. Each finding names its + item, its category, what changed and why that breaks; additions are listed as compatible. +- **An intended break passes only when this file names it**: a line the change adds here, in + an entry that says **Breaking**, with the finding's id (as `wai breaking` prints it, such as + api:c:wai_envelope_pack) in backticks. Otherwise the run exits 1. +- **`.github/workflows/wai-breaking.yml`** runs it on every pull request that touches WAI, + against main. WAI's libraries go to public registries behind this gate. +- **`wai conform --suite --impl `** (operation + `conformance.run`) runs one of WAI's corpora against another implementation: verifiers + (`claim`, `jwp`, `signer`), a decoder (`score`), a packager (`policy`) and an origin + (`http`). A command-line adapter gives each case as a file (`{case}`, `{in}`), the corpus + whole (`{corpus}`), or standard input, and reads the answer from standard output or `{out}`; + an HTTP adapter posts each case to a URL, or, for an origin, fetches from it. Every + expectation is the corpus's own. +- **The result is a signed record**, `conform-result/1` (`*.wai-conform.json`): the corpus + digest, the implementation (its command and the SHA-256 of each file it names, or its URL), + each case's outcome, the summary and the standard's version, signed with Ed25519 by a key + made for the run or one given with `--key`. **`wai verify`** checks its signature and that its + summary and verdict are what its cases say. CI runs the corpora's independent Python + verifiers and the reference packager through it. +- Both follow the toolchain's agent rules: the JSON envelope `envelope/1` with `--json` or off a + terminal, one exit status for each outcome (0 held, 1 a check failed, 2 usage, 4 a record + would be replaced without `--confirm`, 5 not found, 6 the implementation does not answer, 7 + internal), and `wai conform --dry-run`. + ### The developer's path: `wai hello`, `create`, `dev` and `verify`; the floor with no system library; the examples - **`floor`** (wai-rs feature): the mandatory floor of SPEC §5 (PNG, FLAC, zstd) decoded with no diff --git a/wai/README.md b/wai/README.md index bcf907c98..6b60370f0 100644 --- a/wai/README.md +++ b/wai/README.md @@ -86,6 +86,20 @@ cargo run --no-default-features --bin wai -- capabilities # the registry, one r cargo build --release --lib # with the codecs feature: libwai.dylib/.so/.dll + libwai.a ``` +Two commands hold what is published to what was published before, and other implementations +to WAI's corpora: + +```bash +wai breaking --against origin/main # what this tree breaks: wire, record and API (CI runs it on every pull request) +wai conform verifier --suite claim --impl "my-verifier verdicts {corpus}" # a corpus run against your implementation +wai verify claim.wai-conform.json # the signed result, checked +``` + +A break passes only when [`CHANGELOG.md`](CHANGELOG.md) names it: an entry this change adds that +says **Breaking** and gives the finding's id in backticks. `wai conform --list` names every kind +(verifier, decoder, packager, origin) and suite, with its adapters: a command line, or an +`http://` URL. + With the `codecs` feature, `wai-rs/build.rs` finds libFLAC through pkg-config and, on macOS without pkg-config, in the Homebrew "flac" formula prefix (`/opt/homebrew/opt/flac/lib` or `/usr/local/opt/flac/lib`). Where neither finds diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index f7aa41ab8..4c5bde015 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -1467,6 +1467,17 @@ name = "cli_dx" path = "tests/cli_dx.rs" required-features = ["quickstart"] +# `wai breaking` needs no feature; `wai conform` runs the reference verifiers, the score +# renderer and the origin of this crate as the implementations under test. +[[test]] +name = "cli_breaking" +path = "tests/cli_breaking.rs" + +[[test]] +name = "cli_conform" +path = "tests/cli_conform.rs" +required-features = ["quickstart", "score", "key_discovery"] + [[test]] name = "jpegai_decode_symbols" path = "tests/jpegai_decode_symbols.rs" diff --git a/wai/wai-rs/src/bin/wai_cli/dx/breaking/c_header.rs b/wai/wai-rs/src/bin/wai_cli/dx/breaking/c_header.rs new file mode 100644 index 000000000..0669138f1 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/breaking/c_header.rs @@ -0,0 +1,368 @@ +//! API: the C header (`wai/wai-rs/include/wai.h`, generated by cbindgen from the FFI and +//! committed). A program compiled and linked against the old header must still build and +//! run against the new library. So a function that is gone, or whose return type or +//! parameter types changed (their names are not compared), breaks; so does a struct whose +//! fields changed in any way (its size or layout differs), an opaque type or typedef that +//! is gone or changed, an enumerator or macro constant that is gone or holds another +//! value, and a declaration moved under a preprocessor condition. New declarations are +//! compatible. + +use super::{Category, Finding}; +use std::collections::BTreeMap; + +pub const PATH: &str = "wai/wai-rs/include/wai.h"; + +/// One declaration of the header. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Decl { + /// A function: its return type and its parameters' types, in order. + Function { ret: String, params: Vec }, + /// A struct or union with its fields (`type name`, in order), or none for an opaque one. + Struct { fields: Option> }, + /// An enumeration: each enumerator and its value. + Enum { values: Vec<(String, String)> }, + Typedef { ty: String }, + /// A macro constant, with every value it is defined to (cbindgen repeats some). + Macro { values: Vec }, + Variable { ty: String }, +} + +/// A declaration and the preprocessor conditions it sits under (empty for none). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Item { + pub decl: Decl, + pub cond: String, +} + +fn strip_comments(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + let b = src.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i] == b'/' && b.get(i + 1) == Some(&b'*') { + i += 2; + while i + 1 < b.len() && !(b[i] == b'*' && b[i + 1] == b'/') { + if b[i] == b'\n' { + out.push('\n'); + } + i += 1; + } + i += 2; + } else if b[i] == b'/' && b.get(i + 1) == Some(&b'/') { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + } else { + let ch = src[i..].chars().next().unwrap_or(' '); + out.push(ch); + i += ch.len_utf8(); + } + } + out +} + +/// Whitespace collapsed, with `*`, `(`, `)`, `[`, `]` and `,` set apart so spellings +/// that differ only in spacing compare equal. +fn norm(s: &str) -> String { + let spaced: String = s.chars().flat_map(|c| if "*()[],".contains(c) { vec![' ', c, ' '] } else { vec![c] }).collect(); + spaced.split_whitespace().collect::>().join(" ") +} + +fn ident(s: &str) -> bool { + !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !s.starts_with(|c: char| c.is_ascii_digit()) +} + +/// A parameter's type: its text without the trailing name. +fn param_type(p: &str) -> String { + let n = norm(p); + let toks: Vec<&str> = n.split(' ').collect(); + let last = toks.last().copied().unwrap_or(""); + let basic = matches!(last, "void" | "int" | "char" | "long" | "short" | "unsigned" | "signed" | "float" | "double" | "bool"); + if toks.len() > 1 && ident(last) && !basic { toks[..toks.len() - 1].join(" ") } else { n.clone() } +} + +fn split_top(s: &str, sep: char) -> Vec { + let mut out = Vec::new(); + let (mut depth, mut cur) = (0i32, String::new()); + for c in s.chars() { + match c { + '(' | '{' | '[' => depth += 1, + ')' | '}' | ']' => depth -= 1, + _ => {} + } + if c == sep && depth == 0 { + out.push(std::mem::take(&mut cur)); + } else { + cur.push(c); + } + } + if !cur.trim().is_empty() { + out.push(cur); + } + out +} + +fn declaration(text: &str, cond: &str, out: &mut BTreeMap) { + let t = norm(text); + if t.is_empty() { + return; + } + let item = |decl| Item { decl, cond: cond.to_string() }; + let body = |t: &str| -> Option<(String, String, String)> { + let open = t.find('{')?; + let close = t.rfind('}')?; + Some((t[..open].trim().to_string(), t[open + 1..close].trim().to_string(), t[close + 1..].trim().to_string())) + }; + if let Some((head, inner, tail)) = body(&t) { + let words: Vec<&str> = head.split(' ').collect(); + let is_enum = words.contains(&"enum"); + let tag = words.last().filter(|w| ident(w) && !matches!(**w, "struct" | "union" | "enum" | "typedef")).map(|w| w.to_string()); + let name = if tail.is_empty() { tag } else { Some(tail.trim_end_matches(';').trim().to_string()) }; + let Some(name) = name else { return }; + if is_enum { + let mut values = Vec::new(); + let mut next: i128 = 0; + for e in split_top(&inner, ',') { + let e = e.trim(); + if e.is_empty() { + continue; + } + let (n, v) = match e.split_once('=') { + Some((n, v)) => (n.trim().to_string(), norm(v)), + None => (e.to_string(), next.to_string()), + }; + next = v.parse::().map(|x| x + 1).unwrap_or(next + 1); + values.push((n, v)); + } + out.insert(name, item(Decl::Enum { values })); + } else { + let fields = split_top(&inner, ';').into_iter().map(|f| norm(&f)).filter(|f| !f.is_empty()).collect(); + out.insert(name, item(Decl::Struct { fields: Some(fields) })); + } + return; + } + if let Some(rest) = t.strip_prefix("typedef ") { + let words: Vec<&str> = rest.split(' ').collect(); + if matches!(words.first(), Some(&"struct") | Some(&"union")) && words.len() == 3 && !rest.contains('(') { + out.entry(words[2].to_string()).or_insert_with(|| item(Decl::Struct { fields: None })); + return; + } + // `typedef `, or a function pointer `typedef R ( * name ) ( … )`. + let name = if let Some(i) = rest.find("( * ") { + rest[i + 4..].split(' ').next().unwrap_or("").to_string() + } else { + words.last().unwrap_or(&"").to_string() + }; + if ident(&name) { + out.insert(name, item(Decl::Typedef { ty: rest.to_string() })); + } + return; + } + if let Some(open) = t.find(" ( ") { + let head = &t[..open]; + let name = head.split(' ').next_back().unwrap_or("").to_string(); + if !ident(&name) { + return; + } + let ret = head[..head.len() - name.len()].trim().trim_start_matches("extern ").to_string(); + let close = t.rfind(')').unwrap_or(t.len()); + let inner = &t[open + 3..close]; + let params: Vec = split_top(inner, ',').iter().map(|p| param_type(p)).filter(|p| !p.is_empty()).collect(); + let params = if params == ["void"] { Vec::new() } else { params }; + out.insert(name, item(Decl::Function { ret, params })); + return; + } + let words: Vec<&str> = t.split(' ').collect(); + if words.len() >= 2 && ident(words[words.len() - 1]) { + let name = words[words.len() - 1].to_string(); + let ty = words[..words.len() - 1].join(" ").trim_start_matches("extern ").to_string(); + out.insert(name, item(Decl::Variable { ty })); + } +} + +/// Every declaration of a header, by name. +pub fn parse(src: &str) -> BTreeMap { + let text = strip_comments(src); + let mut out: BTreeMap = BTreeMap::new(); + let mut conds: Vec = Vec::new(); + let mut guard: Option = None; + let mut pending = String::new(); + let mut lines = text.lines().peekable(); + while let Some(line) = lines.next() { + let mut l = line.trim().to_string(); + while l.ends_with('\\') { + l.pop(); + l.push(' '); + l.push_str(lines.next().unwrap_or("").trim()); + } + if let Some(d) = l.strip_prefix('#') { + let d = d.trim(); + let (word, rest) = d.split_once(char::is_whitespace).map(|(a, b)| (a, b.trim())).unwrap_or((d, "")); + match word { + "ifndef" if guard.is_none() && conds.is_empty() => { + guard = Some(rest.to_string()); + conds.push(String::new()); + } + "if" | "ifdef" | "ifndef" => conds.push(format!("{word} {rest}")), + "else" => { + if let Some(c) = conds.last_mut() { + *c = format!("not ({c})"); + } + } + "elif" => { + if let Some(c) = conds.last_mut() { + *c = format!("elif {rest}"); + } + } + "endif" => { + conds.pop(); + } + "define" => { + let (name, value) = rest.split_once(char::is_whitespace).map(|(a, b)| (a, norm(b))).unwrap_or((rest, String::new())); + if Some(name) != guard.as_deref() && ident(name) { + let cond = conds.iter().filter(|c| !c.is_empty()).cloned().collect::>().join(" && "); + match out.get_mut(name) { + Some(Item { decl: Decl::Macro { values }, .. }) => { + if !values.contains(&value) { + values.push(value); + values.sort(); + } + } + _ => { + out.insert(name.to_string(), Item { decl: Decl::Macro { values: vec![value] }, cond }); + } + } + } + } + _ => {} + } + continue; + } + // The `extern "C" {` wrapper sits under `__cplusplus`; nothing there is a declaration. + if conds.iter().any(|c| c.contains("__cplusplus")) { + continue; + } + let cond = conds.iter().filter(|c| !c.is_empty()).cloned().collect::>().join(" && "); + for ch in l.chars().chain(std::iter::once(' ')) { + pending.push(ch); + if ch == ';' { + let depth: i32 = pending.chars().map(|c| match c { + '{' => 1, + '}' => -1, + _ => 0, + }).sum(); + if depth == 0 { + let decl = std::mem::take(&mut pending); + declaration(decl.trim().trim_end_matches(';'), &cond, &mut out); + } + } + } + } + out +} + +fn show(d: &Decl, name: &str) -> String { + match d { + Decl::Function { ret, params } => format!("{ret} {name}({})", params.join(", ")), + Decl::Struct { fields: Some(f) } => format!("struct {name} {{ {} }}", f.join("; ")), + Decl::Struct { fields: None } => format!("struct {name} (opaque)"), + Decl::Enum { values } => format!("enum {name} {{ {} }}", values.iter().map(|(n, v)| format!("{n} = {v}")).collect::>().join(", ")), + Decl::Typedef { ty } => format!("typedef {ty}"), + Decl::Macro { values } => format!("#define {name} {}", values.join(" | ")), + Decl::Variable { ty } => format!("{ty} {name}"), + } +} + +fn kind(d: &Decl) -> &'static str { + match d { + Decl::Function { .. } => "function", + Decl::Struct { .. } => "struct", + Decl::Enum { .. } => "enum", + Decl::Typedef { .. } => "typedef", + Decl::Macro { .. } => "constant", + Decl::Variable { .. } => "variable", + } +} + +/// The header's findings, from the two headers (`None` where a tree has none). +pub fn compare(old: Option<&str>, new: Option<&str>) -> Vec { + let a = old.map(parse).unwrap_or_default(); + let b = new.map(parse).unwrap_or_default(); + let mut out = Vec::new(); + for (name, x) in &a { + let k = kind(&x.decl); + let Some(y) = b.get(name) else { + out.push(Finding::broke( + Category::Api, + "c", + name.clone(), + "removed".into(), + format!("the {k} {} is gone", show(&x.decl, name)), + format!("a program that uses {name} no longer compiles against the header, or no longer links against the library"), + )); + continue; + }; + let why = match (&x.decl, &y.decl) { + _ if x.cond != y.cond => Some(format!("now declared under `{}` (was `{}`)", y.cond, x.cond)), + (Decl::Struct { fields: Some(_) }, Decl::Struct { fields: None }) => Some("its fields are no longer declared".into()), + (Decl::Struct { fields: None }, Decl::Struct { fields: Some(_) }) => None, + (Decl::Enum { values: va }, Decl::Enum { values: vb }) => { + let gone: Vec = va.iter().filter(|e| !vb.contains(e)).map(|(n, v)| format!("{n} = {v}")).collect(); + (!gone.is_empty()).then(|| format!("{} gone or renumbered", gone.join(", "))) + } + (Decl::Macro { values: va }, Decl::Macro { values: vb }) => { + (va.iter().any(|v| !vb.contains(v))).then(|| format!("value {} → {}", va.join(" | "), vb.join(" | "))) + } + (p, q) if p != q => Some(format!("{} → {}", show(p, name), show(q, name))), + _ => None, + }; + if let Some(what) = why { + let reason = match &x.decl { + Decl::Function { .. } => "a caller compiled against the old prototype passes or reads the wrong types: undefined behaviour, or a build that fails", + Decl::Struct { .. } => "the struct's size or layout differs, so a caller compiled against the old header reads and writes the wrong bytes", + Decl::Macro { .. } | Decl::Enum { .. } => "a program compiled with the old value passes or compares a value the library now reads differently", + _ => "a program compiled against the old declaration no longer matches the library", + }; + out.push(Finding::broke(Category::Api, "c", name.clone(), "changed".into(), format!("the {k} {name}: {what}"), reason.into())); + } else if x.decl != y.decl { + out.push(Finding::compatible(Category::Api, "c", name.clone(), format!("{} → {}", show(&x.decl, name), show(&y.decl, name)))); + } + } + for (name, y) in &b { + if !a.contains_key(name) { + out.push(Finding::compatible(Category::Api, "c", name.clone(), format!("the {} {} is new", kind(&y.decl), show(&y.decl, name)))); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const H: &str = "#ifndef WAI_H\n#define WAI_H\n#include \n/** doc */\n#define WAI_OK 0\n#define CREATE 1\n#define CREATE 1\n\ + typedef struct wai_sink_t wai_sink_t;\ntypedef struct wai_buffer_t {\n uint8_t *data; // the bytes\n uintptr_t len;\n} wai_buffer_t;\n\ + #ifdef __cplusplus\nextern \"C\" {\n#endif\n int wai_pack(const char *manifest,\n const uint8_t *payload, uintptr_t len,\n struct wai_buffer_t *out);\n\ + uint32_t wai_abi(void);\n#ifdef __cplusplus\n}\n#endif\n#endif\n"; + + #[test] + fn parses_what_cbindgen_writes() { + let m = parse(H); + assert_eq!(m["wai_pack"].decl, Decl::Function { ret: "int".into(), params: vec!["const char *".into(), "const uint8_t *".into(), "uintptr_t".into(), "struct wai_buffer_t *".into()] }); + assert_eq!(m["wai_abi"].decl, Decl::Function { ret: "uint32_t".into(), params: vec![] }); + assert_eq!(m["wai_buffer_t"].decl, Decl::Struct { fields: Some(vec!["uint8_t * data".into(), "uintptr_t len".into()]) }); + assert_eq!(m["wai_sink_t"].decl, Decl::Struct { fields: None }); + assert_eq!(m["CREATE"].decl, Decl::Macro { values: vec!["1".into()] }); + assert!(!m.contains_key("WAI_H")); + assert_eq!(m["wai_pack"].cond, ""); + } + + #[test] + fn a_changed_parameter_type_breaks_and_a_renamed_one_does_not() { + let renamed = H.replace("uintptr_t len,\n", "uintptr_t payload_len,\n"); + assert!(compare(Some(H), Some(&renamed)).iter().all(|f| !f.breaking)); + let changed = H.replace("uintptr_t len,\n", "uint32_t len,\n"); + let f = compare(Some(H), Some(&changed)); + assert!(f.iter().any(|f| f.breaking && f.id == "api:c:wai_pack"), "{f:?}"); + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/breaking/corpora.rs b/wai/wai-rs/src/bin/wai_cli/dx/breaking/corpora.rs new file mode 100644 index 000000000..d30828180 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/breaking/corpora.rs @@ -0,0 +1,110 @@ +//! Wire and record: the conformance corpora (`wai/*-conformance/`) and the pinned vectors +//! of the reference (`wai/wai-rs/tests/vectors/`). Each vector states bytes a conforming +//! reader must accept or refuse, and what it must make of them: a decoded output's digest, +//! a verdict, a reason. The conformance workflow holds every corpus to the code that +//! generates it, byte for byte, so a change in what the code writes or signs (a field, a +//! signing preimage, a magic) reaches these files, or that workflow fails. +//! +//! So a vector file that is gone, or whose bytes changed, breaks; a JSON file breaks where +//! an entry or member is gone or a value changed, and is compatible where it only gained +//! entries; a JSON Lines file breaks where a line is gone. A new file is compatible. +//! Verifier code, documentation and scripts beside a corpus are not vectors and are not +//! compared. The record corpora (signed records and their verdicts) report under +//! `record`; the others, under `wire`. + +use super::git::{Repo, Status}; +use super::jsondiff; +use super::super::envelope::OpError; +use super::{Category, Finding}; + +pub const PATHSPECS: &[&str] = &[":(glob)wai/*-conformance/**", ":(glob)wai/wai-rs/tests/vectors/**"]; + +/// The corpora of signed records and their verdicts. +const RECORD_CORPORA: &[&str] = &[ + "claim-conformance", + "signer-conformance", + "jwp-conformance", + "pq-conformance", + "oer-conformance", + "pin-conformance", + "edge-conformance", + "live-conformance", +]; + +/// Files beside a corpus that are not vectors: verifiers, generators, scripts, notes. +const NOT_VECTORS: &[&str] = &[ + ".py", ".pyc", ".mjs", ".js", ".ts", ".md", ".sh", ".rs", ".toml", ".lock", ".patch", ".txt", ".gitattributes", ".gitignore", +]; + +fn is_vector(path: &str) -> bool { + !path.split('/').any(|c| c == "__pycache__" || (c == "src" && path.starts_with("wai/byte-exact-conformance/"))) + && !NOT_VECTORS.iter().any(|x| path.ends_with(x)) +} + +fn category(path: &str) -> Category { + let dir = path.split('/').nth(1).unwrap_or(""); + if RECORD_CORPORA.contains(&dir) { Category::Record } else { Category::Wire } +} + +fn list(xs: &[String]) -> String { + let n = xs.len(); + let shown: Vec<&str> = xs.iter().take(4).map(String::as_str).collect(); + if n > 4 { format!("{} (and {} more)", shown.join("; "), n - 4) } else { shown.join("; ") } +} + +/// The corpora's findings between `base` and the working tree. +pub fn compare(repo: &Repo, base: &str) -> Result, OpError> { + let changed: Vec<(Status, String)> = repo.changed(base, PATHSPECS)?.into_iter().filter(|(_, p)| is_vector(p)).collect(); + let modified: Vec = changed.iter().filter(|(s, p)| *s == Status::Modified && (p.ends_with(".json") || p.ends_with(".jsonl"))).map(|x| x.1.clone()).collect(); + let olds = repo.base_files(base, &modified)?; + let mut out = Vec::new(); + for (status, path) in changed { + let cat = category(&path); + let why_bytes = if cat == Category::Record { + "a verifier conforms by reaching each vector's stated verdict on these bytes; records signed or read as before no longer match it" + } else { + "a reader conforms by decoding these bytes to the stated output; readers built against the old vector disagree with the new one" + }; + match status { + Status::Added => out.push(Finding::compatible(cat, "corpus", path, "a new vector file".into())), + Status::Removed => out.push(Finding::broke(cat, "corpus", path, "removed".into(), "the vector file is gone".into(), why_bytes.into())), + Status::Modified => { + let json = path.ends_with(".json"); + let lines = path.ends_with(".jsonl"); + let parsed = if json || lines { + let old = olds.get(&path).cloned().unwrap_or_default(); + let new = repo.head_file(&path).unwrap_or_default(); + if lines { + Some(jsondiff::diff_lines(&String::from_utf8_lossy(&old), &String::from_utf8_lossy(&new))) + } else { + match (serde_json::from_slice(&old), serde_json::from_slice(&new)) { + (Ok(a), Ok(b)) => Some(jsondiff::diff(&a, &b)), + _ => None, + } + } + } else { + None + }; + match parsed { + None => out.push(Finding::broke(cat, "corpus", path, "changed".into(), "the vector's bytes changed".into(), why_bytes.into())), + Some(d) if d.is_empty() => {} + Some(d) if !d.breaks() => out.push(Finding::compatible(cat, "corpus", path, format!("added {}", list(&d.added)))), + Some(d) => { + let mut what = Vec::new(); + if !d.removed.is_empty() { + what.push(format!("removed {}", list(&d.removed))); + } + if !d.changed.is_empty() { + what.push(format!("changed {}", list(&d.changed))); + } + if !d.added.is_empty() { + what.push(format!("added {}", d.added.len())); + } + out.push(Finding::broke(cat, "corpus", path, "changed".into(), what.join("; "), why_bytes.into())); + } + } + } + } + } + Ok(out) +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/breaking/domains.rs b/wai/wai-rs/src/bin/wai_cli/dx/breaking/domains.rs new file mode 100644 index 000000000..679dcde48 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/breaking/domains.rs @@ -0,0 +1,165 @@ +//! Record: the constants a signed or framed record is built from, in the WAI crates' library +//! sources (`wai/*/src/`, binaries and tests left out). A constant whose name has the word +//! `DOMAIN`, `MAGIC` or `VERSION` and whose value is a literal is one of: +//! +//! - a domain-separation prefix of a signing preimage or a hash (`SIGN_DOMAIN`, +//! `LINK_DOMAIN`): another value changes every signature and link over the record, and a +//! verifier of the old records refuses the new ones; +//! - a record's magic (`MAGIC`, `GRANT_MAGIC`): a reader that matches the old magic refuses +//! the new records; +//! - a record's version (`RECORD_VERSION`): a reader that knows the old version refuses +//! the new one. +//! +//! So such a constant breaks when its value changes or it is gone (unless the same name +//! and value now sit in another file: a move). A new one is compatible. A record's other +//! fields and its preimage's shape reach the corpora (see `corpora`), which hold the bytes +//! the code writes and signs. + +use super::git::Repo; +use super::super::envelope::OpError; +use super::{Category, Finding}; +use std::collections::BTreeMap; + +pub const PATHSPECS: &[&str] = &[":(glob)wai/*/src/**/*.rs", ":(exclude,glob)wai/*/src/bin/**"]; + +/// The prefilter `git grep` runs (POSIX extended syntax). +const GREP: &str = "const[[:space:]]+[A-Z0-9_]*(DOMAIN|MAGIC|VERSION)"; + +/// One constant: its name, its type and its value as written (spaces dropped). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Const { + pub name: String, + pub ty: String, + pub value: String, +} + +fn named(name: &str) -> bool { + let words: Vec<&str> = name.split('_').collect(); + words.iter().any(|w| matches!(*w, "DOMAIN" | "MAGIC" | "VERSION")) + && !words.iter().any(|w| matches!(*w, "MAX" | "MIN" | "LEN" | "UNKNOWN" | "API" | "HEADER" | "VERSIONS")) +} + +fn literal(v: &str) -> bool { + v.starts_with("b\"") || v.starts_with("*b\"") || v.starts_with('"') || v.starts_with("b'") || v.starts_with('[') || v.starts_with("&[") || v.starts_with(|c: char| c.is_ascii_digit()) +} + +/// The constants of one source file, keyed by name (`NAME`, then `NAME#2`… for a name +/// declared again in the same file). Scanning stops at the file's test module. +pub fn scan(src: &str) -> BTreeMap { + let mut out = BTreeMap::new(); + let lines: Vec<&str> = src.lines().collect(); + let mut i = 0; + while i < lines.len() { + let t = lines[i].trim(); + if t == "#[cfg(test)]" && lines.get(i + 1).is_some_and(|n| n.trim_start().starts_with("mod ")) { + break; + } + let rest = t.strip_prefix("pub ").or_else(|| t.strip_prefix("pub(crate) ")).or_else(|| t.strip_prefix("pub(super) ")).unwrap_or(t); + if let Some(decl) = rest.strip_prefix("const ") { + // The declaration, joined across lines until its `;`. + let mut text = decl.to_string(); + let mut j = i; + while !text.contains(';') && j + 1 < lines.len() && j < i + 6 { + j += 1; + text.push(' '); + text.push_str(lines[j].trim()); + } + if let Some((name, after)) = text.split_once(':') { + let name = name.trim(); + if name.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') + && named(name) + && let Some((ty, value)) = after.split_once('=') + { + let value: String = value.split(';').next().unwrap_or("").split_whitespace().collect::>().join(""); + if literal(&value) { + let ty: String = ty.split_whitespace().collect(); + let mut key = name.to_string(); + let mut n = 1; + while out.contains_key(&key) { + n += 1; + key = format!("{name}#{n}"); + } + out.insert(key, Const { name: name.to_string(), ty, value }); + } + } + } + i = j; + } + i += 1; + } + out +} + +fn role(name: &str) -> (&'static str, &'static str) { + if name.split('_').any(|w| w == "DOMAIN") { + ("signing or hashing domain", "every signature and hash made under it changes; a verifier of the records made before refuses those made after, and the other way round") + } else if name.split('_').any(|w| w == "MAGIC") { + ("magic", "a reader that matches the old magic refuses the records written with the new one") + } else { + ("version", "a reader that knows the old version refuses records of the new one, and a writer of the old version is refused by the new reader") + } +} + +fn all(repo: &Repo, base: Option<&str>) -> Result, OpError> { + let files = repo.grep_files(base, GREP, PATHSPECS)?; + let texts: BTreeMap> = match base { + Some(b) => repo.base_files(b, &files)?, + None => files.iter().filter_map(|f| Some((f.clone(), repo.head_file(f)?))).collect(), + }; + let mut out = BTreeMap::new(); + for (f, t) in texts { + for (k, c) in scan(&String::from_utf8_lossy(&t)) { + out.insert((f.clone(), k), c); + } + } + Ok(out) +} + +/// The constants' findings between `base` and the working tree. +pub fn compare(repo: &Repo, base: &str) -> Result, OpError> { + let old = all(repo, Some(base))?; + let new = all(repo, None)?; + let mut out = Vec::new(); + let moved = |c: &Const| new.values().any(|n| n.name == c.name && n.value == c.value); + for ((file, key), c) in &old { + let item = format!("{file}::{key}"); + let (what_it_is, why) = role(&c.name); + match new.get(&(file.clone(), key.clone())) { + None if moved(c) => {} + None => out.push(Finding::broke(Category::Record, "domain", item, "removed".into(), format!("the {what_it_is} {} = {} is gone", c.name, c.value), why.into())), + Some(n) if n.value != c.value || n.ty != c.ty => out.push(Finding::broke( + Category::Record, + "domain", + item, + "changed".into(), + format!("the {what_it_is} {} changed: {} → {}", c.name, c.value, n.value), + why.into(), + )), + Some(_) => {} + } + } + for ((file, key), c) in &new { + if !old.contains_key(&(file.clone(), key.clone())) && !old.values().any(|o| o.name == c.name && o.value == c.value) { + out.push(Finding::compatible(Category::Record, "domain", format!("{file}::{key}"), format!("{} = {} is new", c.name, c.value))); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::scan; + + #[test] + fn scans_domains_magics_and_versions() { + let src = "pub const SIGN_DOMAIN: &[u8] = b\"wai:x\\x01\";\nconst MAGIC: [u8; 4] = *b\"WEV1\";\npub const RECORD_VERSION: u8 = 1;\n\ + pub(crate) const DOMAIN_LEN: u64 = X.len() as u64;\nconst MAX_VERSION: u8 = 3;\nconst OTHER: u8 = 1;\n\ + pub const LONG_DOMAIN: &[u8] =\n b\"wai:long\\x01\";\n#[cfg(test)]\nmod tests {\n const TEST_DOMAIN: &[u8] = b\"t\";\n}\n"; + let m = scan(src); + let names: Vec<&str> = m.keys().map(String::as_str).collect(); + assert_eq!(names, vec!["LONG_DOMAIN", "MAGIC", "RECORD_VERSION", "SIGN_DOMAIN"]); + assert_eq!(m["SIGN_DOMAIN"].value, "b\"wai:x\\x01\""); + assert_eq!(m["LONG_DOMAIN"].value, "b\"wai:long\\x01\""); + assert_eq!(m["MAGIC"].ty, "[u8;4]"); + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/breaking/git.rs b/wai/wai-rs/src/bin/wai_cli/dx/breaking/git.rs new file mode 100644 index 000000000..e1e404d5a --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/breaking/git.rs @@ -0,0 +1,332 @@ +//! The two trees `wai breaking` compares, read through `git`: the base (a commit) and the +//! working tree (what is on disk, committed or not, untracked files included). + +use super::super::envelope::{EXIT_INTERNAL, EXIT_NOT_FOUND, OpError}; +use std::collections::BTreeMap; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// A checkout: its top-level directory. +pub struct Repo { + pub root: PathBuf, +} + +/// How a path differs between the base and the working tree. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + Added, + Modified, + Removed, +} + +fn internal(e: impl std::fmt::Display) -> OpError { + OpError::new(EXIT_INTERNAL, "git-failed", e.to_string(), "run it in a git checkout with git on PATH") +} + +impl Repo { + /// The checkout holding `dir`. + pub fn open(dir: &Path) -> Result { + let out = Command::new("git").arg("-C").arg(dir).args(["rev-parse", "--show-toplevel"]).output().map_err(|e| { + OpError::new(EXIT_NOT_FOUND, "git-not-found", format!("git: {e}"), "install git; `wai breaking` reads both trees through it") + })?; + if !out.status.success() { + return Err(OpError::new( + EXIT_NOT_FOUND, + "not-a-checkout", + format!("{}: not in a git checkout", dir.display()), + "run it inside the repository, or pass --root ", + )); + } + let root = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Ok(Repo { root: PathBuf::from(root) }) + } + + fn git(&self, args: &[&str]) -> Result, OpError> { + let out = Command::new("git").arg("-C").arg(&self.root).args(args).output().map_err(internal)?; + if !out.status.success() { + return Err(internal(format!("git {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim()))); + } + Ok(out.stdout) + } + + /// The commit `rev` names, or `None` when it names none. + pub fn commit(&self, rev: &str) -> Option { + let out = Command::new("git").arg("-C").arg(&self.root).args(["rev-parse", "--verify", "--quiet", &format!("{rev}^{{commit}}")]).output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) + } + + /// The best common ancestor of two commits. + pub fn merge_base(&self, a: &str, b: &str) -> Option { + let out = Command::new("git").arg("-C").arg(&self.root).args(["merge-base", a, b]).output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) + } + + /// Every path under `pathspecs` that differs between `base` and the working tree. + pub fn changed(&self, base: &str, pathspecs: &[&str]) -> Result, OpError> { + let mut args = vec!["diff", "--name-status", "--no-renames", "-z", base, "--"]; + args.extend_from_slice(pathspecs); + let out = self.git(&args)?; + let mut v = Vec::new(); + let mut it = out.split(|b| *b == 0).filter(|s| !s.is_empty()); + while let (Some(st), Some(p)) = (it.next(), it.next()) { + let s = match st.first() { + Some(b'A') => Status::Added, + Some(b'D') => Status::Removed, + _ => Status::Modified, + }; + v.push((s, String::from_utf8_lossy(p).into_owned())); + } + let mut args = vec!["ls-files", "--others", "--exclude-standard", "-z", "--"]; + args.extend_from_slice(pathspecs); + for p in self.git(&args)?.split(|b| *b == 0).filter(|s| !s.is_empty()) { + v.push((Status::Added, String::from_utf8_lossy(p).into_owned())); + } + v.sort_by(|a, b| a.1.cmp(&b.1)); + v.dedup_by(|a, b| a.1 == b.1); + Ok(v) + } + + /// The paths under `pathspecs` in `base`. (`git ls-tree` takes no pathspec magic, so + /// the listing is filtered here, by [`matches`].) + pub fn ls_base(&self, base: &str, pathspecs: &[&str]) -> Result, OpError> { + let all = split0(&self.git(&["ls-tree", "-r", "--name-only", "-z", base])?); + Ok(all.into_iter().filter(|p| matches(pathspecs, p)).collect()) + } + + /// The paths under `pathspecs` in the working tree: tracked files still on disk, and + /// untracked files that are not ignored. + pub fn ls_head(&self, pathspecs: &[&str]) -> Result, OpError> { + let mut args = vec!["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--"]; + args.extend_from_slice(pathspecs); + let mut v: Vec = split0(&self.git(&args)?).into_iter().filter(|p| self.root.join(p).is_file()).collect(); + v.sort(); + v.dedup(); + Ok(v) + } + + /// The files under `pathspecs` with a line matching the extended regular expression + /// `re`: in `base` when it is given, else in the working tree (untracked files too). + pub fn grep_files(&self, base: Option<&str>, re: &str, pathspecs: &[&str]) -> Result, OpError> { + let mut args = vec!["grep", "-l", "-I", "-z", "-E", "-e", re]; + match base { + Some(b) => args.push(b), + None => args.push("--untracked"), + } + args.push("--"); + args.extend_from_slice(pathspecs); + let out = Command::new("git").arg("-C").arg(&self.root).args(&args).output().map_err(internal)?; + // git grep exits 1 when nothing matched. + if !out.status.success() && out.status.code() != Some(1) { + return Err(internal(format!("git grep: {}", String::from_utf8_lossy(&out.stderr).trim()))); + } + let prefix = base.map(|b| format!("{b}:")).unwrap_or_default(); + Ok(split0(&out.stdout).into_iter().map(|p| p.strip_prefix(&prefix).map(String::from).unwrap_or(p)).collect()) + } + + /// A file of the working tree, or `None` when it is not there. + pub fn head_file(&self, path: &str) -> Option> { + std::fs::read(self.root.join(path)).ok() + } + + /// Files of `base`, read in one `git cat-file --batch`: each path's bytes, or no entry + /// when `base` does not hold it. + pub fn base_files(&self, base: &str, paths: &[String]) -> Result>, OpError> { + let mut out = BTreeMap::new(); + if paths.is_empty() { + return Ok(out); + } + let mut child = Command::new("git") + .arg("-C") + .arg(&self.root) + .args(["cat-file", "--batch"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(internal)?; + let mut stdin = child.stdin.take().ok_or_else(|| internal("git cat-file: no stdin"))?; + let req: String = paths.iter().map(|p| format!("{base}:{p}\n")).collect(); + let writer = std::thread::spawn(move || { + let _ = stdin.write_all(req.as_bytes()); + }); + let mut all = Vec::new(); + child.stdout.take().ok_or_else(|| internal("git cat-file: no stdout"))?.read_to_end(&mut all).map_err(internal)?; + let _ = writer.join(); + let _ = child.wait(); + let mut rest: &[u8] = &all; + for p in paths { + let nl = rest.iter().position(|b| *b == b'\n').ok_or_else(|| internal("git cat-file: short answer"))?; + let header = String::from_utf8_lossy(&rest[..nl]).into_owned(); + rest = &rest[nl + 1..]; + if header.ends_with(" missing") || header.ends_with(" ambiguous") { + continue; + } + let size: usize = header.rsplit(' ').next().and_then(|s| s.parse().ok()).ok_or_else(|| internal(format!("git cat-file: {header}")))?; + if rest.len() < size + 1 { + return Err(internal("git cat-file: short object")); + } + if header.split(' ').nth(1) == Some("blob") { + out.insert(p.clone(), rest[..size].to_vec()); + } + rest = &rest[size + 1..]; + } + Ok(out) + } + + /// One file of `base`. + pub fn base_file(&self, base: &str, path: &str) -> Result>, OpError> { + Ok(self.base_files(base, &[path.to_string()])?.remove(path)) + } + + /// `base`'s tree under `path`, written below `dest` at the same relative paths. + pub fn extract(&self, base: &str, path: &str, dest: &Path) -> Result<(), OpError> { + std::fs::create_dir_all(dest).map_err(internal)?; + let mut archive = Command::new("git") + .arg("-C") + .arg(&self.root) + .args(["archive", "--format=tar", base, "--", path]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(internal)?; + let tar_in = archive.stdout.take().ok_or_else(|| internal("git archive: no stdout"))?; + let tar = Command::new("tar").arg("-x").arg("-C").arg(dest).stdin(tar_in).output().map_err(internal)?; + let a = archive.wait_with_output().map_err(internal)?; + if !a.status.success() || !tar.status.success() { + return Err(internal(format!( + "extracting {path} of {base}: {}{}", + String::from_utf8_lossy(&a.stderr).trim(), + String::from_utf8_lossy(&tar.stderr).trim() + ))); + } + Ok(()) + } + + /// The lines `base`'s CHANGELOG at `path` does not have and the working tree's has. + pub fn added_lines(&self, base: &str, path: &str) -> Result, OpError> { + let old = self.base_file(base, path)?.unwrap_or_default(); + let new = self.head_file(path).unwrap_or_default(); + Ok(added_lines(&String::from_utf8_lossy(&old), &String::from_utf8_lossy(&new))) + } +} + +/// Whether `path` is under `pathspecs`, as git reads the forms used here: a plain path (the +/// file, or everything below the directory), `:(glob)` and `:(exclude,glob)`, +/// where `*` matches within one path segment and `**` matches any number of segments. +pub fn matches(pathspecs: &[&str], path: &str) -> bool { + let mut included = false; + for spec in pathspecs { + let (exclude, pattern, glob) = if let Some(p) = spec.strip_prefix(":(exclude,glob)") { + (true, p, true) + } else if let Some(p) = spec.strip_prefix(":(glob)") { + (false, p, true) + } else { + (false, *spec, false) + }; + let hit = if glob { glob_match(&pattern.split('/').collect::>(), &path.split('/').collect::>()) } else { path == pattern || path.starts_with(&format!("{}/", pattern.trim_end_matches('/'))) }; + if hit && exclude { + return false; + } + included |= hit; + } + included +} + +fn glob_match(pat: &[&str], path: &[&str]) -> bool { + match (pat.first(), path.first()) { + (None, None) => true, + (Some(&"**"), _) => glob_match(&pat[1..], path) || (!path.is_empty() && glob_match(pat, &path[1..])), + (Some(p), Some(s)) => segment_match(p.as_bytes(), s.as_bytes()) && glob_match(&pat[1..], &path[1..]), + _ => false, + } +} + +fn segment_match(p: &[u8], s: &[u8]) -> bool { + match (p.first(), s.first()) { + (None, None) => true, + (Some(b'*'), _) => segment_match(&p[1..], s) || (!s.is_empty() && segment_match(p, &s[1..])), + (Some(b'?'), Some(_)) => segment_match(&p[1..], &s[1..]), + (Some(a), Some(b)) if a == b => segment_match(&p[1..], &s[1..]), + _ => false, + } +} + +fn split0(b: &[u8]) -> Vec { + b.split(|x| *x == 0).filter(|s| !s.is_empty()).map(|s| String::from_utf8_lossy(s).into_owned()).collect() +} + +/// The lines of `new` that a longest common subsequence with `old` leaves over: what an +/// edit added, in order. A line moved within the file counts as added where it now is. +pub fn added_lines(old: &str, new: &str) -> Vec { + let a: Vec<&str> = old.lines().collect(); + let b: Vec<&str> = new.lines().collect(); + // Trim the common head and tail first: a changelog edit is a few lines near the top of a + // long file, and what is left between is small. + let mut lo = 0; + while lo < a.len() && lo < b.len() && a[lo] == b[lo] { + lo += 1; + } + let (mut ea, mut eb) = (a.len(), b.len()); + while ea > lo && eb > lo && a[ea - 1] == b[eb - 1] { + ea -= 1; + eb -= 1; + } + let (a, b) = (&a[lo..ea], &b[lo..eb]); + if a.is_empty() { + return b.iter().map(|s| s.to_string()).collect(); + } + if (a.len() as u64) * (b.len() as u64) > 40_000_000 { + // Too far apart for the table: every line not in the old file counts as added. + let old: std::collections::HashSet<&str> = a.iter().copied().collect(); + return b.iter().filter(|l| !old.contains(*l)).map(|s| s.to_string()).collect(); + } + let (n, m) = (a.len(), b.len()); + let mut t = vec![0u32; (n + 1) * (m + 1)]; + for i in (0..n).rev() { + for j in (0..m).rev() { + t[i * (m + 1) + j] = if a[i] == b[j] { t[(i + 1) * (m + 1) + j + 1] + 1 } else { t[(i + 1) * (m + 1) + j].max(t[i * (m + 1) + j + 1]) }; + } + } + let (mut i, mut j) = (0, 0); + let mut out = Vec::new(); + while j < m { + if i < n && a[i] == b[j] { + i += 1; + j += 1; + } else if i < n && t[(i + 1) * (m + 1) + j] >= t[i * (m + 1) + j + 1] { + i += 1; + } else { + out.push(b[j].to_string()); + j += 1; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::added_lines; + + #[test] + fn pathspecs_match_as_git_reads_them() { + use super::matches; + let specs = [":(glob)wai/*/src/**/*.rs", ":(exclude,glob)wai/*/src/bin/**"]; + assert!(matches(&specs, "wai/wai-rs/src/live_events.rs")); + assert!(matches(&specs, "wai/wai-rs/src/a/b/c.rs")); + assert!(!matches(&specs, "wai/wai-rs/src/bin/wai.rs")); + assert!(!matches(&specs, "wai/wai-rs/tests/x.rs")); + assert!(matches(&[":(glob)wai/**/openapi*.json"], "wai/openapi.json")); + assert!(matches(&["joule-code/x/openapi.json"], "joule-code/x/openapi.json")); + assert!(matches(&[":(glob)wai/*-conformance/**"], "wai/claim-conformance/vectors.json")); + assert!(!matches(&[":(glob)wai/*-conformance/**"], "wai/wai-rs/x")); + } + + #[test] + fn added_lines_are_the_new_ones() { + assert_eq!(added_lines("a\nb\nc\n", "a\nx\nb\nc\ny\n"), vec!["x", "y"]); + assert_eq!(added_lines("", "a\n"), vec!["a"]); + assert!(added_lines("a\nb\n", "a\n").is_empty()); + assert_eq!(added_lines("a\nb\n", "b\na\n"), vec!["a"]); + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/breaking/jsondiff.rs b/wai/wai-rs/src/bin/wai_cli/dx/breaking/jsondiff.rs new file mode 100644 index 000000000..521652ea5 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/breaking/jsondiff.rs @@ -0,0 +1,158 @@ +//! What changed between two JSON documents, as a reader of the old one sees it: members +//! and entries that went away, values that changed, and members and entries that are new. +//! +//! An array whose entries are objects named by one member (`name`, `file`, `id`, …) is +//! compared entry by entry under that name, so a corpus that gains a vector shows one added +//! entry, not a shifted list. Any other array is compared position by position, and one +//! that only grew at its end shows the new positions as added. Prose members (`about`, +//! `description`, `note`, `notes`, `comment`) are not compared: rewording them changes no +//! byte a reader acts on. + +use serde_json::Value; + +/// Members never compared: prose about the document, not data a reader acts on. +pub const PROSE: &[&str] = &["about", "description", "note", "notes", "comment"]; + +/// The members an array's entries may be named by, in the order they are tried. +const ENTRY_KEYS: &[&str] = &["name", "file", "id", "capability", "identifier", "label", "case"]; + +/// Paths (`$.vectors[name=x].expected`) of what was removed, changed and added. +#[derive(Default, Debug)] +pub struct Diff { + pub removed: Vec, + pub changed: Vec, + pub added: Vec, +} + +impl Diff { + pub fn breaks(&self) -> bool { + !self.removed.is_empty() || !self.changed.is_empty() + } + pub fn is_empty(&self) -> bool { + !self.breaks() && self.added.is_empty() + } +} + +/// The difference from `old` to `new`. +pub fn diff(old: &Value, new: &Value) -> Diff { + let mut d = Diff::default(); + walk(old, new, "$", &mut d); + d +} + +/// A value shortened for a message. +pub fn short(v: &Value) -> String { + let s = v.to_string(); + if s.chars().count() > 72 { format!("{}…", s.chars().take(72).collect::()) } else { s } +} + +fn entry_key(a: &[Value], b: &[Value]) -> Option<&'static str> { + ENTRY_KEYS.iter().copied().find(|k| { + let named = |xs: &[Value]| { + let mut seen = std::collections::HashSet::new(); + xs.iter().all(|x| x.get(k).and_then(Value::as_str).is_some_and(|n| seen.insert(n.to_string()))) + }; + !(a.is_empty() && b.is_empty()) && named(a) && named(b) + }) +} + +fn walk(old: &Value, new: &Value, path: &str, d: &mut Diff) { + match (old, new) { + (Value::Object(a), Value::Object(b)) => { + for (k, av) in a { + if PROSE.contains(&k.as_str()) { + continue; + } + let p = format!("{path}.{k}"); + match b.get(k) { + None => d.removed.push(p), + Some(bv) => walk(av, bv, &p, d), + } + } + for k in b.keys() { + if !a.contains_key(k) && !PROSE.contains(&k.as_str()) { + d.added.push(format!("{path}.{k}")); + } + } + } + (Value::Array(a), Value::Array(b)) => { + if let Some(key) = entry_key(a, b) { + let name = |x: &Value| x.get(key).and_then(Value::as_str).unwrap_or_default().to_string(); + let bm: std::collections::HashMap = b.iter().map(|x| (name(x), x)).collect(); + let am: std::collections::HashSet = a.iter().map(name).collect(); + for x in a { + let n = name(x); + let p = format!("{path}[{key}={n}]"); + match bm.get(&n) { + None => d.removed.push(p), + Some(y) => walk(x, y, &p, d), + } + } + for y in b { + let n = name(y); + if !am.contains(&n) { + d.added.push(format!("{path}[{key}={n}]")); + } + } + } else { + for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() { + walk(x, y, &format!("{path}[{i}]"), d); + } + for i in b.len()..a.len() { + d.removed.push(format!("{path}[{i}]")); + } + for i in a.len()..b.len() { + d.added.push(format!("{path}[{i}]")); + } + } + } + (x, y) if x == y => {} + (x, y) => d.changed.push(format!("{path}: {} → {}", short(x), short(y))), + } +} + +/// The difference between two JSON Lines documents: lines that are gone, and lines that +/// are new. Lines are compared as text. +pub fn diff_lines(old: &str, new: &str) -> Diff { + let a: std::collections::HashSet<&str> = old.lines().filter(|l| !l.trim().is_empty()).collect(); + let b: std::collections::HashSet<&str> = new.lines().filter(|l| !l.trim().is_empty()).collect(); + let mut d = Diff::default(); + for (i, l) in old.lines().enumerate() { + if !l.trim().is_empty() && !b.contains(l) { + d.removed.push(format!("line {}: {}", i + 1, short(&Value::String(l.to_string())))); + } + } + for (i, l) in new.lines().enumerate() { + if !l.trim().is_empty() && !a.contains(l) { + d.added.push(format!("line {}", i + 1)); + } + } + d +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn named_entries_are_matched_by_name() { + let a = json!({"about": "x", "vectors": [{"name": "a", "expected": "accept"}, {"name": "b", "expected": "reject"}]}); + let b = json!({"about": "y", "vectors": [{"name": "c", "expected": "accept"}, {"name": "a", "expected": "accept"}, {"name": "b", "expected": "reject"}]}); + let d = diff(&a, &b); + assert!(!d.breaks(), "{d:?}"); + assert_eq!(d.added, vec!["$.vectors[name=c]"]); + let c = json!({"about": "x", "vectors": [{"name": "a", "expected": "reject"}]}); + let d = diff(&a, &c); + assert_eq!(d.removed, vec!["$.vectors[name=b]"]); + assert_eq!(d.changed, vec![r#"$.vectors[name=a].expected: "accept" → "reject""#]); + } + + #[test] + fn a_list_that_grew_at_its_end_only_added() { + let d = diff(&json!([1, 2]), &json!([1, 2, 3])); + assert!(!d.breaks()); + let d = diff(&json!([1, 2]), &json!([2, 1])); + assert!(d.breaks()); + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/breaking/mod.rs b/wai/wai-rs/src/bin/wai_cli/dx/breaking/mod.rs new file mode 100644 index 000000000..2be15de64 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/breaking/mod.rs @@ -0,0 +1,352 @@ +//! `wai breaking --against ` (operation `compat.check`): what the working tree +//! breaks, by category, for a reader, a verifier or a program written against the ref. +//! +//! - **wire**: the bytes a conforming reader must accept. The registry export (capability +//! names and their classes, deterministic identifiers, wire identifiers: magics, +//! document kinds, headers, schemes, tags) and the conformance corpora of media and +//! delivery (`registry`, `corpora`). +//! - **record**: signed record formats. The domain-separation prefixes, magics and +//! versions records are built from, the corpora of signed records and their verdicts, +//! and the published JSON Schemas (`domains`, `corpora`, `schemas`). +//! - **api**: the Rust public API of the WAI library crates, the C header and the HTTP +//! services' OpenAPI documents (`rust`, `c`, `openapi`). +//! +//! The categories are cumulative, strictest by default: `--category api` (the default) +//! reports all three, `record` reports wire and record, `wire` reports wire alone. The +//! base is the merge base of the ref and `HEAD`, so a ref that moved on since the branch +//! left it does not read as the branch removing what the ref gained. +//! +//! Each finding names its item, its category, what changed and why that breaks; additions +//! are listed as compatible. A break is intended only when `wai/CHANGELOG.md` says so: a +//! line this change adds to it, in an entry that has the word "breaking", naming the +//! finding's id in backticks. Then it is reported as acknowledged and passes; every other +//! break fails the run (exit 1). + +pub mod c_header; +pub mod corpora; +pub mod domains; +pub mod git; +pub mod jsondiff; +pub mod openapi; +pub mod registry; +pub mod rust_api; +pub mod schema; + +use super::envelope::{self, EXIT_FAILED, EXIT_NOT_FOUND, OpError}; +use serde_json::{Map, Value, json}; +use std::collections::BTreeSet; +use std::path::PathBuf; + +pub const OP: &str = "compat.check"; + +pub const USAGE: &str = "wai breaking --against [--category wire|record|api] [--only ,...] [--skip ,...]\n\ + [--root ] [--target-dir ] [--json|--text] what the working tree breaks against :\n\ + wire, record and API; a break passes only where wai/CHANGELOG.md names it (operation compat.check)"; + +/// The changelog whose added lines acknowledge a break. +pub const CHANGELOG: &str = "wai/CHANGELOG.md"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Category { + Wire, + Record, + Api, +} + +impl Category { + pub fn name(self) -> &'static str { + match self { + Category::Wire => "wire", + Category::Record => "record", + Category::Api => "api", + } + } + fn parse(s: &str) -> Option { + match s { + "wire" => Some(Category::Wire), + "record" => Some(Category::Record), + "api" => Some(Category::Api), + _ => None, + } + } +} + +/// One change between the base and the working tree. +#[derive(Clone, Debug)] +pub struct Finding { + /// `::`, the name a changelog entry acknowledges it by. + pub id: String, + pub category: Category, + pub surface: &'static str, + pub item: String, + /// `removed`, `changed` or `added`. + pub change: String, + pub what: String, + /// Why it breaks; empty for a compatible change. + pub why: String, + pub breaking: bool, + pub acknowledged: bool, +} + +impl Finding { + pub fn broke(category: Category, surface: &'static str, item: String, change: String, what: String, why: String) -> Finding { + Finding { id: format!("{}:{surface}:{item}", category.name()), category, surface, item, change, what, why, breaking: true, acknowledged: false } + } + pub fn compatible(category: Category, surface: &'static str, item: String, what: String) -> Finding { + Finding { id: format!("{}:{surface}:{item}", category.name()), category, surface, item, change: "added".into(), what, why: String::new(), breaking: false, acknowledged: false } + } + fn to_json(&self) -> Value { + json!({ + "id": self.id, "category": self.category.name(), "surface": self.surface, "item": self.item, + "change": self.change, "what": self.what, "why": self.why, "breaking": self.breaking, "acknowledged": self.acknowledged, + }) + } +} + +/// The surfaces, each with the most permissive category it reports under. +const SURFACES: &[(&str, Category)] = &[ + ("registry", Category::Wire), + ("corpora", Category::Wire), + ("domains", Category::Record), + ("schemas", Category::Record), + ("c", Category::Api), + ("openapi", Category::Api), + ("rust", Category::Api), +]; + +/// What to check. +pub struct Options { + pub against: String, + pub category: Category, + pub surfaces: BTreeSet<&'static str>, + pub root: PathBuf, + pub target_dir: Option, +} + +/// The report of one run. +pub struct Report { + pub against: String, + pub base: String, + pub category: Category, + pub surfaces: Vec<&'static str>, + pub findings: Vec, + pub notes: Vec, +} + +impl Report { + pub fn unacknowledged(&self) -> usize { + self.findings.iter().filter(|f| f.breaking && !f.acknowledged).count() + } + pub fn to_json(&self) -> Map { + let mut m = Map::new(); + m.insert("against".into(), json!(self.against)); + m.insert("base".into(), json!(self.base)); + m.insert("category".into(), json!(self.category.name())); + m.insert("surfaces".into(), json!(self.surfaces)); + m.insert("breaking".into(), json!(self.unacknowledged())); + m.insert("acknowledged".into(), json!(self.findings.iter().filter(|f| f.breaking && f.acknowledged).count())); + m.insert("compatible".into(), json!(self.findings.iter().filter(|f| !f.breaking).count())); + m.insert("findings".into(), Value::Array(self.findings.iter().map(Finding::to_json).collect())); + m.insert("notes".into(), json!(self.notes)); + m + } +} + +/// The ids a changelog's added lines acknowledge: every backticked span of an entry (a +/// bullet and its continuation lines, or a paragraph) that has the word "breaking". +pub fn acknowledgements(added: &[String]) -> BTreeSet { + let mut entries: Vec = Vec::new(); + for l in added { + let t = l.trim_start(); + let starts = t.starts_with("- ") || t.starts_with("* ") || t.starts_with('#') || (!l.starts_with(' ') && !t.is_empty()); + if starts || entries.is_empty() { + entries.push(String::new()); + } + if t.is_empty() { + entries.push(String::new()); + continue; + } + if let Some(e) = entries.last_mut() { + e.push_str(t); + e.push(' '); + } + } + let mut ids = BTreeSet::new(); + for e in entries { + if !e.to_lowercase().contains("breaking") { + continue; + } + let mut parts = e.split('`'); + parts.next(); + while let Some(span) = parts.next() { + ids.insert(span.trim().to_string()); + parts.next(); + } + } + ids +} + +/// Run the check. +pub fn run(o: &Options) -> Result { + let repo = git::Repo::open(&o.root)?; + let target = repo.commit(&o.against).ok_or_else(|| { + OpError::new(EXIT_NOT_FOUND, "ref-not-found", format!("{}: no such commit", o.against), "fetch it first (git fetch origin main), or name a commit, tag or branch this checkout holds") + })?; + let base = repo.commit("HEAD").and_then(|h| repo.merge_base(&target, &h)).unwrap_or(target); + let mut findings = Vec::new(); + let mut notes = Vec::new(); + let checked: Vec<&'static str> = SURFACES.iter().filter(|(s, c)| *c <= o.category && o.surfaces.contains(s)).map(|x| x.0).collect(); + for s in &checked { + eprintln!("wai breaking: {s}"); + match *s { + "registry" => { + let old = repo.base_file(&base, registry::PATH)?.and_then(|b| serde_json::from_slice::(&b).ok()); + let new = repo.head_file(registry::PATH).and_then(|b| serde_json::from_slice::(&b).ok()); + findings.extend(registry::compare(old.as_ref(), new.as_ref())); + } + "corpora" => findings.extend(corpora::compare(&repo, &base)?), + "domains" => findings.extend(domains::compare(&repo, &base)?), + "schemas" => findings.extend(schema::compare(&repo, &base)?), + "c" => { + let old = repo.base_file(&base, c_header::PATH)?.map(|b| String::from_utf8_lossy(&b).into_owned()); + let new = repo.head_file(c_header::PATH).map(|b| String::from_utf8_lossy(&b).into_owned()); + findings.extend(c_header::compare(old.as_deref(), new.as_deref())); + } + "openapi" => findings.extend(openapi::compare(&repo, &base)?), + "rust" => { + let scratch = std::env::temp_dir().join(format!("wai-breaking-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0))); + let toolchain = std::fs::read_to_string(repo.root.join("rust-toolchain.toml")).ok().and_then(|t| { + t.lines().find_map(|l| l.trim().strip_prefix("channel").and_then(|r| r.trim_start().strip_prefix('=')).map(|v| v.trim().trim_matches('"').to_string())) + }); + let builder = rust_api::Builder { target_dir: o.target_dir.clone().unwrap_or_else(|| scratch.join("target")), toolchain }; + let r = rust_api::compare(&repo, &base, &builder, &scratch, &mut notes); + let _ = std::fs::remove_dir_all(&scratch); + findings.extend(r?); + } + _ => {} + } + } + findings.retain(|f| f.category <= o.category); + let acks = acknowledgements(&repo.added_lines(&base, CHANGELOG)?); + for f in findings.iter_mut().filter(|f| f.breaking) { + f.acknowledged = acks.contains(&f.id); + } + findings.sort_by(|a, b| (!a.breaking, a.acknowledged, a.category, &a.id).cmp(&(!b.breaking, b.acknowledged, b.category, &b.id))); + Ok(Report { against: o.against.clone(), base, category: o.category, surfaces: checked, findings, notes }) +} + +fn options(args: &[String]) -> Result { + let flags = ["--against", "--category", "--only", "--skip", "--root", "--target-dir", "--json", "--text", "--all"]; + let with_value = ["--against", "--category", "--only", "--skip", "--root", "--target-dir"]; + envelope::check_args(args, &flags, &with_value, 0, USAGE)?; + let against = envelope::value(args, "--against", USAGE)?.ok_or_else(|| OpError::usage("--against is required", USAGE))?.to_string(); + let category = match envelope::value(args, "--category", USAGE)? { + None => Category::Api, + Some(c) => Category::parse(c).ok_or_else(|| OpError::usage(format!("--category {c}: wire, record or api"), USAGE))?, + }; + let names = |v: Option<&str>| -> Result>, OpError> { + let Some(v) = v else { return Ok(None) }; + v.split(',') + .map(|n| SURFACES.iter().find(|s| s.0 == n.trim()).map(|s| s.0).ok_or_else(|| OpError::usage(format!("{n}: not a surface ({})", SURFACES.iter().map(|s| s.0).collect::>().join(", ")), USAGE))) + .collect::, _>>() + .map(Some) + }; + let mut surfaces: BTreeSet<&'static str> = names(envelope::value(args, "--only", USAGE)?)?.unwrap_or_else(|| SURFACES.iter().map(|s| s.0).collect()); + if let Some(skip) = names(envelope::value(args, "--skip", USAGE)?)? { + surfaces.retain(|s| !skip.contains(s)); + } + Ok(Options { + against, + category, + surfaces, + root: envelope::value(args, "--root", USAGE)?.map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")), + target_dir: envelope::value(args, "--target-dir", USAGE)?.map(PathBuf::from), + }) +} + +fn text(r: &Report, all: bool) { + let short = r.base.get(..12).unwrap_or(&r.base); + println!("wai breaking: against {} (merge base {short}); {} and every category below it; checked {}", r.against, r.category.name(), r.surfaces.join(", ")); + let breaks: Vec<&Finding> = r.findings.iter().filter(|f| f.breaking).collect(); + let compat: Vec<&Finding> = r.findings.iter().filter(|f| !f.breaking).collect(); + if !breaks.is_empty() { + println!(); + println!("breaking changes ({}, {} acknowledged in {CHANGELOG}):", breaks.len(), breaks.iter().filter(|f| f.acknowledged).count()); + for f in &breaks { + println!(" {} {}", if f.acknowledged { "ACK " } else { "BREAK" }, f.id); + println!(" {} ({} · {}): {}", f.change, f.category.name(), f.surface, f.what); + println!(" why it breaks: {}", f.why); + } + } + if !compat.is_empty() { + println!(); + println!("compatible changes ({}):", compat.len()); + let shown = if all { compat.len() } else { compat.len().min(40) }; + for f in &compat[..shown] { + println!(" + {} {}", f.id, f.what); + } + if shown < compat.len() { + println!(" … and {} more (--all lists them)", compat.len() - shown); + } + } + for n in &r.notes { + println!("note: {n}"); + } + println!(); + let n = r.unacknowledged(); + if n == 0 { + println!("wai breaking: no unacknowledged breaking change"); + } else { + println!("wai breaking: {n} breaking change{} not acknowledged. If {} intended, add to {CHANGELOG}, under the current version:", if n == 1 { "" } else { "s" }, if n == 1 { "it is" } else { "they are" }); + for f in breaks.iter().filter(|f| !f.acknowledged).take(8) { + println!(" - **Breaking:** `{}`: ", f.id); + } + } +} + +pub fn main(args: &[String]) -> Result { + let json_mode = envelope::json_wanted(args); + let o = match options(args) { + Ok(o) => o, + Err(e) => envelope::fail(OP, json_mode, &e), + }; + let r = match run(&o) { + Ok(r) => r, + Err(e) => envelope::fail(OP, json_mode, &e), + }; + let ok = r.unacknowledged() == 0; + if json_mode { + println!("{}", envelope::envelope(OP, ok, r.to_json())); + } else { + text(&r, args.iter().any(|a| a == "--all")); + } + if !ok { + envelope::exit(EXIT_FAILED); + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::acknowledgements; + + #[test] + fn an_entry_acknowledges_only_when_it_says_breaking() { + let added: Vec = [ + "- **Breaking:** `api:c:wai_pack` takes a", + " `uint64_t` now, and api:rust:x is not in backticks.", + "- `wire:capability:wai.x` is new (no such word here).", + "", + "Breaking, as a paragraph: `record:domain:a.rs::SIGN_DOMAIN`.", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + let a = acknowledgements(&added); + assert!(a.contains("api:c:wai_pack")); + assert!(a.contains("uint64_t")); + assert!(a.contains("record:domain:a.rs::SIGN_DOMAIN")); + assert!(!a.contains("wire:capability:wai.x")); + assert!(!a.contains("api:rust:x")); + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/breaking/openapi.rs b/wai/wai-rs/src/bin/wai_cli/dx/breaking/openapi.rs new file mode 100644 index 000000000..4edc469ac --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/breaking/openapi.rs @@ -0,0 +1,175 @@ +//! API: the HTTP services' OpenAPI documents (the room service's +//! `joule-moq-room/openapi.json`, and any `openapi*.json` under `wai/`). A client written +//! against the old document must still work. So a route (a path and method) that is gone +//! breaks; so does an operation whose `operationId` changed (the generated clients and +//! tools are named by it), a parameter that is gone, moved or newly required, a request +//! body that becomes required, a success response that is gone, and a schema a request +//! carries that gains a required property, or one a response carries that loses a +//! property or changes a property's type. New routes, optional parameters and response +//! properties are compatible. + +use super::git::Repo; +use super::super::envelope::OpError; +use super::{Category, Finding}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +/// The documents read, by their path in the repository. +pub const PATHSPECS: &[&str] = &["joule-code/joule-code-rs/crates/joule-moq-room/openapi.json", ":(glob)wai/**/openapi*.json"]; + +const METHODS: &[&str] = &["get", "put", "post", "delete", "options", "head", "patch", "trace"]; + +/// A short name for a document: its crate's directory, else its path. +fn service(path: &str) -> String { + let parts: Vec<&str> = path.split('/').collect(); + if parts.len() >= 2 { parts[parts.len() - 2].to_string() } else { path.to_string() } +} + +fn resolve<'a>(doc: &'a Value, v: &'a Value) -> &'a Value { + let mut v = v; + for _ in 0..16 { + let Some(r) = v.get("$ref").and_then(Value::as_str) else { break }; + let Some(p) = r.strip_prefix("#/") else { break }; + let mut t = doc; + for seg in p.split('/') { + match t.get(seg.replace("~1", "/").replace("~0", "~")) { + Some(x) => t = x, + None => return v, + } + } + v = t; + } + v +} + +fn params(doc: &Value, path_item: &Value, op: &Value) -> BTreeMap<(String, String), bool> { + let mut m = BTreeMap::new(); + for list in [path_item.get("parameters"), op.get("parameters")].into_iter().flatten() { + for p in list.as_array().into_iter().flatten() { + let p = resolve(doc, p); + if let (Some(n), Some(i)) = (p.get("name").and_then(Value::as_str), p.get("in").and_then(Value::as_str)) { + m.insert((n.to_string(), i.to_string()), p.get("required").and_then(Value::as_bool).unwrap_or(i == "path")); + } + } + } + m +} + +/// The properties of a schema, resolved: each name with its type (or `$ref` name) and +/// whether it is required. +fn props(doc: &Value, schema: &Value) -> BTreeMap { + let s = resolve(doc, schema); + let required: BTreeSet = s.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.as_str().map(String::from)).collect(); + let mut m = BTreeMap::new(); + if let Some(p) = s.get("properties").and_then(Value::as_object) { + for (k, v) in p { + let ty = v.get("$ref").and_then(Value::as_str).map(String::from).or_else(|| v.get("type").map(|t| t.to_string())).unwrap_or_default(); + m.insert(k.clone(), (ty, required.contains(k))); + } + } + m +} + +fn json_schema<'a>(doc: &'a Value, body: &'a Value) -> Option<&'a Value> { + let body = resolve(doc, body); + let content = body.get("content")?.as_object()?; + content.get("application/json").or_else(|| content.values().next())?.get("schema") +} + +fn ops(doc: &Value) -> BTreeMap { + let mut m = BTreeMap::new(); + for (path, item) in doc.get("paths").and_then(Value::as_object).into_iter().flatten() { + for meth in METHODS { + if let Some(op) = item.get(*meth) { + m.insert(format!("{} {path}", meth.to_uppercase()), (item, op)); + } + } + } + m +} + +/// One document's findings (`None` where a tree has none). +pub fn compare_doc(path: &str, old: Option<&Value>, new: Option<&Value>) -> Vec { + let svc = service(path); + let mut out = Vec::new(); + let empty = Value::Null; + let (a, b) = (old.unwrap_or(&empty), new.unwrap_or(&empty)); + let (oa, ob) = (ops(a), ops(b)); + for (route, (ia, xa)) in &oa { + let item = format!("{svc}:{route}"); + let Some((ib, xb)) = ob.get(route) else { + out.push(Finding::broke(Category::Api, "openapi", item, "removed".into(), format!("the route {route} is gone from {path}"), "a client that calls it gets no such route".into())); + continue; + }; + let mut what = Vec::new(); + if xa.get("operationId") != xb.get("operationId") && xa.get("operationId").is_some() { + what.push(format!("operationId {} → {}", xa.get("operationId").unwrap_or(&empty), xb.get("operationId").unwrap_or(&empty))); + } + let (pa, pb) = (params(a, ia, xa), params(b, ib, xb)); + for ((n, i), req) in &pa { + match pb.get(&(n.clone(), i.clone())) { + None => what.push(format!("the {i} parameter {n} is gone")), + Some(r) if *r && !req => what.push(format!("the {i} parameter {n} is now required")), + _ => {} + } + } + for ((n, i), req) in &pb { + if *req && !pa.contains_key(&(n.clone(), i.clone())) { + what.push(format!("a required {i} parameter {n} is new")); + } + } + let body_required = |x: &Value, d: &Value| x.get("requestBody").map(|r| resolve(d, r).get("required").and_then(Value::as_bool).unwrap_or(false)).unwrap_or(false); + if body_required(xb, b) && !body_required(xa, a) { + what.push("the request body is now required".into()); + } + if let (Some(sa), Some(sb)) = (xa.get("requestBody").and_then(|r| json_schema(a, r)), xb.get("requestBody").and_then(|r| json_schema(b, r))) { + let (qa, qb) = (props(a, sa), props(b, sb)); + for (k, (_, req)) in &qb { + if *req && !qa.get(k).is_some_and(|x| x.1) { + what.push(format!("the request property {k} is now required")); + } + } + } + let codes = |x: &Value| -> Vec { x.get("responses").and_then(Value::as_object).map(|r| r.keys().cloned().collect()).unwrap_or_default() }; + let (ca, cb) = (codes(xa), codes(xb)); + for c in ca.iter().filter(|c| c.starts_with('2') && !cb.contains(c)) { + what.push(format!("the {c} response is gone")); + } + for c in ca.iter().filter(|c| c.starts_with('2') && cb.contains(c)) { + let schema = |x: &Value, d: &Value| x.get("responses").and_then(|r| r.get(c.as_str())).and_then(|r| json_schema(d, r)).map(|s| props(d, s)); + if let (Some(ra), Some(rb)) = (schema(xa, a), schema(xb, b)) { + for (k, (ty, _)) in &ra { + match rb.get(k) { + None => what.push(format!("the {c} response's property {k} is gone")), + Some((t, _)) if t != ty => what.push(format!("the {c} response's property {k}: {ty} → {t}")), + _ => {} + } + } + } + } + if !what.is_empty() { + out.push(Finding::broke(Category::Api, "openapi", item, "changed".into(), format!("{route}: {}", what.join("; ")), "a client written against the old document sends what is now refused, or reads what is no longer sent".into())); + } + } + for route in ob.keys() { + if !oa.contains_key(route) { + out.push(Finding::compatible(Category::Api, "openapi", format!("{svc}:{route}"), format!("the route {route} is new in {path}"))); + } + } + out +} + +/// Every document's findings between `base` and the working tree. +pub fn compare(repo: &Repo, base: &str) -> Result, OpError> { + let mut paths: BTreeSet = repo.ls_base(base, PATHSPECS)?.into_iter().collect(); + paths.extend(repo.ls_head(PATHSPECS)?); + let list: Vec = paths.into_iter().collect(); + let olds = repo.base_files(base, &list)?; + let mut out = Vec::new(); + for p in &list { + let old: Option = olds.get(p).and_then(|b| serde_json::from_slice(b).ok()); + let new: Option = repo.head_file(p).and_then(|b| serde_json::from_slice(&b).ok()); + out.extend(compare_doc(p, old.as_ref(), new.as_ref())); + } + Ok(out) +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/breaking/registry.rs b/wai/wai-rs/src/bin/wai_cli/dx/breaking/registry.rs new file mode 100644 index 000000000..85780c84e --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/breaking/registry.rs @@ -0,0 +1,165 @@ +//! Wire: the registry export (`wai/registry/capabilities.json`, written by `wai registry +//! export` from REGISTRY.md and held to it by `registry export --check`). It names every +//! capability a reader dispatches on, the deterministic identifiers, and the wire +//! identifiers: magics, document kinds, headers, paths, schemes and tags. +//! +//! A capability, identifier or wire identifier that is gone breaks: a reader that +//! dispatches on it, or a writer that emits it, no longer conforms. So does a capability +//! whose kind, media, role, tier, licence, replicate class, edition or operator set +//! changed (one the old export did not state at all is new, and compatible), one that left a set or joined the mandatory floor (every old reader lacks +//! it), a registered capability demoted to candidate, a wire identifier (named by its kind +//! and name together) whose status fell back from `defined`, and an identifier whose +//! meaning changed. A section moving between documents is editorial and is not compared. + +use super::jsondiff; +use super::{Category, Finding}; +use serde_json::{Map, Value}; + +pub const PATH: &str = "wai/registry/capabilities.json"; + +const COMPARED: &[&str] = &["kind", "media", "role", "tier", "licence", "replicate", "edition", "ops"]; + +fn by<'a>(doc: &'a Value, list: &str, key: &str) -> Vec<(String, &'a Map)> { + doc.get(list) + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|x| Some((x.get(key)?.as_str()?.to_string(), x.as_object()?))).collect()) + .unwrap_or_default() +} + +/// The wire identifiers, each named by its kind and its name together (`fourcc/wai1`): one +/// name can be, say, a `codecs` prefix and a sample-entry 4CC at once. +fn wire(doc: &Value) -> Vec<(String, &Map)> { + by(doc, "wire_identifiers", "name").into_iter().map(|(n, m)| (format!("{}/{n}", m.get("kind").and_then(Value::as_str).unwrap_or("identifier")), m)).collect() +} + +fn s(m: &Map, k: &str) -> String { + m.get(k).map(jsondiff::short).unwrap_or_else(|| "absent".into()) +} + +/// The registry's findings, from the two exports (`None` where a tree has none). +pub fn compare(old: Option<&Value>, new: Option<&Value>) -> Vec { + let mut out = Vec::new(); + let (old, new) = match (old, new) { + (None, None) => return out, + (None, Some(_)) => { + out.push(Finding::compatible(Category::Wire, "registry", PATH.into(), "the registry export is new".into())); + return out; + } + (Some(_), None) => { + out.push(Finding::broke( + Category::Wire, + "registry", + PATH.into(), + "removed".into(), + "the registry export is gone".into(), + "every capability and wire identifier a reader dispatches on loses its registration".into(), + )); + return out; + } + (Some(a), Some(b)) => (a, b), + }; + + let caps_new: std::collections::HashMap> = by(new, "capabilities", "capability").into_iter().collect(); + let caps_old: std::collections::HashSet = by(old, "capabilities", "capability").into_iter().map(|x| x.0).collect(); + for (name, a) in by(old, "capabilities", "capability") { + let id = name.clone(); + let Some(b) = caps_new.get(&name) else { + out.push(Finding::broke(Category::Wire, "capability", id, "removed".into(), format!("capability {name} is no longer registered"), "a reader that dispatches on it, or a writer that names it, no longer conforms".into())); + continue; + }; + let mut what = Vec::new(); + let mut gained = Vec::new(); + for k in COMPARED { + match (a.get(*k), b.get(*k)) { + (x, y) if x == y => {} + // A member the old export did not have is new information, not a change. + (None, Some(_)) => gained.push(format!("{k} {}", s(b, k))), + _ => what.push(format!("{k} {} → {}", s(a, k), s(b, k))), + } + } + if a.get("status").and_then(Value::as_str) == Some("registered") && b.get("status").and_then(Value::as_str) != Some("registered") { + what.push(format!("status registered → {}", s(b, "status"))); + } + let sets = |m: &Map| -> Vec { m.get("sets").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default() }; + let (sa, sb) = (sets(a), sets(b)); + for x in sa.iter().filter(|x| !sb.contains(x)) { + what.push(format!("left the set {x}")); + } + if sb.iter().any(|x| x == "floor") && !sa.iter().any(|x| x == "floor") { + what.push("joined the mandatory floor, which every older reader lacks".into()); + } + if !what.is_empty() { + out.push(Finding::broke(Category::Wire, "capability", id, "changed".into(), what.join("; "), "readers and writers dispatch, select and verify by these fields".into())); + } else if a.get("status") != b.get("status") || sb.iter().any(|x| !sa.contains(x)) || !gained.is_empty() { + gained.insert(0, format!("status {} → {}, sets {:?} → {:?}", s(a, "status"), s(b, "status"), sa, sb)); + out.push(Finding::compatible(Category::Wire, "capability", name.clone(), gained.join("; "))); + } + } + for (name, _) in by(new, "capabilities", "capability") { + if !caps_old.contains(&name) { + out.push(Finding::compatible(Category::Wire, "capability", name.clone(), format!("capability {name} is registered"))); + } + } + + let ids_new: std::collections::HashMap> = by(new, "identifiers", "identifier").into_iter().collect(); + let ids_old: std::collections::HashSet = by(old, "identifiers", "identifier").into_iter().map(|x| x.0).collect(); + for (name, a) in by(old, "identifiers", "identifier") { + let id = name.clone(); + match ids_new.get(&name) { + None => out.push(Finding::broke(Category::Wire, "identifier", id, "removed".into(), format!("identifier {name} is gone"), "a manifest that names it is no longer read as it was".into())), + Some(b) if a.get("meaning") != b.get("meaning") => out.push(Finding::broke( + Category::Wire, + "identifier", + id, + "changed".into(), + format!("its meaning changed: {} → {}", s(a, "meaning"), s(b, "meaning")), + "a deterministic identifier names one arithmetic; another meaning gives other bytes under the same name".into(), + )), + Some(_) => {} + } + } + for (name, _) in by(new, "identifiers", "identifier") { + if !ids_old.contains(&name) { + out.push(Finding::compatible(Category::Wire, "identifier", name.clone(), format!("identifier {name} is new"))); + } + } + + let rank = |m: &Map| match m.get("status").and_then(Value::as_str) { + Some("defined") => 2, + Some("provisional") => 1, + _ => 0, + }; + let wire_new: std::collections::HashMap> = wire(new).into_iter().collect(); + let wire_old: std::collections::HashSet = wire(old).into_iter().map(|x| x.0).collect(); + for (id, a) in wire(old) { + let kind = a.get("kind").and_then(Value::as_str).unwrap_or("identifier"); + let name = a.get("name").and_then(Value::as_str).unwrap_or("?"); + match wire_new.get(&id) { + None => out.push(Finding::broke( + Category::Wire, + "wire-identifier", + id.clone(), + "removed".into(), + format!("the {kind} {name} ({}) is gone", s(a, "document")), + format!("a reader that matches this {kind} refuses what is written without it, and what was written with it is no longer defined"), + )), + Some(b) if rank(b) < rank(a) => out.push(Finding::broke( + Category::Wire, + "wire-identifier", + id.clone(), + "changed".into(), + format!("the {kind} {name}: status {} → {}", s(a, "status"), s(b, "status")), + format!("readers and writers rely on this {kind} as it was defined"), + )), + Some(_) => {} + } + } + for (id, b) in wire(new) { + if !wire_old.contains(&id) { + let kind = b.get("kind").and_then(Value::as_str).unwrap_or("identifier"); + let name = b.get("name").and_then(Value::as_str).unwrap_or("?"); + out.push(Finding::compatible(Category::Wire, "wire-identifier", id.clone(), format!("the {kind} {name} is new"))); + } + } + out +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/breaking/rust_api.rs b/wai/wai-rs/src/bin/wai_cli/dx/breaking/rust_api.rs new file mode 100644 index 000000000..e2833108e --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/breaking/rust_api.rs @@ -0,0 +1,682 @@ +//! API: the Rust public API of the WAI library crates, read from the compiler's own +//! description of it. Each crate is documented twice by `cargo rustdoc` in rustdoc's JSON +//! output, once from the base (its tree extracted to a scratch directory) and once from the +//! working tree, with the same toolchain and the same features: every feature but those +//! that need system libraries or downloads (`DENY`, and any feature that enables one). +//! +//! The public surface is walked from the crate root through public modules and re-exports: +//! every public path to a module, function, struct and its public fields, enum and its +//! variants, union, trait and its items, inherent method and associated item, constant, +//! static, type alias and macro, and every trait a public type implements (auto traits +//! included, blanket implementations left out). A path that is gone breaks. So does a +//! signature that changed (parameter and return types, generics and bounds, `unsafe`, +//! `const`, `async`, ABI; parameter names are not part of it), a field's or alias's type +//! that changed, a struct that can no longer be built with a literal, an enum or struct +//! that became `#[non_exhaustive]`, a new public field of a struct a literal could build, +//! a new variant of an exhaustive enum, and a new required method of a trait. Other new +//! paths are compatible. +//! +//! Rustdoc's JSON is an unstable output of a stable toolchain, read here with +//! `RUSTC_BOOTSTRAP=1`; both sides are written by one toolchain, so they share one format +//! version, and a run that finds two refuses to compare them. + +use super::git::Repo; +use super::super::envelope::{EXIT_INTERNAL, EXIT_NOT_FOUND, OpError}; +use super::{Category, Finding}; +use serde_json::{Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// A library crate whose public API is held. +pub struct Crate { + pub name: &'static str, + pub dir: &'static str, + /// Features left out of the documented set: they need system libraries, a download, or + /// a GPU toolchain, which a check of the API does not. + pub deny: &'static [&'static str], +} + +pub const CRATES: &[Crate] = &[ + Crate { name: "wai", dir: "wai/wai-rs", deny: &["default", "codecs", "semantic", "neural", "c2pa_emit", "ops_gpu"] }, + Crate { name: "wai-quantum", dir: "wai/wai-quantum", deny: &["default"] }, +]; + +/// One public item: what it is, its comparable form, how it reads, and the facts the +/// semver rules above need. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ApiItem { + pub kind: &'static str, + pub sig: String, + pub show: String, + /// A struct every field of which is public and which is not `#[non_exhaustive]`, or an + /// enum with every variant public and not `#[non_exhaustive]`. + pub exhaustive: bool, + /// A trait method, type or constant with no default. + pub required: bool, +} + +/// The public surface of one crate: each public path, and its item. +pub type Surface = BTreeMap; + +struct Doc<'a> { + index: &'a Map, + paths: &'a Map, + crate_name: String, +} + +impl Doc<'_> { + fn item(&self, id: &Value) -> Option<&Value> { + self.index.get(&id_key(id)) + } + fn path_of(&self, id: &Value) -> Option { + let p = self.paths.get(&id_key(id))?.get("path")?.as_array()?; + Some(p.iter().filter_map(Value::as_str).collect::>().join("::")) + } +} + +fn id_key(id: &Value) -> String { + match id { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +fn inner<'a>(item: &'a Value, kind: &str) -> Option<&'a Value> { + item.get("inner")?.get(kind) +} + +fn attrs_have(item: &Value, word: &str) -> bool { + item.get("attrs").and_then(Value::as_array).is_some_and(|a| a.iter().any(|x| x.as_str() == Some(word) || x.to_string().contains(word))) +} + +/// `v` with every `id` dropped and every resolved path written as its full path, keys +/// sorted: equal for two builds of one API. +fn canon(doc: &Doc, v: &Value) -> Value { + match v { + Value::Object(m) => { + let mut keys: Vec<&String> = m.keys().collect(); + keys.sort(); + let mut out = Map::new(); + for k in keys { + if k == "id" { + continue; + } + let mut x = canon(doc, &m[k]); + if k == "path" && x.is_string() && let Some(full) = m.get("id").and_then(|id| doc.path_of(id)) { + x = Value::String(full); + } + out.insert(k.clone(), x); + } + Value::Object(out) + } + Value::Array(a) => Value::Array(a.iter().map(|x| canon(doc, x)).collect()), + other => other.clone(), + } +} + +/// A type as Rust spells it, as far as these forms go; anything else as JSON. +fn ty(doc: &Doc, t: &Value) -> String { + let Some((k, v)) = t.as_object().and_then(|m| m.iter().next()) else { + return t.as_str().map(String::from).unwrap_or_else(|| t.to_string()); + }; + match k.as_str() { + "resolved_path" => path_ty(doc, v), + "generic" | "primitive" => v.as_str().unwrap_or("?").to_string(), + "borrowed_ref" => format!( + "&{}{}{}", + v.get("lifetime").and_then(Value::as_str).map(|l| format!("{l} ")).unwrap_or_default(), + if v.get("is_mutable").and_then(Value::as_bool) == Some(true) { "mut " } else { "" }, + ty(doc, &v["type"]) + ), + "raw_pointer" => format!("*{} {}", if v.get("is_mutable").and_then(Value::as_bool) == Some(true) { "mut" } else { "const" }, ty(doc, &v["type"])), + "slice" => format!("[{}]", ty(doc, v)), + "array" => format!("[{}; {}]", ty(doc, &v["type"]), v.get("len").and_then(Value::as_str).unwrap_or("?")), + "tuple" => format!("({})", v.as_array().map(|a| a.iter().map(|x| ty(doc, x)).collect::>().join(", ")).unwrap_or_default()), + "impl_trait" => format!("impl {}", bounds(doc, v)), + "dyn_trait" => format!( + "dyn {}", + v.get("traits").and_then(Value::as_array).map(|a| a.iter().map(|x| path_ty(doc, &x["trait"])).collect::>().join(" + ")).unwrap_or_default() + ), + "qualified_path" => format!( + "<{} as {}>::{}", + ty(doc, &v["self_type"]), + v.get("trait").filter(|t| !t.is_null()).map(|t| path_ty(doc, t)).unwrap_or_default(), + v.get("name").and_then(Value::as_str).unwrap_or("?") + ), + "function_pointer" => { + let sig = &v["sig"]; + format!("fn({}){}", inputs(doc, sig), output(doc, sig)) + } + "infer" => "_".into(), + _ => t.to_string(), + } +} + +fn path_ty(doc: &Doc, v: &Value) -> String { + let name = v.get("id").and_then(|id| doc.path_of(id)).or_else(|| v.get("path").and_then(Value::as_str).map(String::from)).unwrap_or_default(); + format!("{name}{}", args(doc, v.get("args"))) +} + +fn args(doc: &Doc, a: Option<&Value>) -> String { + let Some(a) = a.filter(|a| !a.is_null()) else { return String::new() }; + if let Some(ab) = a.get("angle_bracketed") { + let mut parts: Vec = ab + .get("args") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|x| match x { + Value::Object(m) if m.contains_key("type") => ty(doc, &m["type"]), + Value::Object(m) if m.contains_key("lifetime") => m["lifetime"].as_str().unwrap_or("'_").to_string(), + other => other.to_string(), + }) + .collect(); + for c in ab.get("constraints").and_then(Value::as_array).into_iter().flatten() { + let n = c.get("name").and_then(Value::as_str).unwrap_or("?"); + match c.get("binding").and_then(|b| b.get("equality")) { + Some(e) => parts.push(format!("{n} = {}", e.get("type").map(|t| ty(doc, t)).unwrap_or_else(|| e.to_string()))), + None => parts.push(format!("{n}: …")), + } + } + return if parts.is_empty() { String::new() } else { format!("<{}>", parts.join(", ")) }; + } + if let Some(p) = a.get("parenthesized") { + let ins = p.get("inputs").and_then(Value::as_array).map(|i| i.iter().map(|x| ty(doc, x)).collect::>().join(", ")).unwrap_or_default(); + let out = p.get("output").filter(|o| !o.is_null()).map(|o| format!(" -> {}", ty(doc, o))).unwrap_or_default(); + return format!("({ins}){out}"); + } + a.to_string() +} + +fn bounds(doc: &Doc, b: &Value) -> String { + b.as_array() + .map(|a| { + a.iter() + .map(|x| match x.get("trait_bound") { + Some(tb) => path_ty(doc, &tb["trait"]), + None => x.get("outlives").and_then(Value::as_str).map(String::from).unwrap_or_else(|| x.to_string()), + }) + .collect::>() + .join(" + ") + }) + .unwrap_or_default() +} + +fn inputs(doc: &Doc, sig: &Value) -> String { + sig.get("inputs").and_then(Value::as_array).map(|a| a.iter().map(|p| ty(doc, &p[1])).collect::>().join(", ")).unwrap_or_default() +} + +fn output(doc: &Doc, sig: &Value) -> String { + sig.get("output").filter(|o| !o.is_null()).map(|o| format!(" -> {}", ty(doc, o))).unwrap_or_default() +} + +fn generics_show(doc: &Doc, g: &Value) -> String { + let params: Vec = g + .get("params") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|p| { + let n = p.get("name").and_then(Value::as_str).unwrap_or("?").to_string(); + match p.get("kind").and_then(|k| k.get("type")).and_then(|t| t.get("bounds")) { + Some(b) if b.as_array().is_some_and(|a| !a.is_empty()) => format!("{n}: {}", bounds(doc, b)), + _ => n, + } + }) + .filter(|n| !n.starts_with("impl ")) + .collect(); + if params.is_empty() { String::new() } else { format!("<{}>", params.join(", ")) } +} + +/// A function's comparable form (types only, parameter names dropped) and how it reads. +fn function(doc: &Doc, name: &str, f: &Value) -> (String, String) { + let sig = &f["sig"]; + let types: Vec = sig.get("inputs").and_then(Value::as_array).into_iter().flatten().map(|p| canon(doc, &p[1])).collect(); + let cmp = serde_json::json!({"inputs": types, "output": canon(doc, &sig["output"]), "c_variadic": sig.get("is_c_variadic"), "generics": canon(doc, &f["generics"]), "header": canon(doc, &f["header"])}); + let h = &f["header"]; + let mut q = String::new(); + for (k, w) in [("is_const", "const "), ("is_async", "async "), ("is_unsafe", "unsafe ")] { + if h.get(k).and_then(Value::as_bool) == Some(true) { + q.push_str(w); + } + } + let show = format!("{q}fn {name}{}({}){}", generics_show(doc, &f["generics"]), inputs(doc, sig), output(doc, sig)); + (cmp.to_string(), show) +} + +struct Walk<'a, 'b> { + doc: &'b Doc<'a>, + out: Surface, + stack: Vec, +} + +impl Walk<'_, '_> { + fn put(&mut self, path: String, kind: &'static str, sig: String, show: String, exhaustive: bool, required: bool) { + self.out.insert(path, ApiItem { kind, sig, show, exhaustive, required }); + } + + fn module(&mut self, id: &Value, path: &str) { + let key = id_key(id); + if self.stack.contains(&key) || self.stack.len() > 32 { + return; + } + let Some(m) = self.doc.item(id).and_then(|i| inner(i, "module")) else { return }; + self.stack.push(key); + for child in m.get("items").and_then(Value::as_array).into_iter().flatten() { + self.child(child, path); + } + self.stack.pop(); + } + + fn child(&mut self, id: &Value, parent: &str) { + let doc = self.doc; + let Some(item) = doc.item(id) else { return }; + if item.get("visibility").and_then(Value::as_str) != Some("public") { + return; + } + if let Some(u) = inner(item, "use") { + let name = u.get("name").and_then(Value::as_str).unwrap_or("?"); + let source = u.get("source").and_then(Value::as_str).unwrap_or("?").to_string(); + let target = u.get("id").filter(|t| !t.is_null()).and_then(|t| doc.item(t).map(|i| (t, i))); + let glob = u.get("is_glob").and_then(Value::as_bool) == Some(true); + match (glob, target) { + (true, Some((t, i))) if inner(i, "module").is_some() => self.module(t, parent), + (false, Some((t, _))) => self.named(t, &format!("{parent}::{name}")), + (true, _) => self.put(format!("{parent}::*{source}"), "re-export", source.clone(), format!("pub use {source}::*"), false, false), + (false, None) => self.put(format!("{parent}::{name}"), "re-export", source.clone(), format!("pub use {source}"), false, false), + } + return; + } + let Some(name) = item.get("name").and_then(Value::as_str) else { return }; + self.named(id, &format!("{parent}::{name}")); + } + + /// The item `id` at the public path `path`, and its members below it. + fn named(&mut self, id: &Value, path: &str) { + let doc = self.doc; + let Some(item) = doc.item(id) else { return }; + let Some((kind, v)) = item.get("inner").and_then(Value::as_object).and_then(|m| m.iter().next()) else { return }; + let name = path.rsplit("::").next().unwrap_or(path).to_string(); + let non_exhaustive = attrs_have(item, "non_exhaustive"); + match kind.as_str() { + "module" => { + self.put(path.into(), "module", String::new(), format!("mod {name}"), false, false); + self.module(id, path); + } + "function" => { + let (sig, show) = function(doc, &name, v); + self.put(path.into(), "function", sig, show, false, false); + } + "struct" | "union" => { + let k = &v["kind"]; + let mut exhaustive = !non_exhaustive; + let fields: Vec<&Value> = if let Some(p) = k.get("plain") { + exhaustive &= p.get("has_stripped_fields").and_then(Value::as_bool) != Some(true); + p.get("fields").and_then(Value::as_array).map(|a| a.iter().collect()).unwrap_or_default() + } else if let Some(t) = k.get("tuple").and_then(Value::as_array) { + exhaustive &= t.iter().all(|f| !f.is_null()); + t.iter().filter(|f| !f.is_null()).collect() + } else if let Some(f) = v.get("fields").and_then(Value::as_array) { + exhaustive &= v.get("has_stripped_fields").and_then(Value::as_bool) != Some(true); + f.iter().collect() + } else { + Vec::new() + }; + let shape = if k.get("tuple").is_some() { "tuple" } else if k.as_str() == Some("unit") { "unit" } else { "plain" }; + let kind_name: &'static str = if kind == "union" { "union" } else { "struct" }; + self.put(path.into(), kind_name, format!("{shape} {}", canon(doc, &v["generics"])), format!("{kind_name} {name}{}", generics_show(doc, &v["generics"])), exhaustive, false); + for f in fields { + if let Some(fi) = doc.item(f) + && fi.get("visibility").and_then(Value::as_str) == Some("public") + { + let fname = fi.get("name").and_then(Value::as_str).unwrap_or("?"); + let t = inner(fi, "struct_field").cloned().unwrap_or(Value::Null); + self.put(format!("{path}::{fname}"), "field", canon(doc, &t).to_string(), format!("{fname}: {}", ty(doc, &t)), false, false); + } + } + self.impls(v, path); + } + "enum" => { + let exhaustive = !non_exhaustive && v.get("has_stripped_variants").and_then(Value::as_bool) != Some(true); + self.put(path.into(), "enum", canon(doc, &v["generics"]).to_string(), format!("enum {name}{}", generics_show(doc, &v["generics"])), exhaustive, false); + for var in v.get("variants").and_then(Value::as_array).into_iter().flatten() { + if let Some(vi) = doc.item(var) { + let vname = vi.get("name").and_then(Value::as_str).unwrap_or("?"); + let vk = inner(vi, "variant").cloned().unwrap_or(Value::Null); + let mut fields = Vec::new(); + let field_ids: Vec<&Value> = match vk.get("kind") { + Some(k) if k.get("tuple").is_some() => k["tuple"].as_array().map(|a| a.iter().collect()).unwrap_or_default(), + Some(k) if k.get("struct").is_some() => k["struct"].get("fields").and_then(Value::as_array).map(|a| a.iter().collect()).unwrap_or_default(), + _ => Vec::new(), + }; + for fid in field_ids { + let t = doc.item(fid).and_then(|fi| inner(fi, "struct_field").map(|t| (fi.get("name").and_then(Value::as_str).unwrap_or("?").to_string(), t.clone()))); + if let Some((fname, t)) = t { + fields.push(serde_json::json!([fname, canon(doc, &t)])); + } + } + let sig = serde_json::json!({"shape": vk.get("kind").map(|k| if k.is_string() { "plain" } else if k.get("tuple").is_some() { "tuple" } else { "struct" }), "fields": fields, "discriminant": vk.get("discriminant")}); + self.put(format!("{path}::{vname}"), "variant", sig.to_string(), format!("{name}::{vname}"), false, false); + } + } + self.impls(v, path); + } + "trait" => { + let sig = serde_json::json!({"generics": canon(doc, &v["generics"]), "bounds": canon(doc, &v["bounds"]), "unsafe": v.get("is_unsafe"), "auto": v.get("is_auto")}); + self.put(path.into(), "trait", sig.to_string(), format!("trait {name}{}", generics_show(doc, &v["generics"])), false, false); + for t in v.get("items").and_then(Value::as_array).into_iter().flatten() { + let Some(ti) = doc.item(t) else { continue }; + let tname = ti.get("name").and_then(Value::as_str).unwrap_or("?"); + let Some((tk, tv)) = ti.get("inner").and_then(Value::as_object).and_then(|m| m.iter().next()) else { continue }; + let p = format!("{path}::{tname}"); + match tk.as_str() { + "function" => { + let (sig, show) = function(doc, tname, tv); + let required = tv.get("has_body").and_then(Value::as_bool) != Some(true); + self.put(p, "trait method", sig, show, false, required); + } + "assoc_type" => { + let required = tv.get("type").is_none_or(Value::is_null); + self.put(p, "associated type", canon(doc, &tv["bounds"]).to_string(), format!("type {tname}"), false, required); + } + "assoc_const" => { + let required = tv.get("value").is_none_or(Value::is_null); + self.put(p, "associated constant", canon(doc, &tv["type"]).to_string(), format!("const {tname}: {}", ty(doc, &tv["type"])), false, required); + } + _ => {} + } + } + } + "constant" => { + let t = v.get("type").cloned().unwrap_or(Value::Null); + self.put(path.into(), "constant", canon(doc, &t).to_string(), format!("const {name}: {}", ty(doc, &t)), false, false); + } + "static" => { + let t = v.get("type").cloned().unwrap_or(Value::Null); + let sig = serde_json::json!({"type": canon(doc, &t), "mutable": v.get("is_mutable")}); + self.put(path.into(), "static", sig.to_string(), format!("static {name}: {}", ty(doc, &t)), false, false); + } + "type_alias" => { + let t = v.get("type").cloned().unwrap_or(Value::Null); + let sig = serde_json::json!({"type": canon(doc, &t), "generics": canon(doc, &v["generics"])}); + self.put(path.into(), "type alias", sig.to_string(), format!("type {name} = {}", ty(doc, &t)), false, false); + } + "macro" | "proc_macro" => self.put(path.into(), "macro", String::new(), format!("macro {name}!"), false, false), + _ => {} + } + } + + /// The inherent methods and associated items, and the traits implemented, of the type + /// at `path`. + fn impls(&mut self, v: &Value, path: &str) { + let doc = self.doc; + for imp in v.get("impls").and_then(Value::as_array).into_iter().flatten() { + let Some(ii) = doc.item(imp).and_then(|i| inner(i, "impl")) else { continue }; + if ii.get("blanket_impl").is_some_and(|b| !b.is_null()) || ii.get("is_negative").and_then(Value::as_bool) == Some(true) { + continue; + } + match ii.get("trait").filter(|t| !t.is_null()) { + Some(t) => { + let tr = path_ty(doc, t); + let sig = serde_json::json!({"generics": canon(doc, &ii["generics"]), "unsafe": ii.get("is_unsafe")}); + self.put(format!("impl {tr} for {path}"), "trait implementation", sig.to_string(), format!("impl {tr} for {path}"), false, false); + } + None => { + for m in ii.get("items").and_then(Value::as_array).into_iter().flatten() { + let Some(mi) = doc.item(m) else { continue }; + if mi.get("visibility").and_then(Value::as_str) != Some("public") { + continue; + } + let mname = mi.get("name").and_then(Value::as_str).unwrap_or("?"); + let Some((mk, mv)) = mi.get("inner").and_then(Value::as_object).and_then(|m| m.iter().next()) else { continue }; + let p = format!("{path}::{mname}"); + match mk.as_str() { + "function" => { + let (sig, show) = function(doc, mname, mv); + self.put(p, "method", sig, show, false, false); + } + "assoc_const" => self.put(p, "associated constant", canon(doc, &mv["type"]).to_string(), format!("const {mname}: {}", ty(doc, &mv["type"])), false, false), + "assoc_type" => self.put(p, "associated type", canon(doc, &mv["type"]).to_string(), format!("type {mname}"), false, false), + _ => {} + } + } + } + } + } + } +} + +/// The public surface a rustdoc JSON document describes, and its format version. +pub fn surface(json: &Value) -> Result<(Surface, u64), String> { + let index = json.get("index").and_then(Value::as_object).ok_or("rustdoc JSON without an index")?; + let paths = json.get("paths").and_then(Value::as_object).ok_or("rustdoc JSON without paths")?; + let root = json.get("root").ok_or("rustdoc JSON without a root")?; + let crate_name = index.get(&id_key(root)).and_then(|r| r.get("name")).and_then(Value::as_str).unwrap_or("crate").to_string(); + let doc = Doc { index, paths, crate_name }; + let mut w = Walk { doc: &doc, out: Surface::new(), stack: Vec::new() }; + let name = doc.crate_name.clone(); + w.module(root, &name); + Ok((w.out, json.get("format_version").and_then(Value::as_u64).unwrap_or(0))) +} + +fn parent(path: &str) -> &str { + path.rsplit_once("::").map(|x| x.0).unwrap_or("") +} + +/// The findings between two surfaces of the crate `name`. +pub fn compare_surfaces(old: &Surface, new: &Surface) -> Vec { + let mut out = Vec::new(); + for (path, a) in old { + match new.get(path) { + None => out.push(Finding::broke( + Category::Api, + "rust", + path.clone(), + "removed".into(), + format!("the {} {} is gone", a.kind, a.show), + format!("code that names {path} no longer compiles"), + )), + Some(b) if a.kind != b.kind => out.push(Finding::broke(Category::Api, "rust", path.clone(), "changed".into(), format!("a {} is now a {}", a.kind, b.kind), "code that uses it as it was no longer compiles".into())), + Some(b) if a.sig != b.sig => { + let what = if a.show != b.show { format!("{} → {}", a.show, b.show) } else { format!("the {} {}: its generics, bounds or qualifiers changed", a.kind, a.show) }; + out.push(Finding::broke(Category::Api, "rust", path.clone(), "changed".into(), what, "code written against the old signature no longer compiles, or means something else".into())); + } + Some(b) if a.exhaustive && !b.exhaustive => out.push(Finding::broke( + Category::Api, + "rust", + path.clone(), + "changed".into(), + format!("the {} {} is no longer exhaustive (a private field or variant, or #[non_exhaustive])", a.kind, a.show), + if a.kind == "enum" { "a match over every variant no longer compiles outside the crate".into() } else { "a struct literal or pattern that lists every field no longer compiles outside the crate".into() }, + )), + Some(b) if !a.required && b.required => out.push(Finding::broke(Category::Api, "rust", path.clone(), "changed".into(), format!("{} lost its default", b.show), "every implementation outside the crate must now provide it".into())), + Some(_) => {} + } + } + for (path, b) in new { + if old.contains_key(path) { + continue; + } + let up = old.get(parent(path)); + let (breaks, why) = match (b.kind, up) { + ("field", Some(s)) if s.exhaustive && new.get(parent(path)).is_some_and(|n| n.kind == s.kind) => (true, "a struct literal or pattern that lists every field no longer compiles outside the crate"), + ("variant", Some(e)) if e.exhaustive && e.kind == "enum" => (true, "a match over every variant no longer compiles outside the crate"), + ("trait method" | "associated type" | "associated constant", Some(t)) if b.required && t.kind == "trait" => (true, "every implementation outside the crate must now provide it"), + _ => (false, ""), + }; + if breaks { + out.push(Finding::broke(Category::Api, "rust", path.clone(), "added".into(), format!("the {} {} is new", b.kind, b.show), why.into())); + } else { + out.push(Finding::compatible(Category::Api, "rust", path.clone(), format!("the {} {} is new", b.kind, b.show))); + } + } + out +} + +/// The scratch space and toolchain the two documentation builds share. +pub struct Builder { + pub target_dir: PathBuf, + pub toolchain: Option, +} + +fn internal(e: impl std::fmt::Display) -> OpError { + OpError::new(EXIT_INTERNAL, "rustdoc-failed", e.to_string(), "the API check builds each crate's documentation with cargo; run `cargo rustdoc` in the crate to see why it fails") +} + +impl Builder { + fn cargo(&self, dir: &Path) -> Command { + let mut c = Command::new("cargo"); + c.current_dir(dir).env("RUSTC_BOOTSTRAP", "1").env_remove("RUSTDOCFLAGS"); + if let Some(t) = &self.toolchain { + c.env("RUSTUP_TOOLCHAIN", t); + } + c + } + + /// The features documented for `krate` in its directory `dir`: all, less the denied + /// ones and every feature that enables a denied one. + fn features(&self, krate: &Crate, dir: &Path) -> Result, OpError> { + let out = self.cargo(dir).args(["metadata", "--no-deps", "--format-version", "1", "--offline"]).output().map_err(|e| { + OpError::new(EXIT_NOT_FOUND, "cargo-not-found", format!("cargo: {e}"), "the API check needs a Rust toolchain on PATH, or `--skip rust`") + })?; + if !out.status.success() { + return Err(internal(format!("cargo metadata in {}: {}", dir.display(), String::from_utf8_lossy(&out.stderr).trim()))); + } + let meta: Value = serde_json::from_slice(&out.stdout).map_err(internal)?; + let pkg = meta["packages"].as_array().and_then(|a| a.iter().find(|p| p["name"] == krate.name)).ok_or_else(|| internal(format!("no package {} in {}", krate.name, dir.display())))?; + let feats: BTreeMap> = pkg["features"] + .as_object() + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default())).collect()) + .unwrap_or_default(); + let mut denied: BTreeSet = krate.deny.iter().map(|s| s.to_string()).collect(); + loop { + let more: Vec = feats.iter().filter(|(k, v)| !denied.contains(*k) && v.iter().any(|x| denied.contains(x))).map(|x| x.0.clone()).collect(); + if more.is_empty() { + break; + } + denied.extend(more); + } + Ok(feats.keys().filter(|k| !denied.contains(*k)).cloned().collect()) + } + + /// `krate`'s public surface in the tree whose top is `top`. + pub fn surface(&self, krate: &Crate, top: &Path) -> Result<(Surface, u64), OpError> { + let dir = top.join(krate.dir); + let feats = self.features(krate, &dir)?; + let mut c = self.cargo(&dir); + c.args(["rustdoc", "--lib", "--no-default-features", "--quiet"]); + if !feats.is_empty() { + c.arg("--features").arg(feats.join(",")); + } + if dir.join("Cargo.lock").exists() { + c.arg("--locked"); + } + c.arg("--target-dir").arg(&self.target_dir); + c.args(["--", "-Z", "unstable-options", "--output-format", "json", "--cap-lints", "allow"]); + eprintln!("wai breaking: documenting {} in {}", krate.name, dir.display()); + let out = c.output().map_err(internal)?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + let tail: Vec<&str> = err.lines().rev().take(30).collect::>().into_iter().rev().collect(); + return Err(internal(format!("cargo rustdoc for {} in {}:\n{}", krate.name, dir.display(), tail.join("\n")))); + } + let file = self.target_dir.join("doc").join(format!("{}.json", krate.name.replace('-', "_"))); + let bytes = std::fs::read(&file).map_err(|e| internal(format!("{}: {e}", file.display())))?; + let json: Value = serde_json::from_slice(&bytes).map_err(internal)?; + let _ = std::fs::remove_file(&file); + surface(&json).map_err(internal) + } +} + +/// The directories `dir`'s crate depends on by path, `dir` included, found through each +/// manifest's `path = "…"` dependencies: what can change its API. +pub fn path_closure(read: &dyn Fn(&str) -> Option, dir: &str) -> BTreeSet { + let mut seen = BTreeSet::new(); + let mut todo = vec![dir.to_string()]; + while let Some(d) = todo.pop() { + if !seen.insert(d.clone()) { + continue; + } + let Some(toml) = read(&format!("{d}/Cargo.toml")) else { continue }; + let mut section = String::new(); + for line in toml.lines() { + let l = line.trim(); + if l.starts_with('[') { + section = l.to_string(); + continue; + } + if !section.contains("dependencies") { + continue; + } + if let Some(i) = l.find("path") { + let rest = l[i + 4..].trim_start(); + if let Some(rest) = rest.strip_prefix('=') + && let Some(p) = rest.trim_start().strip_prefix('"').and_then(|r| r.split('"').next()) + { + let mut parts: Vec<&str> = d.split('/').collect(); + for seg in p.split('/') { + match seg { + ".." => { + parts.pop(); + } + "." | "" => {} + s => parts.push(s), + } + } + todo.push(parts.join("/")); + } + } + } + } + seen +} + +/// The Rust API's findings between `base` and the working tree, for every crate in +/// [`CRATES`] whose sources (or those it depends on by path) differ. +pub fn compare(repo: &Repo, base: &str, builder: &Builder, scratch: &Path, notes: &mut Vec) -> Result, OpError> { + let mut out = Vec::new(); + let mut extracted: Option = None; + for krate in CRATES { + let read_head = |p: &str| std::fs::read_to_string(repo.root.join(p)).ok(); + let read_base = |p: &str| repo.base_file(base, p).ok().flatten().map(|b| String::from_utf8_lossy(&b).into_owned()); + let mut dirs = path_closure(&read_head, krate.dir); + dirs.extend(path_closure(&read_base, krate.dir)); + let specs: Vec = dirs.iter().cloned().collect(); + let spec_refs: Vec<&str> = specs.iter().map(String::as_str).collect(); + let in_head = repo.root.join(krate.dir).join("Cargo.toml").exists(); + let in_base = read_base(&format!("{}/Cargo.toml", krate.dir)).is_some(); + if !in_head && !in_base { + continue; + } + if repo.changed(base, &spec_refs)?.is_empty() { + notes.push(format!("rust: {} unchanged (nothing under {} differs)", krate.name, specs.join(", "))); + continue; + } + let new = if in_head { Some(builder.surface(krate, &repo.root)?) } else { None }; + let old = if in_base { + let top = match &extracted { + Some(t) => t.clone(), + None => { + let t = scratch.join("base"); + repo.extract(base, "wai", &t)?; + extracted = Some(t.clone()); + t + } + }; + Some(builder.surface(krate, &top)?) + } else { + None + }; + if let (Some((_, fa)), Some((_, fb))) = (&old, &new) + && fa != fb + { + return Err(internal(format!("rustdoc wrote format {fa} for the base and {fb} for the working tree; they are not compared"))); + } + let empty = Surface::new(); + let (a, b) = (old.as_ref().map(|x| &x.0).unwrap_or(&empty), new.as_ref().map(|x| &x.0).unwrap_or(&empty)); + notes.push(format!("rust: {} documented, {} public paths in the base and {} in the working tree", krate.name, a.len(), b.len())); + out.extend(compare_surfaces(a, b)); + } + Ok(out) +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/breaking/schema.rs b/wai/wai-rs/src/bin/wai_cli/dx/breaking/schema.rs new file mode 100644 index 000000000..b63c8f7f6 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/breaking/schema.rs @@ -0,0 +1,112 @@ +//! Record: the JSON Schemas WAI publishes for its documents (`wai/**/*.schema.json`). A +//! document that validated before must validate after. So a schema breaks where a property +//! is gone, a property becomes required, a type or enumeration narrows, a pattern or +//! constant changes, or additional properties are no longer allowed; it is compatible +//! where it only gains optional properties, types or enumerated values. + +use super::git::{Repo, Status}; +use super::super::envelope::OpError; +use super::{Category, Finding}; +use serde_json::Value; + +pub const PATHSPECS: &[&str] = &[":(glob)wai/**/*.schema.json"]; + +fn strings(v: Option<&Value>) -> Vec { + match v { + Some(Value::Array(a)) => a.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect(), + Some(Value::String(s)) => vec![s.clone()], + _ => Vec::new(), + } +} + +/// What a document valid under `old` may fail under `new` (`breaks`), and what `new` admits +/// that `old` did not (`adds`), each a line naming the schema path. +pub fn walk(old: &Value, new: &Value, path: &str, breaks: &mut Vec, adds: &mut Vec) { + let (Some(a), Some(b)) = (old.as_object(), new.as_object()) else { + if old != new { + breaks.push(format!("{path}: {old} → {new}")); + } + return; + }; + let (ta, tb) = (strings(a.get("type")), strings(b.get("type"))); + if (!tb.is_empty() && ta.iter().any(|t| !tb.contains(t))) || (ta.is_empty() && !tb.is_empty()) { + breaks.push(format!("{path}: type {ta:?} → {tb:?}")); + } + if let (Some(Value::Array(ea)), Some(Value::Array(eb))) = (a.get("enum"), b.get("enum")) { + for x in ea.iter().filter(|x| !eb.contains(x)) { + breaks.push(format!("{path}: the value {x} is no longer allowed")); + } + for x in eb.iter().filter(|x| !ea.contains(x)) { + adds.push(format!("{path}: the value {x} is allowed")); + } + } else if a.get("enum").is_none() && b.get("enum").is_some() { + breaks.push(format!("{path}: values are now enumerated")); + } + for k in ["pattern", "const", "format", "maxLength", "maximum", "maxItems", "minLength", "minimum", "minItems"] { + if a.get(k) != b.get(k) && b.get(k).is_some() { + breaks.push(format!("{path}: {k} {} → {}", a.get(k).map(Value::to_string).unwrap_or_else(|| "absent".into()), b.get(k).map(Value::to_string).unwrap_or_default())); + } + } + let (ra, rb) = (strings(a.get("required")), strings(b.get("required"))); + for r in rb.iter().filter(|r| !ra.contains(r)) { + breaks.push(format!("{path}: {r} is now required")); + } + if b.get("additionalProperties") == Some(&Value::Bool(false)) && a.get("additionalProperties") != Some(&Value::Bool(false)) { + breaks.push(format!("{path}: other properties are no longer allowed")); + } + for key in ["properties", "$defs", "definitions"] { + let (pa, pb) = (a.get(key).and_then(Value::as_object), b.get(key).and_then(Value::as_object)); + if let Some(pa) = pa { + for (name, sa) in pa { + match pb.and_then(|pb| pb.get(name)) { + None => breaks.push(format!("{path}/{key}/{name}: removed")), + Some(sb) => walk(sa, sb, &format!("{path}/{key}/{name}"), breaks, adds), + } + } + } + if let Some(pb) = pb { + for name in pb.keys().filter(|n| pa.is_none_or(|pa| !pa.contains_key(*n))) { + adds.push(format!("{path}/{key}/{name}: new")); + } + } + } + for key in ["items", "additionalProperties", "not"] { + if let (Some(x @ Value::Object(_)), Some(y @ Value::Object(_))) = (a.get(key), b.get(key)) { + walk(x, y, &format!("{path}/{key}"), breaks, adds); + } + } + for key in ["anyOf", "oneOf", "allOf"] { + if a.get(key) != b.get(key) { + breaks.push(format!("{path}: {key} changed")); + } + } +} + +/// The schemas' findings between `base` and the working tree. +pub fn compare(repo: &Repo, base: &str) -> Result, OpError> { + let changed = repo.changed(base, PATHSPECS)?; + let olds = repo.base_files(base, &changed.iter().map(|x| x.1.clone()).collect::>())?; + let mut out = Vec::new(); + for (status, path) in changed { + match status { + Status::Added => out.push(Finding::compatible(Category::Record, "schema", path, "a new schema".into())), + Status::Removed => out.push(Finding::broke(Category::Record, "schema", path, "removed".into(), "the schema is gone".into(), "documents written against it lose their published shape".into())), + Status::Modified => { + let old: Option = olds.get(&path).and_then(|b| serde_json::from_slice(b).ok()); + let new: Option = repo.head_file(&path).and_then(|b| serde_json::from_slice(&b).ok()); + let (Some(old), Some(new)) = (old, new) else { + out.push(Finding::broke(Category::Record, "schema", path, "changed".into(), "the schema is no longer JSON".into(), "documents cannot be checked against it".into())); + continue; + }; + let (mut breaks, mut adds) = (Vec::new(), Vec::new()); + walk(&old, &new, "#", &mut breaks, &mut adds); + if !breaks.is_empty() { + out.push(Finding::broke(Category::Record, "schema", path, "changed".into(), breaks.join("; "), "a document valid under the old schema is refused under the new one".into())); + } else if !adds.is_empty() { + out.push(Finding::compatible(Category::Record, "schema", path, adds.join("; "))); + } + } + } + } + Ok(out) +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/conform/adapter.rs b/wai/wai-rs/src/bin/wai_cli/dx/conform/adapter.rs new file mode 100644 index 000000000..f1e4e0e65 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/conform/adapter.rs @@ -0,0 +1,196 @@ +//! The two ways `wai conform` reaches an implementation. +//! +//! **Command.** `--impl` is a command line, run by the system shell once a case (or once for +//! the whole corpus, where the suite allows it), with these placeholders replaced by quoted +//! paths: `{case}` a file holding the case (a verifier's vector, as the corpus writes it), +//! `{corpus}` the corpus file whole, `{in}` the input file, `{out}` a file to write the +//! output to. Without `{case}` or `{in}`, the case or input is given on standard input; +//! without `{out}`, the output is read from standard output. What the command prints first +//! on standard output is its verdict; its exit status says whether it refused (non-zero) +//! where the suite asks. +//! +//! **HTTP.** `--impl` is an `http://` URL: each case is a `POST` of the case or input to +//! it (an origin is instead fetched from, below it), and the response's status and body are +//! the answer. + +use sha2::{Digest, Sha256}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// What a command did. +pub struct Ran { + pub code: Option, + pub stdout: Vec, + pub stderr: Vec, + pub timed_out: bool, +} + +/// A path quoted for the shell that runs the command. +pub fn quote(p: &Path) -> String { + let s = p.to_string_lossy(); + if cfg!(windows) { format!("\"{s}\"") } else { format!("'{}'", s.replace('\'', "'\\''")) } +} + +/// `cmd` with each `{name}` replaced by its quoted path. +pub fn fill(cmd: &str, with: &[(&str, &Path)]) -> String { + let mut s = cmd.to_string(); + for (k, p) in with { + s = s.replace(&format!("{{{k}}}"), "e(p)); + } + s +} + +/// Run `cmd` through the shell, `stdin` given to it, killed after `timeout`. +pub fn run(cmd: &str, stdin: Option<&[u8]>, timeout: Duration) -> Result { + let mut c = if cfg!(windows) { + let mut c = Command::new("cmd"); + c.arg("/C").arg(cmd); + c + } else { + let mut c = Command::new("sh"); + c.arg("-c").arg(cmd); + c + }; + let mut child = c + .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("{cmd}: {e}"))?; + let writer = match (stdin, child.stdin.take()) { + (Some(b), Some(mut w)) => { + let b = b.to_vec(); + Some(std::thread::spawn(move || { + let _ = w.write_all(&b); + })) + } + _ => None, + }; + let read = |r: Option>| { + std::thread::spawn(move || { + let mut v = Vec::new(); + if let Some(mut r) = r { + let _ = r.read_to_end(&mut v); + } + v + }) + }; + let out = read(child.stdout.take().map(|x| Box::new(x) as Box)); + let err = read(child.stderr.take().map(|x| Box::new(x) as Box)); + let t0 = Instant::now(); + let mut timed_out = false; + let status = loop { + match child.try_wait().map_err(|e| e.to_string())? { + Some(s) => break Some(s), + None if t0.elapsed() >= timeout => { + timed_out = true; + let _ = child.kill(); + break child.wait().ok(); + } + None => std::thread::sleep(Duration::from_millis(5)), + } + }; + if let Some(w) = writer { + let _ = w.join(); + } + let stdout = out.join().unwrap_or_default(); + let stderr = err.join().unwrap_or_default(); + Ok(Ran { code: status.and_then(|s| s.code()), stdout, stderr, timed_out }) +} + +/// The first line of `b`, trimmed. +pub fn first_line(b: &[u8]) -> String { + String::from_utf8_lossy(b).lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("").to_string() +} + +pub fn sha256_hex(b: &[u8]) -> String { + Sha256::digest(b).iter().map(|x| format!("{x:02x}")).collect() +} + +/// The words of a command line, quotes removed (the shell's rules, roughly). +fn words(cmd: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut quote: Option = None; + let mut any = false; + for c in cmd.chars() { + match (quote, c) { + (Some(q), c) if c == q => quote = None, + (Some(_), c) => cur.push(c), + (None, '\'' | '"') => { + quote = Some(c); + any = true; + } + (None, c) if c.is_whitespace() => { + if any || !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + any = false; + } + } + (None, c) => cur.push(c), + } + } + if any || !cur.is_empty() { + out.push(cur); + } + out +} + +fn on_path(name: &str) -> Option { + if name.contains('/') || name.contains('\\') { + return Path::new(name).is_file().then(|| PathBuf::from(name)); + } + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path).map(|d| d.join(name)).find(|p| p.is_file()) +} + +/// The files a command line names (its program found on `PATH`, and every argument that +/// is a file), each with its SHA-256: what identifies the implementation that ran. +pub fn identity(cmd: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + for (i, w) in words(cmd).iter().enumerate() { + if w.starts_with('{') && w.ends_with('}') { + continue; + } + let p = if i == 0 { on_path(w) } else { Path::new(w).is_file().then(|| PathBuf::from(w)) }; + if let Some(p) = p + && let Ok(b) = std::fs::read(&p) + { + out.push((p.to_string_lossy().into_owned(), sha256_hex(&b))); + } + } + out +} + +/// Whether the program a command line runs can be found. +pub fn program_found(cmd: &str) -> bool { + match words(cmd).first() { + None => false, + Some(p) => on_path(p).is_some() || matches!(p.as_str(), "cd" | "exec" | "env" | "echo" | "printf" | "test" | "[" | "true" | "false"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn placeholders_are_quoted_paths() { + let s = fill("dec {in} {out}", &[("in", Path::new("/t/a b.wscr")), ("out", Path::new("/t/it's.pcm"))]); + assert_eq!(s, "dec '/t/a b.wscr' '/t/it'\\''s.pcm'"); + assert_eq!(words("python3 -I 'a b.py' verdicts {corpus}"), vec!["python3", "-I", "a b.py", "verdicts", "{corpus}"]); + } + + #[cfg(unix)] + #[test] + fn a_command_runs_and_one_that_hangs_is_stopped() { + let r = run("cat", Some(b"x\ny"), Duration::from_secs(20)).unwrap(); + assert_eq!(r.stdout, b"x\ny"); + assert_eq!(first_line(&r.stdout), "x"); + let r = run("sleep 30", None, Duration::from_millis(200)).unwrap(); + assert!(r.timed_out); + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/conform/http.rs b/wai/wai-rs/src/bin/wai_cli/dx/conform/http.rs new file mode 100644 index 000000000..3c4cf8a41 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/conform/http.rs @@ -0,0 +1,169 @@ +//! A small HTTP/1.1 client over plain TCP, enough to drive an implementation behind a URL: +//! one request a connection (`Connection: close`), a body by `Content-Length` or chunked, +//! and a response read whole up to a bound. + +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::{Duration, Instant}; + +/// The most a response may hold. +const MAX_RESPONSE: usize = 512 << 20; + +/// An `http://` URL: where to connect, the `Host` header, and the path it starts with. +#[derive(Clone, Debug)] +pub struct Url { + pub authority: String, + pub prefix: String, +} + +impl Url { + pub fn parse(u: &str) -> Result { + let rest = u.strip_prefix("http://").ok_or_else(|| { + if u.starts_with("https://") { + format!("{u}: this build speaks plain HTTP to an implementation; serve it on an http:// address (a loopback port, or a tunnel to one)") + } else { + format!("{u}: not an http:// URL") + } + })?; + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, "/"), + }; + if authority.is_empty() { + return Err(format!("{u}: no host")); + } + Ok(Url { authority: authority.to_string(), prefix: path.trim_end_matches('/').to_string() }) + } + + /// The path `p` (which starts with `/`, or is empty for the URL itself) below the URL. + pub fn join(&self, p: &str) -> String { + let s = format!("{}{p}", self.prefix); + if s.is_empty() { "/".into() } else { s } + } +} + +#[derive(Debug)] +pub struct Response { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +impl Response { + /// Every value of the header `name` (case-insensitive), joined with `, `. + pub fn header(&self, name: &str) -> Option { + let v: Vec<&str> = self.headers.iter().filter(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()).collect(); + if v.is_empty() { None } else { Some(v.join(", ")) } + } +} + +/// Why a request got no response. +#[derive(Debug)] +pub enum Failure { + /// No connection: the implementation is not there (or not yet). + Unreachable(String), + /// A connection, and no readable response. + Broken(String), +} + +impl std::fmt::Display for Failure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Failure::Unreachable(e) => write!(f, "unreachable: {e}"), + Failure::Broken(e) => write!(f, "{e}"), + } + } +} + +fn dechunk(mut b: &[u8]) -> Option> { + let mut out = Vec::new(); + loop { + let nl = b.windows(2).position(|w| w == b"\r\n")?; + let size_text = std::str::from_utf8(&b[..nl]).ok()?; + let size = usize::from_str_radix(size_text.split(';').next()?.trim(), 16).ok()?; + b = &b[nl + 2..]; + if size == 0 { + return Some(out); + } + if b.len() < size + 2 { + return None; + } + out.extend_from_slice(&b[..size]); + b = &b[size + 2..]; + } +} + +/// One request, answered whole within `timeout`. +pub fn request(url: &Url, method: &str, path: &str, headers: &[(&str, &str)], body: Option<&[u8]>, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let addrs: Vec<_> = url.authority.to_socket_addrs().map_err(|e| Failure::Unreachable(format!("{}: {e}", url.authority)))?.collect(); + let mut last = String::from("no address"); + let mut stream = None; + for a in &addrs { + match TcpStream::connect_timeout(a, timeout.min(Duration::from_secs(10))) { + Ok(s) => { + stream = Some(s); + break; + } + Err(e) => last = format!("{a}: {e}"), + } + } + let mut s = stream.ok_or(Failure::Unreachable(last))?; + let left = || deadline.saturating_duration_since(Instant::now()).max(Duration::from_millis(1)); + let _ = s.set_read_timeout(Some(left())); + let _ = s.set_write_timeout(Some(left())); + let mut head = format!("{method} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nUser-Agent: wai-conform\r\n", url.join(path), url.authority); + for (k, v) in headers { + head.push_str(&format!("{k}: {v}\r\n")); + } + if let Some(b) = body { + head.push_str(&format!("Content-Length: {}\r\n", b.len())); + } + head.push_str("\r\n"); + let broken = |e: std::io::Error| Failure::Broken(format!("{method} {path}: {e}")); + s.write_all(head.as_bytes()).map_err(broken)?; + if let Some(b) = body { + s.write_all(b).map_err(broken)?; + } + let mut buf = Vec::new(); + let mut chunk = [0u8; 64 * 1024]; + loop { + let _ = s.set_read_timeout(Some(left())); + match s.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + buf.extend_from_slice(&chunk[..n]); + if buf.len() > MAX_RESPONSE { + return Err(Failure::Broken(format!("{method} {path}: the response is larger than {MAX_RESPONSE} bytes"))); + } + } + Err(e) if Instant::now() >= deadline => return Err(Failure::Broken(format!("{method} {path}: no complete response in {timeout:?} ({e})"))), + Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted) => {} + Err(e) => { + if buf.is_empty() { + return Err(broken(e)); + } + break; + } + } + } + let end = buf.windows(4).position(|w| w == b"\r\n\r\n").ok_or_else(|| Failure::Broken(format!("{method} {path}: no response head")))?; + let head = String::from_utf8_lossy(&buf[..end]).into_owned(); + let mut lines = head.split("\r\n"); + let status_line = lines.next().unwrap_or(""); + let status: u16 = status_line.split(' ').nth(1).and_then(|s| s.parse().ok()).ok_or_else(|| Failure::Broken(format!("{method} {path}: status line {status_line:?}")))?; + let headers: Vec<(String, String)> = lines.filter_map(|l| l.split_once(':').map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))).collect(); + let mut body = buf[end + 4..].to_vec(); + let r = Response { status, headers, body: Vec::new() }; + if method == "HEAD" || status == 204 || status == 304 { + body.clear(); + } else if r.header("Transfer-Encoding").is_some_and(|t| t.to_ascii_lowercase().contains("chunked")) { + body = dechunk(&body).ok_or_else(|| Failure::Broken(format!("{method} {path}: a chunked body that does not read")))?; + } else if let Some(n) = r.header("Content-Length").and_then(|v| v.trim().parse::().ok()) { + if body.len() < n { + return Err(Failure::Broken(format!("{method} {path}: {} of {n} body bytes", body.len()))); + } + body.truncate(n); + } + Ok(Response { body, ..r }) +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/conform/mod.rs b/wai/wai-rs/src/bin/wai_cli/dx/conform/mod.rs new file mode 100644 index 000000000..6f913c03e --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/conform/mod.rs @@ -0,0 +1,578 @@ +//! `wai conform --suite --impl ` (operation +//! `conformance.run`): one of WAI's conformance corpora run against another implementation, +//! and the outcome written as a signed result record (`record`), which `wai verify` checks. +//! +//! The kinds and their suites are in `suites`; the two adapters (a command line, or an +//! `http://` URL) in `adapter`. Every case's expectation is the corpus's own. A run reads +//! the corpus from a checkout of the standard (`--root `, or found from the +//! current directory), and records the digest of every corpus file it read, the +//! implementation it ran (the command and the SHA-256 of every file it names, or the URL), +//! each case's outcome, and the standard's version. +//! +//! Exit status: 0 when every case passed, 1 when one did not, 2 for a usage error, 4 when +//! the record would replace a file and `--confirm` was not given, 5 when the suite, corpus +//! or program is not found, 6 when the implementation's URL does not answer, 7 otherwise. + +mod adapter; +mod http; +pub mod record; +pub mod suites; + +use super::envelope::{self, EXIT_CONFIRM, EXIT_FAILED, EXIT_INTERNAL, EXIT_NOT_FOUND, EXIT_UNAVAILABLE, OpError}; +use serde_json::{Map, Value, json}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use suites::{Case, Expect, Input, SUITES, Suite}; + +pub const OP: &str = "conformance.run"; + +const USAGE: &str = "wai conform --suite --impl [--key ] [--out ] [--confirm]\n\ + [--root ] [--timeout ] [--name ] [--version ] [--dry-run] [--all] [--json|--text]\n\ + wai conform --list"; + +/// How the implementation is reached. +enum Impl { + Command(String), + Http(http::Url), +} + +/// One case's outcome. +struct Outcome { + name: String, + pass: bool, + expected: String, + got: String, +} + +fn cut(s: &str) -> String { + if s.chars().count() > 300 { format!("{}…", s.chars().take(300).collect::()) } else { s.to_string() } +} + +/// The `wai/` directory of a checkout: `--root`, else the first directory up from here +/// that is one, or holds one. +fn wai_root(given: Option<&str>) -> Result { + let is_root = |d: &Path| d.join("SPEC.md").is_file() && d.join("registry").join("capabilities.json").is_file(); + if let Some(g) = given { + let d = PathBuf::from(g); + return if is_root(&d) { + Ok(d) + } else { + Err(OpError::new(EXIT_NOT_FOUND, "no-corpus", format!("{g}: not the wai/ directory of a checkout of the standard"), "--root names the directory that holds SPEC.md and the *-conformance corpora")) + }; + } + let mut d = std::env::current_dir().map_err(|e| OpError::new(EXIT_INTERNAL, "internal", e.to_string(), ""))?; + loop { + if is_root(&d) { + return Ok(d); + } + if is_root(&d.join("wai")) { + return Ok(d.join("wai")); + } + if !d.pop() { + return Err(OpError::new( + EXIT_NOT_FOUND, + "no-corpus", + "no checkout of the WAI standard here, so no corpus to run", + "run it inside a checkout (git clone https://git.transaction.science/Transaction-Science/open-standards), or pass --root /wai", + )); + } + } +} + +/// The standard's version: the first `## v…` heading of `CHANGELOG.md`. +fn standard_version(root: &Path) -> String { + std::fs::read_to_string(root.join("CHANGELOG.md")) + .ok() + .and_then(|t| t.lines().find_map(|l| l.strip_prefix("## v").map(|r| format!("v{}", r.split_whitespace().next().unwrap_or(""))))) + .unwrap_or_else(|| "unknown".into()) +} + +/// Now, in RFC 3339 UTC to the second. +fn now_rfc3339() -> String { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(m <= 2); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60) +} + +fn corpus_digest(root: &Path, files: &[PathBuf]) -> Result { + let mut lines: Vec = Vec::new(); + for f in files { + let b = std::fs::read(f).map_err(|e| OpError::new(EXIT_NOT_FOUND, "corpus-file-missing", format!("{}: {e}", f.display()), "the corpus is incomplete; check out the standard again"))?; + let rel = f.strip_prefix(root).unwrap_or(f).to_string_lossy().replace('\\', "/"); + lines.push(format!("{} {rel}\n", adapter::sha256_hex(&b))); + } + lines.sort(); + lines.dedup(); + Ok(format!("sha256:{}", adapter::sha256_hex(lines.concat().as_bytes()))) +} + +/// A packager's verdict line read: `ok `, or a refusal's reason, a leading copy of +/// the input's path ignored. +fn packager_tokens(line: &str, input: &Path) -> Vec { + let mut t: Vec = line.split_whitespace().map(String::from).collect(); + if t.first().is_some_and(|w| Path::new(w) == input || input.file_name().is_some_and(|n| w.ends_with(&*n.to_string_lossy()))) { + t.remove(0); + } + t +} + +fn expected_text(e: &Expect) -> String { + match e { + Expect::Verdict { line, prefix: true } => format!("{line}…"), + Expect::Verdict { line, .. } => line.clone(), + Expect::Output { sha256 } => format!("output sha256 {sha256}"), + Expect::Admit { digest } => format!("ok {digest}"), + Expect::Refuse { reason } => reason.clone(), + Expect::Object { cid, .. } => format!("{cid}.wai served as http-delivery §5.3 and §6.2 require"), + } +} + +fn judge(e: &Expect, got: &str, input: Option<&Path>) -> bool { + match e { + Expect::Verdict { line, prefix } => { + if *prefix { + got.starts_with(line.as_str()) + } else { + got == line + } + } + Expect::Output { sha256 } => got == format!("output sha256 {sha256}"), + Expect::Admit { digest } => { + let t = packager_tokens(got, input.unwrap_or(Path::new(""))); + t.len() >= 2 && t[0] == "ok" && t[1] == *digest + } + Expect::Refuse { reason } => packager_tokens(got, input.unwrap_or(Path::new(""))).first() == Some(reason), + Expect::Object { .. } => got.is_empty(), + } +} + +/// What an origin serving the object answers, against http-delivery §5.3 and §6.2: the +/// problems found, none when it conforms. +fn check_object(url: &http::Url, cid: &str, bytes: &[u8], head_proof: Option<&[u8]>, timeout: Duration) -> Result, http::Failure> { + let mut p = Vec::new(); + let path = format!("/{cid}.wai"); + let r = http::request(url, "GET", &path, &[], None, timeout)?; + if r.status != 200 { + p.push(format!("GET {path}: status {} (200)", r.status)); + } else if r.body != bytes { + p.push(format!("GET {path}: the body is not the object ({} bytes, sha256 {})", r.body.len(), &adapter::sha256_hex(&r.body)[..16])); + } + let want_etag = format!("\"{cid}\""); + let h = |name: &str| r.header(name).unwrap_or_default(); + if h("ETag") != want_etag { + p.push(format!("ETag {:?} ({want_etag})", h("ETag"))); + } + if !h("Content-Type").starts_with("application/wai") { + p.push(format!("Content-Type {:?} (application/wai)", h("Content-Type"))); + } + let cc: Vec = h("Cache-Control").split(',').map(|x| x.trim().to_ascii_lowercase()).collect(); + for want in ["public", "max-age=31536000", "immutable", "no-transform"] { + if !cc.iter().any(|x| x == want) { + p.push(format!("Cache-Control {:?} lacks {want}", h("Cache-Control"))); + } + } + if !h("Accept-Ranges").eq_ignore_ascii_case("bytes") { + p.push(format!("Accept-Ranges {:?} (bytes)", h("Accept-Ranges"))); + } + if h("Access-Control-Allow-Origin") != "*" { + p.push(format!("Access-Control-Allow-Origin {:?} (*)", h("Access-Control-Allow-Origin"))); + } + let expose = h("Access-Control-Expose-Headers").to_ascii_lowercase(); + for want in ["content-range", "content-length", "etag"] { + if !expose.split(',').any(|x| x.trim() == want) { + p.push(format!("Access-Control-Expose-Headers {:?} lacks {want}", h("Access-Control-Expose-Headers"))); + } + } + if h("Timing-Allow-Origin") != "*" { + p.push(format!("Timing-Allow-Origin {:?} (*)", h("Timing-Allow-Origin"))); + } + if r.header("Vary").is_some() { + p.push(format!("Vary {:?} (an object is never selected by a request header)", h("Vary"))); + } + if r.header("Content-Encoding").is_some_and(|e| !e.eq_ignore_ascii_case("identity")) { + p.push(format!("Content-Encoding {:?} (none)", h("Content-Encoding"))); + } + // A single byte range: 206 with the bytes and the whole length, or 200 with the object. + let last = bytes.len().min(32).saturating_sub(1); + let range = format!("bytes=0-{last}"); + let r = http::request(url, "GET", &path, &[("Range", &range)], None, timeout)?; + match r.status { + 206 => { + let want = format!("bytes 0-{last}/{}", bytes.len()); + if r.header("Content-Range").as_deref() != Some(want.as_str()) { + p.push(format!("Range {range}: Content-Range {:?} ({want})", r.header("Content-Range"))); + } + if r.body != bytes[..=last] { + p.push(format!("Range {range}: the body is not those bytes")); + } + } + 200 if r.body == bytes => {} + s => p.push(format!("Range {range}: status {s} (206 with the bytes, or 200 with the object)")), + } + let r = http::request(url, "HEAD", &path, &[], None, timeout)?; + if r.status != 200 || r.header("ETag").as_deref() != Some(want_etag.as_str()) { + p.push(format!("HEAD {path}: status {}, ETag {:?}", r.status, r.header("ETag"))); + } + let r = http::request(url, "OPTIONS", &path, &[("Origin", "https://conformance.example"), ("Access-Control-Request-Method", "GET"), ("Access-Control-Request-Headers", "range, priority")], None, timeout)?; + let allow = r.header("Access-Control-Allow-Headers").unwrap_or_default().to_ascii_lowercase(); + for want in ["range", "priority"] { + if !allow.split(',').any(|x| x.trim() == want) { + p.push(format!("preflight: Access-Control-Allow-Headers {allow:?} lacks {want}")); + } + } + if r.header("Access-Control-Max-Age").is_none() { + p.push("preflight: no Access-Control-Max-Age".into()); + } + if let Some(hp) = head_proof { + let hpath = format!("/{cid}.wai.hp"); + let r = http::request(url, "GET", &hpath, &[], None, timeout)?; + if r.status != 200 || r.body != hp { + p.push(format!("GET {hpath}: status {}, {} bytes (the head proof, {} bytes)", r.status, r.body.len(), hp.len())); + } else if r.header("Content-Type").as_deref() != Some("application/octet-stream") { + p.push(format!("GET {hpath}: Content-Type {:?} (application/octet-stream)", r.header("Content-Type"))); + } + } + Ok(p) +} + +struct Run<'a> { + imp: &'a Impl, + timeout: Duration, + scratch: PathBuf, +} + +impl Run<'_> { + fn http_failure(&self, f: http::Failure) -> Result { + match f { + http::Failure::Unreachable(e) => Err(OpError::new(EXIT_UNAVAILABLE, "implementation-unreachable", format!("the implementation does not answer: {e}"), "start it, then run again")), + http::Failure::Broken(e) => Ok(format!("no answer: {e}")), + } + } + + /// One case's answer, as the text `judge` reads. + fn answer(&self, case: &Case) -> Result { + let internal = |e: String| OpError::new(EXIT_INTERNAL, "internal", e, ""); + match (self.imp, &case.input, &case.expect) { + (Impl::Http(url), _, Expect::Object { cid, bytes_file, head_proof }) => { + let bytes = std::fs::read(bytes_file).map_err(|e| internal(e.to_string()))?; + let hp = head_proof.as_ref().and_then(|p| std::fs::read(p).ok()); + match check_object(url, cid, &bytes, hp.as_deref(), self.timeout) { + Ok(problems) => Ok(problems.join("; ")), + Err(f) => self.http_failure(f), + } + } + (Impl::Http(url), input, expect) => { + let (body, ctype) = match input { + Input::Json(v) => (v.to_string().into_bytes(), "application/json"), + Input::File(p) => (std::fs::read(p).map_err(|e| internal(format!("{}: {e}", p.display())))?, "application/octet-stream"), + Input::Served => (Vec::new(), "application/octet-stream"), + }; + match http::request(url, "POST", "", &[("Content-Type", ctype)], Some(&body), self.timeout) { + Err(f) => self.http_failure(f), + Ok(r) => Ok(match expect { + Expect::Output { .. } if r.status == 200 => format!("output sha256 {}", adapter::sha256_hex(&r.body)), + Expect::Output { .. } => format!("refused ({}): {}", r.status, adapter::first_line(&r.body)), + _ => match serde_json::from_slice::(&r.body).ok().and_then(|v| v.get("verdict").and_then(Value::as_str).map(String::from)) { + Some(v) => v, + None => adapter::first_line(&r.body), + }, + }), + } + } + (Impl::Command(cmd), input, expect) => { + let case_file = self.scratch.join("case.json"); + let out_file = self.scratch.join("out"); + let _ = std::fs::remove_file(&out_file); + let mut with: Vec<(&str, &Path)> = vec![("out", out_file.as_path())]; + let mut stdin: Option> = None; + match input { + Input::Json(v) => { + if cmd.contains("{case}") { + std::fs::write(&case_file, v.to_string()).map_err(|e| internal(e.to_string()))?; + with.push(("case", case_file.as_path())); + } else { + stdin = Some(v.to_string().into_bytes()); + } + } + Input::File(p) => { + if cmd.contains("{in}") { + with.push(("in", p.as_path())); + } else { + stdin = Some(std::fs::read(p).map_err(|e| internal(format!("{}: {e}", p.display())))?); + } + } + Input::Served => {} + } + let line = adapter::fill(cmd, &with); + let ran = adapter::run(&line, stdin.as_deref(), self.timeout).map_err(internal)?; + if ran.timed_out { + return Ok(format!("timed out after {:?}", self.timeout)); + } + Ok(match expect { + Expect::Output { .. } => { + if ran.code != Some(0) { + format!("refused (exit {:?}): {}", ran.code, adapter::first_line(&ran.stdout)) + } else if cmd.contains("{out}") { + match std::fs::read(&out_file) { + Ok(b) => format!("output sha256 {}", adapter::sha256_hex(&b)), + Err(_) => "no output written to {out}".into(), + } + } else { + format!("output sha256 {}", adapter::sha256_hex(&ran.stdout)) + } + } + _ => { + let l = adapter::first_line(&ran.stdout); + if l.is_empty() && ran.code != Some(0) { + format!("no verdict (exit {:?}): {}", ran.code, adapter::first_line(&ran.stderr)) + } else { + l + } + } + }) + } + } + } + + /// Every case, the whole corpus given to one run of the command (`{corpus}`), which + /// answers `nameverdict` lines. + fn whole_corpus(&self, cmd: &str, corpus: &Path, cases: &[Case]) -> Result, OpError> { + let line = adapter::fill(cmd, &[("corpus", corpus)]); + let ran = adapter::run(&line, None, self.timeout * 20).map_err(|e| OpError::new(EXIT_INTERNAL, "internal", e, ""))?; + let mut got: HashMap = HashMap::new(); + for l in String::from_utf8_lossy(&ran.stdout).lines() { + if let Some((n, v)) = l.split_once('\t') { + got.entry(n.to_string()).or_insert_with(|| v.trim().to_string()); + } + } + let missing = if ran.timed_out { "timed out".to_string() } else { format!("no verdict (exit {:?}): {}", ran.code, adapter::first_line(&ran.stderr)) }; + Ok(cases + .iter() + .map(|c| { + let g = got.get(&c.name).cloned().unwrap_or_else(|| missing.clone()); + Outcome { name: c.name.clone(), pass: judge(&c.expect, &g, None), expected: cut(&expected_text(&c.expect)), got: cut(&g) } + }) + .collect()) + } +} + +fn list(json_mode: bool, root: Option<&Path>) { + let rows: Vec = SUITES + .iter() + .map(|s| { + let n = root.and_then(|r| (s.load)(&r.join(s.dir)).ok()).map(|l| l.cases.len()); + let adapters: Vec<&str> = [s.command.then_some("command"), s.http.then_some("http")].into_iter().flatten().collect(); + json!({"kind": s.kind, "suite": s.name, "corpus": s.dir, "cases": n, "adapters": adapters, "about": s.about}) + }) + .collect(); + if json_mode { + let mut m = Map::new(); + m.insert("suites".into(), Value::Array(rows)); + println!("{}", envelope::envelope("conformance.list", true, m)); + return; + } + for r in rows { + println!( + "{:<9} {:<7} {:<20} {:>5} cases {:<13} {}", + r["kind"].as_str().unwrap_or(""), + r["suite"].as_str().unwrap_or(""), + r["corpus"].as_str().unwrap_or(""), + r["cases"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "?".into()), + r["adapters"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect::>().join("+")).unwrap_or_default(), + r["about"].as_str().unwrap_or("") + ); + } +} + +pub fn main(args: &[String]) -> Result { + let json_mode = envelope::json_wanted(args); + match run(args, json_mode) { + Ok(true) => Ok(true), + Ok(false) => envelope::exit(EXIT_FAILED), + Err(e) => envelope::fail(OP, json_mode, &e), + } +} + +fn run(args: &[String], json_mode: bool) -> Result { + let flags = ["--suite", "--impl", "--key", "--out", "--confirm", "--root", "--timeout", "--name", "--version", "--dry-run", "--all", "--json", "--text", "--list"]; + let with_value = ["--suite", "--impl", "--key", "--out", "--root", "--timeout", "--name", "--version"]; + let pos = envelope::check_args(args, &flags, &with_value, 1, USAGE)?; + let v = |f: &str| envelope::value(args, f, USAGE); + if args.iter().any(|a| a == "--list") { + let root = wai_root(v("--root")?).ok(); + list(json_mode, root.as_deref()); + return Ok(true); + } + let kind = *pos.first().ok_or_else(|| OpError::usage(format!("name a kind: {}", suites::KINDS.join(", ")), USAGE))?; + if !suites::KINDS.contains(&kind) { + return Err(OpError::new(EXIT_NOT_FOUND, "kind-not-found", format!("{kind}: no suite of that kind; the kinds are {}", suites::KINDS.join(", ")), "wai conform --list names every kind and suite")); + } + let of_kind: Vec<&Suite> = SUITES.iter().filter(|s| s.kind == kind).collect(); + let suite = match v("--suite")? { + Some(n) => *of_kind.iter().find(|s| s.name == n).ok_or_else(|| { + OpError::new(EXIT_NOT_FOUND, "suite-not-found", format!("{kind} has no suite {n}; it has {}", of_kind.iter().map(|s| s.name).collect::>().join(", ")), "wai conform --list names every kind and suite") + })?, + None if of_kind.len() == 1 => of_kind[0], + None => return Err(OpError::usage(format!("--suite: {kind} has {}", of_kind.iter().map(|s| s.name).collect::>().join(", ")), USAGE)), + }; + let imp_text = v("--impl")?.ok_or_else(|| OpError::usage("--impl is required: the implementation to run the corpus against", USAGE))?.to_string(); + let imp = if imp_text.starts_with("http://") || imp_text.starts_with("https://") { + if !suite.http { + return Err(OpError::usage(format!("{kind} {}: no HTTP adapter", suite.name), USAGE)); + } + Impl::Http(http::Url::parse(&imp_text).map_err(|e| OpError::usage(e, USAGE))?) + } else { + if !suite.command { + return Err(OpError::usage(format!("{kind} {}: an origin is reached at an http:// URL", suite.name), USAGE)); + } + if !adapter::program_found(&imp_text) { + return Err(OpError::new(EXIT_NOT_FOUND, "program-not-found", format!("{imp_text}: its program is not found"), "name a program on PATH, or a path to one")); + } + Impl::Command(imp_text.clone()) + }; + let timeout = Duration::from_secs(v("--timeout")?.map(|t| t.parse::().map_err(|_| OpError::usage("--timeout takes whole seconds", USAGE))).transpose()?.unwrap_or(60)); + let root = wai_root(v("--root")?)?; + let loaded = (suite.load)(&root.join(suite.dir)).map_err(|e| OpError::new(EXIT_NOT_FOUND, "corpus-not-read", e, "the corpus is incomplete; check out the standard again"))?; + if loaded.cases.is_empty() { + return Err(OpError::new(EXIT_NOT_FOUND, "no-cases", format!("{}: no cases", suite.dir), "")); + } + let whole = matches!(&imp, Impl::Command(c) if c.contains("{corpus}")); + if whole && loaded.corpus_file.is_none() { + return Err(OpError::usage(format!("{kind} {}: {{corpus}} is for a verifier suite; this suite gives its cases one at a time", suite.name), USAGE)); + } + let out = PathBuf::from(v("--out")?.map(String::from).unwrap_or_else(|| format!("{}{}", suite.name, record::SUFFIX))); + if args.iter().any(|a| a == "--dry-run") { + let first = &loaded.cases[0]; + let plan = match &imp { + Impl::Command(c) if whole => adapter::fill(c, &[("corpus", loaded.corpus_file.as_deref().unwrap_or(Path::new("")))]), + Impl::Command(c) => adapter::fill(c, &[("case", Path::new("")), ("in", match &first.input { + Input::File(p) => p.as_path(), + _ => Path::new(""), + }), ("out", Path::new(""))]), + Impl::Http(u) => format!("{} {}", if matches!(first.expect, Expect::Object { .. }) { "GET, HEAD and OPTIONS below" } else { "POST each case to" }, u.join("")), + }; + if json_mode { + let mut m = Map::new(); + m.insert("kind".into(), json!(kind)); + m.insert("suite".into(), json!(suite.name)); + m.insert("dry_run".into(), json!(true)); + m.insert("cases".into(), json!(loaded.cases.iter().map(|c| c.name.clone()).collect::>())); + m.insert("runs".into(), json!(plan)); + m.insert("record".into(), json!(out.display().to_string())); + println!("{}", envelope::envelope(OP, true, m)); + } else { + println!("wai conform {kind} {}: {} cases from {}; would run: {plan}", suite.name, loaded.cases.len(), suite.dir); + println!("the record would be written to {}", out.display()); + } + return Ok(true); + } + if out.exists() && !args.iter().any(|a| a == "--confirm") { + return Err(OpError::new(EXIT_CONFIRM, "confirmation-required", format!("{} exists; the record would replace it", out.display()), format!("re-run with --confirm, or --out : wai conform {} --confirm", args.join(" ")))); + } + let key = match v("--key")? { + Some(p) => (record::key_from_file(Path::new(p)).map_err(|e| OpError::new(EXIT_NOT_FOUND, "key-not-read", e, "--key names a file holding a 32-byte Ed25519 seed in hexadecimal"))?, "supplied"), + None => (record::ephemeral_key().map_err(|e| OpError::new(EXIT_INTERNAL, "internal", e, ""))?, "ephemeral"), + }; + let digest = corpus_digest(&root, &loaded.files)?; + let started = now_rfc3339(); + let scratch = std::env::temp_dir().join(format!("wai-conform-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0))); + std::fs::create_dir_all(&scratch).map_err(|e| OpError::new(EXIT_INTERNAL, "internal", e.to_string(), ""))?; + let r = Run { imp: &imp, timeout, scratch: scratch.clone() }; + eprintln!("wai conform {kind} {}: {} cases from {}", suite.name, loaded.cases.len(), suite.dir); + let outcomes: Result, OpError> = match (&imp, &loaded.corpus_file) { + (Impl::Command(c), Some(f)) if whole => r.whole_corpus(c, f, &loaded.cases), + _ => loaded + .cases + .iter() + .map(|c| { + let got = r.answer(c)?; + let input = match &c.input { + Input::File(p) => Some(p.as_path()), + _ => None, + }; + Ok(Outcome { name: c.name.clone(), pass: judge(&c.expect, &got, input), expected: cut(&expected_text(&c.expect)), got: cut(if matches!(c.expect, Expect::Object { .. }) && got.is_empty() { "served as required" } else { &got }) }) + }) + .collect(), + }; + let _ = std::fs::remove_dir_all(&scratch); + let outcomes = outcomes?; + let passed = outcomes.iter().filter(|o| o.pass).count(); + let failed = outcomes.len() - passed; + let conformant = failed == 0; + let (adapter_name, files) = match &imp { + Impl::Command(c) => ("command", adapter::identity(c).into_iter().map(|(p, h)| json!({"path": p, "sha256": h})).collect::>()), + Impl::Http(_) => ("http", Vec::new()), + }; + let mut rec = Map::new(); + rec.insert("wai".into(), json!(record::FORMAT)); + rec.insert("kind".into(), json!(kind)); + rec.insert("suite".into(), json!(suite.name)); + rec.insert("standard".into(), json!(standard_version(&root))); + rec.insert("toolchain".into(), json!({"name": "wai", "version": env!("CARGO_PKG_VERSION")})); + rec.insert("corpus".into(), json!({"dir": suite.dir, "files": loaded.files.len(), "digest": digest})); + rec.insert("implementation".into(), json!({"adapter": adapter_name, "impl": imp_text, "name": v("--name")?, "version": v("--version")?, "files": files})); + rec.insert("started_at".into(), json!(started)); + rec.insert("cases".into(), Value::Array(outcomes.iter().map(|o| json!({"name": o.name, "pass": o.pass, "expected": o.expected, "got": o.got})).collect())); + rec.insert("summary".into(), json!({"cases": outcomes.len(), "passed": passed, "failed": failed})); + rec.insert("conformant".into(), json!(conformant)); + rec.insert("key".into(), json!(key.1)); + let rec = record::sign(rec, &key.0).map_err(|e| OpError::new(EXIT_INTERNAL, "internal", e, ""))?; + let text = serde_json::to_string_pretty(&Value::Object(rec.clone())).map_err(|e| OpError::new(EXIT_INTERNAL, "internal", e.to_string(), ""))?; + std::fs::write(&out, format!("{text}\n")).map_err(|e| OpError::new(EXIT_INTERNAL, "internal", format!("{}: {e}", out.display()), ""))?; + let pubkey = rec.get("signer_pubkey").and_then(Value::as_str).unwrap_or("").to_string(); + if json_mode { + let mut m = Map::new(); + m.insert("kind".into(), json!(kind)); + m.insert("suite".into(), json!(suite.name)); + m.insert("cases".into(), json!(outcomes.len())); + m.insert("passed".into(), json!(passed)); + m.insert("failed".into(), json!(failed)); + m.insert("conformant".into(), json!(conformant)); + m.insert("failures".into(), Value::Array(outcomes.iter().filter(|o| !o.pass).take(200).map(|o| json!({"name": o.name, "expected": o.expected, "got": o.got})).collect())); + m.insert("record".into(), json!(out.display().to_string())); + m.insert("corpus_digest".into(), json!(rec["corpus"]["digest"])); + m.insert("signer_pubkey".into(), json!(pubkey)); + println!("{}", envelope::envelope(OP, conformant, m)); + } else { + let all = args.iter().any(|a| a == "--all"); + let mut shown = 0; + for o in &outcomes { + if o.pass && !all { + continue; + } + if !o.pass { + shown += 1; + if shown > 40 && !all { + continue; + } + } + println!("{} {}", if o.pass { "PASS" } else { "FAIL" }, o.name); + if !o.pass { + println!(" expected: {}", o.expected); + println!(" got: {}", o.got); + } + } + if shown > 40 && !all { + println!("… and {} more failures (--all lists every case)", shown - 40); + } + println!( + "wai conform {kind} {}: {passed} of {} cases passed: {}", + suite.name, + outcomes.len(), + if conformant { "conformant" } else { "not conformant" } + ); + println!("record: {} (signed by {}…, {} key); `wai verify {}` checks it", out.display(), pubkey.get(..16).unwrap_or(""), key.1, out.display()); + } + Ok(conformant) +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/conform/record.rs b/wai/wai-rs/src/bin/wai_cli/dx/conform/record.rs new file mode 100644 index 000000000..52e347db7 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/conform/record.rs @@ -0,0 +1,145 @@ +//! The result record of a `wai conform` run, `conform-result/1`: what was run against what, +//! each case's outcome, and an Ed25519 signature over all of it. +//! +//! ```json +//! { "wai": "conform-result/1", "kind": "verifier", "suite": "claim", +//! "standard": "v0.4.0", "toolchain": {"name": "wai", "version": "0.3.0"}, +//! "corpus": {"dir": "claim-conformance", "files": 1, "digest": "sha256:"}, +//! "implementation": {"adapter": "command", "impl": "", "name": null, +//! "version": null, "files": [{"path": "…", "sha256": ""}]}, +//! "started_at": "2026-10-27T12:00:00Z", +//! "cases": [{"name": "…", "pass": true, "expected": "…", "got": "…"}], +//! "summary": {"cases": 636, "passed": 636, "failed": 0}, "conformant": true, +//! "key": "ephemeral", "signer_pubkey": "", "sig": "" } +//! ``` +//! +//! The corpus digest is the SHA-256 of the lines ` `, +//! one for every corpus file the run read, in path order. The signature is Ed25519 (RFC +//! 8032, verified strictly) by `signer_pubkey` over `wai:conform-result\x01` followed by the +//! record without `sig` in RFC 8785 form. `key` is `ephemeral` for a key made for the run, +//! whose signature shows the record is as the run wrote it, or `supplied` for a key the +//! runner holds (`--key`), which also says who ran it. + +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use serde_json::{Map, Value}; + +pub const FORMAT: &str = "conform-result/1"; +pub const SIGN_DOMAIN: &[u8] = b"wai:conform-result\x01"; +/// The suffix of a result record's file name, which `wai verify` looks for. +pub const SUFFIX: &str = ".wai-conform.json"; + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn unhex(s: &str) -> Option> { + if !s.len().is_multiple_of(2) { + return None; + } + (0..s.len() / 2).map(|i| u8::from_str_radix(s.get(2 * i..2 * i + 2)?, 16).ok()).collect() +} + +fn signing_bytes(record: &Map) -> Result, String> { + let mut r = record.clone(); + r.remove("sig"); + let mut m = SIGN_DOMAIN.to_vec(); + m.extend_from_slice(wai::jcs::jcs(&Value::Object(r)).map_err(|e| format!("the record is not in the signed JSON subset: {e:?}"))?.as_bytes()); + Ok(m) +} + +/// A signing key from `--key`'s file: 64 hexadecimal digits (a 32-byte seed), or the 32 +/// bytes themselves. +pub fn key_from_file(path: &std::path::Path) -> Result { + let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + let t = String::from_utf8_lossy(&b); + let seed: Vec = match unhex(t.trim()) { + Some(s) if s.len() == 32 => s, + _ if b.len() == 32 => b, + _ => return Err(format!("{}: a key file holds a 32-byte Ed25519 seed, as 64 hexadecimal digits or 32 bytes", path.display())), + }; + Ok(SigningKey::from_bytes(&seed.try_into().expect("32 bytes"))) +} + +/// A key made for one run. +pub fn ephemeral_key() -> Result { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).map_err(|e| format!("no randomness for a run key: {e}"))?; + Ok(SigningKey::from_bytes(&seed)) +} + +/// `record` signed with `key`: `signer_pubkey` and `sig` set. +pub fn sign(mut record: Map, key: &SigningKey) -> Result, String> { + record.insert("signer_pubkey".into(), Value::String(hex(key.verifying_key().as_bytes()))); + record.remove("sig"); + let sig = key.sign(&signing_bytes(&record)?); + record.insert("sig".into(), Value::String(hex(&sig.to_bytes()))); + Ok(record) +} + +/// A result record checked: whether it holds, and a line for each thing checked. +pub fn check(bytes: &[u8]) -> (bool, Vec) { + let mut lines = Vec::new(); + let v: Value = match serde_json::from_slice(bytes) { + Ok(v) => v, + Err(e) => return (false, vec![format!("FAIL not JSON: {e}")]), + }; + let Some(r) = v.as_object() else { return (false, vec!["FAIL not a JSON object".into()]) }; + if r.get("wai").and_then(Value::as_str) != Some(FORMAT) { + return (false, vec![format!("FAIL not a {FORMAT} record")]); + } + let mut ok = true; + let key = r.get("signer_pubkey").and_then(Value::as_str).and_then(unhex).and_then(|b| <[u8; 32]>::try_from(b).ok()).and_then(|b| VerifyingKey::from_bytes(&b).ok()); + let sig = r.get("sig").and_then(Value::as_str).and_then(unhex).and_then(|b| <[u8; 64]>::try_from(b).ok()).map(|b| Signature::from_bytes(&b)); + match (key, sig, signing_bytes(r)) { + (Some(k), Some(s), Ok(m)) if k.verify_strict(&m, &s).is_ok() => { + let who = r.get("key").and_then(Value::as_str).unwrap_or("?"); + lines.push(format!("ok signed by {}… ({who} key)", &hex(k.as_bytes())[..16])); + } + (None, _, _) => { + ok = false; + lines.push("FAIL signer_pubkey is not an Ed25519 public key".into()); + } + (_, None, _) => { + ok = false; + lines.push("FAIL sig is not an Ed25519 signature".into()); + } + (_, _, Err(e)) => { + ok = false; + lines.push(format!("FAIL {e}")); + } + _ => { + ok = false; + lines.push("FAIL the signature does not verify: the record is not the one that was signed".into()); + } + } + let cases = r.get("cases").and_then(Value::as_array).cloned().unwrap_or_default(); + let passed = cases.iter().filter(|c| c.get("pass").and_then(Value::as_bool) == Some(true)).count() as u64; + let failed = cases.len() as u64 - passed; + let s = r.get("summary"); + let stated = |k: &str| s.and_then(|s| s.get(k)).and_then(Value::as_u64); + if stated("cases") != Some(cases.len() as u64) || stated("passed") != Some(passed) || stated("failed") != Some(failed) { + ok = false; + lines.push(format!("FAIL the summary does not count the cases: {} cases, {passed} passed, {failed} failed", cases.len())); + } + let conformant = failed == 0 && !cases.is_empty(); + if r.get("conformant").and_then(Value::as_bool) != Some(conformant) { + ok = false; + lines.push(format!("FAIL `conformant` is not what the cases say ({conformant})")); + } + if ok { + let s = |k: &str| r.get(k).and_then(Value::as_str).unwrap_or("?").to_string(); + let corpus = r.get("corpus").and_then(|c| c.get("digest")).and_then(Value::as_str).unwrap_or("?"); + let imp = r.get("implementation").and_then(|i| i.get("impl")).and_then(Value::as_str).unwrap_or("?"); + lines.push(format!( + "ok {} {} against {}: {} of {} cases passed, {}; corpus {corpus}; WAI {}", + s("kind"), + s("suite"), + imp, + passed, + cases.len(), + if conformant { "conformant" } else { "not conformant" }, + s("standard") + )); + } + (ok, lines) +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/conform/suites.rs b/wai/wai-rs/src/bin/wai_cli/dx/conform/suites.rs new file mode 100644 index 000000000..2d2c6c5e9 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/conform/suites.rs @@ -0,0 +1,257 @@ +//! The suites `wai conform` runs: each a kind of implementation, a corpus of WAI's, and the +//! cases read from it with what a conforming implementation answers. Every expectation is +//! the corpus's own (its declared verdicts, digests and reasons), never a verifier's output. +//! +//! | kind | suite | corpus | a case is | it passes when | +//! |---|---|---|---|---| +//! | verifier | `claim` | `claim-conformance/vectors.json` | one vector | the verdict is the one declared (jwp-receipts §3.1) | +//! | verifier | `jwp` | `jwp-conformance/vectors.json` | one vector | the verdict is the one declared (jwp-receipts §2–§6) | +//! | verifier | `signer` | `signer-conformance/vectors.json` | one vector | the verdict is the one declared (jwp-receipts §7) | +//! | decoder | `score` | `score-conformance/` | a `WSCR` score | the rendered `i16le` PCM has the declared SHA-256 | +//! | packager | `policy` | `pack-conformance/` | a policy file | it is admitted with the declared digest, or refused with the declared reason (packaging §3, §12) | +//! | origin | `http` | `http-conformance/objects/` | an object | it is served as http-delivery §5.3 and §6.2 require: bytes, headers, ranges, head proof, preflight | + +use serde_json::Value; +use std::path::{Path, PathBuf}; + +/// What a case expects. +#[derive(Clone, Debug)] +pub enum Expect { + /// A verdict line: exactly this, or, with `prefix`, any line that starts with it. + Verdict { line: String, prefix: bool }, + /// An output whose SHA-256 is this (hexadecimal). + Output { sha256: String }, + /// A packager's verdict: admitted with this digest, or refused with this reason. + Admit { digest: String }, + Refuse { reason: String }, + /// An origin serving this object. + Object { cid: String, bytes_file: PathBuf, head_proof: Option }, +} + +/// What a case gives the implementation. +#[derive(Clone, Debug)] +pub enum Input { + /// A vector, as the corpus writes it. + Json(Value), + /// A file. + File(PathBuf), + /// Nothing: the implementation already serves what is asked of it. + Served, +} + +#[derive(Clone, Debug)] +pub struct Case { + pub name: String, + pub input: Input, + pub expect: Expect, +} + +/// A suite read from a corpus: its cases, the files read (for the corpus digest), and the +/// corpus file a whole-corpus run is given (`{corpus}`), where the suite has one. +pub struct Loaded { + pub cases: Vec, + pub files: Vec, + pub corpus_file: Option, +} + +pub struct Suite { + pub kind: &'static str, + pub name: &'static str, + /// The corpus directory, under `wai/`. + pub dir: &'static str, + pub about: &'static str, + pub http: bool, + pub command: bool, + pub load: fn(&Path) -> Result, +} + +pub const KINDS: &[&str] = &["verifier", "decoder", "packager", "origin"]; + +pub const SUITES: &[Suite] = &[ + Suite { kind: "verifier", name: "claim", dir: "claim-conformance", about: "typed claim receipts (jwp-receipts §3.1): accept with the link hash, or reject at a step with a reason", http: true, command: true, load: claim }, + Suite { kind: "verifier", name: "jwp", dir: "jwp-conformance", about: "labelled leaves, group receipts, totals, chains and the binary form (jwp-receipts §2–§6)", http: true, command: true, load: jwp }, + Suite { kind: "verifier", name: "signer", dir: "signer-conformance", about: "key discovery, signing authority and module authorisation (jwp-receipts §7)", http: true, command: true, load: signer }, + Suite { kind: "decoder", name: "score", dir: "score-conformance", about: "a WSCR score rendered to its canonical i16le PCM (the score extension's mixdown-equivalence)", http: true, command: true, load: score }, + Suite { kind: "packager", name: "policy", dir: "pack-conformance", about: "packaging policies admitted with their digest, or refused with their reason (packaging §3, §12)", http: true, command: true, load: policy }, + Suite { kind: "origin", name: "http", dir: "http-conformance", about: "objects, head proofs, ranges and headers as an origin serves them (http-delivery §5.3, §6.2)", http: true, command: false, load: http_origin }, +]; + +fn read_json(p: &Path) -> Result { + let b = std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))?; + serde_json::from_slice(&b).map_err(|e| format!("{}: {e}", p.display())) +} + +fn vectors(v: &Value, p: &Path) -> Result, String> { + v.get("vectors").and_then(Value::as_array).cloned().ok_or_else(|| format!("{}: no `vectors` list", p.display())) +} + +fn name_of(v: &Value) -> String { + v.get("name").and_then(Value::as_str).unwrap_or("?").to_string() +} + +fn verdict_cases(dir: &Path, expected_of: fn(&Value) -> (String, bool)) -> Result { + let p = dir.join("vectors.json"); + let doc = read_json(&p)?; + let cases = vectors(&doc, &p)? + .into_iter() + .map(|v| { + let (line, prefix) = expected_of(v.get("expected").unwrap_or(&Value::Null)); + Case { name: name_of(&v), input: Input::Json(v), expect: Expect::Verdict { line, prefix } } + }) + .collect(); + Ok(Loaded { cases, files: vec![p.clone()], corpus_file: Some(p) }) +} + +/// The claim corpus's verdicts (its README): `accept link_hash=` for an accepted +/// claim (any link hash where none is pinned), `accept ancestor=` for a recovery, +/// `reject step= reason=