wai key: signing keys in one keystore, and wai --version, docs search and registry show #656

Closed
dcharlot wants to merge 9 commits from wai/toolchain-keys into main
46 changed files with 4567 additions and 278 deletions

View file

@ -33,6 +33,15 @@ on:
# tests/cli_ops.rs builds the revenue share's --dry-run sample from this corpus.
- 'wai/live-conformance/share/**'
- 'wai/live-conformance/channels/**'
# tests/cli_key.rs signs a conformance record against this corpus and an upscale with this prior.
- 'wai/jwp-conformance/**'
- 'wai/live-conformance/upscale/**'
# tests/cli_toolchain.rs holds wai --version and the deprecations to these, and searches the text.
- 'wai/CHANGELOG.md'
- 'wai/VERSIONING.md'
- 'wai/DEPRECATIONS.md'
- 'wai/SPEC.md'
- 'wai/registry/**'
- 'wai/REGISTRY.md'
- 'wai/LICENSE'
- 'wai/wai-quantum/**'
@ -58,6 +67,15 @@ on:
# tests/cli_ops.rs builds the revenue share's --dry-run sample from this corpus.
- 'wai/live-conformance/share/**'
- 'wai/live-conformance/channels/**'
# tests/cli_key.rs signs a conformance record against this corpus and an upscale with this prior.
- 'wai/jwp-conformance/**'
- 'wai/live-conformance/upscale/**'
# tests/cli_toolchain.rs holds wai --version and the deprecations to these, and searches the text.
- 'wai/CHANGELOG.md'
- 'wai/VERSIONING.md'
- 'wai/DEPRECATIONS.md'
- 'wai/SPEC.md'
- 'wai/registry/**'
- 'wai/REGISTRY.md'
- 'wai/LICENSE'
- 'wai/wai-quantum/**'
@ -132,7 +150,12 @@ jobs:
# Create's (app), so it is held on wai app export, with the revenue share's
# (revenue_share), so it is held on wai contract create|accept and wai statement create, and
# with the opportunity report's (opportunity_report), so it is held on wai opportunity
# create and wai statement check.
# create and wai statement check. tests/cli_key.rs: wai key, its keystore, and --key <name> on
# the operations that sign, first in the archive's build, then with the features whose
# operations read --key apart (the revenue share, the exact derivations) and with
# key_discovery, whose wai-keys reads what wai key exports. tests/cli_toolchain.rs: wai --version
# and the one version it names, wai docs search and wai registry show, the deprecation notice,
# and wai/DEPRECATIONS.md held to the catalogue.
- name: The catalogue's rules, and the command line through it
shell: bash
working-directory: wai/wai-rs
@ -141,8 +164,10 @@ jobs:
ran() { grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$1" || { echo "::error::no test ran in $1"; exit 1; }; }
cargo test --locked --no-default-features --lib catalogue 2>&1 | tee "$RUNNER_TEMP/lib.log"
ran "$RUNNER_TEMP/lib.log"
cargo test --locked --no-default-features --features quickstart --bin wai --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli.log"
cargo test --locked --no-default-features --features quickstart --bin wai --test cli_ops --test cli_key --test cli_toolchain 2>&1 | tee "$RUNNER_TEMP/cli.log"
ran "$RUNNER_TEMP/cli.log"
cargo test --locked --no-default-features --features quickstart,key_discovery,revenue_share,exact_upscale,exact_tonemap --test cli_key 2>&1 | tee "$RUNNER_TEMP/cli-key.log"
ran "$RUNNER_TEMP/cli-key.log"
cargo test --locked --no-default-features --features quickstart,generate,score,staged,video_int,audio_int,splat_int,exact_upscale,exact_tonemap --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli-full.log"
ran "$RUNNER_TEMP/cli-full.log"
cargo test --locked --no-default-features --features quickstart,app --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli-app.log"

59
.github/workflows/wai-key-keychain.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: wai key · the real keychain
# `wai key` keeps a secret in macOS's keychain through /usr/bin/security. The binary's other tests
# reach it through a stand-in, on Linux; this one runs the real tool, in a scratch keychain the test
# makes and removes (never the login keychain), and needs no dialog.
#
# The macOS runner is CI's bottleneck, so this runs on one thing alone: a pull request whose own diff
# changes wai/wai-rs/src/bin/wai_cli/dx/key.rs or this file. The forge's filter matches a push's
# delta, so a rebase can fire it; the gate below (on Linux) reads the pull request's own diff, and
# the macOS job does not start without it.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
pull_request:
paths:
- 'wai/wai-rs/src/bin/wai_cli/dx/key.rs'
- '.github/workflows/wai-key-keychain.yml'
jobs:
relevant:
name: does this change touch it
runs-on: linux
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-key-keychain.yml \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}")
echo "this change touches wai key's keychain: $r"
echo "run=$r" >> "$GITHUB_OUTPUT"
keychain:
name: the real keychain · macos
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: macos
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
# launchd's PATH omits cargo on the macOS host runner.
- name: Put cargo on PATH
shell: bash
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: The one test
shell: bash
working-directory: wai/wai-rs
run: |
set -euo pipefail
nice -n 10 cargo test --locked --no-default-features --features quickstart --bin wai -- --exact dx::key::tests::the_real_keychain_keeps_reads_replaces_and_removes_a_secret 2>&1 | tee "$RUNNER_TEMP/keychain.log"
grep -qE '^test result: ok\. 1 passed; 0 failed' "$RUNNER_TEMP/keychain.log" || { echo "::error::the test did not run"; exit 1; }

View file

@ -2302,6 +2302,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"wai-ops",
"windows-sys 0.61.2",
]
[[package]]

View file

@ -3742,6 +3742,7 @@ dependencies = [
"sha2 0.10.9",
"wai-ops",
"wai-quantum",
"windows-sys 0.61.2",
]
[[package]]

10
wai/DEPRECATIONS.md Normal file
View file

@ -0,0 +1,10 @@
# The WAI toolchain: deprecations
Each item of the `wai` command line's public surface that is deprecated, the version that deprecated
it, its replacement, and the version that removed it. An item is listed here from the release that
deprecates it, and stays an alias for at least two minor releases and six months. What is public,
and how a deprecated item says so where it is used, is in [VERSIONING.md](VERSIONING.md).
| Kind | Item | Deprecated in | Replacement | Removed in |
|---|---|---|---|---|
| flag | `--confirm` | 0.3.0 | `--yes` | |

84
wai/VERSIONING.md Normal file
View file

@ -0,0 +1,84 @@
# The WAI toolchain: versions, stability and deprecations
This page says what anyone building on the `wai` command line can rely on: which parts are public,
what a version number promises about each, and how a change is announced before it happens. It
covers the `wai` binary, the operation catalogue and the doors the catalogue names. WAI Quantum's
packages have their own page, [wai-quantum/VERSIONING.md](wai-quantum/VERSIONING.md).
The deprecated items, each with the version that deprecated it and its replacement, are listed in
[DEPRECATIONS.md](DEPRECATIONS.md).
## One version
`wai`, the standard's text and the reference implementation are released together, under one
version: the `wai` crate's (`wai-rs/Cargo.toml`). The newest release heading of
[CHANGELOG.md](CHANGELOG.md) names it, the catalogue's export carries it, and a test holds the three
equal, so a version can be read from the binary and never has to be read from a page:
```text
wai --version --json
```
| Member | What it says |
|---|---|
| `toolchain` | This binary's version |
| `catalogue` | The export's format, its operations, and the SHA-256 of `wai ops export`, which is [catalogue/ops.json](catalogue/ops.json) byte for byte |
| `envelope` | The version of the JSON envelope `--json` prints |
| `registry` | The registered capabilities, and the SHA-256 of [registry/capabilities.json](registry/capabilities.json) |
| `specification` | The release of the standard this binary implements, and the envelope formats it reads |
| `features` | The build features the catalogue names that this binary was built with, which decide the operations it holds |
## What is public
| Surface | What is public | What holds it in CI |
|---|---|---|
| The operation catalogue | Each operation's id, its command line and declared aliases, its flags and arguments, its effect, when it waits for confirmation, its stability and the schema of its `--json` `data`: [catalogue/ops.json](catalogue/ops.json), which `wai ops schema <id>` prints one entry of | `wai ops export --check`, and the catalogue's rules (`wai-catalogue.yml`). A removal shows in the export's diff; `wai breaking` does not read the catalogue |
| The agent rules | The exit codes, the envelope `envelope/1`, its `error.code` strings, `--json`, `--dry-run`, `--yes` and `--no-input` | `wai-rs/tests/cli_ops.rs` |
| The MCP door | Each tool's name, the operation of the catalogue it is, and its input schema | `wai-rs/tests/cli_mcp.rs` and the binary's tests, in `wai-conformance.yml`; `scripts/check-wai-ops-doors.py` |
| The project file | `wai.json`, as [schema/wai-json-1.schema.json](schema/wai-json-1.schema.json) states it | The reader's tests, which hold the two to each other |
| The C ABI, the Rust API of the library crates and the HTTP services' OpenAPI documents | `wai.h`, what the crates document, and each document's operations | `wai breaking`, category `api` (`wai-breaking.yml`) |
| The wire and the signed records | The registry's capabilities and wire identifiers, the conformance corpora, the signing domains and the published JSON Schemas | `wai breaking`, categories `wire` and `record`, and the corpora's generators and verifiers |
What is not public: the text a command prints without `--json`, which is for people and may change
in any release; an operation marked `internal`, which is never published; and the keystore's own
files (`wai.key/1`), which only `wai key` reads and writes.
An operation's `stability` says how far its entry is promised. A `stable` operation changes only
with a deprecation window. A `preview` operation is published and works, and its shape may still
change: a change to one is in the changelog, and the window below is not owed.
## What a version number promises
The toolchain follows [Semantic Versioning 2.0](https://semver.org). Every release so far is 0.y.z,
for which that promises nothing, so this page does: the first non-zero component is the major.
- **A change of y** (0.4.0 to 0.5.0) may break a public surface. Every break is in the changelog
with what to do instead, and the deprecation rule below holds for a stable operation.
- **A change of z** adds or fixes, and breaks nothing a public surface promised.
The wire format is the exception the standard states: until v1.0 freezes, it may change, and such a
change is recorded in the changelog.
## Deprecation
A renamed command, flag, route or tool stays as an alias for **at least two minor releases and six
months**, and says it is deprecated when used. Removing it after that is a break, in the changelog
with what to do instead.
| Where it is used | How a deprecated item says so |
|---|---|
| A flag on the command line | It still works, and prints one line on standard error: `wai: deprecated: --confirm, use --yes instead` |
| An operation's id or spelling | Its catalogue entry's `deprecated` member (`since`, `use_instead`) |
| Help and the catalogue's export | `wai --help` and each operation's help name it, and `agent_flags` lists it with `deprecated` |
| The list | [DEPRECATIONS.md](DEPRECATIONS.md), from the release that deprecates it |
A test holds [DEPRECATIONS.md](DEPRECATIONS.md) to the catalogue: every flag and operation the
catalogue marks deprecated is listed there, and every item listed there is marked in the catalogue
or has its removal named.
## How a break is acknowledged
`wai breaking --against <git-ref>` reports what the working tree breaks, by category, and exits 1 on
a break the changelog does not acknowledge. A change acknowledges one with a `**Breaking:**` line
naming the finding's id in backticks, in its entry under [changes/](changes/README.md). CI runs it on
every pull request that touches WAI.

View file

@ -752,7 +752,7 @@
" text codecs: any file",
"wai encode avc|hevc <in.y4m> [-o out.wai] [--encoder auto|platform|va|gpu-driver|software] [--library <path>]",
" [--device <node|ordinal>] [--hardware yes|no] [--kbps <n>] [--size WxH] [--idr-every <frames> |",
" --idr-plan <plan.txt>] [--es <out>] [--record <out.json> --key <seed>] [--meter <counter> [--rounds <n>]]",
" --idr-plan <plan.txt>] [--es <out>] [--record <out.json> --key <name|seed>] [--meter <counter> [--rounds <n>]]",
" (a build with `platform_encode`)"
],
"placement": "local",
@ -1091,7 +1091,7 @@
"aliases": [],
"usage": [
"wai upscale run <in.png> --prior <p.wsr> [--derivation sr2|sr4] -o <out.png>",
" [--record <out.json> --envelope <in.wai> --key <seed>] [--issued-at <date-time>] [--json]",
" [--record <out.json> --envelope <in.wai> --key <name|seed>] [--issued-at <date-time>] [--json]",
" an 8-bit RGB PNG; --record signs what exact-upscale §7 recomputes from --envelope and the prior"
],
"placement": "local",
@ -1158,7 +1158,7 @@
},
"key": {
"type": "string",
"description": "The record's signing seed: 64 hexadecimal digits, or a file holding them.",
"description": "The record's signing key: its name in the keystore (wai key list), 64 hexadecimal digits, or a file holding them.",
"x-cli": {
"flag": "--key"
}
@ -1293,7 +1293,7 @@
"aliases": [],
"usage": [
"wai tonemap run <in.wai> --headrooms <h,h,...> --out <dir> [--input-class <class>]",
" [--record <out.json> --key <seed>] [--issued-at <date-time>] [--json]",
" [--record <out.json> --key <name|seed>] [--issued-at <date-time>] [--json]",
" a WAI2 of a PNG base and its gain map; a headroom is in 1/10000 of a stop; each rendition is RGB16"
],
"placement": "local",
@ -1351,7 +1351,7 @@
},
"key": {
"type": "string",
"description": "The record's signing seed: 64 hexadecimal digits, or a file holding them.",
"description": "The record's signing key: its name in the keystore (wai key list), 64 hexadecimal digits, or a file holding them.",
"x-cli": {
"flag": "--key"
}
@ -2977,7 +2977,7 @@
"cli": "wai http seal-playlist",
"aliases": [],
"usage": [
"wai http seal-playlist <m3u8> --key <seed> [--signer-id ID] [--content-id ID] [--parent <mr.json>] [--out <dir>]"
"wai http seal-playlist <m3u8> --key <name|seed> [--signer-id ID] [--content-id ID] [--parent <mr.json>] [--out <dir>]"
],
"placement": "local",
"effect": "writes-project",
@ -3004,8 +3004,7 @@
},
"key": {
"type": "string",
"format": "path",
"description": "The signing seed.",
"description": "The signing key: its name in the keystore (wai key list), 64 hexadecimal digits, or a file holding them.",
"x-cli": {
"flag": "--key"
}
@ -3650,7 +3649,7 @@
"cli": "wai enrolment create",
"aliases": [],
"usage": [
"wai enrolment create <node-key> --role <role> --key <file> [--label L] [--from µs] [--until µs] [--out F]",
"wai enrolment create <node-key> --role <role> --key <name|file> [--label L] [--from µs] [--until µs] [--out F]",
" [--target URL] [--yes] a node's enrolment (WFE1), signed with the organisation key, written to F",
" (<node>.wfe without --target) and, with --target, given to the fleet service there; changing the service",
" or replacing a file waits for --yes (--confirm, deprecated, is the same)"
@ -3686,8 +3685,7 @@
},
"key": {
"type": "string",
"format": "path",
"description": "The organisation key's file: 32 bytes, or 64 hexadecimal digits.",
"description": "The organisation key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits.",
"x-cli": {
"flag": "--key"
}
@ -4115,8 +4113,7 @@
},
"key": {
"type": "string",
"format": "path",
"description": "The operator's key file: 32 bytes, or 64 hexadecimal digits.",
"description": "The operator's key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits.",
"x-cli": {
"flag": "--key"
}
@ -4867,18 +4864,20 @@
{
"id": "registry.show",
"summary": "One capability's registry entry, whether this build decodes it, its role, and the specification lines that name it",
"cli": null,
"cli": "wai registry show",
"aliases": [],
"usage": [
"wai (wai mcp: registry_show)"
"wai registry show <capability> [--spec-root <wai-dir>] one capability's registry entry, whether this build decodes it,",
" its role, and the specification lines that name it, where the specification's text is here (--spec-root,",
" $WAI_SPEC_ROOT, or the tree this binary was built from)"
],
"placement": "local",
"effect": "reads",
"dry_run": false,
"confirm": null,
"confirm": "never",
"route": null,
"doors": {
"cli": false,
"cli": true,
"mcp": true,
"http": false
},
@ -4889,7 +4888,18 @@
"properties": {
"capability": {
"type": "string",
"description": "A capability string, e.g. wai.image.avif."
"description": "A capability string, e.g. wai.image.avif.",
"x-cli": {
"positional": true
}
},
"spec_root": {
"type": "string",
"format": "path",
"description": "The wai/ directory whose text is read, on the command line (wai mcp has a --spec-root of its own).",
"x-cli": {
"flag": "--spec-root"
}
}
},
"required": [
@ -4904,9 +4914,13 @@
"since": "0.3.0",
"deprecated": null,
"receipt": false,
"needs": null,
"needs": {
"all": [
"mcp"
]
},
"names": {
"cli": null,
"cli": "wai registry show",
"mcp": "registry_show",
"c": "wai_registry_show",
"rust": "wai::registry::show",
@ -4916,18 +4930,20 @@
{
"id": "docs.search",
"summary": "Lines of the specification and its extensions holding every word of a query, each with its file, line and section",
"cli": null,
"cli": "wai docs search",
"aliases": [],
"usage": [
"wai (wai mcp: docs_search)"
"wai docs search <word>... [--limit N] [--spec-root <wai-dir>] the lines of the specification and its extensions that hold",
" every word, with each one's file, line and section, offline; the text is --spec-root's, $WAI_SPEC_ROOT's, or the",
" tree this binary was built from"
],
"placement": "local",
"effect": "reads",
"dry_run": false,
"confirm": null,
"confirm": "never",
"route": null,
"doors": {
"cli": false,
"cli": true,
"mcp": true,
"http": false
},
@ -4938,11 +4954,25 @@
"properties": {
"query": {
"type": "string",
"description": "Words that must all appear on a line."
"description": "Words that must all appear on a line.",
"x-cli": {
"positional": true
}
},
"limit": {
"type": "integer",
"description": "At most this many hits (20)."
"description": "At most this many hits (20).",
"x-cli": {
"flag": "--limit"
}
},
"spec_root": {
"type": "string",
"format": "path",
"description": "The wai/ directory whose text is searched, on the command line (wai mcp has a --spec-root of its own).",
"x-cli": {
"flag": "--spec-root"
}
}
},
"required": [
@ -4957,9 +4987,13 @@
"since": "0.3.0",
"deprecated": null,
"receipt": false,
"needs": null,
"needs": {
"all": [
"mcp"
]
},
"names": {
"cli": null,
"cli": "wai docs search",
"mcp": "docs_search",
"c": "wai_docs_search",
"rust": "wai::docs::search",
@ -7211,7 +7245,7 @@
"cli": "wai contract create",
"aliases": [],
"usage": [
"wai contract create <terms.json> --key <file> [--out F] [--yes] the share contract (WRC1) of the terms, signed",
"wai contract create <terms.json> --key <name|file> [--out F] [--yes] the share contract (WRC1) of the terms, signed",
" with the operator key, every countersignature still to come, written to F (<digest>.wrc); each owner adds",
" its own with `wai contract accept`; replacing a file waits for --yes (--confirm, deprecated, is the same)"
],
@ -7240,8 +7274,7 @@
},
"key": {
"type": "string",
"format": "path",
"description": "The operator key's file: 32 bytes, or 64 hexadecimal digits.",
"description": "The operator key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits.",
"x-cli": {
"flag": "--key"
}
@ -7297,7 +7330,7 @@
"cli": "wai contract accept",
"aliases": [],
"usage": [
"wai contract accept <contract> --key <file> [--out F] [--yes] the owner's countersignature added to the contract,",
"wai contract accept <contract> --key <name|file> [--out F] [--yes] the owner's countersignature added to the contract,",
" written to F (the contract's own file when F is not given); replacing a file waits for --yes (--confirm,",
" deprecated, is the same)"
],
@ -7326,8 +7359,7 @@
},
"key": {
"type": "string",
"format": "path",
"description": "The owner key's file: 32 bytes, or 64 hexadecimal digits.",
"description": "The owner key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits.",
"x-cli": {
"flag": "--key"
}
@ -7383,7 +7415,7 @@
"cli": "wai statement create",
"aliases": [],
"usage": [
"wai statement create <inputs.json> --party <key> --start µs --end µs --key <file> [--out F] [--yes] the statement",
"wai statement create <inputs.json> --party <key> --start µs --end µs --key <name|file> [--out F] [--yes] the statement",
" (WRS1) of the party for [start, end), computed from a verification document without its statement (the",
" contract, other contracts, runs and ledger documents), signed with the operator key and written with its",
" document to F (<digest>.wai-share.json) for `wai statement verify`; replacing a file waits for --yes"
@ -7434,8 +7466,7 @@
},
"key": {
"type": "string",
"format": "path",
"description": "The operator key's file: 32 bytes, or 64 hexadecimal digits.",
"description": "The operator key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits.",
"x-cli": {
"flag": "--key"
}
@ -7558,7 +7589,7 @@
"cli": "wai opportunity create",
"aliases": [],
"usage": [
"wai opportunity create <inputs.json> --party <key> --start µs --end µs --key <file> [--out F] [--yes] the report",
"wai opportunity create <inputs.json> --party <key> --start µs --end µs --key <name|file> [--out F] [--yes] the report",
" (WOR1) of the party for [start, end), computed from a verification document without its report (the",
" contract, other contracts, runs and rooms), signed with the issuer key and written with its document to F",
" (<digest>.wai-opportunity.json) for `wai opportunity verify`; replacing a file waits for --yes"
@ -7609,8 +7640,7 @@
},
"key": {
"type": "string",
"format": "path",
"description": "The issuer key's file: 32 bytes, or 64 hexadecimal digits.",
"description": "The issuer key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits.",
"x-cli": {
"flag": "--key"
}
@ -7727,7 +7757,7 @@
"cli": "wai statement check",
"aliases": [],
"usage": [
"wai statement check <report.json> <reported> [--statement <document.json>] [--floor ppm] [--ceiling ppm] --key <file> [--out F] [--yes]",
"wai statement check <report.json> <reported> [--statement <document.json>] [--floor ppm] [--ceiling ppm] --key <name|file> [--out F] [--yes]",
" the check (WCK1) of a reported statement (JSON, or CSV for a file named .csv) beside a report's verification",
" document and a signed statement's, at the bounds given (500000 and 2000000 when not), signed with the issuer",
" key and written with its document to F (<digest>.wai-check.json); replacing a file waits for --yes"
@ -7787,8 +7817,7 @@
},
"key": {
"type": "string",
"format": "path",
"description": "The issuer key's file: 32 bytes, or 64 hexadecimal digits.",
"description": "The issuer key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits.",
"x-cli": {
"flag": "--key"
}
@ -7835,6 +7864,610 @@
"sdk": "wai.statement.check"
}
},
{
"id": "key.create",
"summary": "A new Ed25519 signing key kept under a name, for one role, its secret in macOS's keychain or in a seed file; every signing operation's --key takes the name",
"cli": "wai key create",
"aliases": [],
"usage": [
"wai key create <name> --for <role> [--store keychain|file] [--keystore DIR] a new signing key named <name>",
" (1 to 48 of a-z, 0-9, - and _), for one role: origin, signer, room, audit, ledger, release, remote-host,",
" quantum or dev. Its secret is kept in the login keychain (--store keychain, the default on macOS) or in a",
" file of the keystore (--store file, the default elsewhere: mode 0600 on Unix; on Windows an access list for",
" this account alone, inheritance off), and is never printed; a seed file another account may read is refused.",
" The keystore is DIR, $WAI_KEYSTORE, or ~/.config/wai/keys (%APPDATA%\\wai\\keys on Windows). --key <name> signs",
" with it"
],
"placement": "local",
"effect": "writes-project",
"dry_run": true,
"confirm": "never",
"route": null,
"doors": {
"cli": true,
"mcp": false,
"http": false
},
"toolset": "core",
"stability": "preview",
"input": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The key's name: 1 to 48 of a-z, 0-9, - and _, beginning with a letter or a digit.",
"x-cli": {
"positional": true
}
},
"role": {
"type": "string",
"description": "The key's role: origin, signer, room, audit, ledger, release, remote-host, quantum or dev.",
"x-cli": {
"flag": "--for"
}
},
"store": {
"type": "string",
"description": "Where the secret is kept: keychain (the default on macOS) or file (the default elsewhere: mode 0600 on Unix; on Windows, an access list for this account alone).",
"x-cli": {
"flag": "--store"
}
},
"keystore": {
"type": "string",
"format": "path",
"description": "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys).",
"x-cli": {
"flag": "--keystore"
}
}
},
"required": [
"name",
"role"
],
"additionalProperties": false
},
"output": {
"type": "object",
"required": [
"name",
"role",
"id",
"key",
"store",
"keystore"
],
"description": "The key kept: its name, role and key id, its public key in hexadecimal and its did:key (null in a dry run, which makes no key), where its secret is kept (store: keychain or file), and the keystore"
},
"stream": null,
"since": "0.5.0",
"deprecated": null,
"receipt": false,
"needs": {
"all": [
"quickstart"
]
},
"names": {
"cli": "wai key create",
"mcp": null,
"c": "wai_key_create",
"rust": "wai::key::create",
"sdk": "wai.key.create"
}
},
{
"id": "key.list",
"summary": "Every key of the keystore: its name, role, key id, public key, status and where its secret is kept",
"cli": "wai key list",
"aliases": [],
"usage": [
"wai key list [--for <role>] [--keystore DIR] every key of the keystore, its newest version, and where its secret is kept"
],
"placement": "local",
"effect": "reads",
"dry_run": false,
"confirm": "never",
"route": null,
"doors": {
"cli": true,
"mcp": false,
"http": false
},
"toolset": "core",
"stability": "preview",
"input": {
"type": "object",
"properties": {
"role": {
"type": "string",
"description": "Only the keys of this role.",
"x-cli": {
"flag": "--for"
}
},
"keystore": {
"type": "string",
"format": "path",
"description": "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys).",
"x-cli": {
"flag": "--keystore"
}
}
},
"required": [],
"additionalProperties": false
},
"output": {
"type": "object",
"required": [
"keystore",
"keys"
],
"description": "Each key of the keystore: its name, role, newest key id, public key, did:key and status, and where its secret is kept"
},
"stream": null,
"since": "0.5.0",
"deprecated": null,
"receipt": false,
"needs": {
"all": [
"quickstart"
]
},
"names": {
"cli": "wai key list",
"mcp": null,
"c": "wai_key_list",
"rust": "wai::key::list",
"sdk": "wai.key.list"
}
},
{
"id": "key.show",
"summary": "One key: its public key and did:key, its role, every version with its status as a key log states it, and the version --key signs with",
"cli": "wai key show",
"aliases": [],
"usage": [
"wai key show <name> [--keystore DIR] one key's versions, each active, retired (until) or revoked (compromised since), as",
" jwp-receipts §7.2's key log states them, and the one --key <name> signs with"
],
"placement": "local",
"effect": "reads",
"dry_run": false,
"confirm": "never",
"route": null,
"doors": {
"cli": true,
"mcp": false,
"http": false
},
"toolset": "core",
"stability": "preview",
"input": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The key's name.",
"x-cli": {
"positional": true
}
},
"keystore": {
"type": "string",
"format": "path",
"description": "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys).",
"x-cli": {
"flag": "--keystore"
}
}
},
"required": [
"name"
],
"additionalProperties": false
},
"output": {
"type": "object",
"required": [
"keystore",
"name",
"role",
"signs_with",
"versions"
],
"description": "The key's versions, oldest first, each with its key id, public key, did:key, status (active, retired, revoked) and times as a key log states them (jwp-receipts §7.2), and where its secret is kept (store; null once destroyed); and the key id --key signs with"
},
"stream": null,
"since": "0.5.0",
"deprecated": null,
"receipt": false,
"needs": {
"all": [
"quickstart"
]
},
"names": {
"cli": "wai key show",
"mcp": null,
"c": "wai_key_show",
"rust": "wai::key::show",
"sdk": "wai.key.show"
}
},
{
"id": "key.rotate",
"summary": "A key's successor made under the same name and a new key id, the old version retired now and its secret destroyed",
"cli": "wai key rotate",
"aliases": [],
"usage": [
"wai key rotate <name> [--store keychain|file] [--keystore DIR] [--yes] a successor under a new key id (<name>.<n>),",
" which --key <name> signs with from now; the old version is retired now and its secret destroyed (jwp-receipts",
" §7.6), so it waits for --yes"
],
"placement": "local",
"effect": "destructive",
"dry_run": true,
"confirm": "always",
"route": null,
"doors": {
"cli": true,
"mcp": false,
"http": false
},
"toolset": "core",
"stability": "preview",
"input": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The key's name.",
"x-cli": {
"positional": true
}
},
"store": {
"type": "string",
"description": "Where the successor's secret is kept (where the old one's was).",
"x-cli": {
"flag": "--store"
}
},
"keystore": {
"type": "string",
"format": "path",
"description": "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys).",
"x-cli": {
"flag": "--keystore"
}
}
},
"required": [
"name"
],
"additionalProperties": false
},
"output": {
"type": "object",
"required": [
"name",
"previous",
"id",
"key",
"store"
],
"description": "The version retired (previous, with until) and the successor made: its key id, public key (null in a dry run) and where its secret is kept (store)"
},
"stream": null,
"since": "0.5.0",
"deprecated": null,
"receipt": false,
"needs": {
"all": [
"quickstart"
]
},
"names": {
"cli": "wai key rotate",
"mcp": null,
"c": "wai_key_rotate",
"rust": "wai::key::rotate",
"sdk": "wai.key.rotate"
}
},
{
"id": "key.revoke",
"summary": "A key's version revoked as compromised from a time, its secret destroyed, so --key signs nothing with it",
"cli": "wai key revoke",
"aliases": [],
"usage": [
"wai key revoke <name> [--id <key-id>] [--compromised-since <date-time>] [--keystore DIR] [--yes] the key's newest",
" version (or --id's) revoked, compromised since the time given (now; YYYY-MM-DDTHH:MM:SSZ), and its secret",
" destroyed (jwp-receipts §7.6); it waits for --yes"
],
"placement": "local",
"effect": "destructive",
"dry_run": true,
"confirm": "always",
"route": null,
"doors": {
"cli": true,
"mcp": false,
"http": false
},
"toolset": "core",
"stability": "preview",
"input": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The key's name.",
"x-cli": {
"positional": true
}
},
"id": {
"type": "string",
"description": "The version to revoke, by its key id (the newest).",
"x-cli": {
"flag": "--id"
}
},
"compromised_since": {
"type": "string",
"description": "From when another party may have held it: a date-time in UTC, YYYY-MM-DDTHH:MM:SSZ (now).",
"x-cli": {
"flag": "--compromised-since"
}
},
"keystore": {
"type": "string",
"format": "path",
"description": "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys).",
"x-cli": {
"flag": "--keystore"
}
}
},
"required": [
"name"
],
"additionalProperties": false
},
"output": {
"type": "object",
"required": [
"name",
"id",
"compromised_since"
],
"description": "The version revoked, the time from which it is held compromised, and whether its secret was destroyed"
},
"stream": null,
"since": "0.5.0",
"deprecated": null,
"receipt": false,
"needs": {
"all": [
"quickstart"
]
},
"names": {
"cli": "wai key revoke",
"mcp": null,
"c": "wai_key_revoke",
"rust": "wai::key::revoke",
"sdk": "wai.key.revoke"
}
},
{
"id": "key.import",
"summary": "A key file's seed kept under a name, as wai key create keeps a new one; a published test seed is refused unless named one",
"cli": "wai key import",
"aliases": [],
"usage": [
"wai key import <name> --for <role> --from <file> [--store keychain|file] [--published-test-seed] [--keystore DIR]",
" the seed of a key file (64 hexadecimal digits or the 32 bytes, as every --key file holds) kept as wai key create",
" keeps one; a seed of one byte repeated 32 times, the form of every seed WAI publishes, is refused unless",
" --published-test-seed names it a test key"
],
"placement": "local",
"effect": "writes-project",
"dry_run": true,
"confirm": "never",
"route": null,
"doors": {
"cli": true,
"mcp": false,
"http": false
},
"toolset": "core",
"stability": "preview",
"input": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The key's name.",
"x-cli": {
"positional": true
}
},
"role": {
"type": "string",
"description": "The key's role: origin, signer, room, audit, ledger, release, remote-host, quantum or dev.",
"x-cli": {
"flag": "--for"
}
},
"from": {
"type": "string",
"format": "path",
"description": "The key file: 64 hexadecimal digits, or the 32 bytes.",
"x-cli": {
"flag": "--from"
}
},
"store": {
"type": "string",
"description": "Where the secret is kept: keychain (the default on macOS) or file.",
"x-cli": {
"flag": "--store"
}
},
"published_test_seed": {
"type": "boolean",
"description": "Keep a published test seed, marked as one.",
"x-cli": {
"flag": "--published-test-seed"
}
},
"keystore": {
"type": "string",
"format": "path",
"description": "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys).",
"x-cli": {
"flag": "--keystore"
}
}
},
"required": [
"name",
"role",
"from"
],
"additionalProperties": false
},
"output": {
"type": "object",
"required": [
"name",
"role",
"id",
"key",
"store",
"keystore"
],
"description": "The key kept: its name, role and key id, its public key in hexadecimal and its did:key (null in a dry run, which makes no key), where its secret is kept (store: keychain or file), and the keystore"
},
"stream": null,
"since": "0.5.0",
"deprecated": null,
"receipt": false,
"needs": {
"all": [
"quickstart"
]
},
"names": {
"cli": "wai key import",
"mcp": null,
"c": "wai_key_import",
"rust": "wai::key::import",
"sdk": "wai.key.import"
}
},
{
"id": "key.export",
"summary": "A key's public half, never its secret: in hexadecimal, as a did:key, as key-log entries, or as the line wai-keys lists it by",
"cli": "wai key export",
"aliases": [],
"usage": [
"wai key export <name> [--format hex|did-key|entry|wai-keys] [--may-sign <authority>,...] [--out F] [--keystore DIR]",
" the public half alone: hex (the default), did-key, entry (each version as a key-log entry, jwp-receipts §7.2) or",
" wai-keys (the newest as wai-keys init --key and append --add take it); entry and wai-keys name the authorities",
" an origin gives it (--may-sign); to stdout, or to F (replacing a file waits for --yes)"
],
"placement": "local",
"effect": "writes-project",
"dry_run": true,
"confirm": "when-replacing-a-file",
"route": null,
"doors": {
"cli": true,
"mcp": false,
"http": false
},
"toolset": "core",
"stability": "preview",
"input": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The key's name.",
"x-cli": {
"positional": true
}
},
"format": {
"type": "string",
"description": "hex (the default), did-key, entry or wai-keys.",
"x-cli": {
"flag": "--format"
}
},
"may_sign": {
"type": "string",
"description": "With entry and wai-keys: the authorities an origin gives the key (jwp-receipts §7.5), comma-separated.",
"x-cli": {
"flag": "--may-sign"
}
},
"out": {
"type": "string",
"format": "path",
"description": "Write it to this file.",
"x-cli": {
"flag": "--out"
}
},
"keystore": {
"type": "string",
"format": "path",
"description": "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys).",
"x-cli": {
"flag": "--keystore"
}
}
},
"required": [
"name"
],
"additionalProperties": false
},
"output": {
"type": "object",
"required": [
"name",
"id",
"format",
"text"
],
"description": "The key's public half in the format asked for (the text, and for entry the key-log entries), and the file it went to"
},
"stream": null,
"since": "0.5.0",
"deprecated": null,
"receipt": false,
"needs": {
"all": [
"quickstart"
]
},
"names": {
"cli": "wai key export",
"mcp": null,
"c": "wai_key_export",
"rust": "wai::key::export",
"sdk": "wai.key.export"
}
},
{
"id": "ops.list",
"summary": "Every operation of this catalogue: its id, command line, effect and whether this build holds it",
@ -8100,6 +8733,61 @@
"rust": "wai::ops::export",
"sdk": "wai.ops.export"
}
},
{
"id": "toolchain.show",
"summary": "This wai's version, the digests of its catalogue and its registry, the release of the standard it implements, and the build features it holds",
"cli": "wai --version",
"aliases": [
"wai version"
],
"usage": [
"wai --version [--json] this binary's version, the SHA-256 of its catalogue (wai/catalogue/ops.json) and of its",
" registry (wai/registry/capabilities.json), the release of the standard it implements with the envelope formats",
" it reads, and the build features it holds (also wai version; wai/VERSIONING.md says what each promises)"
],
"placement": "local",
"effect": "reads",
"dry_run": false,
"confirm": "never",
"route": null,
"doors": {
"cli": true,
"mcp": false,
"http": false
},
"toolset": "core",
"stability": "preview",
"input": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
},
"output": {
"type": "object",
"required": [
"toolchain",
"catalogue",
"envelope",
"registry",
"specification",
"features"
],
"description": "This binary's version, its catalogue's format, operations and SHA-256, the JSON envelope's version, its registry's capabilities and SHA-256, the release of the standard it implements with the envelope formats it reads, and the build features it holds"
},
"stream": null,
"since": "0.5.0",
"deprecated": null,
"receipt": false,
"needs": null,
"names": {
"cli": "wai toolchain show",
"mcp": null,
"c": "wai_toolchain_show",
"rust": "wai::toolchain::show",
"sdk": "wai.toolchain.show"
}
}
]
}

