wai key: signing keys in one keystore, and wai --version, docs search and registry show #656
46 changed files with 4567 additions and 278 deletions
29
.github/workflows/wai-catalogue.yml
vendored
29
.github/workflows/wai-catalogue.yml
vendored
|
|
@ -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
59
.github/workflows/wai-key-keychain.yml
vendored
Normal 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; }
|
||||
|
|
@ -2302,6 +2302,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"wai-ops",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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
10
wai/DEPRECATIONS.md
Normal 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
84
wai/VERSIONING.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
50
wai/changes/2026-11-05-wai-key-signing-keys-one-keystore.md
Normal file
50
wai/changes/2026-11-05-wai-key-signing-keys-one-keystore.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
1
wai/tools/ops-gpu-bench/Cargo.lock
generated
1
wai/tools/ops-gpu-bench/Cargo.lock
generated
|
|
@ -1034,6 +1034,7 @@ dependencies = [
|
|||
"sha2",
|
||||
"wai-ops",
|
||||
"wai-ops-gpu",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
1
wai/wai-audit/Cargo.lock
generated
1
wai/wai-audit/Cargo.lock
generated
|
|
@ -982,6 +982,7 @@ dependencies = [
|
|||
"sha2",
|
||||
"wai-ops",
|
||||
"wai-quantum",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
1
wai/wai-fleet/Cargo.lock
generated
1
wai/wai-fleet/Cargo.lock
generated
|
|
@ -2473,6 +2473,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"wai-ops",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
1
wai/wai-key-proxy/Cargo.lock
generated
1
wai/wai-key-proxy/Cargo.lock
generated
|
|
@ -964,6 +964,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2",
|
||||
"wai-quantum",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
1
wai/wai-ledger/Cargo.lock
generated
1
wai/wai-ledger/Cargo.lock
generated
|
|
@ -986,6 +986,7 @@ dependencies = [
|
|||
"sha2",
|
||||
"wai-ops",
|
||||
"wai-quantum",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
1
wai/wai-live-web/Cargo.lock
generated
1
wai/wai-live-web/Cargo.lock
generated
|
|
@ -780,6 +780,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2",
|
||||
"wai-ops",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
1
wai/wai-pack/Cargo.lock
generated
1
wai/wai-pack/Cargo.lock
generated
|
|
@ -1227,6 +1227,7 @@ dependencies = [
|
|||
"sha2",
|
||||
"wai-ops",
|
||||
"wai-quantum",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
1
wai/wai-quantum-serve/Cargo.lock
generated
1
wai/wai-quantum-serve/Cargo.lock
generated
|
|
@ -595,6 +595,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2",
|
||||
"wai-quantum",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
1
wai/wai-relay/Cargo.lock
generated
1
wai/wai-relay/Cargo.lock
generated
|
|
@ -1096,6 +1096,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
1
wai/wai-rs/Cargo.lock
generated
1
wai/wai-rs/Cargo.lock
generated
|
|
@ -4735,6 +4735,7 @@ dependencies = [
|
|||
"wai-ops",
|
||||
"wai-ops-gpu",
|
||||
"wai-quantum",
|
||||
"windows-sys 0.61.2",
|
||||
"xz2",
|
||||
"zstd",
|
||||
"zstd-safe",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
1
wai/wai-rs/fuzz/Cargo.lock
generated
1
wai/wai-rs/fuzz/Cargo.lock
generated
|
|
@ -1623,6 +1623,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2",
|
||||
"wai-ops",
|
||||
"windows-sys",
|
||||
"xz2",
|
||||
"zstd",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>(®istry).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)]
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
1367
wai/wai-rs/src/bin/wai_cli/dx/key.rs
Normal file
1367
wai/wai-rs/src/bin/wai_cli/dx/key.rs
Normal file
File diff suppressed because it is too large
Load diff
111
wai/wai-rs/src/bin/wai_cli/dx/lookup.rs
Normal file
111
wai/wai-rs/src/bin/wai_cli/dx/lookup.rs
Normal 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")))
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
460
wai/wai-rs/src/bin/wai_cli/win_acl.rs
Normal file
460
wai/wai-rs/src/bin/wai_cli/win_acl.rs
Normal 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(¤t_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(¤t_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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
722
wai/wai-rs/tests/cli_key.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
254
wai/wai-rs/tests/cli_toolchain.rs
Normal file
254
wai/wai-rs/tests/cli_toolchain.rs
Normal 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(®));
|
||||
let caps: Value = serde_json::from_slice(®).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(®)), "{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");
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
1
wai/wai-spz-gen/Cargo.lock
generated
1
wai/wai-spz-gen/Cargo.lock
generated
|
|
@ -1016,6 +1016,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
1
wai/wai-studio/core/Cargo.lock
generated
1
wai/wai-studio/core/Cargo.lock
generated
|
|
@ -816,6 +816,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2",
|
||||
"wai-ops",
|
||||
"windows-sys",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
];
|
||||
|
|
|
|||
1
wai/wai-web/Cargo.lock
generated
1
wai/wai-web/Cargo.lock
generated
|
|
@ -1233,6 +1233,7 @@ dependencies = [
|
|||
"sha2",
|
||||
"wai-ops",
|
||||
"wai-quantum",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Reference in a new issue