View file

@ -0,0 +1,7 @@
### `wai conform` runs a command line on Windows as it is written
On Windows, `--impl`'s command line went to `cmd` with its quotes escaped, so a placeholder's quoted
path reached the implementation with the quotes as part of the path, and every case of a command
adapter failed ("no verdict"). `cmd` now gets the line as it is written, a quoted program first
included. A test on Windows holds both forms, and `wai conform --key <name>` ran there against the
JWP corpus.

View file

@ -0,0 +1,50 @@
### `wai key`: a developer's signing keys in one keystore, and `--key <name>` on the operations that sign
- **Seven operations**, in the catalogue and in `wai/catalogue/ops.json`: `key.create`, `key.list`,
`key.show`, `key.rotate`, `key.revoke`, `key.import` and `key.export` (`wai key
create|list|show|rotate|revoke|import|export`), each with `--json`, and with `--dry-run` where
it changes something; `--yes` is never implied. They run on this machine, in a build with
`quickstart`, which the release archive's `wai` is. `wai mcp` does not serve them.
- **Where keys live.** One keystore, a directory: `--keystore DIR`, `$WAI_KEYSTORE`,
`$XDG_CONFIG_HOME/wai/keys` or `~/.config/wai/keys` (`%APPDATA%\wai\keys` on Windows), made with
mode 0700. Each key is a record, `<name>.json` (format `wai.key/1`), holding public halves alone.
Each secret is kept where `--store` says: `keychain`, the default on macOS, in the login
keychain through `/usr/bin/security`, the secret on the tool's standard input and never on a
command line; or `file`, the default elsewhere, `<key-id>.seed`. No operation prints a secret.
`--store keychain` on another system is refused (`keychain-unavailable`): the keychain of macOS
is the only one `wai` uses, and on Linux and Windows the secret is in the file.
- **What protects a seed file.** On Unix, its mode: it is made with 0600, the keystore with 0700. On
Windows, an access list: the seed, the record and a keystore `wai` makes each have this account as
owner and as their one entry, with inheritance off, so a keystore in `C:\Users\Public` gets what
one in `%APPDATA%` gets. SYSTEM and Administrators are not listed: nothing here needs them. Either
way the call that makes the file restricts it, before the secret is written. A seed file another
account may read is refused, exit 3, `key-file-exposed`, naming the file and the command that fixes
it: on Unix any mode bit for group or other, and on Windows any entry granting another account
anything, read by SID, or another account as its owner. The seed is not encrypted on either system.
- **The keychain path runs against the real tool.** A test on macOS drives `/usr/bin/security` in a
scratch keychain it makes and removes in every case, never the login keychain: an item kept, read
back, replaced through `-U` and removed, no secret on any process argument, and the search list
and the default keychain as they were. `wai-key-keychain.yml` runs that test alone, on a pull
request whose own diff changes `dx/key.rs` or the workflow.
- **WAI's own key rules** (extensions/jwp-receipts.md §7). A key's first version has its name as
its key id, and each rotation adds `<name>.<n>`. A version's status is the key log's: `active`,
`retired` with `until`, or `revoked` with `compromised_since`. `wai key rotate` makes the
successor under a new id and retires the old version at that moment (§7.6); `wai key revoke`
revokes the newest version or `--id`'s, from `--compromised-since` or now, and its time never
changes after. Each destroys the secret of the version it ends, so each waits for `--yes`. No two
keys of a keystore hold one public key (`key-duplicate`).
- **A published seed.** `wai key import` refuses a seed of one byte repeated 32 times, the form of
every seed WAI publishes (§7.8), unless `--published-test-seed` names it a test key, and the
record says so.
- **Into a key log.** `wai key export` gives the public half alone: `hex`, `did-key`, `entry` (each
version as a key-log entry, with the authorities `--may-sign` names) or `wai-keys` (the newest,
as `wai-keys init --key` and `append --add` take it). `wai-keys` itself is unchanged, and still
makes no key. A test builds a log from what `wai key` exports and verifies it.
- **How other commands find a key.** `--key <name>` signs with the key's newest version on `wai
enrolment create`, `wai channel import`, `wai contract create|accept`, `wai statement
create|check`, `wai opportunity create`, `wai upscale run`, `wai tonemap run`, `wai encode avc|hevc`,
`wai http seal-playlist` and `wai conform`. A path that exists is still read as a key file first,
so a command that ran before runs the same. A revoked key signs nothing (exit 2, `key-revoked`).
- **Not folded.** `wai package build` hands `--key` to `wai-pack`, which reads a file. `wai-keys`,
`wai-room keygen`, `wai-remote-host key`, `wai-key-proxy keygen`, `wai-audit key`, `wai-ledger
key` and `wai-quantum key` keep their own keys and verbs, and `wai keylog` is not built.

View file

@ -0,0 +1,23 @@
### `wai --version`, `wai docs search` and `wai registry show`, and what a version of the toolchain promises
- **`wai --version`** (`toolchain.show`, also `wai version`), and with `--json` its envelope: this
binary's version; the catalogue's format, operations and the SHA-256 of `wai/catalogue/ops.json`;
the envelope's version; the registry's capabilities and the SHA-256 of
`wai/registry/capabilities.json`; the release of the standard it implements with the envelope
formats it reads; and the build features the catalogue names that it holds. A test holds the
crate's version, the export's and the newest release of [CHANGELOG.md](CHANGELOG.md) equal.
- **`wai docs search <word>...`** and **`wai registry show <capability>`**: `docs.search` and
`registry.show` have a command line now, each the same tool `wai mcp` serves, and a test holds
each door's answer to the other's. The specification's text is `--spec-root`'s, `$WAI_SPEC_ROOT`'s,
or the tree the binary was built from; without it `wai docs search` exits 5 (`spec-not-found`), and
`wai registry show` answers from the registry and says the text is not here. A capability the
registry does not hold exits 5 (`capability-not-found`). Both now name the feature `mcp`, which
the release archive's `wai` holds.
- **[VERSIONING.md](VERSIONING.md)** says what is public, what holds each surface in CI, what a
version number promises and how a change is announced, and states what the catalogue's check does
not catch: `wai breaking` does not read the catalogue, so an operation removed from it shows in
the export's diff and fails nothing. **[DEPRECATIONS.md](DEPRECATIONS.md)** lists what is
deprecated, and a test holds it to the catalogue both ways.
- **A deprecated flag says so when used.** `--confirm` still does what `--yes` does, and now prints
one line on standard error, `wai: deprecated: --confirm, use --yes instead`. Standard output, the
envelope and the exit code are as they were.

View file

@ -1034,6 +1034,7 @@ dependencies = [
"sha2",
"wai-ops",
"wai-ops-gpu",
"windows-sys",
]
[[package]]

View file

@ -982,6 +982,7 @@ dependencies = [
"sha2",
"wai-ops",
"wai-quantum",
"windows-sys",
]
[[package]]

View file

@ -2473,6 +2473,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"wai-ops",
"windows-sys 0.61.2",
]
[[package]]

View file

@ -964,6 +964,7 @@ dependencies = [
"serde_json",
"sha2",
"wai-quantum",
"windows-sys",
]
[[package]]

View file

@ -986,6 +986,7 @@ dependencies = [
"sha2",
"wai-ops",
"wai-quantum",
"windows-sys",
]
[[package]]

View file

@ -780,6 +780,7 @@ dependencies = [
"serde_json",
"sha2",
"wai-ops",
"windows-sys",
]
[[package]]

View file

@ -1227,6 +1227,7 @@ dependencies = [
"sha2",
"wai-ops",
"wai-quantum",
"windows-sys",
]
[[package]]

View file

@ -595,6 +595,7 @@ dependencies = [
"serde_json",
"sha2",
"wai-quantum",
"windows-sys",
]
[[package]]

View file

@ -1096,6 +1096,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"windows-sys",
]
[[package]]

1
wai/wai-rs/Cargo.lock generated
View file

@ -4735,6 +4735,7 @@ dependencies = [
"wai-ops",
"wai-ops-gpu",
"wai-quantum",
"windows-sys 0.61.2",
"xz2",
"zstd",
"zstd-safe",

View file

@ -129,6 +129,12 @@ wai-ops = { path = "../wai-ops", optional = true }
# by default, so a default build has no GPU interface in it.
wai-ops-gpu = { path = "../wai-ops-gpu", optional = true }
# Windows' access lists, for a secret's file (src/bin/wai_cli/win_acl.rs): made for this account
# alone, and refused when it lets another in, by SID. Declarations only; every lockfile that holds
# this crate already held this version.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading"] }
[features]
default = ["codecs", "identify"]
# The zeroth-condition codec wrappers (image/audio/video/text) + the C
@ -1680,6 +1686,20 @@ name = "cli_fleet"
path = "tests/cli_fleet.rs"
required-features = ["quickstart"]
# `wai key`: a developer's signing keys in one keystore, and `--key <name>` on the operations
# that sign. Its test that wai-keys reads what it exports runs with `key_discovery` too.
[[test]]
name = "cli_key"
path = "tests/cli_key.rs"
required-features = ["quickstart"]
# `wai --version`, the command line's doors to `docs.search` and `registry.show`, and the toolchain's
# VERSIONING.md and DEPRECATIONS.md held to the catalogue.
[[test]]
name = "cli_toolchain"
path = "tests/cli_toolchain.rs"
required-features = ["quickstart"]
[[test]]
name = "jpegai_decode_symbols"
path = "tests/jpegai_decode_symbols.rs"

View file

@ -1623,6 +1623,7 @@ dependencies = [
"serde_json",
"sha2",
"wai-ops",
"windows-sys",
"xz2",
"zstd",
]

View file

@ -41,14 +41,10 @@ fn path_of<'a>(args: &'a [String], flags: &[&str]) -> Option<&'a str> {
None
}
/// `--key`: 64 lowercase hexadecimal digits, or a file holding them.
/// `--key`: a key's name in the keystore, 64 hexadecimal digits, or a file holding them.
fn signing_key(args: &[String]) -> Result<SigningKey, String> {
let s = opt(args, "--key").ok_or("--record needs --key")?;
let text = if s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()) { s.to_string() } else { String::from_utf8_lossy(&read(s)?).trim().to_string() };
let seed: Vec<u8> = (text.len() == 64 && text.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)))
.then(|| (0..32).map(|i| u8::from_str_radix(&text[2 * i..2 * i + 2], 16).expect("hexadecimal")).collect())
.ok_or("--key is 64 lowercase hexadecimal digits, or a file holding them")?;
Ok(SigningKey::from_bytes(&seed.try_into().expect("32 bytes")))
crate::dx::key::signing_key(s, true).map_err(|e| e.message)
}
fn issued_at(args: &[String]) -> String {

View file

@ -75,6 +75,17 @@ pub fn main() -> ExitCode {
argv: raw.iter().filter(|a| *a != "--json").cloned().collect(),
};
let json = c.json;
// A deprecated flag still works, and says so in one line (wai/VERSIONING.md); a child says
// nothing, since its parent has.
if !child {
for f in catalogue::AGENT_FLAGS {
if let Some((alias, _)) = f.deprecated_alias
&& has(alias)
{
eprintln!("wai: deprecated: {alias}, use {} instead", f.flag);
}
}
}
out::set_dry_run(c.dry_run);
agent::set(c);
let args: Vec<String> = raw.iter().filter(|a| !global(a) || (child && *a == "--json")).cloned().collect();
@ -265,7 +276,7 @@ pub fn not_built(op: &Op) -> OpError {
/// 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 <noun> <verb>` runs it\n";
s += "wai — the .WAI™ toolchain. Every operation has an id, noun.verb, and `wai <noun> <verb>` runs it\n";
s += "as well as the command shown.\n\nStart here:\n";
let line = |o: &Op| -> String {
let cmd = o.command();
@ -434,6 +445,59 @@ fn ops_export(a: &[String]) -> Result<bool, OpError> {
}
}
// ─────────────────────────────── wai --version ───────────────────────────────
fn sha256_hex(b: &[u8]) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(b).iter().map(|x| format!("{x:02x}")).collect()
}
/// `wai --version`: one version, the crate's, which the standard's text and the reference
/// implementation are released under (wai/VERSIONING.md, and a test holds wai/CHANGELOG.md's newest
/// release to it); the digests of what this binary was built with; and the build features the
/// catalogue names that it holds.
fn toolchain_show(a: &[String]) -> Result<bool, OpError> {
if let Some(x) = a.first() {
return Err(OpError::usage(format!("wai --version: {x}: takes no argument"), ""));
}
let version = env!("CARGO_PKG_VERSION");
let catalogue_sha = sha256_hex(catalogue::export_text().as_bytes());
let registry = wai::codecs::registry::capabilities_json();
let registry_sha = sha256_hex(registry.as_bytes());
let capabilities = serde_json::from_str::<Value>(&registry).ok().and_then(|v| v["capabilities"].as_array().map(Vec::len)).unwrap_or(0);
let formats = [wai::container::MAGIC, wai::container::MAGIC_V2].map(|m| String::from_utf8_lossy(m).into_owned());
let mut features: Vec<&str> = OPS
.iter()
.flat_map(|o| match o.needs {
catalogue::Needs::Nothing => &[][..],
catalogue::Needs::All(f) | catalogue::Needs::Any(f) => f,
})
.copied()
.filter(|f| catalogue::feature_on(f) == Some(true))
.collect();
features.sort_unstable();
features.dedup();
if agent::ctx().json {
let data = json!({
"toolchain": version,
"catalogue": {"format": catalogue::FORMAT, "operations": OPS.len(), "sha256": catalogue_sha},
"envelope": catalogue::ENVELOPE,
"registry": {"capabilities": capabilities, "sha256": registry_sha},
"specification": {"version": version, "formats": formats},
"features": features,
});
agent::print(&agent::envelope_with("toolchain.show", true, data, None));
} else {
println!("wai {version}");
println!("catalogue {}, {} operations, sha256 {catalogue_sha}", catalogue::FORMAT, OPS.len());
println!("envelope {}", catalogue::ENVELOPE);
println!("registry {capabilities} capabilities, sha256 {registry_sha}");
println!("specification {version}, reading {}", formats.join(" and "));
println!("features {}", if features.is_empty() { "none the catalogue names".to_string() } else { features.join(", ") });
}
Ok(true)
}
// ─────────────────────────────── the other implementations ───────────────────────────────
type R = Result<bool, OpError>;
@ -675,6 +739,7 @@ pub const IMPLS: &[Impl] = &[
Impl { id: "ops.search", run: ops_search, json: Json::Native },
Impl { id: "ops.show", run: ops_schema, json: Json::Native },
Impl { id: "ops.export", run: ops_export, json: Json::Native },
Impl { id: "toolchain.show", run: toolchain_show, json: Json::Native },
];
#[cfg(test)]

View file

@ -126,14 +126,8 @@ pub fn import(args: &[String]) -> R {
];
agent::check_args(args, &with_value, &with_value, 0, &usage)?;
let channel = super::opt(args, "--channel").ok_or_else(|| OpError::usage("--channel: the channel id", &usage))?;
let key_path = super::opt(args, "--key").ok_or_else(|| OpError::usage("--key: the operator's key file (32 bytes, or 64 hexadecimal digits)", &usage))?;
let raw = read(key_path, "the operator's key file")?;
let seed: [u8; 32] = if raw.len() == 32 {
raw.try_into().expect("32")
} else {
std::str::from_utf8(&raw).ok().and_then(|s| unhex(s.trim())).and_then(|b| b.try_into().ok()).ok_or_else(|| OpError::usage(format!("{key_path}: 32 bytes, or 64 hexadecimal digits"), "the operator key's seed"))?
};
let key = ed25519_dalek::SigningKey::from_bytes(&seed);
let key_arg = super::opt(args, "--key").ok_or_else(|| OpError::usage("--key: the operator's key: its name in the keystore, or its file (32 bytes, or 64 hexadecimal digits)", &usage))?;
let key = super::key::signing_key(key_arg, false)?;
let mrss = all(args, "--mrss");
let owners = all(args, "--mrss-owner");
if !owners.is_empty() && owners.len() != mrss.len() {

View file

@ -49,11 +49,18 @@ pub fn fill(cmd: &str, with: &[(&str, &Path)]) -> String {
/// killed, can hold the output open, and the run does not wait for it. The command stays in
/// the caller's process group, so an interrupt of `wai conform` reaches it too.
pub fn run(cmd: &str, stdin: Option<&[u8]>, timeout: Duration) -> Result<Ran, String> {
let mut c = if cfg!(windows) {
#[cfg(windows)]
let mut c = {
// `Command::arg` would escape the quotes `quote` writes, and cmd would hand them on as part
// of each path. So cmd's line goes as it is: /S takes the outer pair off and nothing else,
// whatever the command line begins with.
use std::os::windows::process::CommandExt;
let mut c = Command::new("cmd");
c.arg("/C").arg(cmd);
c.raw_arg("/S /C \"").raw_arg(cmd).raw_arg("\"");
c
} else {
};
#[cfg(not(windows))]
let mut c = {
let mut c = Command::new("sh");
c.arg("-c").arg(cmd);
c
@ -182,6 +189,22 @@ pub fn program_found(cmd: &str) -> bool {
mod tests {
use super::*;
/// A quoted path reaches the command as the path, and the program itself may be quoted.
#[cfg(windows)]
#[test]
fn cmd_gets_the_command_line_as_it_is_written() {
let d = std::env::temp_dir().join(format!("wai-conform-cmd-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let f = d.join("a b.txt");
std::fs::write(&f, "held\r\n").unwrap();
let r = run(&fill("type {f}", &[("f", &f)]), None, Duration::from_secs(20)).unwrap();
assert_eq!((r.code, first_line(&r.stdout).trim()), (Some(0), "held"), "{}", String::from_utf8_lossy(&r.stderr));
let findstr = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()).join("System32").join("findstr.exe");
let r = run(&fill("{p} held {f}", &[("p", &findstr), ("f", &f)]), None, Duration::from_secs(20)).unwrap();
assert_eq!((r.code, first_line(&r.stdout).trim()), (Some(0), "held"), "{}", String::from_utf8_lossy(&r.stderr));
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn placeholders_are_quoted_paths() {

View file

@ -13,24 +13,10 @@ pub use wai::conform_result::FORMAT;
/// The suffix of a result record's file name, which `wai verify` looks for.
pub const SUFFIX: &str = ".wai-conform.json";
fn unhex(s: &str) -> Option<Vec<u8>> {
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()
}
/// A signing key from `--key`'s file: 64 hexadecimal digits (a 32-byte seed), or the 32
/// bytes themselves.
/// A signing key from `--key`: its name in the keystore, or a key file (64 hexadecimal digits, a
/// 32-byte seed, or the 32 bytes themselves).
pub fn key_from_file(path: &std::path::Path) -> Result<SigningKey, String> {
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
let t = String::from_utf8_lossy(&b);
let seed: Vec<u8> = 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")))
super::super::key::signing_key(&path.to_string_lossy(), false).map_err(|e| e.message)
}
/// A key made for one run.

View file

@ -157,14 +157,7 @@ pub fn enrolment_create(args: &[String]) -> R {
let r = role::code(role_arg)
.or_else(|| role_arg.parse::<u8>().ok().filter(|r| role::name(*r).is_some()))
.ok_or_else(|| OpError::usage(format!("--role {role_arg}: relay, compositor, ingest, room, ledger or audit"), &usage))?;
let key_path = required(args, "--key", "the organisation key's file (32 bytes, or 64 hexadecimal digits)", &usage)?;
let raw = std::fs::read(key_path).map_err(|e| io_fail(key_path, &e, "give the organisation key's file"))?;
let seed: [u8; 32] = if raw.len() == 32 {
raw.try_into().expect("32")
} else {
std::str::from_utf8(&raw).ok().and_then(|s| unhex(s.trim())).and_then(|b| b.try_into().ok()).ok_or_else(|| OpError::usage(format!("{key_path}: 32 bytes, or 64 hexadecimal digits"), "the organisation key's seed"))?
};
let key = ed25519_dalek::SigningKey::from_bytes(&seed);
let key = super::key::signing_key(required(args, "--key", "the organisation key: its name in the keystore, or its file (32 bytes, or 64 hexadecimal digits)", &usage)?, false)?;
let label = super::opt(args, "--label").unwrap_or("");
let (from, until) = (number(args, "--from", 0)?, number(args, "--until", NO_END)?);
let e = Enrolment::new(key.verifying_key().to_bytes(), node, r, from, until, label).sign(&key);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,111 @@
//! `wai docs search` (`docs.search`) and `wai registry show` (`registry.show`): the command line's
//! door to two operations `wai mcp` serves. Each runs the same tool (`../mcp/tools.rs`) and prints
//! its answer: the envelope's `data` with `--json`, lines for a person otherwise. Nothing leaves
//! the machine.
#![cfg_attr(not(feature = "mcp"), allow(dead_code, unused_imports))]
use super::envelope::{self as agent, EXIT_NOT_FOUND, OpError};
use serde_json::{Value, json};
use std::path::PathBuf;
type R = Result<bool, OpError>;
/// A tool's error, as the command line's: a specification that is not here, or a capability the
/// registry does not hold, is not found (exit 5).
fn failure(op: &str, e: String) -> OpError {
if e.contains("no specification tree") || e.contains("holds no SPEC.md") {
let m = e.split(": start the server").next().unwrap_or(&e).to_string();
return OpError::new(EXIT_NOT_FOUND, "spec-not-found", format!("{op}: {m}"), "give --spec-root <the repository's wai/ directory>, or set WAI_SPEC_ROOT");
}
if let Some((c, _)) = e.split_once(" is not in the registry") {
return OpError::new(EXIT_NOT_FOUND, "capability-not-found", format!("{c} is not in the registry"), "wai capabilities lists them");
}
OpError::from(e)
}
/// The tool's answer, as the envelope with `--json` and as `text` of it otherwise.
#[cfg(feature = "mcp")]
fn answer(op: &str, args: &Value, spec_root: Option<PathBuf>, text: impl Fn(&Value) -> String) -> R {
let v = super::mcp::run_once(op, args, spec_root).map_err(|e| failure(op, e))?;
if agent::ctx().json {
agent::print(&agent::envelope_with(op, true, v, None));
} else {
println!("{}", text(&v));
}
Ok(true)
}
/// `wai docs search <word>... [--limit N] [--spec-root <wai-dir>]`.
#[cfg(feature = "mcp")]
pub fn docs_search(args: &[String]) -> R {
let usage = super::usage("docs.search");
let with_value = ["--limit", "--spec-root"];
let words = agent::check_args(args, &with_value, &with_value, usize::MAX, &usage)?;
if words.is_empty() {
return Err(OpError::usage("the words to search for", &usage));
}
let mut a = json!({"query": words.join(" ")});
if let Some(l) = super::opt(args, "--limit") {
let n = l.parse::<u64>().ok().filter(|n| *n >= 1).ok_or_else(|| OpError::usage(format!("--limit {l}: a whole number, at least 1"), &usage))?;
a["limit"] = json!(n);
}
answer("docs.search", &a, super::opt(args, "--spec-root").map(PathBuf::from), |v| {
let hits = v["hits"].as_array().cloned().unwrap_or_default();
let more = if v["truncated"] == true { "; there may be more (--limit)" } else { "" };
let mut t = format!("{} line(s) in {} file(s) under {}{more}", hits.len(), v["files_searched"], v["root"].as_str().unwrap_or("?"));
for h in &hits {
t.push_str(&format!("\n{}:{} {}\n {}", h["file"].as_str().unwrap_or("?"), h["line"], h["section"].as_str().unwrap_or(""), h["text"].as_str().unwrap_or("")));
}
t
})
}
/// `wai registry show <capability> [--spec-root <wai-dir>]`.
#[cfg(feature = "mcp")]
pub fn registry_show(args: &[String]) -> R {
let usage = super::usage("registry.show");
let with_value = ["--spec-root"];
let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?;
let c = *pos.first().ok_or_else(|| OpError::usage("the capability", &usage))?;
answer("registry.show", &json!({"capability": c}), super::opt(args, "--spec-root").map(PathBuf::from), |v| {
let r = &v["registry"];
let member = |k: &str| r[k].as_str().unwrap_or("none").to_string();
let yes = |k: &str| if v[k] == true { "yes" } else { "no" };
let mut t = format!(
"{c}\n kind {}, status {}, media {}, role {}, tier {}, licence {}, replicate {}\n {}\n decoded natively by this build: {}; dispatched standalone: {}; companion only: {}; a candidate: {}",
member("kind"),
member("status"),
member("media"),
member("role"),
member("tier"),
member("licence"),
member("replicate"),
member("spec_section"),
yes("decoded_natively_here"),
yes("dispatched_standalone"),
yes("companion_only"),
yes("candidate"),
);
match v["spec_lines"].as_array() {
Some(lines) => {
t.push_str("\n in the specification:");
for h in lines {
t.push_str(&format!("\n {}:{} {}", h["file"].as_str().unwrap_or("?"), h["line"], h["text"].as_str().unwrap_or("")));
}
}
None => t.push_str("\n the specification's text is not here (--spec-root, or WAI_SPEC_ROOT)"),
}
t
})
}
#[cfg(not(feature = "mcp"))]
pub fn docs_search(_: &[String]) -> R {
Err(crate::door::not_built(wai::catalogue::op("docs.search").expect("listed")))
}
#[cfg(not(feature = "mcp"))]
pub fn registry_show(_: &[String]) -> R {
Err(crate::door::not_built(wai::catalogue::op("registry.show").expect("listed")))
}

View file

@ -34,6 +34,12 @@
//! `statement.check` (`wai statement check`): the ad opportunities a channel's signed records
//! prove for an owner, and a platform's reported figures checked against them
//! (extensions/opportunity-report.md §12; `opportunity.rs`).
//! - `key.create`, `key.list`, `key.show`, `key.rotate`, `key.revoke`, `key.import` and
//! `key.export` (`wai key create|list|show|rotate|revoke|import|export`): a developer's signing
//! keys in one keystore, each named, so that every signing operation's `--key` takes its name
//! (`key.rs`).
//! - `docs.search` (`wai docs search`) and `registry.show` (`wai registry show`): the specification
//! searched and one capability shown, by the tools `wai mcp` serves them with (`lookup.rs`).
//!
//! [`envelope`] holds the agent rules every command keeps: the exit codes, the JSON envelope,
//! `--dry-run`, `--yes`.
@ -58,6 +64,9 @@ mod hello;
mod opportunity;
mod share;
mod verify;
// A developer's signing keys (`wai key`), and the keys every signing operation's `--key` names.
pub(crate) mod key;
mod lookup;
#[cfg(feature = "mcp")]
#[path = "../mcp/mod.rs"]
@ -101,6 +110,17 @@ pub const VERBS: &[Impl] = &[
Impl { id: "opportunity.create", run: opportunity::opportunity_create, json: Json::Native },
Impl { id: "opportunity.verify", run: opportunity::opportunity_verify, json: Json::Native },
Impl { id: "statement.check", run: opportunity::statement_check, json: Json::Native },
// A developer's signing keys, in one keystore (`key.rs`).
Impl { id: "key.create", run: key::create, json: Json::Native },
Impl { id: "key.list", run: key::list, json: Json::Native },
Impl { id: "key.show", run: key::show, json: Json::Native },
Impl { id: "key.rotate", run: key::rotate, json: Json::Native },
Impl { id: "key.revoke", run: key::revoke, json: Json::Native },
Impl { id: "key.import", run: key::import, json: Json::Native },
Impl { id: "key.export", run: key::export, json: Json::Native },
// Two operations whose tool `wai mcp` also serves (`lookup.rs`).
Impl { id: "docs.search", run: lookup::docs_search, json: Json::Native },
Impl { id: "registry.show", run: lookup::registry_show, json: Json::Native },
];
/// An operation's synopsis, from the catalogue, for a usage error.

View file

@ -59,22 +59,11 @@ pub(super) fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
/// A key file: 32 bytes, or 64 hexadecimal digits.
/// A signing key by its name in the keystore, or from a key file (32 bytes, or 64 hexadecimal
/// digits): what `--key` takes on every signing operation.
#[cfg(feature = "revenue_share")]
pub(super) fn read_key(path: &str, what: &str) -> Result<ed25519_dalek::SigningKey, OpError> {
let raw = std::fs::read(path).map_err(|e| io_fail(path, &e, what))?;
let seed: [u8; 32] = if raw.len() == 32 {
raw.try_into().expect("32")
} else {
std::str::from_utf8(&raw)
.ok()
.map(str::trim)
.filter(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()))
.map(|s| (0..32).map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).expect("hex")).collect::<Vec<u8>>())
.and_then(|b| b.try_into().ok())
.ok_or_else(|| OpError::usage(format!("{path}: 32 bytes, or 64 hexadecimal digits"), what))?
};
Ok(ed25519_dalek::SigningKey::from_bytes(&seed))
pub(super) fn read_key(arg: &str, what: &str) -> Result<ed25519_dalek::SigningKey, OpError> {
super::key::signing_key(arg, false).map_err(|e| if e.hint.is_empty() { e.with_hint(what) } else { e })
}
#[cfg(feature = "revenue_share")]

View file

@ -18,19 +18,6 @@ fn write(p: &str, b: &[u8]) -> Result<(), String> {
crate::out::write(p, b).map_err(|e| format!("{p}: {e}"))
}
fn seed(s: &str) -> Result<[u8; 32], String> {
let text = if s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()) { s.to_string() } else { String::from_utf8_lossy(&read(Path::new(s))?).trim().to_string() };
let ok = text.len() == 64 && text.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c));
if !ok {
return Err("--key is 64 lowercase hexadecimal digits, or a file holding them".into());
}
let mut out = [0u8; 32];
for (i, o) in out.iter_mut().enumerate() {
*o = u8::from_str_radix(&text[2 * i..2 * i + 2], 16).map_err(|e| e.to_string())?;
}
Ok(out)
}
fn options(args: &[String]) -> Result<Options, String> {
let hardware = match opt(args, "--hardware") {
None => None,
@ -145,7 +132,7 @@ pub fn encode(args: &[String]) -> Result<(), String> {
println!("energy {} µJ ({}, {}, {}, counter {}, ±{} ppm over {} µs)", e.joules_micro, e.class, e.coverage, e.attribution, e.counter, e.uncertainty.map_or(0, |u| u.0), e.uncertainty.map_or(0, |u| u.1));
}
if let Some(path) = opt(rest, "--record") {
let key = ed25519_dalek::SigningKey::from_bytes(&seed(opt(rest, "--key").ok_or("--record needs --key")?)?);
let key = crate::dx::key::signing_key(opt(rest, "--key").ok_or("--record needs --key")?, true).map_err(|e| e.message)?;
let issued = opt(rest, "--issued-at").map(String::from).unwrap_or_else(record::now_rfc3339);
let body = record::body(&enc, &prepared.cfg, &prepared.source, &idr, energy.as_ref(), None, &issued);
let signed = record::sign(body, &key);

View file

@ -10,7 +10,7 @@
//! left out: the classical MPD's period, timescale, t and d (its first period and
//! the first adaptation set with a timeline, or those named), checked adjacent to
//! it; with no classical MPD, the classical HLS timeline at timescale 1000 from 0
//! wai http seal-playlist <wai media playlist> --key <seed hex | file>
//! wai http seal-playlist <wai media playlist> --key <name | seed hex | file>
//! [--signer-id ID] [--content-id ID] [--parent <mr.json>]
//! [--grant <grant.json>] [--out <dir>]
//! seal a WAI playlist in its rendered form, under the publisher's grant (a JSON
@ -239,20 +239,13 @@ pub fn mpd(args: &[String]) -> Result<(), String> {
Ok(())
}
/// A 32-byte seed: 64 hexadecimal digits, given or in a file.
#[cfg(feature = "video_manifest")]
fn seed(s: &str) -> Result<[u8; 32], String> {
let text = if wai::http_binding::unhex32(s.trim()).is_some() { s.to_string() } else { String::from_utf8_lossy(&read(Path::new(s))?).to_string() };
wai::http_binding::unhex32(text.trim()).ok_or_else(|| "--key is 64 lowercase hexadecimal digits, or a file holding them".to_string())
}
#[cfg(feature = "video_manifest")]
pub fn seal(args: &[String]) -> Result<(), String> {
use wai::http_binding::playlist_receipt::seal_playlist;
use wai::wai_video_manifest::ManifestReceipt;
let path = args.first().filter(|a| !a.starts_with("--")).ok_or("seal-playlist needs the playlist")?;
let text = String::from_utf8(read(Path::new(path))?).map_err(|_| format!("{path}: not UTF-8"))?;
let key = ed25519_dalek::SigningKey::from_bytes(&seed(opt(args, "--key").ok_or("seal-playlist needs --key")?)?);
let key = crate::dx::key::signing_key(opt(args, "--key").ok_or("seal-playlist needs --key")?, true).map_err(|e| e.message)?;
let parent = opt(args, "--parent").map(|p| ManifestReceipt::from_json(&String::from_utf8_lossy(&read(Path::new(p))?)).map_err(|e| format!("{p}: {e}"))).transpose()?;
let stem = Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("playlist");
// The publisher's grant (Appendix H's form), or none: a grant with no ceilings.

View file

@ -367,6 +367,43 @@ fn options(args: &[String]) -> Result<Options, String> {
Ok(Options { toolsets, spec_root, room_bin, project, read_only })
}
/// One tool that only reads, run once for the command line's door to the same operation: one
/// implementation behind both doors. `spec_root` is what `--spec-root` is to the server, and the
/// specification's text is found the same way without it.
pub(crate) fn run_once(op: &str, args: &Value, spec_root: Option<PathBuf>) -> Result<Value, String> {
let project = std::env::current_dir().map_err(|e| format!("the current directory: {e}"))?;
let mut server = Server::new(vec![tools::Toolset::Core], spec_root, None, project, true);
tools::call(&mut server, &op.replace('.', "_"), args).unwrap_or_else(|| Err(format!("{op}: wai mcp offers no such tool to read with")))
}
#[cfg(test)]
mod tests {
use super::*;
/// The operations with a door here and a tool there take the catalogue's input in both: the
/// tool's arguments are the catalogue's, less what the command line alone takes (the server has
/// its own `--spec-root`).
#[test]
fn an_operation_of_both_doors_takes_the_catalogues_input_in_both() {
for id in ["docs.search", "registry.show"] {
let o = wai::catalogue::op(id).expect("a catalogue operation");
assert!(o.mcp && o.cli.is_some(), "{id} has both doors");
let t = tools::TOOLS.iter().find(|t| t.op == id).expect("a tool");
let schema = (t.schema)();
let mut have: Vec<&str> = schema["properties"].as_object().unwrap().keys().map(String::as_str).collect();
let mut want: Vec<&str> = o.input.iter().map(|a| a.name).filter(|n| *n != "spec_root").collect();
have.sort();
want.sort();
assert_eq!(have, want, "{id}: the tool's arguments are the catalogue's input");
let mut req: Vec<&str> = schema["required"].as_array().unwrap().iter().map(|r| r.as_str().unwrap()).collect();
let mut want_req: Vec<&str> = o.input.iter().filter(|a| a.required).map(|a| a.name).collect();
req.sort();
want_req.sort();
assert_eq!(req, want_req, "{id}: the required arguments");
}
}
}
/// `wai mcp --dry-run`: the tools it would serve, and nothing served (no bridge started).
pub fn plan(args: &[String]) -> Result<bool, String> {
let o = options(args)?;

View file

@ -3,6 +3,11 @@
//! what the command reports it would have done. A request that would change a service the
//! command line names (a fleet service) is named in the plan the same way, and not sent.
// A secret's file on Windows: an access list for this account alone. Here because every binary
// that includes this file makes its private files through it.
#[path = "win_acl.rs"]
pub mod win_acl;
use serde_json::{Value, json};
use std::io::{self, Write};
use std::path::Path;
@ -27,7 +32,8 @@ fn note(v: Value) {
}
/// What a dry run would have done, in order: `{"write": path, "bytes": n}`,
/// `{"create-dir": path}`, `{"remove": path}`, `{"request": "METHOD /path", "target": url}`.
/// `{"create-dir": path}`, `{"remove": path}`, `{"request": "METHOD /path", "target": url}`,
/// `{"keychain": "add" | "delete", "service", "id"}`.
pub fn plan() -> Vec<Value> {
PLAN.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
@ -78,6 +84,77 @@ pub fn write_new(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> io::Result<
f.write_all(b)
}
/// A new file only its owner may read or write, which must not exist yet, open for writing: mode
/// 0600 on Unix, and on Windows an access list for this account alone (`win_acl.rs`). The one call
/// that makes the file sets its protection, so the file is never under anything wider, not even
/// before a byte is in it, and nothing can hold it open from a moment when it was.
fn create_private(p: &Path) -> io::Result<std::fs::File> {
#[cfg(windows)]
{
win_acl::create_new_file(p)
}
#[cfg(not(windows))]
{
let mut o = std::fs::OpenOptions::new();
o.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.mode(0o600);
}
o.open(p)
}
}
/// A new file only its owner may read or write (a key's secret, a key's record), which must not
/// exist yet.
#[cfg_attr(not(feature = "quickstart"), allow(dead_code))]
pub fn write_new_private(path: impl AsRef<Path>, 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 = create_private(p)?;
f.write_all(b)?;
f.sync_all()
}
/// A file only its owner may read or write, replaced whole: written beside it, then renamed over
/// it, so that a reader finds the old file or the new one and never part of either.
#[cfg_attr(not(feature = "quickstart"), allow(dead_code))]
pub fn replace_private(path: impl AsRef<Path>, 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(());
}
let mut name = p.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "not a file name"))?.to_os_string();
name.push(format!(".{}.new", std::process::id()));
let tmp = p.with_file_name(name);
let _ = std::fs::remove_file(&tmp);
let written = create_private(&tmp).and_then(|mut f| f.write_all(b).and_then(|_| f.sync_all()));
match written.and_then(|_| std::fs::rename(&tmp, p)) {
Ok(()) => Ok(()),
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e)
}
}
}
/// A step a dry run would take that is not a file of this machine's (a secret kept in the
/// keychain, or removed from it): named in the plan, in a dry run alone.
#[cfg_attr(not(feature = "quickstart"), allow(dead_code))]
pub fn would(step: Value) {
if dry_run() {
note(step);
}
}
/// `std::fs::create_dir_all`.
pub fn create_dir_all(path: impl AsRef<Path>) -> io::Result<()> {
let p = path.as_ref();
@ -127,9 +204,33 @@ pub fn plan_lines() -> Vec<String> {
format!("remove {p}")
} else if let (Some(r), Some(t)) = (v["request"].as_str(), v["target"].as_str()) {
format!("send {r} to {t}")
} else if let (Some(k), Some(id)) = (v["keychain"].as_str(), v["id"].as_str()) {
let into = if k == "add" { "keep the secret of" } else { "remove the secret of" };
format!("{into} {id} {} the login keychain", if k == "add" { "in" } else { "from" })
} else {
v.to_string()
}
})
.collect()
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
/// The file is the owner's alone before a byte of the secret is in it.
#[test]
fn a_private_file_is_restricted_before_it_holds_anything() {
use std::os::unix::fs::PermissionsExt;
let d = std::env::temp_dir().join(format!("wai-out-private-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
let p = d.join("k.seed");
let f = create_private(&p).unwrap();
let m = std::fs::metadata(&p).unwrap();
assert_eq!((m.len(), m.permissions().mode() & 0o777), (0, 0o600));
drop(f);
assert_eq!(create_private(&p).unwrap_err().kind(), io::ErrorKind::AlreadyExists, "never over a file that exists");
std::fs::remove_dir_all(&d).unwrap();
}
}

View file

@ -0,0 +1,460 @@
//! A secret's file on Windows: an access list for this account alone, from the file's creation, and
//! the check that refuses a seed file whose list lets anyone else in. What mode 0600 and its check
//! are on Unix (`out.rs`, `dx/key.rs`).
//!
//! Everything is by SID, read through the system's calls in numeric form, never by a name, which is
//! localised, and never from `icacls`' output. All of this binary's unsafe code for access lists is
//! the block below marked `cfg(windows)`; what decides ([`exposure`], [`private_descriptor`],
//! [`Exposure::fix_command`]) is ordinary code over plain values, so its tests run on every system.
//!
//! **The list.** Owner: this account. One entry: this account, all rights. Inheritance off, so the
//! folder's entries never reach the file, whichever folder it is. SYSTEM and Administrators are not
//! listed. Nothing real needs them: a backup or a virus scanner reads through its privileges rather
//! than through the list, an administrator can still take ownership, as root can read a file of mode
//! 0600, and the account itself reads it whether or not it is elevated. So the rule is one rule.
//!
//! **The order.** A file is made by one call that carries its list (`CreateFileW`, with the security
//! descriptor), so it never exists under a wider list, not even empty, and no other account can hold
//! a handle to it from a moment before its list was set.
#![cfg_attr(not(all(windows, feature = "quickstart")), allow(dead_code, unused_imports))]
/// One entry of an access list, as its header states it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
/// The ACE type.
pub kind: u8,
/// The ACE flags.
pub flags: u8,
/// The rights.
pub mask: u32,
/// Whom it is for, numeric; `None` for a kind whose layout this code does not read.
pub sid: Option<String>,
}
/// A file's owner and access list.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Acl {
pub owner: Option<String>,
/// Whether inheritance is off.
pub protected: bool,
/// The entries; `None` when the file has no list at all, which lets everyone in.
pub entries: Option<Vec<Entry>>,
}
const ACCESS_ALLOWED: u8 = 0;
const ACCESS_DENIED: u8 = 1;
const ACCESS_DENIED_OBJECT: u8 = 6;
const ACCESS_ALLOWED_CALLBACK: u8 = 9;
const ACCESS_DENIED_CALLBACK: u8 = 10;
const ACCESS_DENIED_CALLBACK_OBJECT: u8 = 12;
const INHERIT_ONLY_ACE: u8 = 8;
/// The security descriptor of a file (or a directory, whose children would inherit its one entry)
/// for the account `me` alone, in the system's own descriptor language: `P` is inheritance off, `FA`
/// all of a file's rights, and `OICI` that files and directories inside inherit the entry.
pub fn private_descriptor(me: &str, directory: bool) -> String {
format!("O:{me}D:P(A;{};FA;;;{me})", if directory { "OICI" } else { "" })
}
/// Who, besides the account a file is for, its list lets in.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Exposure {
/// The other accounts an entry grants anything to, numeric.
pub granted: Vec<String>,
/// The file has no list at all.
pub no_list: bool,
/// An entry that grants something, of a kind this code cannot read the account of.
pub unread_entry: bool,
/// Another account owns it, and an owner may change the list.
pub other_owner: Option<String>,
/// Whether the file inherits from its folder.
pub inherits: bool,
}
/// What lets anyone but `me` in, or `None`. As Unix's check refuses any bit of a seed's mode for
/// group or other, this refuses an entry that grants another account anything at all. A deny entry
/// grants nothing, and an entry that only children inherit is not this file's.
pub fn exposure(acl: &Acl, me: &str) -> Option<Exposure> {
let mut x = Exposure { inherits: !acl.protected, ..Exposure::default() };
match &acl.entries {
None => x.no_list = true,
Some(entries) => {
for e in entries {
let deny = matches!(e.kind, ACCESS_DENIED | ACCESS_DENIED_OBJECT | ACCESS_DENIED_CALLBACK | ACCESS_DENIED_CALLBACK_OBJECT);
if deny || e.flags & INHERIT_ONLY_ACE != 0 || e.mask == 0 {
continue;
}
match (&e.sid, e.kind) {
(Some(s), ACCESS_ALLOWED | ACCESS_ALLOWED_CALLBACK) => {
if s != me && !x.granted.contains(s) {
x.granted.push(s.clone());
}
}
_ => x.unread_entry = true,
}
}
}
}
if acl.owner.as_deref() != Some(me) {
x.other_owner = Some(acl.owner.clone().unwrap_or_else(|| "no account".into()));
}
(x.no_list || x.unread_entry || !x.granted.is_empty() || x.other_owner.is_some()).then_some(x)
}
/// A well-known account's English name, for a person reading a message; nothing decides by it.
fn known(sid: &str) -> Option<&'static str> {
Some(match sid {
"S-1-1-0" => "Everyone",
"S-1-5-3" => "BATCH",
"S-1-5-4" => "INTERACTIVE",
"S-1-5-6" => "SERVICE",
"S-1-5-11" => "Authenticated Users",
"S-1-5-18" => "SYSTEM",
"S-1-5-32-544" => "Administrators",
"S-1-5-32-545" => "Users",
_ => return None,
})
}
fn shown(sid: &str) -> String {
match known(sid) {
Some(n) => format!("{n} ({sid})"),
None => sid.to_string(),
}
}
impl Exposure {
/// What is wrong, for the message that names the file.
pub fn describe(&self) -> String {
let mut what = Vec::new();
if self.no_list {
what.push("it has no access list, which lets everyone in".to_string());
}
if !self.granted.is_empty() {
what.push(format!("its access list lets {} in", self.granted.iter().map(|s| shown(s)).collect::<Vec<_>>().join(", ")));
}
if self.unread_entry {
what.push("its access list holds an entry of a kind wai does not read".to_string());
}
if let Some(o) = &self.other_owner {
what.push(format!("it is owned by {}, who may change who reads it", shown(o)));
}
what.join("; ")
}
/// The command that makes the file the account's alone, naming every account by its SID.
pub fn fix_command(&self, path: &str, me: &str) -> String {
let mut c = String::new();
if self.other_owner.is_some() {
c.push_str(&format!("icacls \"{path}\" /setowner *{me} && "));
}
c.push_str(&format!("icacls \"{path}\""));
if self.inherits {
c.push_str(" /inheritance:r");
}
if !self.granted.is_empty() {
c.push_str(" /remove");
for s in &self.granted {
c.push_str(&format!(" *{s}"));
}
}
c.push_str(&format!(" /grant:r *{me}:F"));
c
}
}
// ─────────────────────────────── the system's calls ───────────────────────────────
#[cfg(windows)]
mod sys {
use super::{Acl, Entry};
use std::ffi::{OsStr, c_void};
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
use std::ptr::null_mut;
use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, LocalFree};
use windows_sys::Win32::Security::Authorization::{ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, GetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT};
use windows_sys::Win32::Security::{
ACE_HEADER, ACL, ACL_SIZE_INFORMATION, AclSizeInformation, DACL_SECURITY_INFORMATION, GetAce, GetAclInformation, GetSecurityDescriptorControl, GetTokenInformation, OWNER_SECURITY_INFORMATION, PSID,
SE_DACL_PROTECTED, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, TokenUser,
};
use windows_sys::Win32::Storage::FileSystem::{CREATE_NEW, CreateDirectoryW, CreateFileW, FILE_ATTRIBUTE_NORMAL};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
/// Memory the system gave with `LocalAlloc`, freed when dropped.
struct Local(*mut c_void);
impl Drop for Local {
fn drop(&mut self) {
if !self.0.is_null() {
// SAFETY: the pointer came from a call that says to free it with LocalFree, once.
unsafe { LocalFree(self.0) };
}
}
}
fn wide(s: &OsStr) -> io::Result<Vec<u16>> {
let mut w: Vec<u16> = s.encode_wide().collect();
if w.contains(&0) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "a NUL in a name"));
}
w.push(0);
Ok(w)
}
/// A SID's numeric text.
///
/// # Safety
/// `sid` points at a valid SID.
unsafe fn sid_text(sid: PSID) -> io::Result<String> {
let mut text: *mut u16 = null_mut();
// SAFETY: the caller's SID; `text` is written by the call.
if unsafe { ConvertSidToStringSidW(sid, &mut text) } == 0 {
return Err(io::Error::last_os_error());
}
let _free = Local(text.cast());
let mut n = 0;
// SAFETY: the system's text ends with a NUL, which this stops at.
while unsafe { *text.add(n) } != 0 {
n += 1;
}
// SAFETY: `n` units were just read from it.
Ok(String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(text, n) }))
}
/// The SID of the account this process runs as.
pub fn current_user() -> io::Result<String> {
let mut token: HANDLE = null_mut();
// SAFETY: the current process's pseudo-handle; `token` is written by the call.
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
return Err(io::Error::last_os_error());
}
let mut len = 0u32;
// SAFETY: a query for the length alone; it fails with the length written.
unsafe { GetTokenInformation(token, TokenUser, null_mut(), 0, &mut len) };
// u64s, for TOKEN_USER's alignment.
let mut buf = vec![0u64; (len as usize).div_ceil(8).max(1)];
// SAFETY: `buf` holds `len` bytes.
let ok = unsafe { GetTokenInformation(token, TokenUser, buf.as_mut_ptr().cast(), len, &mut len) };
let err = io::Error::last_os_error();
// SAFETY: the handle opened above, closed once.
unsafe { CloseHandle(token) };
if ok == 0 {
return Err(err);
}
// SAFETY: the call wrote a TOKEN_USER at the start of `buf`, whose SID lies inside `buf`.
unsafe { sid_text((*buf.as_ptr().cast::<TOKEN_USER>()).User.Sid) }
}
/// `f`, given security attributes that carry the descriptor `text`.
fn with_descriptor<T>(text: &str, f: impl FnOnce(*const SECURITY_ATTRIBUTES) -> T) -> io::Result<T> {
let w = wide(OsStr::new(text))?;
let mut sd: *mut c_void = null_mut();
// SAFETY: `w` ends with a NUL; `sd` is written by the call and freed by `_free`.
if unsafe { ConvertStringSecurityDescriptorToSecurityDescriptorW(w.as_ptr(), SDDL_REVISION_1, &mut sd, null_mut()) } == 0 {
return Err(io::Error::last_os_error());
}
let _free = Local(sd);
let sa = SECURITY_ATTRIBUTES { nLength: size_of::<SECURITY_ATTRIBUTES>() as u32, lpSecurityDescriptor: sd, bInheritHandle: 0 };
Ok(f(&sa))
}
/// A new file, which must not exist yet, under the account's own list from the moment it exists.
pub fn create_new_file(path: &Path) -> io::Result<std::fs::File> {
use std::os::windows::io::{FromRawHandle, RawHandle};
let name = wide(path.as_os_str())?;
let descriptor = super::private_descriptor(&current_user()?, false);
with_descriptor(&descriptor, |sa| {
// SAFETY: `name` ends with a NUL, and `sa` and what it points at live for the call.
let h = unsafe { CreateFileW(name.as_ptr(), GENERIC_WRITE, 0, sa, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, null_mut()) };
if h == INVALID_HANDLE_VALUE {
return Err(io::Error::last_os_error());
}
// SAFETY: a handle just opened, which nothing else owns.
Ok(unsafe { std::fs::File::from_raw_handle(h as RawHandle) })
})?
}
/// A directory under the account's own list, its parents made as usual; one that exists stays as
/// it is, as a directory that exists does on Unix.
pub fn create_dir(path: &Path) -> io::Result<()> {
if path.is_dir() {
return Ok(());
}
if let Some(p) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(p)?;
}
let name = wide(path.as_os_str())?;
let descriptor = super::private_descriptor(&current_user()?, true);
with_descriptor(&descriptor, |sa| {
// SAFETY: `name` ends with a NUL, and `sa` and what it points at live for the call.
if unsafe { CreateDirectoryW(name.as_ptr(), sa) } != 0 {
return Ok(());
}
let e = io::Error::last_os_error();
if path.is_dir() { Ok(()) } else { Err(e) }
})?
}
/// A file's owner and access list.
pub fn read(path: &Path) -> io::Result<Acl> {
let name = wide(path.as_os_str())?;
let (mut owner, mut dacl, mut sd): (PSID, *mut ACL, *mut c_void) = (null_mut(), null_mut(), null_mut());
// SAFETY: `name` ends with a NUL; the out pointers point into the descriptor `sd`, freed below.
let e = unsafe { GetNamedSecurityInfoW(name.as_ptr(), SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &mut owner, null_mut(), &mut dacl, null_mut(), &mut sd) };
if e != 0 {
return Err(io::Error::from_raw_os_error(e as i32));
}
let _free = Local(sd);
let (mut control, mut revision) = (0u16, 0u32);
// SAFETY: the descriptor the call above returned.
if unsafe { GetSecurityDescriptorControl(sd, &mut control, &mut revision) } == 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: the descriptor's owner, when it has one.
let owner = if owner.is_null() { None } else { Some(unsafe { sid_text(owner) }?) };
let mut entries = None;
if !dacl.is_null() {
let mut info = ACL_SIZE_INFORMATION { AceCount: 0, AclBytesInUse: 0, AclBytesFree: 0 };
// SAFETY: the descriptor's list; `info` is the size the class asks for.
if unsafe { GetAclInformation(dacl.cast_const(), (&raw mut info).cast(), size_of::<ACL_SIZE_INFORMATION>() as u32, AclSizeInformation) } == 0 {
return Err(io::Error::last_os_error());
}
let mut list = Vec::with_capacity(info.AceCount as usize);
for i in 0..info.AceCount {
let mut ace: *mut c_void = null_mut();
// SAFETY: an index the list says it holds.
if unsafe { GetAce(dacl.cast_const(), i, &mut ace) } == 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: an ACE begins with its header.
let (kind, flags) = unsafe { ((*ace.cast::<ACE_HEADER>()).AceType, (*ace.cast::<ACE_HEADER>()).AceFlags) };
// SAFETY: every ACE holds its mask after the header's four bytes.
let mask = unsafe { ace.cast::<u8>().add(4).cast::<u32>().read_unaligned() };
// These kinds hold the SID after the mask; the object kinds put more before it.
let sid = if matches!(kind, super::ACCESS_ALLOWED | super::ACCESS_DENIED | super::ACCESS_ALLOWED_CALLBACK | super::ACCESS_DENIED_CALLBACK) {
// SAFETY: the SID of an ACE of such a kind, eight bytes in.
Some(unsafe { sid_text(ace.cast::<u8>().add(8).cast()) }?)
} else {
None
};
list.push(Entry { kind, flags, mask, sid });
}
entries = Some(list);
}
Ok(Acl { owner, protected: control & SE_DACL_PROTECTED != 0, entries })
}
}
#[cfg(windows)]
#[allow(unused_imports)] // A binary that includes `out.rs` for its other writes calls none of these.
pub use sys::{create_dir, create_new_file, current_user, read};
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
use std::io;
const ME: &str = "S-1-5-21-1-2-3-1001";
const FILE_ALL: u32 = 0x1f01ff;
fn allow(sid: &str, mask: u32) -> Entry {
Entry { kind: ACCESS_ALLOWED, flags: 0, mask, sid: Some(sid.into()) }
}
fn acl(entries: Vec<Entry>) -> Acl {
Acl { owner: Some(ME.into()), protected: true, entries: Some(entries) }
}
#[test]
fn the_descriptor_is_this_account_alone_with_inheritance_off() {
assert_eq!(private_descriptor(ME, false), format!("O:{ME}D:P(A;;FA;;;{ME})"));
assert_eq!(private_descriptor(ME, true), format!("O:{ME}D:P(A;OICI;FA;;;{ME})"));
}
#[test]
fn only_this_account_is_let_in_or_the_file_is_exposed() {
assert_eq!(exposure(&acl(vec![allow(ME, FILE_ALL)]), ME), None);
// Each account named, SYSTEM and Administrators as any other, whatever the right.
for sid in ["S-1-1-0", "S-1-5-18", "S-1-5-32-544", "S-1-5-32-545", "S-1-5-21-1-2-3-1002"] {
let x = exposure(&acl(vec![allow(ME, FILE_ALL), allow(sid, 0x120089)]), ME).unwrap_or_else(|| panic!("{sid}"));
assert_eq!(x.granted, [sid]);
let x = exposure(&acl(vec![allow(sid, 0x100000)]), ME).unwrap_or_else(|| panic!("{sid}"));
assert_eq!(x.granted, [sid], "any right, as any bit of a mode");
}
// No list at all lets everyone in.
let x = exposure(&Acl { entries: None, ..acl(vec![]) }, ME).unwrap();
assert!(x.no_list && x.describe().contains("no access list"));
// Another owner may change the list.
let x = exposure(&Acl { owner: Some("S-1-5-32-544".into()), ..acl(vec![allow(ME, FILE_ALL)]) }, ME).unwrap();
assert_eq!(x.other_owner.as_deref(), Some("S-1-5-32-544"));
assert!(x.describe().contains("Administrators (S-1-5-32-544)"), "{}", x.describe());
assert!(exposure(&Acl { owner: None, ..acl(vec![allow(ME, FILE_ALL)]) }, ME).is_some());
// An entry that grants, of a kind whose account is not read, is refused, not passed.
for kind in [5u8, 11, 4, 200] {
let x = exposure(&acl(vec![Entry { kind, flags: 0, mask: 1, sid: None }]), ME).unwrap_or_else(|| panic!("{kind}"));
assert!(x.unread_entry && x.describe().contains("does not read"), "{kind}");
}
// A callback entry is read like an allow entry.
let x = exposure(&acl(vec![Entry { kind: ACCESS_ALLOWED_CALLBACK, flags: 0, mask: 1, sid: Some("S-1-1-0".into()) }]), ME).unwrap();
assert_eq!(x.granted, ["S-1-1-0"]);
}
#[test]
fn what_grants_nothing_to_this_file_is_not_an_exposure() {
let denied = Entry { kind: ACCESS_DENIED, flags: 0, mask: FILE_ALL, sid: Some("S-1-1-0".into()) };
assert_eq!(exposure(&acl(vec![denied, allow(ME, FILE_ALL)]), ME), None);
for kind in [ACCESS_DENIED_OBJECT, ACCESS_DENIED_CALLBACK, ACCESS_DENIED_CALLBACK_OBJECT] {
assert_eq!(exposure(&acl(vec![Entry { kind, flags: 0, mask: 1, sid: None }]), ME), None, "{kind}");
}
let for_children = Entry { kind: ACCESS_ALLOWED, flags: INHERIT_ONLY_ACE, mask: FILE_ALL, sid: Some("S-1-1-0".into()) };
assert_eq!(exposure(&acl(vec![for_children]), ME), None);
assert_eq!(exposure(&acl(vec![allow("S-1-1-0", 0)]), ME), None, "no right at all");
// Inheriting from a folder whose entries are this account's alone lets no one else in.
assert_eq!(exposure(&Acl { protected: false, ..acl(vec![allow(ME, FILE_ALL)]) }, ME), None);
}
#[test]
fn the_fix_names_every_account_by_its_sid() {
let f = "C:\\ks\\k.seed";
let wide = exposure(&Acl { protected: false, ..acl(vec![allow("S-1-1-0", 1), allow("S-1-5-18", 1), allow(ME, FILE_ALL)]) }, ME).unwrap();
assert_eq!(wide.fix_command(f, ME), format!("icacls \"{f}\" /inheritance:r /remove *S-1-1-0 *S-1-5-18 /grant:r *{ME}:F"));
let granted = exposure(&acl(vec![allow("S-1-1-0", 1)]), ME).unwrap();
assert_eq!(granted.fix_command(f, ME), format!("icacls \"{f}\" /remove *S-1-1-0 /grant:r *{ME}:F"));
let owned = exposure(&Acl { owner: Some("S-1-5-32-544".into()), ..acl(vec![allow(ME, FILE_ALL)]) }, ME).unwrap();
assert_eq!(owned.fix_command(f, ME), format!("icacls \"{f}\" /setowner *{ME} && icacls \"{f}\" /grant:r *{ME}:F"));
}
/// The system's calls: a file made under its list before a byte is in it, read back by SID.
#[cfg(windows)]
#[test]
fn a_file_and_a_directory_are_this_accounts_from_their_creation() {
let d = std::env::temp_dir().join(format!("wai-win-acl-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let inner = d.join("parent").join("ks");
create_dir(&inner).unwrap();
create_dir(&inner).unwrap();
let me = current_user().unwrap();
let a = read(&inner).unwrap();
assert!(a.protected, "{a:?}");
assert_eq!(exposure(&a, &me), None, "{a:?}");
// Its entry reaches what is made inside by any means.
let p = inner.join("k.seed");
let f = create_new_file(&p).unwrap();
// Before a byte is written: empty, and already under its list alone.
assert_eq!(std::fs::metadata(&p).unwrap().len(), 0);
let a = read(&p).unwrap();
assert!(a.protected, "{a:?}");
assert_eq!(a.owner.as_deref(), Some(me.as_str()), "{a:?}");
let entries = a.entries.clone().unwrap();
assert_eq!(entries.len(), 1, "{a:?}");
assert_eq!((entries[0].kind, entries[0].sid.as_deref()), (ACCESS_ALLOWED, Some(me.as_str())), "{a:?}");
assert_eq!(exposure(&a, &me), None, "{a:?}");
drop(f);
assert_eq!(create_new_file(&p).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
std::fs::remove_dir_all(&d).unwrap();
}
}

View file

@ -255,6 +255,8 @@ const fn member(name: &'static str, ty: Ty, required: bool, doc: &'static str) -
/// The toolchain version the catalogue's first entries carry.
const V0: &str = "0.3.0";
/// The toolchain version that first carries the operations added after 0.4.0.
const V5: &str = "0.5.0";
const STAGE: Needs = Needs::All(&["staged", "video_int", "audio_int", "splat_int"]);
const HTTP: Needs = Needs::All(&["http"]);
@ -319,6 +321,14 @@ const S_CHANNEL_IMPORT: &str = r#"{"type":"object","required":["channel","receip
const S_CHANNEL_GUIDE: &str = r#"{"type":"object","required":["file","format","schedule_root","lineup"],"description":"The guide's file and format (xmltv or now-next), the sources it names, and its range or instant"}"#;
const S_CHANNEL_VERIFY: &str = r#"{"type":"object","required":["verdict","part","receipt","lineup","guide","entry"],"description":"What extensions/channel-lineup.md §8.3 states: the verdict and the part it is about, the receipt's identity, the lineup's digest, and the guide's programmes, each with whether it aired"}"#;
const S_STAGE_APPLY: &str = r#"{"description":"The trace of every stage reached, as `wai stage apply --json` prints it"}"#;
const S_TOOLCHAIN: &str = r#"{"type":"object","required":["toolchain","catalogue","envelope","registry","specification","features"],"description":"This binary's version, its catalogue's format, operations and SHA-256, the JSON envelope's version, its registry's capabilities and SHA-256, the release of the standard it implements with the envelope formats it reads, and the build features it holds"}"#;
// A developer's signing keys (`wai key`).
const S_KEY: &str = r#"{"type":"object","required":["name","role","id","key","store","keystore"],"description":"The key kept: its name, role and key id, its public key in hexadecimal and its did:key (null in a dry run, which makes no key), where its secret is kept (store: keychain or file), and the keystore"}"#;
const S_KEYS: &str = r#"{"type":"object","required":["keystore","keys"],"description":"Each key of the keystore: its name, role, newest key id, public key, did:key and status, and where its secret is kept"}"#;
const S_KEY_SHOW: &str = r#"{"type":"object","required":["keystore","name","role","signs_with","versions"],"description":"The key's versions, oldest first, each with its key id, public key, did:key, status (active, retired, revoked) and times as a key log states them (jwp-receipts §7.2), and where its secret is kept (store; null once destroyed); and the key id --key signs with"}"#;
const S_KEY_ROTATE: &str = r#"{"type":"object","required":["name","previous","id","key","store"],"description":"The version retired (previous, with until) and the successor made: its key id, public key (null in a dry run) and where its secret is kept (store)"}"#;
const S_KEY_REVOKE: &str = r#"{"type":"object","required":["name","id","compromised_since"],"description":"The version revoked, the time from which it is held compromised, and whether its secret was destroyed"}"#;
const S_KEY_EXPORT: &str = r#"{"type":"object","required":["name","id","format","text"],"description":"The key's public half in the format asked for (the text, and for entry the key-log entries), and the file it went to"}"#;
/// Every operation, in the order help lists them: the developer's path first.
pub const OPS: &[Op] = &[
@ -472,7 +482,7 @@ pub const OPS: &[Op] = &[
text codecs: any file
encode avc|hevc <in.y4m> [-o out.wai] [--encoder auto|platform|va|gpu-driver|software] [--library <path>]
[--device <node|ordinal>] [--hardware yes|no] [--kbps <n>] [--size WxH] [--idr-every <frames> |
--idr-plan <plan.txt>] [--es <out>] [--record <out.json> --key <seed>] [--meter <counter> [--rounds <n>]]
--idr-plan <plan.txt>] [--es <out>] [--record <out.json> --key <name|seed>] [--meter <counter> [--rounds <n>]]
(a build with `platform_encode`)",
effect: Effect::WritesProject,
input: &[
@ -532,7 +542,7 @@ encode avc|hevc <in.y4m> [-o out.wai] [--encoder auto|platform|va|gpu-driver|sof
summary: "Upscale a PNG exactly, by an integer network under a pinned prior, with a signed record a verifier recomputes",
cli: Some("upscale run"),
usage: "upscale run <in.png> --prior <p.wsr> [--derivation sr2|sr4] -o <out.png>
[--record <out.json> --envelope <in.wai> --key <seed>] [--issued-at <date-time>] [--json]
[--record <out.json> --envelope <in.wai> --key <name|seed>] [--issued-at <date-time>] [--json]
an 8-bit RGB PNG; --record signs what exact-upscale §7 recomputes from --envelope and the prior",
effect: Effect::WritesProject,
input: &[
@ -542,7 +552,7 @@ encode avc|hevc <in.y4m> [-o out.wai] [--encoder auto|platform|va|gpu-driver|sof
flag("out", "-o", Ty::Path, true, "The picture, a PNG."),
flag("record", "--record", Ty::Path, false, "The signed upscale record."),
flag("envelope", "--envelope", Ty::Path, false, "The WAI1 of the input, which the record names."),
flag("key", "--key", Ty::String, false, "The record's signing seed: 64 hexadecimal digits, or a file holding them."),
flag("key", "--key", Ty::String, false, "The record's signing key: its name in the keystore (wai key list), 64 hexadecimal digits, or a file holding them."),
],
output: Output::Json(S_DERIVED),
receipt: true,
@ -570,7 +580,7 @@ encode avc|hevc <in.y4m> [-o out.wai] [--encoder auto|platform|va|gpu-driver|sof
summary: "Render a still's gain map exactly at the headrooms named, with a signed record a verifier recomputes",
cli: Some("tonemap run"),
usage: "tonemap run <in.wai> --headrooms <h,h,...> --out <dir> [--input-class <class>]
[--record <out.json> --key <seed>] [--issued-at <date-time>] [--json]
[--record <out.json> --key <name|seed>] [--issued-at <date-time>] [--json]
a WAI2 of a PNG base and its gain map; a headroom is in 1/10000 of a stop; each rendition is RGB16",
effect: Effect::WritesProject,
input: &[
@ -579,7 +589,7 @@ encode avc|hevc <in.y4m> [-o out.wai] [--encoder auto|platform|va|gpu-driver|sof
flag("out", "--out", Ty::Path, true, "The directory the renditions are written in."),
flag("input_class", "--input-class", Ty::String, false, "The input's class (captured)."),
flag("record", "--record", Ty::Path, false, "The signed rendition record."),
flag("key", "--key", Ty::String, false, "The record's signing seed: 64 hexadecimal digits, or a file holding them."),
flag("key", "--key", Ty::String, false, "The record's signing key: its name in the keystore (wai key list), 64 hexadecimal digits, or a file holding them."),
],
output: Output::Json(S_DERIVED),
receipt: true,
@ -858,10 +868,10 @@ encode avc|hevc <in.y4m> [-o out.wai] [--encoder auto|platform|va|gpu-driver|sof
id: "playlist.lock",
summary: "A media playlist sealed with a signed manifest receipt",
cli: Some("http seal-playlist"),
usage: "http seal-playlist <m3u8> --key <seed> [--signer-id ID] [--content-id ID] [--parent <mr.json>] [--out <dir>]",
usage: "http seal-playlist <m3u8> --key <name|seed> [--signer-id ID] [--content-id ID] [--parent <mr.json>] [--out <dir>]",
effect: Effect::WritesProject,
stability: Stability::Preview,
input: &[pos("playlist", Ty::Path, true, "The playlist."), flag("key", "--key", Ty::Path, true, "The signing seed.")],
input: &[pos("playlist", Ty::Path, true, "The playlist."), flag("key", "--key", Ty::String, true, "The signing key: its name in the keystore (wai key list), 64 hexadecimal digits, or a file holding them.")],
receipt: true,
needs: Needs::All(&["http", "video_manifest"]),
..OP
@ -980,7 +990,7 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
id: "enrolment.create",
summary: "A node's enrolment signed with the organisation key, written to a file and, with --target, given to a fleet service",
cli: Some("enrolment create"),
usage: "enrolment create <node-key> --role <role> --key <file> [--label L] [--from µs] [--until µs] [--out F]
usage: "enrolment create <node-key> --role <role> --key <name|file> [--label L] [--from µs] [--until µs] [--out F]
[--target URL] [--yes] a node's enrolment (WFE1), signed with the organisation key, written to F
(<node>.wfe without --target) and, with --target, given to the fleet service there; changing the service
or replacing a file waits for --yes (--confirm, deprecated, is the same)",
@ -990,7 +1000,7 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
input: &[
pos("node", Ty::String, true, "The node's Ed25519 public key, 64 hexadecimal digits."),
flag("role", "--role", Ty::String, true, "relay, compositor, ingest, room, ledger or audit."),
flag("key", "--key", Ty::Path, true, "The organisation key's file: 32 bytes, or 64 hexadecimal digits."),
flag("key", "--key", Ty::String, true, "The organisation key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits."),
flag("label", "--label", Ty::String, false, "The node's label, at most 64 bytes."),
flag("from", "--from", Ty::Integer, false, "The start of the span it holds, microseconds since 1970 (0)."),
flag("until", "--until", Ty::Integer, false, "The end of that span, microseconds since 1970 (none)."),
@ -1085,7 +1095,7 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
flag("mrss_owner", "--mrss-owner", Ty::Strings, false, "The rights holder of each --mrss document's items, in their order: none, or one each."),
flag("feed", "--feed", Ty::Path, false, "The JSON form of a schedule, its catalogue and its breaks."),
flag("playlist", "--playlist", Ty::Path, false, "The channel's HLS Media Playlist, whose SCTE-35 date ranges are its breaks."),
flag("key", "--key", Ty::Path, true, "The operator's key file: 32 bytes, or 64 hexadecimal digits."),
flag("key", "--key", Ty::String, true, "The operator's key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits."),
flag("owner", "--owner", Ty::String, false, "The rights holder of every asset no other input gives one."),
flag("signer_id", "--signer-id", Ty::String, false, "The receipt's signer id (the operator key's did:key)."),
flag("id_system", "--id-system", Ty::String, false, "The XMLTV episode-num system that names a programme's asset (wai-asset-id)."),
@ -1240,28 +1250,41 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
needs: Needs::All(&["app"]),
..OP
},
// ─── served by wai mcp alone ───
// ─── the command line and wai mcp, one tool ───
Op {
id: "registry.show",
summary: "One capability's registry entry, whether this build decodes it, its role, and the specification lines that name it",
usage: "(wai mcp: registry_show)",
cli: Some("registry show"),
usage: "registry show <capability> [--spec-root <wai-dir>] one capability's registry entry, whether this build decodes it,
its role, and the specification lines that name it, where the specification's text is here (--spec-root,
$WAI_SPEC_ROOT, or the tree this binary was built from)",
mcp: true,
input: &[member("capability", Ty::String, true, "A capability string, e.g. wai.image.avif.")],
input: &[
pos("capability", Ty::String, true, "A capability string, e.g. wai.image.avif."),
flag("spec_root", "--spec-root", Ty::Path, false, "The wai/ directory whose text is read, on the command line (wai mcp has a --spec-root of its own)."),
],
output: Output::Json(S_ANY),
needs: Needs::All(&["mcp"]),
..OP
},
Op {
id: "docs.search",
summary: "Lines of the specification and its extensions holding every word of a query, each with its file, line and section",
usage: "(wai mcp: docs_search)",
cli: Some("docs search"),
usage: "docs search <word>... [--limit N] [--spec-root <wai-dir>] the lines of the specification and its extensions that hold
every word, with each one's file, line and section, offline; the text is --spec-root's, $WAI_SPEC_ROOT's, or the
tree this binary was built from",
mcp: true,
input: &[
member("query", Ty::String, true, "Words that must all appear on a line."),
member("limit", Ty::Integer, false, "At most this many hits (20)."),
pos("query", Ty::String, true, "Words that must all appear on a line."),
flag("limit", "--limit", Ty::Integer, false, "At most this many hits (20)."),
flag("spec_root", "--spec-root", Ty::Path, false, "The wai/ directory whose text is searched, on the command line (wai mcp has a --spec-root of its own)."),
],
output: Output::Json(S_ANY),
needs: Needs::All(&["mcp"]),
..OP
},
// ─── served by wai mcp alone ───
Op {
id: "dev.stop",
summary: "The development relay and room service a wai mcp server started, stopped",
@ -1719,14 +1742,14 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
id: "contract.create",
summary: "A channel's share contract from its terms, signed with the operator key; each owner countersigns it",
cli: Some("contract create"),
usage: "contract create <terms.json> --key <file> [--out F] [--yes] the share contract (WRC1) of the terms, signed
usage: "contract create <terms.json> --key <name|file> [--out F] [--yes] the share contract (WRC1) of the terms, signed
with the operator key, every countersignature still to come, written to F (<digest>.wrc); each owner adds
its own with `wai contract accept`; replacing a file waits for --yes (--confirm, deprecated, is the same)",
effect: Effect::WritesProject,
stability: Stability::Preview,
input: &[
pos("terms", Ty::Path, true, "The terms: start, end, currency, channel, channel_signer, rule, between, carriers, owners, splits and assets."),
flag("key", "--key", Ty::Path, true, "The operator key's file: 32 bytes, or 64 hexadecimal digits."),
flag("key", "--key", Ty::String, true, "The operator key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits."),
flag("out", "--out", Ty::Path, false, "The contract's file (<digest>.wrc)."),
],
output: Output::Json(S_SHARE_CONTRACT),
@ -1739,14 +1762,14 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
id: "contract.update",
summary: "An owner's countersignature added to a share contract, after its rules and the operator's signature are checked",
cli: Some("contract accept"),
usage: "contract accept <contract> --key <file> [--out F] [--yes] the owner's countersignature added to the contract,
usage: "contract accept <contract> --key <name|file> [--out F] [--yes] the owner's countersignature added to the contract,
written to F (the contract's own file when F is not given); replacing a file waits for --yes (--confirm,
deprecated, is the same)",
effect: Effect::WritesProject,
stability: Stability::Preview,
input: &[
pos("contract", Ty::Path, true, "The contract's file (WRC1)."),
flag("key", "--key", Ty::Path, true, "The owner key's file: 32 bytes, or 64 hexadecimal digits."),
flag("key", "--key", Ty::String, true, "The owner key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits."),
flag("out", "--out", Ty::Path, false, "Where the countersigned contract goes (the contract's own file)."),
],
output: Output::Json(S_SHARE_CONTRACT),
@ -1759,7 +1782,7 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
id: "statement.create",
summary: "A party's revenue-share statement for a period, recomputed from the ledger's invoices and the channel's program-as-run, signed with the operator key",
cli: Some("statement create"),
usage: "statement create <inputs.json> --party <key> --start µs --end µs --key <file> [--out F] [--yes] the statement
usage: "statement create <inputs.json> --party <key> --start µs --end µs --key <name|file> [--out F] [--yes] the statement
(WRS1) of the party for [start, end), computed from a verification document without its statement (the
contract, other contracts, runs and ledger documents), signed with the operator key and written with its
document to F (<digest>.wai-share.json) for `wai statement verify`; replacing a file waits for --yes",
@ -1770,7 +1793,7 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
flag("party", "--party", Ty::String, true, "The party's key, 64 hexadecimal digits: the operator or an owner."),
flag("start", "--start", Ty::Integer, true, "The period's start, microseconds since 1970."),
flag("end", "--end", Ty::Integer, true, "The period's end, after its start."),
flag("key", "--key", Ty::Path, true, "The operator key's file: 32 bytes, or 64 hexadecimal digits."),
flag("key", "--key", Ty::String, true, "The operator key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits."),
flag("out", "--out", Ty::Path, false, "The verification document's file (<digest>.wai-share.json)."),
],
output: Output::Json(S_SHARE_STATEMENT),
@ -1796,7 +1819,7 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
id: "opportunity.create",
summary: "A party's opportunity report for a period: the ad breaks a channel's signed cues and program-as-run prove in the programmes it shares in, signed with the issuer key",
cli: Some("opportunity create"),
usage: "opportunity create <inputs.json> --party <key> --start µs --end µs --key <file> [--out F] [--yes] the report
usage: "opportunity create <inputs.json> --party <key> --start µs --end µs --key <name|file> [--out F] [--yes] the report
(WOR1) of the party for [start, end), computed from a verification document without its report (the
contract, other contracts, runs and rooms), signed with the issuer key and written with its document to F
(<digest>.wai-opportunity.json) for `wai opportunity verify`; replacing a file waits for --yes",
@ -1807,7 +1830,7 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
flag("party", "--party", Ty::String, true, "The party's key, 64 hexadecimal digits: the operator or an owner."),
flag("start", "--start", Ty::Integer, true, "The period's start, microseconds since 1970."),
flag("end", "--end", Ty::Integer, true, "The period's end, after its start."),
flag("key", "--key", Ty::Path, true, "The issuer key's file: 32 bytes, or 64 hexadecimal digits."),
flag("key", "--key", Ty::String, true, "The issuer key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits."),
flag("out", "--out", Ty::Path, false, "The verification document's file (<digest>.wai-opportunity.json)."),
],
output: Output::Json(S_OPPORTUNITY),
@ -1832,7 +1855,7 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
id: "statement.check",
summary: "A platform's reported statement checked against an opportunity report and, when given, the operator's signed statement, signed with the issuer key",
cli: Some("statement check"),
usage: "statement check <report.json> <reported> [--statement <document.json>] [--floor ppm] [--ceiling ppm] --key <file> [--out F] [--yes]
usage: "statement check <report.json> <reported> [--statement <document.json>] [--floor ppm] [--ceiling ppm] --key <name|file> [--out F] [--yes]
the check (WCK1) of a reported statement (JSON, or CSV for a file named .csv) beside a report's verification
document and a signed statement's, at the bounds given (500000 and 2000000 when not), signed with the issuer
key and written with its document to F (<digest>.wai-check.json); replacing a file waits for --yes",
@ -1844,7 +1867,7 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
flag("statement", "--statement", Ty::Path, false, "A signed revenue-share statement's verification document of the same party and period."),
flag("floor", "--floor", Ty::Integer, false, "The lower bound, in millionths of the period's rate (500000)."),
flag("ceiling", "--ceiling", Ty::Integer, false, "The upper bound, in millionths of the period's rate (2000000)."),
flag("key", "--key", Ty::Path, true, "The issuer key's file: 32 bytes, or 64 hexadecimal digits."),
flag("key", "--key", Ty::String, true, "The issuer key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits."),
flag("out", "--out", Ty::Path, false, "The verification document's file (<digest>.wai-check.json)."),
],
output: Output::Json(S_STATEMENT_CHECK),
@ -1853,6 +1876,149 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
confirm: Some(Confirm::AnyFile),
..OP
},
// ─── a developer's signing keys, in one keystore (WAI toolchain v1, step 4) ───
Op {
id: "key.create",
summary: "A new Ed25519 signing key kept under a name, for one role, its secret in macOS's keychain or in a seed file; every signing operation's --key takes the name",
cli: Some("key create"),
usage: "key create <name> --for <role> [--store keychain|file] [--keystore DIR] a new signing key named <name>
(1 to 48 of a-z, 0-9, - and _), for one role: origin, signer, room, audit, ledger, release, remote-host,
quantum or dev. Its secret is kept in the login keychain (--store keychain, the default on macOS) or in a
file of the keystore (--store file, the default elsewhere: mode 0600 on Unix; on Windows an access list for
this account alone, inheritance off), and is never printed; a seed file another account may read is refused.
The keystore is DIR, $WAI_KEYSTORE, or ~/.config/wai/keys (%APPDATA%\\wai\\keys on Windows). --key <name> signs
with it",
effect: Effect::WritesProject,
stability: Stability::Preview,
input: &[
pos("name", Ty::String, true, "The key's name: 1 to 48 of a-z, 0-9, - and _, beginning with a letter or a digit."),
flag("role", "--for", Ty::String, true, "The key's role: origin, signer, room, audit, ledger, release, remote-host, quantum or dev."),
flag("store", "--store", Ty::String, false, "Where the secret is kept: keychain (the default on macOS) or file (the default elsewhere: mode 0600 on Unix; on Windows, an access list for this account alone)."),
flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."),
],
output: Output::Json(S_KEY),
since: V5,
needs: Needs::All(&["quickstart"]),
confirm: Some(Confirm::Never),
..OP
},
Op {
id: "key.list",
summary: "Every key of the keystore: its name, role, key id, public key, status and where its secret is kept",
cli: Some("key list"),
usage: "key list [--for <role>] [--keystore DIR] every key of the keystore, its newest version, and where its secret is kept",
stability: Stability::Preview,
input: &[
flag("role", "--for", Ty::String, false, "Only the keys of this role."),
flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."),
],
output: Output::Json(S_KEYS),
since: V5,
needs: Needs::All(&["quickstart"]),
..OP
},
Op {
id: "key.show",
summary: "One key: its public key and did:key, its role, every version with its status as a key log states it, and the version --key signs with",
cli: Some("key show"),
usage: "key show <name> [--keystore DIR] one key's versions, each active, retired (until) or revoked (compromised since), as
jwp-receipts §7.2's key log states them, and the one --key <name> signs with",
stability: Stability::Preview,
input: &[
pos("name", Ty::String, true, "The key's name."),
flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."),
],
output: Output::Json(S_KEY_SHOW),
since: V5,
needs: Needs::All(&["quickstart"]),
..OP
},
Op {
id: "key.rotate",
summary: "A key's successor made under the same name and a new key id, the old version retired now and its secret destroyed",
cli: Some("key rotate"),
usage: "key rotate <name> [--store keychain|file] [--keystore DIR] [--yes] a successor under a new key id (<name>.<n>),
which --key <name> signs with from now; the old version is retired now and its secret destroyed (jwp-receipts
§7.6), so it waits for --yes",
effect: Effect::Destructive,
stability: Stability::Preview,
input: &[
pos("name", Ty::String, true, "The key's name."),
flag("store", "--store", Ty::String, false, "Where the successor's secret is kept (where the old one's was)."),
flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."),
],
output: Output::Json(S_KEY_ROTATE),
since: V5,
needs: Needs::All(&["quickstart"]),
..OP
},
Op {
id: "key.revoke",
summary: "A key's version revoked as compromised from a time, its secret destroyed, so --key signs nothing with it",
cli: Some("key revoke"),
usage: "key revoke <name> [--id <key-id>] [--compromised-since <date-time>] [--keystore DIR] [--yes] the key's newest
version (or --id's) revoked, compromised since the time given (now; YYYY-MM-DDTHH:MM:SSZ), and its secret
destroyed (jwp-receipts §7.6); it waits for --yes",
effect: Effect::Destructive,
stability: Stability::Preview,
input: &[
pos("name", Ty::String, true, "The key's name."),
flag("id", "--id", Ty::String, false, "The version to revoke, by its key id (the newest)."),
flag("compromised_since", "--compromised-since", Ty::String, false, "From when another party may have held it: a date-time in UTC, YYYY-MM-DDTHH:MM:SSZ (now)."),
flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."),
],
output: Output::Json(S_KEY_REVOKE),
since: V5,
needs: Needs::All(&["quickstart"]),
..OP
},
Op {
id: "key.import",
summary: "A key file's seed kept under a name, as wai key create keeps a new one; a published test seed is refused unless named one",
cli: Some("key import"),
usage: "key import <name> --for <role> --from <file> [--store keychain|file] [--published-test-seed] [--keystore DIR]
the seed of a key file (64 hexadecimal digits or the 32 bytes, as every --key file holds) kept as wai key create
keeps one; a seed of one byte repeated 32 times, the form of every seed WAI publishes, is refused unless
--published-test-seed names it a test key",
effect: Effect::WritesProject,
stability: Stability::Preview,
input: &[
pos("name", Ty::String, true, "The key's name."),
flag("role", "--for", Ty::String, true, "The key's role: origin, signer, room, audit, ledger, release, remote-host, quantum or dev."),
flag("from", "--from", Ty::Path, true, "The key file: 64 hexadecimal digits, or the 32 bytes."),
flag("store", "--store", Ty::String, false, "Where the secret is kept: keychain (the default on macOS) or file."),
switch("published_test_seed", "--published-test-seed", "Keep a published test seed, marked as one."),
flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."),
],
output: Output::Json(S_KEY),
since: V5,
needs: Needs::All(&["quickstart"]),
confirm: Some(Confirm::Never),
..OP
},
Op {
id: "key.export",
summary: "A key's public half, never its secret: in hexadecimal, as a did:key, as key-log entries, or as the line wai-keys lists it by",
cli: Some("key export"),
usage: "key export <name> [--format hex|did-key|entry|wai-keys] [--may-sign <authority>,...] [--out F] [--keystore DIR]
the public half alone: hex (the default), did-key, entry (each version as a key-log entry, jwp-receipts §7.2) or
wai-keys (the newest as wai-keys init --key and append --add take it); entry and wai-keys name the authorities
an origin gives it (--may-sign); to stdout, or to F (replacing a file waits for --yes)",
effect: Effect::WritesProject,
stability: Stability::Preview,
input: &[
pos("name", Ty::String, true, "The key's name."),
flag("format", "--format", Ty::String, false, "hex (the default), did-key, entry or wai-keys."),
flag("may_sign", "--may-sign", Ty::String, false, "With entry and wai-keys: the authorities an origin gives the key (jwp-receipts §7.5), comma-separated."),
flag("out", "--out", Ty::Path, false, "Write it to this file."),
flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."),
],
output: Output::Json(S_KEY_EXPORT),
since: V5,
needs: Needs::All(&["quickstart"]),
confirm: Some(Confirm::AnyFile),
..OP
},
// ─── the catalogue itself ───
Op {
id: "ops.list",
@ -1894,6 +2060,20 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
output: Output::Json(S_OPS_EXPORT),
..OP
},
// ─── the toolchain itself ───
Op {
id: "toolchain.show",
summary: "This wai's version, the digests of its catalogue and its registry, the release of the standard it implements, and the build features it holds",
cli: Some("--version"),
aliases: &["version"],
usage: "--version [--json] this binary's version, the SHA-256 of its catalogue (wai/catalogue/ops.json) and of its
registry (wai/registry/capabilities.json), the release of the standard it implements with the envelope formats
it reads, and the build features it holds (also wai version; wai/VERSIONING.md says what each promises)",
stability: Stability::Preview,
output: Output::Json(S_TOOLCHAIN),
since: V5,
..OP
},
];
/// The operation `id`.

722
wai/wai-rs/tests/cli_key.rs Normal file
View file

@ -0,0 +1,722 @@
//! `wai key` through the `wai` binary, as a person and an agent meet it (`--features quickstart
//! --test cli_key`; with `key_discovery` too, what it exports is read by `wai-keys`):
//!
//! - `wai key create|import` keep a key under a name, its secret in a file of mode 0600 in a
//! keystore of mode 0700, and no command prints it or writes it anywhere else;
//! - `wai key list|show|export` give the public halves, with each version's status as a key log
//! states it (jwp-receipts §7.2);
//! - `--key <name>` signs with the key's newest version on the operations that sign;
//! - `wai key rotate|revoke` wait for `--yes` (exit 4, nothing changed), retire or revoke a version
//! and destroy its secret; a revoked key signs nothing;
//! - a published test seed, a seed file others may read, a name taken, a key held twice and a
//! record that does not read are refused, each with its exit code and its stable code.
//!
//! Every test keeps its keys in a keystore of its own scratch directory, in files: none touches
//! the person's keystore or keychain, and the seeds here are made for these tests.
#![cfg(feature = "quickstart")]
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
fn scratch(name: &str) -> PathBuf {
let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join("cli_key").join(name);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// `wai` in `d`, its keystore `d/ks` (`$WAI_KEYSTORE`), no input and no terminal.
fn wai(d: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(d).args(args).env("WAI_KEYSTORE", d.join("ks")).stdin(Stdio::null()).output().expect("wai runs")
}
fn text(o: &Output) -> String {
format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
}
/// The JSON envelope of a command that succeeds, and its `data`.
fn ok(d: &Path, args: &[&str]) -> Value {
let mut a = args.to_vec();
a.push("--json");
let o = wai(d, &a);
assert!(o.status.success(), "{args:?}: {}", text(&o));
let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{args:?}: not one envelope ({e}): {}", text(&o)));
assert_eq!(v["wai"], "envelope/1", "{v}");
assert_eq!(v["ok"], true, "{v}");
v["data"].clone()
}
/// The exit status and stable code of a command that fails.
fn refused(d: &Path, args: &[&str]) -> (i32, String) {
let mut a = args.to_vec();
a.push("--json");
let o = wai(d, &a);
let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{args:?}: not one envelope ({e}): {}", text(&o)));
assert_eq!(v["ok"], false, "{args:?}: {v}");
(o.status.code().unwrap(), v["error"]["code"].as_str().unwrap().to_string())
}
fn create(d: &Path, name: &str) -> Value {
ok(d, &["key", "create", name, "--for", "signer", "--store", "file"])
}
/// The hexadecimal seed kept for key id `id`.
fn seed_of(d: &Path, id: &str) -> String {
std::fs::read_to_string(d.join("ks").join(format!("{id}.seed"))).unwrap().trim().to_string()
}
const NODE: &str = "1111111111111111111111111111111111111111111111111111111111111111";
/// The organisation key of an enrolment `--key arg` signs, so a test sees which key signed.
fn signer_of_enrolment(d: &Path, arg: &str) -> String {
let e = ok(d, &["enrolment", "create", NODE, "--role", "relay", "--key", arg, "--out", "n.wfe", "--yes"]);
e["organisation"].as_str().unwrap().to_string()
}
#[test]
fn a_key_is_made_kept_private_and_never_printed() {
let d = scratch("create");
let mut all = String::new();
let run = |args: &[&str], all: &mut String| {
let o = wai(&d, args);
assert!(o.status.success(), "{args:?}: {}", text(&o));
all.push_str(&text(&o));
String::from_utf8_lossy(&o.stdout).into_owned()
};
let t = run(&["key", "create", "signer", "--for", "signer", "--store", "file"], &mut all);
assert!(t.contains("never printed") && t.contains("--key signer"), "{t}");
// It says what protects the seed here, and claims no mode where there is none.
assert_eq!(t.contains("mode 0600"), cfg!(unix), "{t}");
assert_eq!(t.contains("an access list for this account alone"), cfg!(windows), "{t}");
let c = ok(&d, &["key", "show", "signer"]);
let v = &c["versions"][0];
let (id, key) = (v["id"].as_str().unwrap(), v["key"].as_str().unwrap());
assert_eq!((id, v["status"].as_str(), v["store"].as_str(), c["signs_with"].as_str(), c["role"].as_str()), ("signer", Some("active"), Some("file"), Some("signer"), Some("signer")));
assert_eq!(key.len(), 64);
assert_eq!(v["did_key"].as_str().unwrap(), wai::signer_id::did_key(&<[u8; 32]>::try_from(unhex(key)).unwrap()));
// The secret is the key's, and in the store alone.
let seed = seed_of(&d, "signer");
let sk = ed25519_dalek::SigningKey::from_bytes(&<[u8; 32]>::try_from(unhex(&seed)).unwrap());
assert_eq!(hex(&sk.verifying_key().to_bytes()), key);
assert!(!seed.bytes().all(|b| b == seed.as_bytes()[0]), "a seed of one byte repeated");
let record = std::fs::read_to_string(d.join("ks/signer.json")).unwrap();
assert!(!record.contains(&seed), "the record holds no secret");
let r: Value = serde_json::from_str(&record).unwrap();
assert_eq!(r["format"], "wai.key/1");
// No command prints it, as text or in an envelope.
for args in [&["key", "list"][..], &["key", "show", "signer"], &["key", "export", "signer"], &["key", "export", "signer", "--format", "did-key"], &["key", "export", "signer", "--format", "entry", "--may-sign", "delivery"], &["key", "export", "signer", "--format", "wai-keys", "--may-sign", "delivery"]] {
run(args, &mut all);
all.push_str(&ok(&d, args).to_string());
}
assert!(!all.contains(&seed), "a command printed the secret: {all}");
// A second key has a seed of its own; the two are listed.
create(&d, "other");
assert_ne!(seed_of(&d, "other"), seed);
let l = ok(&d, &["key", "list"]);
let names: Vec<&str> = l["keys"].as_array().unwrap().iter().map(|k| k["name"].as_str().unwrap()).collect();
assert_eq!(names, ["other", "signer"]);
assert_eq!(l["keys"][1]["key"].as_str().unwrap(), key);
}
#[cfg(unix)]
#[test]
fn the_keystore_and_its_files_are_the_owners_alone() {
use std::os::unix::fs::PermissionsExt;
let d = scratch("modes");
create(&d, "k");
ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"]);
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&d.join("ks")), 0o700);
assert_eq!(mode(&d.join("ks/k.json")), 0o600);
assert_eq!(mode(&d.join("ks/k.2.seed")), 0o600);
// A seed file another user may read does not sign: exit 3, with what to do.
std::fs::set_permissions(d.join("ks/k.2.seed"), std::fs::Permissions::from_mode(0o640)).unwrap();
let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe"]);
assert_eq!(o.status.code(), Some(3), "{}", text(&o));
assert!(text(&o).contains("chmod 600"), "{}", text(&o));
assert_eq!(refused(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe"]).1, "key-file-exposed");
assert!(!d.join("n.wfe").exists());
}
#[test]
fn the_keystore_is_found_by_flag_then_environment_then_the_users_configuration() {
let d = scratch("where");
let home = d.join("home");
let list = |envs: &[(&str, &Path)], args: &[&str]| -> String {
let mut c = Command::new(env!("CARGO_BIN_EXE_wai"));
c.current_dir(&d).args(["key", "list", "--json"]).args(args).stdin(Stdio::null()).env_remove("WAI_KEYSTORE").env_remove("XDG_CONFIG_HOME").env_remove("APPDATA").env("HOME", &home);
for (k, v) in envs {
c.env(k, v);
}
let o = c.output().unwrap();
assert!(o.status.success(), "{}", text(&o));
let v: Value = serde_json::from_slice(&o.stdout).unwrap();
assert_eq!(v["data"]["keys"].as_array().unwrap().len(), 0, "{v}");
v["data"]["keystore"].as_str().unwrap().to_string()
};
let under = |x: &Path, first: &str| x.join(first).join("wai").join("keys").display().to_string();
#[cfg(windows)]
assert_eq!(list(&[("APPDATA", &d.join("roaming"))], &[]), d.join("roaming").join("wai").join("keys").display().to_string());
assert_eq!(list(&[], &[]), under(&home, ".config"));
assert_eq!(list(&[("XDG_CONFIG_HOME", &d.join("xdg"))], &[]), d.join("xdg").join("wai").join("keys").display().to_string());
assert_eq!(list(&[("XDG_CONFIG_HOME", &d.join("xdg")), ("WAI_KEYSTORE", &d.join("env"))], &[]), d.join("env").display().to_string());
assert_eq!(list(&[("WAI_KEYSTORE", &d.join("env"))], &["--keystore", "flag"]), "flag");
// Listing a keystore that is not there makes nothing.
assert!(!home.exists());
}
#[test]
fn every_signing_operation_of_this_build_takes_a_keys_name() {
let d = scratch("sign");
let key = create(&d, "org")["key"].as_str().unwrap().to_string();
// An enrolment: a name, and a file as before.
assert_eq!(signer_of_enrolment(&d, "org"), key);
let seed = seed_of(&d, "org");
std::fs::write(d.join("org.hex"), &seed).unwrap();
assert_eq!(signer_of_enrolment(&d, "org.hex"), key);
std::fs::write(d.join("org.raw"), unhex(&seed)).unwrap();
assert_eq!(signer_of_enrolment(&d, "org.raw"), key);
// A file of that name comes first: what ran before still runs the same.
let other: String = (1..=32u8).map(|b| format!("{b:02x}")).collect();
std::fs::write(d.join("org"), &other).unwrap();
assert_ne!(signer_of_enrolment(&d, "org"), key);
std::fs::remove_file(d.join("org")).unwrap();
// Not a file and not a key: exit 5, saying where it looked.
for missing in ["absent", "absent.key"] {
let a = ["enrolment", "create", NODE, "--role", "relay", "--key", missing, "--out", "n.wfe", "--yes"];
assert_eq!(refused(&d, &a), (5, "key-not-found".to_string()), "{missing}");
}
// A conformance record, through its own reader of --key.
let wai_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
// A command line of the system's shell: single quotes quote nothing to cmd.
let vectors = env!("CARGO_BIN_EXE_wai_jwp_vectors");
let imp = if cfg!(windows) { format!("\"{vectors}\" verdicts {{corpus}}") } else { format!("'{vectors}' verdicts {{corpus}}") };
ok(&d, &["conform", "verifier", "--suite", "jwp", "--impl", &imp, "--root", wai_dir.to_str().unwrap(), "--key", "org", "--out", "jwp.wai-conform.json"]);
let rec: Value = serde_json::from_slice(&std::fs::read(d.join("jwp.wai-conform.json")).unwrap()).unwrap();
assert_eq!(rec["signer_pubkey"].as_str().unwrap(), key, "{rec}");
// A channel's receipt (the sample channel of extensions/channel-lineup.md §11).
let c = wai_dir.join("live-conformance/channels");
let f = |n: &str| c.join(n).to_str().unwrap().to_string();
let (x, a, b, p, art) = (f("sample.xmltv.xml"), f("sample-a.mrss.xml"), f("sample-b.mrss.xml"), f("sample.m3u8"), f(""));
ok(&d, &["channel", "import", "--channel", "sample-1", "--xmltv", &x, "--mrss", &a, "--mrss-owner", "owner-a", "--mrss", &b, "--mrss-owner", "owner-b", "--owner", "owner-c", "--playlist", &p, "--artwork", &art, "--key", "org", "--out-dir", "channel"]);
let receipt = std::fs::read_to_string(d.join("channel/channel.json")).unwrap();
assert!(receipt.contains(&key), "the receipt names the key that signed it: {receipt}");
}
#[cfg(feature = "video_manifest")]
#[test]
fn a_playlist_is_sealed_with_a_keys_name_or_a_seed_given_as_before() {
let d = scratch("seal");
let key = create(&d, "pub")["key"].as_str().unwrap().to_string();
let wai_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let obj = |id: &str| -> String {
let c = wai_dir.join("http-conformance");
let listing: Value = serde_json::from_slice(&std::fs::read(c.join("objects.json")).unwrap()).unwrap();
c.join(listing[id]["path"].as_str().unwrap()).to_str().unwrap().to_string()
};
let objs: Vec<String> = ["O9a", "O9b", "O9c"].iter().map(|i| obj(i)).collect();
std::fs::write(d.join("classical.m3u8"), "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:1\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:0.640,\nc0.m4s\n#EXTINF:0.640,\nc1.m4s\n#EXTINF:0.640,\nc2.m4s\n#EXT-X-ENDLIST\n").unwrap();
let list = serde_json::json!({
"uri_prefix": "objects/",
"variants": [{"uri": "wai-180.m3u8", "classical": "classical.m3u8", "resolution": [320, 180], "frame_rate_milli": 25000,
"segments": objs.iter().map(|o| serde_json::json!({"object": o})).collect::<Vec<_>>()}],
});
std::fs::write(d.join("list.json"), list.to_string()).unwrap();
assert!(wai(&d, &["http", "hls", "--objects", "list.json", "--out", "."]).status.success());
let sealed_by = |k: &str, out: &str| -> String {
let o = wai(&d, &["http", "seal-playlist", "wai-180.m3u8", "--key", k, "--out", out]);
assert!(o.status.success(), "{}", text(&o));
let mut found = String::new();
for e in std::fs::read_dir(d.join(out).join("mr")).unwrap() {
found.push_str(&std::fs::read_to_string(e.unwrap().path()).unwrap());
}
found
};
assert!(sealed_by("pub", "a").contains(&key));
let seed = seed_of(&d, "pub");
assert!(sealed_by(&seed, "b").contains(&key), "64 hexadecimal digits, as this command took before");
}
/// A share contract, through the revenue share's and the opportunity report's reader of `--key`.
#[cfg(feature = "revenue_share")]
#[test]
fn a_contract_is_signed_with_a_keys_name() {
let d = scratch("contract");
let key = create(&d, "operator")["key"].as_str().unwrap().to_string();
let owner = "33".repeat(32);
let terms = serde_json::json!({
"start": 0, "end": 10, "currency": "EUR", "channel": "fast-7", "channel_signer": "22".repeat(32), "rule": "preceding", "between": 0,
"carriers": [{"room": "11".repeat(32)}], "owners": [owner],
"splits": [{"operator": 500_000, "owners": {owner.clone(): 500_000}}], "assets": {"a/ep-1": 0},
});
std::fs::write(d.join("terms.json"), terms.to_string()).unwrap();
let c = ok(&d, &["contract", "create", "terms.json", "--key", "operator", "--out", "c.wrc"]);
assert_eq!(c["operator"].as_str().unwrap(), key, "{c}");
assert_eq!(refused(&d, &["contract", "create", "terms.json", "--key", "absent", "--out", "c2.wrc"]), (5, "key-not-found".to_string()));
}
/// An upscale's signed record, through the derivations' reader of `--key`, which takes the seed
/// itself as well, as it did.
#[cfg(feature = "exact_upscale")]
#[test]
fn an_upscale_record_is_signed_with_a_keys_name() {
let d = scratch("upscale");
let key = create(&d, "edge")["key"].as_str().unwrap().to_string();
let prior = Path::new(env!("CARGO_MANIFEST_DIR")).join("../live-conformance/upscale/priors/generic.wsr");
std::fs::write(d.join("in.png"), wai::png_still::encode(&[9u8; 3 * 8 * 6], 8, 6)).unwrap();
let signer = |k: &str, rec: &str| -> String {
let o = wai(&d, &["upscale", "run", "in.png", "--prior", prior.to_str().unwrap(), "-o", "out.png", "--record", rec, "--envelope", "in.wai", "--key", k, "--yes"]);
assert!(o.status.success(), "{}", text(&o));
std::fs::read_to_string(d.join(rec)).unwrap()
};
assert!(signer("edge", "a.json").contains(&key));
assert!(signer(&seed_of(&d, "edge"), "b.json").contains(&key));
}
#[test]
fn a_rotation_waits_for_yes_then_retires_the_old_key_and_destroys_its_secret() {
let d = scratch("rotate");
let first = create(&d, "k")["key"].as_str().unwrap().to_string();
let before = std::fs::read(d.join("ks/k.json")).unwrap();
let old_seed = seed_of(&d, "k");
// Off a terminal, without --yes: exit 4, the proposal and the command, nothing changed.
let o = wai(&d, &["key", "rotate", "k", "--store", "file", "--json"]);
assert_eq!(o.status.code(), Some(4), "{}", text(&o));
let v: Value = serde_json::from_slice(&o.stdout).unwrap();
assert_eq!(v["error"]["code"], "confirmation-required", "{v}");
assert_eq!(v["proposed"][0]["new"], "k.2", "{v}");
assert_eq!(v["proposed"][0]["retire"], "k", "{v}");
assert_eq!(v["proposed"][0]["destroy_secret"], "k", "{v}");
assert!(v["confirm"].as_str().unwrap().ends_with("--yes"), "{v}");
// --no-input never asks either.
assert_eq!(wai(&d, &["key", "rotate", "k", "--store", "file", "--no-input"]).status.code(), Some(4));
assert_eq!(std::fs::read(d.join("ks/k.json")).unwrap(), before);
assert!(d.join("ks/k.seed").is_file() && !d.join("ks/k.2.seed").exists());
let r = ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"]);
assert_eq!((r["previous"].as_str(), r["id"].as_str(), r["secret_destroyed"].as_bool()), (Some("k"), Some("k.2"), Some(true)), "{r}");
let until = r["until"].as_str().unwrap();
let second = r["key"].as_str().unwrap();
assert_ne!(second, first);
assert!(!d.join("ks/k.seed").exists(), "the old secret is gone");
let new_seed = seed_of(&d, "k.2");
assert_ne!(new_seed, old_seed);
// The record states both versions as a key log does: the old retired with until, the new active.
let s = ok(&d, &["key", "show", "k"]);
assert_eq!(s["signs_with"], "k.2");
let (v0, v1) = (&s["versions"][0], &s["versions"][1]);
assert_eq!((v0["id"].as_str(), v0["key"].as_str(), v0["status"].as_str(), v0["until"].as_str(), v0["compromised_since"].as_str(), v0["store"].as_str()), (Some("k"), Some(first.as_str()), Some("retired"), Some(until), None, None));
assert_eq!((v1["id"].as_str(), v1["key"].as_str(), v1["status"].as_str(), v1["until"].as_str(), v1["store"].as_str()), (Some("k.2"), Some(second), Some("active"), None, Some("file")));
// --key signs with the successor; a further rotation numbers on.
assert_eq!(signer_of_enrolment(&d, "k"), second);
assert_eq!(ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"])["id"], "k.3");
// The export names every version; the line for a log names the newest.
let e = ok(&d, &["key", "export", "k", "--format", "entry", "--may-sign", "delivery,energy/Estimated"]);
let ids: Vec<&str> = e["entries"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["k", "k.2", "k.3"]);
assert_eq!(e["entries"][0]["until"].as_str(), Some(until));
assert_eq!(e["entries"][0]["may_sign"], serde_json::json!(["delivery", "energy/Estimated"]));
let l = ok(&d, &["key", "export", "k", "--format", "wai-keys", "--may-sign", "delivery,energy/Estimated"]);
assert!(l["text"].as_str().unwrap().starts_with("id=k.3,key="), "{l}");
assert!(l["text"].as_str().unwrap().trim_end().ends_with(",may_sign=delivery|energy/Estimated"), "{l}");
}
#[test]
fn a_revoked_key_signs_nothing_and_its_time_never_changes() {
let d = scratch("revoke");
let first = create(&d, "k")["key"].as_str().unwrap().to_string();
let args = ["key", "revoke", "k", "--compromised-since", "2026-11-01T00:00:00Z"];
assert_eq!(refused(&d, &args), (4, "confirmation-required".to_string()));
assert!(d.join("ks/k.seed").is_file(), "nothing changed");
assert_eq!(refused(&d, &["key", "revoke", "k", "--compromised-since", "yesterday", "--yes"]).0, 2);
assert_eq!(refused(&d, &["key", "revoke", "k", "--id", "k.9", "--yes"]), (5, "key-not-found".to_string()));
let mut yes = args.to_vec();
yes.push("--yes");
let r = ok(&d, &yes);
assert_eq!((r["id"].as_str(), r["compromised_since"].as_str(), r["secret_destroyed"].as_bool()), (Some("k"), Some("2026-11-01T00:00:00Z"), Some(true)), "{r}");
assert!(!d.join("ks/k.seed").exists());
// It signs nothing, and says so; the log's line refuses it; the record stays.
let a = ["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe"];
assert_eq!(refused(&d, &a), (2, "key-revoked".to_string()));
assert!(!d.join("n.wfe").exists());
assert_eq!(refused(&d, &["key", "export", "k", "--format", "wai-keys", "--may-sign", "delivery"]).1, "key-revoked");
let s = ok(&d, &["key", "show", "k"]);
assert_eq!((s["signs_with"].as_str(), s["versions"][0]["status"].as_str(), s["versions"][0]["key"].as_str(), s["versions"][0]["store"].as_str()), (None, Some("revoked"), Some(first.as_str()), None));
// Its time, once set, does not change (jwp-receipts §7.2 rule 6).
assert_eq!(refused(&d, &["key", "revoke", "k", "--yes"]), (2, "key-already-revoked".to_string()));
// A successor signs again; the revoked version stays revoked, with no until.
let n = ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"]);
assert_eq!((n["id"].as_str(), n["secret_destroyed"].as_bool()), (Some("k.2"), Some(false)));
assert_eq!(signer_of_enrolment(&d, "k"), n["key"].as_str().unwrap());
let s = ok(&d, &["key", "show", "k"]);
assert_eq!((s["versions"][0]["status"].as_str(), s["versions"][0]["until"].as_str()), (Some("revoked"), None));
// An earlier version revoked keeps the time it was retired at; the newest still signs.
let d = scratch("revoke-old");
create(&d, "k");
let until = ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"])["until"].as_str().unwrap().to_string();
let newest = ok(&d, &["key", "show", "k"])["versions"][1]["key"].as_str().unwrap().to_string();
let r = ok(&d, &["key", "revoke", "k", "--id", "k", "--yes"]);
assert_eq!(r["secret_destroyed"], false, "a retired version's secret is gone already");
let s = ok(&d, &["key", "show", "k"]);
assert_eq!((s["versions"][0]["status"].as_str(), s["versions"][0]["until"].as_str()), (Some("revoked"), Some(until.as_str())));
assert_eq!(signer_of_enrolment(&d, "k"), newest);
}
#[test]
fn a_seed_file_is_imported_and_a_published_one_refused() {
let d = scratch("import");
let seed: [u8; 32] = core::array::from_fn(|i| 100 + i as u8);
let want = hex(&ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes());
std::fs::write(d.join("seed.hex"), format!("{}\n", hex(&seed))).unwrap();
let args = ["key", "import", "old", "--for", "audit", "--from", "seed.hex", "--store", "file"];
let r = ok(&d, &args);
assert_eq!((r["key"].as_str(), r["role"].as_str(), r["published_test_seed"].as_bool()), (Some(want.as_str()), Some("audit"), Some(false)), "{r}");
assert_eq!(seed_of(&d, "old"), hex(&seed));
assert!(text(&wai(&d, &["key", "show", "old"])).contains("old"));
assert!(d.join("seed.hex").is_file(), "the file it came from is the person's to remove");
assert_eq!(signer_of_enrolment(&d, "old"), want);
// The name is taken; the key is held already, under any name; each exits 2 with its code.
assert_eq!(refused(&d, &args), (2, "key-exists".to_string()));
assert_eq!(refused(&d, &["key", "create", "old", "--for", "dev", "--store", "file"]), (2, "key-exists".to_string()));
assert_eq!(refused(&d, &["key", "import", "again", "--for", "dev", "--from", "seed.hex", "--store", "file"]), (2, "key-duplicate".to_string()));
assert!(!d.join("ks/again.json").exists() && !d.join("ks/again.seed").exists());
// 32 raw bytes read as well.
let raw: [u8; 32] = core::array::from_fn(|i| 200 - i as u8);
std::fs::write(d.join("seed.raw"), raw).unwrap();
assert_eq!(ok(&d, &["key", "import", "raw", "--for", "dev", "--from", "seed.raw", "--store", "file"])["key"].as_str().unwrap(), hex(&ed25519_dalek::SigningKey::from_bytes(&raw).verifying_key().to_bytes()));
// A file that holds neither, or is not there.
std::fs::write(d.join("bad.hex"), "not a seed").unwrap();
assert_eq!(refused(&d, &["key", "import", "bad", "--for", "dev", "--from", "bad.hex", "--store", "file"]), (2, "key-file-malformed".to_string()));
assert_eq!(refused(&d, &["key", "import", "bad", "--for", "dev", "--from", "absent.hex", "--store", "file"]).0, 5);
// A published test seed (one byte repeated) is refused unless named one, and the record says so.
std::fs::write(d.join("published.hex"), "07".repeat(32)).unwrap();
assert_eq!(refused(&d, &["key", "import", "t", "--for", "dev", "--from", "published.hex", "--store", "file"]), (2, "published-test-seed".to_string()));
assert!(!d.join("ks/t.json").exists());
let r = ok(&d, &["key", "import", "t", "--for", "dev", "--from", "published.hex", "--store", "file", "--published-test-seed"]);
assert_eq!(r["published_test_seed"], true);
assert_eq!(ok(&d, &["key", "show", "t"])["versions"][0]["published_test_seed"], true);
assert!(text(&wai(&d, &["key", "show", "t"])).contains("a published test seed"));
}
#[test]
fn a_dry_run_makes_no_key() {
let d = scratch("dry");
let r = ok(&d, &["key", "create", "k", "--for", "dev", "--store", "file", "--dry-run"]);
assert!(r["key"].is_null(), "{r}");
assert!(!d.join("ks").exists());
let o = wai(&d, &["key", "create", "k", "--for", "dev", "--store", "file", "--dry-run", "--json"]);
let v: Value = serde_json::from_slice(&o.stdout).unwrap();
let plan = v["plan"].to_string();
assert!(plan.contains("k.json") && plan.contains("k.seed"), "{plan}");
create(&d, "k");
let before = std::fs::read(d.join("ks/k.json")).unwrap();
let r = ok(&d, &["key", "rotate", "k", "--store", "file", "--dry-run"]);
assert_eq!((r["id"].as_str(), r["key"].is_null()), (Some("k.2"), true), "{r}");
let o = wai(&d, &["key", "revoke", "k", "--dry-run", "--json"]);
let v: Value = serde_json::from_slice(&o.stdout).unwrap();
assert!(v["plan"].to_string().contains("k.seed"), "{v}");
assert_eq!(std::fs::read(d.join("ks/k.json")).unwrap(), before);
assert!(d.join("ks/k.seed").is_file());
}
#[test]
fn what_the_command_line_gets_wrong_is_refused_by_exit_code_and_stable_code() {
let d = scratch("usage");
for args in [
&["key", "create"][..],
&["key", "create", "k"],
&["key", "create", "K", "--for", "dev"],
&["key", "create", "a.b", "--for", "dev"],
&["key", "create", "k", "--for", "boss"],
&["key", "create", "k", "--for", "dev", "--store", "cloud"],
&["key", "create", "k", "--for", "dev", "--nope"],
&["key", "import", "k", "--for", "dev"],
&["key", "list", "--for", "boss"],
&["key", "show"],
&["key", "export"],
] {
assert_eq!(wai(&d, args).status.code(), Some(2), "{args:?}");
}
assert!(!d.join("ks").exists(), "nothing was made");
assert_eq!(refused(&d, &["key", "show", "absent"]), (5, "key-not-found".to_string()));
assert_eq!(refused(&d, &["key", "rotate", "absent", "--yes"]), (5, "key-not-found".to_string()));
assert_eq!(refused(&d, &["key", "revoke", "absent", "--yes"]), (5, "key-not-found".to_string()));
assert_eq!(refused(&d, &["key", "export", "absent"]), (5, "key-not-found".to_string()));
create(&d, "k");
for a in [&["key", "export", "k", "--format", "entry"][..], &["key", "export", "k", "--format", "wai-keys"], &["key", "export", "k", "--may-sign", "delivery"], &["key", "export", "k", "--format", "pem"], &["key", "export", "k", "--format", "entry", "--may-sign", "a b"]] {
assert_eq!(refused(&d, a).0, 2, "{a:?}");
}
// Replacing a file the export would write waits for --yes.
std::fs::write(d.join("k.pub"), "there").unwrap();
assert_eq!(refused(&d, &["key", "export", "k", "--out", "k.pub"]), (4, "confirmation-required".to_string()));
assert_eq!(std::fs::read(d.join("k.pub")).unwrap(), b"there");
let key = ok(&d, &["key", "export", "k", "--out", "k.pub", "--yes"])["text"].as_str().unwrap().to_string();
assert_eq!(std::fs::read_to_string(d.join("k.pub")).unwrap(), key);
#[cfg(not(target_os = "macos"))]
assert_eq!(refused(&d, &["key", "create", "c", "--for", "dev", "--store", "keychain"]), (2, "keychain-unavailable".to_string()));
}
#[test]
fn a_record_that_does_not_read_is_refused_and_listed_as_such() {
let d = scratch("malformed");
create(&d, "good");
create(&d, "broken");
let p = d.join("ks/broken.json");
let t = std::fs::read_to_string(&p).unwrap();
std::fs::write(&p, t.replace("\"role\": \"signer\"", "\"role\": \"signer\",\n \"extra\": 1")).unwrap();
assert_eq!(refused(&d, &["key", "show", "broken"]), (2, "key-record-malformed".to_string()));
let l = ok(&d, &["key", "list"]);
assert_eq!(l["keys"][0]["error"]["code"], "key-record-malformed", "{l}");
assert_eq!(l["keys"][1]["name"], "good", "{l}");
let a = ["enrolment", "create", NODE, "--role", "relay", "--key", "broken", "--out", "n.wfe"];
assert_eq!(refused(&d, &a), (2, "key-record-malformed".to_string()));
// A record and a secret changed apart do not sign.
let other: String = (1..=32u8).map(|b| format!("{b:02x}")).collect();
let seed = d.join("ks/good.seed");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&seed, std::fs::Permissions::from_mode(0o600)).unwrap();
}
std::fs::write(&seed, other).unwrap();
let a = ["enrolment", "create", NODE, "--role", "relay", "--key", "good", "--out", "n.wfe"];
assert_eq!(refused(&d, &a), (2, "key-mismatch".to_string()));
}
/// An origin's key log lists what `wai key` exports, as it is: a rotation and a revocation carried
/// over by `wai-keys`, and the log verified (jwp-receipts §7.2, §7.3).
#[cfg(feature = "key_discovery")]
#[test]
fn wai_keys_reads_what_wai_key_exports() {
let d = scratch("log");
let wk = |args: &[&str]| -> Output { Command::new(env!("CARGO_BIN_EXE_wai-keys")).current_dir(&d).args(args).output().expect("wai-keys runs") };
create(&d, "origin-root");
create(&d, "next-root");
create(&d, "delivery");
let root_file = d.join("ks/origin-root.seed");
let root_file = root_file.to_str().unwrap();
let next_key = ok(&d, &["key", "export", "next-root"])["text"].as_str().unwrap().trim().to_string();
let line = ok(&d, &["key", "export", "delivery", "--format", "wai-keys", "--may-sign", "delivery"])["text"].as_str().unwrap().trim().to_string();
let o = wk(&["init", "--origin", "https://example.test", "--root-seed-file", root_file, "--next-root-key", &next_key, "--issued-at", "2026-11-05T00:00:00Z", "--key", &line]);
assert!(o.status.success(), "{}", text(&o));
std::fs::write(d.join("log.json"), &o.stdout).unwrap();
// A rotation: the successor's line and the old id's until, as `wai key rotate` states them.
let r = ok(&d, &["key", "rotate", "delivery", "--store", "file", "--yes"]);
let retire = format!("{}@{}", r["previous"].as_str().unwrap(), r["until"].as_str().unwrap());
let line = ok(&d, &["key", "export", "delivery", "--format", "wai-keys", "--may-sign", "delivery"])["text"].as_str().unwrap().trim().to_string();
let until = r["until"].as_str().unwrap();
let o = wk(&["append", "log.json", "--root-seed-file", root_file, "--issued-at", until, "--add", &line, "--retire", &retire]);
assert!(o.status.success(), "{}", text(&o));
std::fs::write(d.join("log.json"), &o.stdout).unwrap();
// A revocation, at the time `wai key revoke` states.
let v = ok(&d, &["key", "revoke", "delivery", "--id", "delivery", "--compromised-since", "2026-11-05T00:00:00Z", "--yes"]);
let revoke = format!("{}@{}", v["id"].as_str().unwrap(), v["compromised_since"].as_str().unwrap());
let o = wk(&["append", "log.json", "--root-seed-file", root_file, "--issued-at", until, "--revoke", &revoke]);
assert!(o.status.success(), "{}", text(&o));
std::fs::write(d.join("log.json"), &o.stdout).unwrap();
let o = wk(&["verify", "log.json", "--origin", "https://example.test"]);
assert!(o.status.success(), "{}", text(&o));
assert!(String::from_utf8_lossy(&o.stdout).starts_with("anchored"), "{}", text(&o));
// The log states what the keystore states, entry for entry.
let log: Value = serde_json::from_slice(&std::fs::read(d.join("log.json")).unwrap()).unwrap();
let head = log["revisions"].as_array().unwrap().last().unwrap();
let e = ok(&d, &["key", "export", "delivery", "--format", "entry", "--may-sign", "delivery"]);
assert_eq!(head["keys"], e["entries"], "the head's entries are the export's");
}
/// Windows' access lists, read here as the system's own descriptor language, which names accounts
/// by SID in every locale, and widened by SID.
#[cfg(windows)]
mod access_lists {
use super::*;
fn powershell(command: &str) -> String {
// Without PSModulePath: a parent PowerShell 7's would keep this one from loading Get-Acl.
let o = Command::new("powershell").args(["-NoProfile", "-NonInteractive", "-Command", command]).env_remove("PSModulePath").stdin(Stdio::null()).output().expect("powershell runs");
assert!(o.status.success(), "{command}: {}", text(&o));
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
fn me() -> String {
powershell("[Security.Principal.WindowsIdentity]::GetCurrent().User.Value")
}
fn descriptor(p: &Path) -> String {
powershell(&format!("(Get-Acl -LiteralPath '{}').Sddl", p.display()))
}
/// `p`'s owner is `me`, its inheritance is off, and its one entry is `me`'s, `entry` being that
/// entry's flags.
fn holds_only_me(p: &Path, me: &str, flags: &str) {
let s = descriptor(p);
assert!(s.starts_with(&format!("O:{me}G:")), "{}: its owner: {s}", p.display());
let (_, dacl) = s.split_once("D:").unwrap_or_else(|| panic!("{}: no list: {s}", p.display()));
let (control, entries) = dacl.split_once('(').unwrap_or_else(|| panic!("{}: no entry: {s}", p.display()));
assert!(control.contains('P'), "{}: inheritance is on: {s}", p.display());
assert_eq!(entries, format!("A;{flags};FA;;;{me})"), "{}: its entries: {s}", p.display());
}
fn icacls(p: &Path, args: &[&str]) {
let o = Command::new("icacls").arg(p).args(args).output().expect("icacls runs");
assert!(o.status.success(), "icacls {args:?}: {}", text(&o));
}
/// A directory in the folder every user of the machine shares, removed in every case.
struct Shared(PathBuf);
impl Shared {
fn new(name: &str) -> Shared {
let d = PathBuf::from(std::env::var_os("PUBLIC").expect("%PUBLIC%")).join(format!("wai-key-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
Shared(d)
}
}
impl Drop for Shared {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn wai_in(cwd: &Path, keystore: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(cwd).args(args).env("WAI_KEYSTORE", keystore).stdin(Stdio::null()).output().expect("wai runs")
}
#[test]
fn a_keystore_in_a_folder_every_user_shares_gets_what_one_in_a_profile_gets() {
let me = me();
let d = scratch("win-shared");
std::fs::write(d.join("seed.hex"), (100..132u8).map(|b| format!("{b:02x}")).collect::<String>()).unwrap();
// One the toolchain makes; and one that exists already, under the folder's wide list, which
// stays as it is while what wai makes inside does not take it.
let (made, there) = (Shared::new("made"), Shared::new("there"));
std::fs::create_dir_all(&there.0).unwrap();
for ks in [&made.0, &there.0] {
let store = ks.to_str().unwrap();
for args in [&["key", "create", "k", "--for", "signer", "--keystore", store][..], &["key", "import", "held", "--for", "signer", "--from", "seed.hex", "--keystore", store]] {
let o = wai(&d, args);
assert!(o.status.success(), "{args:?}: {}", text(&o));
}
for f in ["k.seed", "k.json", "held.seed", "held.json"] {
holds_only_me(&ks.join(f), &me, "");
}
// A successor's seed, and the record replaced beside it.
let o = wai(&d, &["key", "rotate", "k", "--keystore", store, "--yes"]);
assert!(o.status.success(), "{}", text(&o));
holds_only_me(&ks.join("k.2.seed"), &me, "");
holds_only_me(&ks.join("k.json"), &me, "");
// It signs: this account's list is enough for this account.
let o = wai_in(&d, ks, &["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe", "--yes"]);
assert!(o.status.success(), "{}", text(&o));
}
holds_only_me(&made.0, &me, "OICI");
assert!(!descriptor(&there.0).contains(&format!("D:P(A;OICI;FA;;;{me})")), "a directory that was there stays as it was");
}
#[test]
fn a_seed_another_account_may_read_signs_nothing_until_the_fix_it_names() {
let d = scratch("win-widened");
let ks = d.join("ks");
create(&d, "k");
let seed = ks.join("k.seed");
let sign = ["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe", "--yes"];
let good = signer_of_enrolment(&d, "k");
std::fs::remove_file(d.join("n.wfe")).unwrap();
// Everyone, SYSTEM and Administrators alike, each by its SID.
for sid in ["S-1-1-0", "S-1-5-18", "S-1-5-32-544"] {
icacls(&seed, &["/grant", &format!("*{sid}:R")]);
let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe", "--yes", "--json"]);
assert_eq!(o.status.code(), Some(3), "{sid}: {}", text(&o));
assert!(!d.join("n.wfe").exists(), "{sid}: it signed");
let v: Value = serde_json::from_slice(&o.stdout).unwrap();
assert_eq!(v["error"]["code"], "key-file-exposed", "{v}");
let message = v["error"]["message"].as_str().unwrap();
assert!(message.contains(seed.to_str().unwrap()) && message.contains(sid), "{message}");
// The command it names is the whole fix.
let hint = v["error"]["hint"].as_str().unwrap();
assert!(hint.contains(&format!("*{sid}")), "{hint}");
use std::os::windows::process::CommandExt;
let fixed = Command::new("cmd").raw_arg(format!("/S /C \"{hint}\"")).stdin(Stdio::null()).output().unwrap();
assert!(fixed.status.success(), "{hint}: {}", text(&fixed));
assert_eq!(signer_of_enrolment(&d, "k"), good, "{sid}: fixed, it signs again");
std::fs::remove_file(d.join("n.wfe")).unwrap();
}
// A list wider than any one grant: inheritance on again, under the wide list of this folder.
std::fs::rename(ks.join("k.seed"), d.join("k.seed")).unwrap();
std::fs::rename(ks.join("k.json"), d.join("k.json")).unwrap();
let o = wai_in(&d, &d, &sign);
assert!(o.status.success(), "moved with its list, it is still its owner's: {}", text(&o));
std::fs::remove_file(d.join("n.wfe")).unwrap();
icacls(&d.join("k.seed"), &["/inheritance:e"]);
let o = wai_in(&d, &d, &sign);
assert_eq!(o.status.code(), Some(3), "inheritance on: {}", text(&o));
// Another owner may change the list, where this account may hand a file over.
let handed = Command::new("icacls").arg(d.join("k.seed")).args(["/setowner", "*S-1-5-32-544"]).output().unwrap();
if handed.status.success() {
icacls(&d.join("k.seed"), &["/inheritance:r", "/remove", "*S-1-5-32-544", "*S-1-5-18"]);
let o = wai_in(&d, &d, &sign);
assert_eq!(o.status.code(), Some(3), "another owner: {}", text(&o));
}
}
#[test]
fn the_default_keystore_under_appdata_works_end_to_end() {
let me = me();
let d = scratch("win-appdata");
let run = |args: &[&str]| -> Output {
Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(&d).args(args).env_remove("WAI_KEYSTORE").env_remove("XDG_CONFIG_HOME").env("APPDATA", d.join("roaming")).stdin(Stdio::null()).output().unwrap()
};
let ok = |args: &[&str]| {
let o = run(args);
assert!(o.status.success(), "{args:?}: {}", text(&o));
String::from_utf8_lossy(&o.stdout).into_owned()
};
let ks = d.join("roaming").join("wai").join("keys");
assert!(ok(&["key", "create", "k", "--for", "signer"]).contains("an access list for this account alone"));
holds_only_me(&ks, &me, "OICI");
holds_only_me(&ks.join("k.seed"), &me, "");
holds_only_me(&ks.join("k.json"), &me, "");
assert!(ok(&["key", "list"]).contains("k"));
ok(&["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe"]);
ok(&["key", "rotate", "k", "--yes"]);
assert!(!ks.join("k.seed").exists() && ks.join("k.2.seed").is_file());
ok(&["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n2.wfe"]);
ok(&["key", "revoke", "k", "--yes"]);
assert!(!ks.join("k.2.seed").exists());
assert_eq!(run(&["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n3.wfe"]).status.code(), Some(2));
}
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}

View file

@ -541,6 +541,18 @@ fn samples(d: &Path, origin_port: u16) -> Vec<(&'static str, Vec<String>, bool)>
more.push(("stage.wrap", s(&["stage", "pack", "b0.wai", "l0.wai", "-o", "x.wai"]), true));
more.push(("stage.unwrap", s(&["stage", "explode", "x0.wai", "0", "-o", "e.wai"]), true));
}
// A developer's keys, in a keystore of this directory and its files: the person's keystore and
// keychain are never touched here.
for k in ["turning", "spent"] {
let o = wai_in(d, &["key", "create", k, "--for", "signer", "--keystore", "ks", "--store", "file"]);
assert!(o.status.success(), "{}", text(&o));
}
std::fs::write(d.join("seed.hex"), (100..132u8).map(|b| format!("{b:02x}")).collect::<String>()).unwrap();
more.push(("key.create", s(&["key", "create", "made", "--for", "dev", "--keystore", "ks", "--store", "file"]), true));
more.push(("key.import", s(&["key", "import", "held", "--for", "signer", "--from", "seed.hex", "--keystore", "ks", "--store", "file"]), true));
more.push(("key.rotate", s(&["key", "rotate", "turning", "--keystore", "ks", "--store", "file", "--yes"]), true));
more.push(("key.revoke", s(&["key", "revoke", "spent", "--keystore", "ks", "--yes"]), true));
more.push(("key.export", s(&["key", "export", "turning", "--keystore", "ks", "--out", "turning.pub"]), true));
let mut v = vec![
("toolchain.test", s(&["hello", "hello-out"]), true),
("project.create", s(&["create", "proj", "--template", "video-room"]), true),

View file

@ -0,0 +1,254 @@
//! The toolchain's own surface through the `wai` binary (`--features quickstart --test
//! cli_toolchain`):
//!
//! - `wai --version` and `--json` name one version, the digests of the catalogue and the registry
//! as they are committed, the release of the standard and the build's features; the crate, the
//! export and the changelog's newest release say the same version;
//! - `wai docs search` and `wai registry show` give what `wai mcp`'s tools give, with a refusal's
//! exit code and stable code;
//! - a deprecated flag still works and says so in one line, and `wai/DEPRECATIONS.md` is held to
//! the catalogue, both ways.
#![cfg(feature = "quickstart")]
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use wai::catalogue::{self, OPS};
fn wai_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("..").canonicalize().unwrap()
}
fn scratch(name: &str) -> PathBuf {
let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join("cli_toolchain").join(name);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// `wai`, with no specification root from its environment.
fn wai(d: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(d).args(args).env_remove("WAI_SPEC_ROOT").stdin(Stdio::null()).output().expect("wai runs")
}
fn text(o: &Output) -> String {
format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
}
/// The envelope of a `--json` run.
fn envelope(d: &Path, args: &[&str]) -> (Option<i32>, Value) {
let mut a = args.to_vec();
a.push("--json");
let o = wai(d, &a);
let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{args:?}: not one envelope ({e}): {}", text(&o)));
assert_eq!(v["wai"], catalogue::ENVELOPE, "{v}");
(o.status.code(), v)
}
fn sha256(b: &[u8]) -> String {
Sha256::digest(b).iter().map(|x| format!("{x:02x}")).collect()
}
#[test]
fn version_names_the_toolchain_its_catalogue_its_registry_and_the_standard() {
let d = scratch("version");
let (code, v) = envelope(&d, &["--version"]);
assert_eq!(code, Some(0), "{v}");
assert_eq!((v["ok"].as_bool(), v["op"].as_str()), (Some(true), Some("toolchain.show")), "{v}");
let data = &v["data"];
let version = env!("CARGO_PKG_VERSION");
assert_eq!(data["toolchain"], version);
assert_eq!(data["specification"]["version"], version);
assert_eq!(data["specification"]["formats"], json!(["WAI1", "WAI2"]));
assert_eq!(data["envelope"], catalogue::ENVELOPE);
assert_eq!(data["catalogue"]["format"], catalogue::FORMAT);
assert_eq!(data["catalogue"]["operations"], OPS.len());
// The digests are of the committed files, byte for byte.
let (ops, reg) = (std::fs::read(wai_dir().join("catalogue/ops.json")).unwrap(), std::fs::read(wai_dir().join("registry/capabilities.json")).unwrap());
assert_eq!(data["catalogue"]["sha256"], sha256(&ops));
assert_eq!(data["registry"]["sha256"], sha256(&reg));
let caps: Value = serde_json::from_slice(&reg).unwrap();
assert_eq!(data["registry"]["capabilities"], caps["capabilities"].as_array().unwrap().len());
// The features named are this build's: each answers on, and quickstart is among them here.
let features: Vec<&str> = data["features"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
assert!(features.contains(&"quickstart"), "{features:?}");
assert!(features.iter().all(|f| catalogue::feature_on(f) == Some(true)), "{features:?}");
// Text, for a person; the other spellings run the same operation.
let o = wai(&d, &["--version"]);
assert!(o.status.success(), "{}", text(&o));
let t = String::from_utf8_lossy(&o.stdout).into_owned();
assert_eq!(t.lines().next(), Some(format!("wai {version}").as_str()), "{t}");
assert!(t.contains(&sha256(&ops)) && t.contains(&sha256(&reg)), "{t}");
for spelling in [&["version"][..], &["toolchain", "show"]] {
assert_eq!(wai(&d, spelling).stdout, o.stdout, "{spelling:?}");
}
assert_eq!(wai(&d, &["--version", "extra"]).status.code(), Some(2));
let h = wai(&d, &["--version", "--help"]);
assert!(h.status.success() && String::from_utf8_lossy(&h.stdout).contains("toolchain.show"), "{}", text(&h));
}
/// One version source: what the binary says is the crate's, the export's and the newest release of
/// the changelog (WAI toolchain v1, U1).
#[test]
fn the_crate_the_export_and_the_changelog_name_one_version() {
let version = env!("CARGO_PKG_VERSION");
let ops: Value = serde_json::from_slice(&std::fs::read(wai_dir().join("catalogue/ops.json")).unwrap()).unwrap();
assert_eq!(ops["toolchain"], version, "wai/catalogue/ops.json");
let log = std::fs::read_to_string(wai_dir().join("CHANGELOG.md")).unwrap();
let newest = log.lines().find_map(|l| l.strip_prefix("## v")).expect("a release heading");
let newest = newest.split_whitespace().next().unwrap();
assert_eq!(newest, version, "wai/CHANGELOG.md's newest release is not this crate's version");
}
/// A `wai mcp` server's answer to one tool, as `structuredContent`.
fn tool(args: Value, name: &str, project: &Path) -> Value {
let mut c = Command::new(env!("CARGO_BIN_EXE_wai"))
.args(["mcp", "--spec-root", wai_dir().to_str().unwrap(), "--project", project.to_str().unwrap()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.expect("wai mcp starts");
let mut stdin = c.stdin.take().unwrap();
let mut out = BufReader::new(c.stdout.take().unwrap());
let init = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}}});
let call = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": name, "arguments": args}});
writeln!(stdin, "{init}\n{}\n{call}", json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).unwrap();
let mut line = String::new();
out.read_line(&mut line).unwrap();
line.clear();
out.read_line(&mut line).unwrap();
drop(stdin);
let _ = c.wait();
let r: Value = serde_json::from_str(&line).unwrap();
r["result"]["structuredContent"].clone()
}
#[test]
fn docs_search_is_the_tool_wai_mcp_serves() {
let d = scratch("docs");
let root = wai_dir();
let root = root.to_str().unwrap();
let args = ["docs", "search", "signer_id", "origin-bound", "--spec-root", root];
let (code, v) = envelope(&d, &args);
assert_eq!(code, Some(0), "{v}");
assert_eq!(v["op"], "docs.search");
let hits = v["data"]["hits"].as_array().unwrap();
assert!(!hits.is_empty(), "{v}");
for h in hits {
let t = h["text"].as_str().unwrap().to_ascii_lowercase();
assert!(t.contains("signer_id") && t.contains("origin-bound"), "{h}");
assert!(h["file"].is_string() && h["line"].is_u64() && h["section"].is_string(), "{h}");
}
// The door gives what the tool gives.
assert_eq!(v["data"], tool(json!({"query": "signer_id origin-bound"}), "docs_search", &d));
// Text names each hit's file and line.
let o = wai(&d, &args);
let t = String::from_utf8_lossy(&o.stdout).into_owned();
assert!(t.contains(&format!("{}:{}", hits[0]["file"].as_str().unwrap(), hits[0]["line"])), "{t}");
let (_, l) = envelope(&d, &["docs", "search", "capability", "--limit", "2", "--spec-root", root]);
assert_eq!((l["data"]["count"].as_u64(), l["data"]["truncated"].as_bool()), (Some(2), Some(true)), "{l}");
let (code, none) = envelope(&d, &["docs", "search", "zzzqqqzzz", "--spec-root", root]);
assert_eq!((code, none["data"]["count"].as_u64()), (Some(0), Some(0)), "no line holds it, and that is an answer");
// The refusals.
assert_eq!(wai(&d, &["docs", "search"]).status.code(), Some(2));
assert_eq!(wai(&d, &["docs", "search", "x", "--nope"]).status.code(), Some(2));
assert_eq!(wai(&d, &["docs", "search", "x", "--limit", "0"]).status.code(), Some(2));
let (code, e) = envelope(&d, &["docs", "search", "x", "--spec-root", d.to_str().unwrap()]);
assert_eq!((code, e["error"]["code"].as_str()), (Some(5), Some("spec-not-found")), "{e}");
assert!(e["error"]["hint"].as_str().unwrap().contains("--spec-root"), "{e}");
}
#[test]
fn registry_show_is_the_tool_wai_mcp_serves() {
let d = scratch("registry");
let root = wai_dir();
let root = root.to_str().unwrap();
let args = ["registry", "show", "wai.image.png", "--spec-root", root];
let (code, v) = envelope(&d, &args);
assert_eq!(code, Some(0), "{v}");
assert_eq!(v["op"], "registry.show");
assert_eq!(v["data"]["registry"]["capability"], "wai.image.png", "{v}");
assert_eq!(v["data"]["decoded_natively_here"], true, "the floor, in this build: {v}");
assert!(!v["data"]["spec_lines"].as_array().unwrap().is_empty(), "{v}");
assert_eq!(v["data"], tool(json!({"capability": "wai.image.png"}), "registry_show", &d));
let t = String::from_utf8_lossy(&wai(&d, &args).stdout).into_owned();
assert!(t.starts_with("wai.image.png\n") && t.contains("decoded natively by this build: yes") && t.contains("in the specification:"), "{t}");
// Without the specification's text it still answers from the registry, and says so.
let (_, no_text) = envelope(&d, &["registry", "show", "wai.image.png", "--spec-root", d.to_str().unwrap()]);
assert!(no_text["data"]["spec_lines"].is_null(), "{no_text}");
assert_eq!(no_text["data"]["registry"]["capability"], "wai.image.png");
let (code, e) = envelope(&d, &["registry", "show", "wai.image.none", "--spec-root", root]);
assert_eq!((code, e["error"]["code"].as_str()), (Some(5), Some("capability-not-found")), "{e}");
assert_eq!(wai(&d, &["registry", "show"]).status.code(), Some(2));
assert_eq!(wai(&d, &["registry", "show", "a", "b"]).status.code(), Some(2));
// The operations beside it still answer as they did.
assert!(wai(&d, &["registry", "list"]).status.success());
}
#[test]
fn a_deprecated_flag_works_and_says_so_in_one_line() {
let d = scratch("deprecated");
std::fs::write(d.join("p.bin"), b"hello").unwrap();
let notice = "wai: deprecated: --confirm, use --yes instead";
let o = wai(&d, &["wrap", "wai.text.zstd", "p.bin", "--confirm"]);
assert!(o.status.success(), "{}", text(&o));
assert_eq!(String::from_utf8_lossy(&o.stderr).matches(notice).count(), 1, "{}", text(&o));
assert!(!String::from_utf8_lossy(&o.stdout).contains("deprecated"), "standard error alone");
// With --json the envelope stays one document, and the notice is said once.
let o = wai(&d, &["wrap", "wai.text.zstd", "p.bin", "--confirm", "--json"]);
assert!(o.status.success(), "{}", text(&o));
assert_eq!(String::from_utf8_lossy(&o.stderr).matches(notice).count(), 1, "{}", text(&o));
let v: Value = serde_json::from_slice(&o.stdout).unwrap();
assert_eq!(v["ok"], true, "{v}");
let o = wai(&d, &["wrap", "wai.text.zstd", "p.bin", "--yes"]);
assert!(o.status.success() && !text(&o).contains("deprecated"), "{}", text(&o));
}
/// `wai/DEPRECATIONS.md` lists what the catalogue marks deprecated, and nothing it does not, unless
/// the row names its removal; `wai/VERSIONING.md` states the window and links the list.
#[test]
fn the_deprecations_page_is_the_catalogues_list() {
let page = std::fs::read_to_string(wai_dir().join("DEPRECATIONS.md")).unwrap();
// (kind, item, deprecated in, replacement, removed in)
let rows: Vec<Vec<String>> = page
.lines()
.filter(|l| l.starts_with('|'))
.skip(2)
.map(|l| l.trim().trim_matches('|').split('|').map(|c| c.trim().trim_matches('`').to_string()).collect())
.collect();
assert!(rows.iter().all(|r| r.len() == 5), "{rows:?}");
let mut marked: Vec<(String, String, String)> = Vec::new();
for f in catalogue::AGENT_FLAGS {
if let Some((alias, since)) = f.deprecated_alias {
marked.push((alias.to_string(), since.to_string(), f.flag.to_string()));
}
}
for o in OPS {
if let Some(d) = o.deprecated {
marked.push((o.id.to_string(), d.since.to_string(), d.use_instead.to_string()));
}
}
for (item, since, instead) in &marked {
let r = rows.iter().find(|r| r[1] == *item).unwrap_or_else(|| panic!("the catalogue marks {item} deprecated, and wai/DEPRECATIONS.md does not list it"));
assert_eq!((&r[2], &r[3]), (since, instead), "{item}");
}
for r in &rows {
assert!(marked.iter().any(|(i, _, _)| *i == r[1]) || !r[4].is_empty(), "wai/DEPRECATIONS.md lists {}, which the catalogue does not mark deprecated and no release removed", r[1]);
}
let v = std::fs::read_to_string(wai_dir().join("VERSIONING.md")).unwrap().split_whitespace().collect::<Vec<_>>().join(" ");
for needle in ["(DEPRECATIONS.md)", "at least two minor releases and six months", "wai --version --json", "wai: deprecated: --confirm, use --yes instead"] {
assert!(v.contains(needle), "wai/VERSIONING.md does not state {needle:?}");
}
assert!(page.contains("(VERSIONING.md)"), "wai/DEPRECATIONS.md links the policy");
}

View file

@ -15,6 +15,7 @@ import json
import os
import re
import socket
import socketserver
import sys
import threading
import time
@ -55,6 +56,14 @@ class Room(http.server.BaseHTTPRequestHandler):
pass
class Server(http.server.ThreadingHTTPServer):
"""HTTPServer looks up a name for its address before it listens, and nothing here reads the
name. On some hosts that lookup takes 35 s, past what `wai dev` waits for a service."""
def server_bind(self):
socketserver.TCPServer.server_bind(self)
def main(argv):
if argv[:1] in (["keygen"], ["relay-lines"]):
open(argv[1], "a").close()
@ -73,7 +82,7 @@ def main(argv):
while True:
c, _ = s.accept()
c.close()
http.server.ThreadingHTTPServer(listen_addr(config, "listen"), Room).serve_forever()
Server(listen_addr(config, "listen"), Room).serve_forever()
return 0

View file

@ -1016,6 +1016,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"windows-sys",
]
[[package]]

View file

@ -816,6 +816,7 @@ dependencies = [
"serde_json",
"sha2",
"wai-ops",
"windows-sys",
"zstd",
]

View file

@ -1,7 +1,7 @@
// GENERATED by scripts/actions.mjs from the WAI operation catalogue. Do not edit: change the
// catalogue (wai-rs/src/catalogue.rs, exported by `wai ops export`), then run
// `node scripts/actions.mjs`. `node scripts/actions.mjs --check` fails when this file differs.
export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.4.0","sha256":"bbb0d3f85cfb9d2c1e6a0a2a69fff6f9b8fc6762a1a9560834d8136a0b7c47bd"};
export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.4.0","sha256":"6691889d5b136d736264a2d607c33e58fbdf1021a911da8676f54a0373ebae09"};
export const ACTIONS = [
{"id":"toolchain.test","noun":"toolchain","verb":"test","menu":"Toolchain","item":"Test","palette":"toolchain test","summary":"WAI's four steps (name, dispatch, rebuild, receipt) on a picture made here, its receipt checked offline","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai hello","mcp":null,"route":null,"call":"toolchain_test"},
{"id":"project.create","noun":"project","verb":"create","menu":"Project","item":"Create","palette":"project create","summary":"A new project from one of the examples, written into a new or empty directory; --list names the templates","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai create","mcp":null,"route":null,"call":"project_create"},
@ -60,8 +60,8 @@ export const ACTIONS = [
{"id":"app.check","noun":"app","verb":"check","menu":"App","item":"Check","palette":"app check","summary":"An app program checked against the WAI registry and WAI's rules, every error with its line and reason code; in a project, its room and runtime too","placement":"local","effect":"reads","stability":"preview","toolset":"app","cli":"wai app check","mcp":"app_check","route":null,"call":"app_check"},
{"id":"app.export","noun":"app","verb":"export","menu":"App","item":"Export","palette":"app export","summary":"An app made with Create written as a project: the program, wai.json naming its room, the page and its runtime, a README, AGENTS.md and the licence","placement":"local","effect":"writes-project","stability":"preview","toolset":"app","cli":"wai app export","mcp":"app_export","route":null,"call":"app_export"},
{"id":"app.stream","noun":"app","verb":"stream","menu":"App","item":"Stream","palette":"app stream","summary":"A program streamed into the person's open Create page through the bridge wai mcp runs on loopback","placement":"local","effect":"runs","stability":"preview","toolset":"app","cli":null,"mcp":"app_stream","route":null,"call":"app_stream"},
{"id":"registry.show","noun":"registry","verb":"show","menu":"Registry","item":"Show","palette":"registry show","summary":"One capability's registry entry, whether this build decodes it, its role, and the specification lines that name it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":null,"mcp":"registry_show","route":null,"call":"registry_show"},
{"id":"docs.search","noun":"docs","verb":"search","menu":"Docs","item":"Search","palette":"docs search","summary":"Lines of the specification and its extensions holding every word of a query, each with its file, line and section","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":null,"mcp":"docs_search","route":null,"call":"docs_search"},
{"id":"registry.show","noun":"registry","verb":"show","menu":"Registry","item":"Show","palette":"registry show","summary":"One capability's registry entry, whether this build decodes it, its role, and the specification lines that name it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai registry show","mcp":"registry_show","route":null,"call":"registry_show"},
{"id":"docs.search","noun":"docs","verb":"search","menu":"Docs","item":"Search","palette":"docs search","summary":"Lines of the specification and its extensions holding every word of a query, each with its file, line and section","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai docs search","mcp":"docs_search","route":null,"call":"docs_search"},
{"id":"dev.stop","noun":"dev","verb":"stop","menu":"Dev","item":"Stop","palette":"dev stop","summary":"The development relay and room service a wai mcp server started, stopped","placement":"local","effect":"runs","stability":"stable","toolset":"dev","cli":null,"mcp":"dev_stop","route":null,"call":"dev_stop"},
{"id":"room.join","noun":"room","verb":"join","menu":"Room","item":"Join","palette":"room join","summary":"A test principal enters the development room with its grant, publishes its tracks, and is seen joined by the room's clock","placement":"client","effect":"writes-target","stability":"stable","toolset":"dev","cli":null,"mcp":"room_join","route":null,"call":"room_join"},
{"id":"room.leave","noun":"room","verb":"leave","menu":"Room","item":"Leave","palette":"room leave","summary":"A test principal's session in the development room closed, and its leave seen by the room's clock","placement":"client","effect":"writes-target","stability":"stable","toolset":"dev","cli":null,"mcp":"room_leave","route":null,"call":"room_leave"},
@ -113,8 +113,16 @@ export const ACTIONS = [
{"id":"opportunity.create","noun":"opportunity","verb":"create","menu":"Opportunity","item":"Create","palette":"opportunity create","summary":"A party's opportunity report for a period: the ad breaks a channel's signed cues and program-as-run prove in the programmes it shares in, signed with the issuer key","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai opportunity create","mcp":null,"route":null,"call":"opportunity_create"},
{"id":"opportunity.verify","noun":"opportunity","verb":"verify","menu":"Opportunity","item":"Verify","palette":"opportunity verify","summary":"An opportunity report's or a statement check's verification document checked: every break, line and flag recomputed from the records, or the reason it is refused","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai opportunity verify","mcp":null,"route":null,"call":"opportunity_verify"},
{"id":"statement.check","noun":"statement","verb":"check","menu":"Statement","item":"Check","palette":"statement check","summary":"A platform's reported statement checked against an opportunity report and, when given, the operator's signed statement, signed with the issuer key","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai statement check","mcp":null,"route":null,"call":"statement_check"},
{"id":"key.create","noun":"key","verb":"create","menu":"Key","item":"Create","palette":"key create","summary":"A new Ed25519 signing key kept under a name, for one role, its secret in macOS's keychain or in a seed file; every signing operation's --key takes the name","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai key create","mcp":null,"route":null,"call":"key_create"},
{"id":"key.list","noun":"key","verb":"list","menu":"Key","item":"List","palette":"key list","summary":"Every key of the keystore: its name, role, key id, public key, status and where its secret is kept","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai key list","mcp":null,"route":null,"call":"key_list"},
{"id":"key.show","noun":"key","verb":"show","menu":"Key","item":"Show","palette":"key show","summary":"One key: its public key and did:key, its role, every version with its status as a key log states it, and the version --key signs with","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai key show","mcp":null,"route":null,"call":"key_show"},
{"id":"key.rotate","noun":"key","verb":"rotate","menu":"Key","item":"Rotate","palette":"key rotate","summary":"A key's successor made under the same name and a new key id, the old version retired now and its secret destroyed","placement":"local","effect":"destructive","stability":"preview","toolset":"core","cli":"wai key rotate","mcp":null,"route":null,"call":"key_rotate"},
{"id":"key.revoke","noun":"key","verb":"revoke","menu":"Key","item":"Revoke","palette":"key revoke","summary":"A key's version revoked as compromised from a time, its secret destroyed, so --key signs nothing with it","placement":"local","effect":"destructive","stability":"preview","toolset":"core","cli":"wai key revoke","mcp":null,"route":null,"call":"key_revoke"},
{"id":"key.import","noun":"key","verb":"import","menu":"Key","item":"Import","palette":"key import","summary":"A key file's seed kept under a name, as wai key create keeps a new one; a published test seed is refused unless named one","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai key import","mcp":null,"route":null,"call":"key_import"},
{"id":"key.export","noun":"key","verb":"export","menu":"Key","item":"Export","palette":"key export","summary":"A key's public half, never its secret: in hexadecimal, as a did:key, as key-log entries, or as the line wai-keys lists it by","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai key export","mcp":null,"route":null,"call":"key_export"},
{"id":"ops.list","noun":"ops","verb":"list","menu":"Ops","item":"List","palette":"ops list","summary":"Every operation of this catalogue: its id, command line, effect and whether this build holds it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops list","mcp":null,"route":null,"call":"ops_list"},
{"id":"ops.search","noun":"ops","verb":"search","menu":"Ops","item":"Search","palette":"ops search","summary":"The operations that match a task, ranked, offline: up to five","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops search","mcp":null,"route":null,"call":"ops_search"},
{"id":"ops.show","noun":"ops","verb":"show","menu":"Ops","item":"Show","palette":"ops show","summary":"One operation's entry: its input and output schemas, effect, placement and command line","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops show","mcp":null,"route":null,"call":"ops_show"},
{"id":"ops.export","noun":"ops","verb":"export","menu":"Ops","item":"Export","palette":"ops export","summary":"The catalogue as JSON (wai.ops/1), what other tools and CI read; --check compares a file with it","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai ops export","mcp":null,"route":null,"call":"ops_export"},
{"id":"toolchain.show","noun":"toolchain","verb":"show","menu":"Toolchain","item":"Show","palette":"toolchain show","summary":"This wai's version, the digests of its catalogue and its registry, the release of the standard it implements, and the build features it holds","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai --version","mcp":null,"route":null,"call":"toolchain_show"},
];

View file

@ -1233,6 +1233,7 @@ dependencies = [
"sha2",
"wai-ops",
"wai-quantum",
"windows-sys",
]
[[package]]