From 58acd89a9507c1a5e333e7843d7a73b28c6932cb Mon Sep 17 00:00:00 2001 From: Bartek Kus <7887446+bartekus@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:02:57 -0600 Subject: [PATCH] feat(003): auth + control-plane API client (login, whoami, api module) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement spec 003. `stagecraft login` performs the browser-assisted bearer-token handoff chosen in the spec's 2026-07-14 amendment: paste a control-plane session token (piped or at a prompt), validate it against GET /api/v1/auth/me, then store it in ~/.config/stagecraft/credentials.toml at mode 0600, keyed by base URL. `stagecraft whoami` GETs the same endpoint and renders id/email, exiting 1 when unauthenticated. The api module gives every later verb a typed, authenticated request path: a uniform error taxonomy (network, auth, api-4xx with server message, api-5xx), retry with jitter for idempotent GETs only, and a global --debug flag that dumps request/response metadata to stderr without credential material. reqwest is rustls-only (never native-tls); tokio drives the async verbs on a per-invocation runtime. Auth decision (recorded in the spec): the preferred RFC 8252 loopback OIDC flow needs control-plane additions that do not exist yet (a public rauthy client with a loopback redirect, and a token-return endpoint), so the paste fallback is the sanctioned v1. The credentials store and bearer replay are independent of acquisition, so the loopback flow drops in later with no change to the store or the api module. Testing: cargo fmt --check, clippy -D warnings, and 35 tests (credentials 0600 round-trip and permission tightening, GET-only retry, 401 to login hint, whoami happy/sad via httpmock, whoami JSON shape). Manual e2e against a live control plane is deferred per spec §1: the control plane is pre-code, so no plane is reachable; the spec stays implementation: in-progress with a dated Status note. Spine gates green (compile, index check, lint, couple). Spec-Drift-Waiver: spec 003 fills in the scaffold spec 002 established (002 §2 placed the src/ tree and login/whoami stubs "so later specs fill them in" and predicted "Async arrives with spec 003 (tokio + reqwest)"). The 002-owned paths here (Cargo.toml, Cargo.lock, src/cli.rs, src/commands.rs) plus the new src/api.rs and src/auth.rs modules are that fill-in; no 002 design is contradicted. --- .../by-spec/003-auth-api-client.json | 42 +- .../by-spec/003-auth-api-client.json | 8 +- Cargo.lock | 2507 +++++++++++++++-- Cargo.toml | 12 + specs/003-auth-api-client/spec.md | 60 +- src/api.rs | 477 ++++ src/auth.rs | 341 +++ src/cli.rs | 4 + src/commands.rs | 4 +- src/main.rs | 2 + tests/cli.rs | 38 +- 11 files changed, 3276 insertions(+), 219 deletions(-) create mode 100644 src/api.rs create mode 100644 src/auth.rs diff --git a/.derived/codebase-index/by-spec/003-auth-api-client.json b/.derived/codebase-index/by-spec/003-auth-api-client.json index c9c605a..70d289f 100644 --- a/.derived/codebase-index/by-spec/003-auth-api-client.json +++ b/.derived/codebase-index/by-spec/003-auth-api-client.json @@ -1,25 +1,25 @@ { - "diagnostics": { - "errors": [], - "warnings": [ - { - "code": "W-001", - "message": "spec '003-auth-api-client' symbol unit 'stagecraft_cli::api' did not resolve" - }, - { - "code": "W-001", - "message": "spec '003-auth-api-client' symbol unit 'stagecraft_cli::auth' did not resolve" - } - ] - }, "mapping": { "dependsOn": [ "002-crate-scaffold" ], - "implementingPaths": [], + "implementingPaths": [ + { + "path": "src/main.rs", + "source": "spec-edge" + } + ], "resolvedUnits": [ { - "locations": [], + "locations": [ + { + "file": "src/main.rs", + "span": { + "endLine": 5, + "startLine": 5 + } + } + ], "ownership": true, "sourceField": "establishes", "unit": { @@ -28,7 +28,15 @@ } }, { - "locations": [], + "locations": [ + { + "file": "src/main.rs", + "span": { + "endLine": 6, + "startLine": 6 + } + } + ], "ownership": true, "sourceField": "establishes", "unit": { @@ -41,5 +49,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "f4fb107776f2e575bc5601766bdb66a8c65e0c104d65d0b927f806d446b03be7" + "shardHash": "e6fd7efd240ef302be413f7448761f2261659e888da91930484cbc6439de53bb" } diff --git a/.derived/spec-registry/by-spec/003-auth-api-client.json b/.derived/spec-registry/by-spec/003-auth-api-client.json index bc9c24f..b719548 100644 --- a/.derived/spec-registry/by-spec/003-auth-api-client.json +++ b/.derived/spec-registry/by-spec/003-auth-api-client.json @@ -15,19 +15,21 @@ } ], "id": "003-auth-api-client", - "implementation": "pending", + "implementation": "in-progress", "sectionHeadings": [ "003: Auth + API client", "1. Cross-repo dependency", "2. Behavior", + "Auth mechanism decision (2026-07-14 amendment)", "3. Acceptance", - "4. Out of scope" + "4. Out of scope", + "5. Status (2026-07-14)" ], "specPath": "specs/003-auth-api-client/spec.md", "status": "approved", "summary": "The binary learns to authenticate against a Stagecraft control plane and speak its API. Auth v1 is a browser-assisted session-cookie handoff (the control plane's chassis auth is cookie based, and the embedded rauthy exposes OIDC; the exact mechanism is DECIDE-AT-IMPLEMENTATION between OAuth device-flow-style polling and a localhost callback, constrained below). Tokens/cookies are stored in a 0600 credentials file, never in the config file. An api module gives every later verb a typed, authenticated request path with consistent error mapping.\n", "title": "Auth + control-plane API client" }, - "shardHash": "52751edf74cf1cfe466235156a7023210ce7fd6ad29c294b63c0bc33ae43963e", + "shardHash": "6468f4075cab0994b5c4bd86701bf2f1fe0198df6c066cd60cfc4210bda375b4", "specVersion": "1.1.0" } diff --git a/Cargo.lock b/Cargo.lock index 04facd5..8cf717f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "1.0.0" @@ -38,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -59,368 +68,2327 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] -name = "cfg-if" -version = "1.0.4" +name = "ascii-canvas" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] [[package]] -name = "clap" -version = "4.6.1" +name = "assert-json-diff" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" dependencies = [ - "clap_builder", - "clap_derive", + "serde", + "serde_json", ] [[package]] -name = "clap_builder" -version = "4.6.0" +name = "async-attributes" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", + "quote", + "syn 1.0.109", ] [[package]] -name = "clap_complete" -version = "4.6.7" +name = "async-channel" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" dependencies = [ - "clap", + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", ] [[package]] -name = "clap_derive" -version = "4.6.1" +name = "async-channel" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", ] [[package]] -name = "clap_lex" -version = "1.1.0" +name = "async-executor" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] [[package]] -name = "colorchoice" -version = "1.0.5" +name = "async-global-executor" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] [[package]] -name = "directories" -version = "6.0.0" +name = "async-io" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ - "dirs-sys", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", ] [[package]] -name = "dirs-sys" -version = "0.5.0" +name = "async-lock" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys", + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "async-object-pool" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "333c456b97c3f2d50604e8b2624253b7f787208cb72eb75e64b0ad11b221652c" +dependencies = [ + "async-std", +] [[package]] -name = "getrandom" -version = "0.2.17" +name = "async-process" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", "cfg-if", - "libc", - "wasi", + "event-listener 5.4.1", + "futures-lite", + "rustix", ] [[package]] -name = "hashbrown" -version = "0.17.1" +name = "async-signal" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] [[package]] -name = "heck" -version = "0.5.0" +name = "async-std" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] [[package]] -name = "indexmap" -version = "2.14.0" +name = "async-task" +version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ - "equivalent", - "hashbrown", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "itoa" -version = "1.0.18" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "libc" -version = "0.2.186" +name = "base64" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] -name = "libredox" -version = "0.1.18" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "basic-cookies" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67bd8fd42c16bdb08688243dc5f0cc117a3ca9efeeaba3a345a18a6159ad96f7" dependencies = [ - "libc", + "lalrpop", + "lalrpop-util", + "regex", ] [[package]] -name = "memchr" -version = "2.8.3" +name = "bit-set" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "bit-vec" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" [[package]] -name = "option-ext" -version = "0.2.0" +name = "bitflags" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "blocking" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "unicode-ident", + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", ] [[package]] -name = "quote" -version = "1.0.46" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" -dependencies = [ - "proc-macro2", -] +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "redox_users" -version = "0.5.2" +name = "bytes" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom", - "libredox", - "thiserror", -] +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] -name = "serde" -version = "1.0.228" +name = "cc" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ - "serde_core", - "serde_derive", + "find-msvc-tools", + "shlex", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "serde_derive" -version = "1.0.228" +name = "cfg_aliases" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] -name = "serde_json" -version = "1.0.150" +name = "chacha20" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "cfg-if", + "cpufeatures", + "rand_core", ] [[package]] -name = "serde_spanned" -version = "0.6.9" +name = "clap" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ - "serde", + "clap_builder", + "clap_derive", ] [[package]] -name = "stagecraft-cli" -version = "0.1.0" +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anyhow", - "clap", - "clap_complete", - "directories", - "serde", - "serde_json", - "thiserror", - "toml", + "anstream", + "anstyle", + "clap_lex", + "strsim", ] [[package]] -name = "strsim" -version = "0.11.1" +name = "clap_complete" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +dependencies = [ + "clap", +] [[package]] -name = "syn" -version = "2.0.118" +name = "clap_derive" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ + "heck", "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.118", ] [[package]] -name = "thiserror" -version = "2.0.18" +name = "clap_lex" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] -name = "thiserror-impl" -version = "2.0.18" +name = "colorchoice" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ - "proc-macro2", - "quote", - "syn", + "crossbeam-utils", ] [[package]] -name = "toml" -version = "0.8.23" +name = "cookie" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", + "percent-encoding", + "time", + "version_check", ] [[package]] -name = "toml_datetime" -version = "0.6.11" +name = "cookie_store" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", "serde", + "serde_derive", + "serde_json", + "time", + "url", ] [[package]] -name = "toml_edit" -version = "0.22.27" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", + "libc", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "crossbeam-utils" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "crunchy" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] -name = "utf8parse" -version = "0.2.2" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] -name = "wasi" +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.4.2", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body 1.1.0", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "httpmock" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ec9586ee0910472dec1a1f0f8acf52f0fdde93aea74d70d4a3107b4be0fd5b" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-std", + "async-trait", + "base64 0.21.7", + "basic-cookies", + "crossbeam-utils", + "form_urlencoded", + "futures-util", + "hyper 0.14.32", + "lazy_static", + "levenshtein", + "log", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "tokio", + "url", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.4.2", + "http-body 1.1.0", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.2", + "hyper 1.10.1", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.2", + "http-body 1.1.0", + "hyper 1.10.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +dependencies = [ + "value-bag", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "futures-core", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "hyper 1.10.1", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_regex" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bafc8d0c5330cecff10f16b459b479fd9acaa5b4acd7167301414e21b0057012" +dependencies = [ + "regex", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stagecraft-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "clap_complete", + "directories", + "httpmock", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "toml", +] + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.2", + "http-body 1.1.0", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "value-bag" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd4ec1eb1d240636e354a30110a1dfcb37047169a4d9bd6d9d3469df574b5c4" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -430,6 +2398,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -439,6 +2471,95 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index a043dc3..79c75fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,15 @@ anyhow = "1" thiserror = "2" # Platform-appropriate config paths. directories = "6" +# Async runtime and HTTP client for the control-plane API (spec 003). +# rustls only, never native-tls (CLAUDE.md: no OpenSSL on customer machines). +tokio = { version = "1", features = ["rt-multi-thread", "time", "net"] } +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "cookies", + "rustls-tls", +] } + +[dev-dependencies] +# Mock HTTP server so `cargo test` never gates on a live control plane (spec 003 §1). +httpmock = "0.7" diff --git a/specs/003-auth-api-client/spec.md b/specs/003-auth-api-client/spec.md index b809ab8..41b7d63 100644 --- a/specs/003-auth-api-client/spec.md +++ b/specs/003-auth-api-client/spec.md @@ -3,7 +3,7 @@ id: "003-auth-api-client" title: "Auth + control-plane API client" status: approved created: "2026-07-14" -implementation: pending +implementation: in-progress depends_on: - "002-crate-scaffold" establishes: @@ -62,6 +62,48 @@ check as pending. - JSON output shapes for whoami defined and tested (the MCP face reuses them). +### Auth mechanism decision (2026-07-14 amendment) + +Chosen v1 mechanism: **browser-assisted bearer-token handoff (paste)**, +the fallback sanctioned by constraint 1 above. `stagecraft login +[--base-url URL]` guides the operator to sign in through a browser at +the control plane, then reads the resulting chassis session token +(`access_token`) from stdin (a piped value or an interactive prompt, so +a headless machine with a browser elsewhere is supported), validates it +with `GET /api/v1/auth/me`, and writes it to the credentials file. The +api module replays the stored token as `Authorization: Bearer` on every +request. + +Why not the preferred RFC 8252 loopback OIDC flow: it needs two +control-plane additions that do not exist today. Verified against the +enrahitu chassis that the control plane imports (chassis specs +004-auth-core, 005-rauthy-same-origin, both complete): + +1. No public rauthy OIDC client with a `127.0.0.1:` loopback + redirect URI. The sole bootstrapped client (`enrahitu`) is + confidential, its redirect is fixed to `localhost:4000`, and dynamic + client registration is off. The `device_code` grant (RFC 8628) is + not enabled either, so device flow is equally blocked. +2. No endpoint that returns the app-minted token to a local listener. + The gateway verifies an app-issued RS256 JWT (issuer `enrahitu`, + deposited only as an httpOnly cookie), not a rauthy-issued token, so + even a perfect loopback exchange against rauthy would yield a token + the API rejects. There is also no personal-access-token or API-key + issuance surface. + +Forward compatibility: the credentials store and the Bearer replay in +the api module are independent of how the token was acquired. When the +control plane adds the loopback client plus a token-return endpoint, +`login` gains that acquisition path with no change to the credentials +contract or the api module. + +Refresh: the paste mechanism hands the CLI no refresh token, so v1 has +no transparent refresh; on HTTP 401 the CLI errors with "run stagecraft +login". Longer-lived or refreshable CLI tokens are control-plane work +to add alongside the loopback flow. (The chassis `access_token` is a +15-minute JWT, a control-plane limitation reported here, not a CLI +defect.) + ## 3. Acceptance - Unit: credentials file round-trip with permission assertion; 401 -> @@ -77,3 +119,19 @@ check as pending. - OS keychain integration (file + 0600 is v1; a later spec may add keychain backends). - Multi-user/profile switching UX beyond per-base-url entries. + +## 5. Status (2026-07-14) + +Implementation landed: the `auth` and `api` modules, the credentials +store (0600, per-base-url), `login` (paste handoff) and `whoami`, the +uniform error taxonomy, GET-only retry with jitter, and `--debug`, all +covered by unit and mock-server (httpmock) tests. + +Outstanding: the §3 manual e2e against a **live** control plane (login, +whoami, a raw authenticated GET) is deferred per §1. No control plane +is reachable: the stagecraft repo is pre-code (its service specs +002/004 are approved but `implementation: pending`), and the enrahitu +chassis that supplies the auth surface is not yet stamped into a +running plane. This spec stays `implementation: in-progress` until that +live check can run; everything unit-testable is verified against the +httpmock server per §1. diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..a03cfa4 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,477 @@ +//! The control-plane API client (spec 003 §2). +//! +//! `base_url` + a stored credential become an authenticated request path with +//! a uniform error taxonomy (network, auth, api-4xx, api-5xx), retry with +//! jitter for idempotent GETs only, and a `--debug` metadata dump that never +//! prints credential material. The `whoami` verb is the first consumer; every +//! spec-004 verb hangs off the same client. JSON output shapes are API: the +//! MCP face (spec 005) reuses them. + +use std::time::Duration; + +use reqwest::{Method, StatusCode}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::config::ResolvedConfig; +use crate::error::{AppError, AppResult}; +use crate::output::{self, OutputFormat}; + +/// Sent on every request so control-plane logs can attribute CLI traffic. +const USER_AGENT: &str = concat!("stagecraft/", env!("CARGO_PKG_VERSION")); +/// Total attempts for an idempotent GET: one initial call plus retries. +const MAX_ATTEMPTS: u32 = 3; +/// Backoff base; the nth retry waits `RETRY_BASE * n` plus jitter. +const RETRY_BASE: Duration = Duration::from_millis(100); +/// The chassis auth identity endpoint (spec 003 §2). +const AUTH_ME_PATH: &str = "/api/v1/auth/me"; + +/// The failure taxonomy every API call maps onto (spec 003 §2). All variants +/// are operational (exit 1): a failed request is never a usage error. +#[derive(Debug)] +pub enum ApiError { + /// Transport failure: DNS, connect, TLS, timeout, a dropped connection. + Network(String), + /// HTTP 401: no valid credential. Rendered with the "run login" hint. + Unauthenticated, + /// A 4xx other than 401, carrying the server's own message when present. + Api { status: u16, message: String }, + /// A 5xx that survived retries. + Server { status: u16 }, + /// A 2xx body that was not the JSON shape we expected. + Decode(String), +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ApiError::Network(e) => write!(f, "network error talking to the control plane: {e}"), + ApiError::Unauthenticated => { + write!(f, "not authenticated; run `stagecraft login`") + } + ApiError::Api { status, message } => { + write!(f, "control plane returned {status}: {message}") + } + ApiError::Server { status } => { + write!(f, "control plane returned {status} (server error)") + } + ApiError::Decode(e) => write!(f, "unexpected response from the control plane: {e}"), + } + } +} + +impl std::error::Error for ApiError {} + +/// Every API failure is an operational failure (exit 1), including 401: an +/// unauthenticated call is a runtime condition the operator resolves by +/// logging in, not a misuse of the command line. +impl From for AppError { + fn from(err: ApiError) -> Self { + AppError::Operational(anyhow::anyhow!(err.to_string())) + } +} + +/// The identity subset of the chassis `/auth/me` response we depend on. Extra +/// fields (roles, timestamps) are ignored so the shape can grow server-side. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Identity { + pub id: String, + pub email: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +/// The stable `whoami` JSON shape (API; reused by the MCP face, spec 005). +#[derive(Debug, Serialize)] +pub struct Whoami { + pub base_url: String, + pub id: String, + pub email: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +/// A base-URL + credential bound into an authenticated, retrying HTTP client. +pub struct ApiClient { + base_url: String, + token: Option, + http: reqwest::Client, + debug: bool, +} + +impl ApiClient { + /// Build a client for `base_url`, optionally bearing `token`. rustls only + /// (the crate never links native-tls); the reqwest client is reused across + /// requests so connection pooling and retries share one pool. + pub fn new( + base_url: impl Into, + token: Option, + debug: bool, + ) -> Result { + let http = reqwest::Client::builder() + .user_agent(USER_AGENT) + .build() + .map_err(|e| ApiError::Network(e.to_string()))?; + Ok(Self { + base_url: normalize_base_url(&base_url.into()), + token, + http, + debug, + }) + } + + /// GET the chassis identity endpoint and deserialize the caller identity. + pub async fn fetch_identity(&self) -> Result { + self.get_json(AUTH_ME_PATH).await + } + + /// Join the base URL and a path into a request URL. + fn url(&self, path: &str) -> String { + format!("{}/{}", self.base_url, path.trim_start_matches('/')) + } + + /// Send a retrying request, then map status + body onto the taxonomy. + async fn get_json(&self, path: &str) -> Result { + let (status, body) = self.send_retrying(Method::GET, path).await?; + if status.as_u16() == 401 { + return Err(ApiError::Unauthenticated); + } + if status.is_client_error() { + return Err(ApiError::Api { + status: status.as_u16(), + message: server_message(&body), + }); + } + if status.is_server_error() { + return Err(ApiError::Server { + status: status.as_u16(), + }); + } + serde_json::from_str(&body).map_err(|e| ApiError::Decode(e.to_string())) + } + + /// One HTTP attempt: send, optionally emit `--debug` metadata, return the + /// status and body text. The Authorization header is never logged. + async fn attempt(&self, method: Method, path: &str) -> Result<(StatusCode, String), ApiError> { + let url = self.url(path); + let mut req = self.http.request(method.clone(), url.as_str()); + if let Some(token) = &self.token { + req = req.bearer_auth(token); + } + let start = std::time::Instant::now(); + let resp = req + .send() + .await + .map_err(|e| ApiError::Network(e.to_string()))?; + let status = resp.status(); + if self.debug { + eprintln!( + "[debug] {method} {url} -> {} ({} ms)", + status.as_u16(), + start.elapsed().as_millis() + ); + } + let body = resp + .text() + .await + .map_err(|e| ApiError::Network(e.to_string()))?; + Ok((status, body)) + } + + /// Retry idempotent GETs on transient failure (network error or 5xx) with + /// jittered backoff. Non-idempotent methods and 4xx are never retried. + async fn send_retrying( + &self, + method: Method, + path: &str, + ) -> Result<(StatusCode, String), ApiError> { + let retryable = is_retryable_method(&method); + let mut attempt = 0u32; + loop { + attempt += 1; + match self.attempt(method.clone(), path).await { + Ok((status, body)) => { + if status.is_server_error() && retryable && attempt < MAX_ATTEMPTS { + backoff(attempt).await; + continue; + } + return Ok((status, body)); + } + Err(err) => { + if retryable && attempt < MAX_ATTEMPTS { + backoff(attempt).await; + continue; + } + return Err(err); + } + } + } + } +} + +/// The `whoami` verb: fetch and render the authenticated identity, or exit 1 +/// when unauthenticated (spec 003 §2). +pub fn run_whoami(resolved: &ResolvedConfig, format: OutputFormat, debug: bool) -> AppResult<()> { + let base_url = require_base_url(resolved)?; + let token = crate::auth::load_token(&base_url)?.ok_or_else(|| { + AppError::Operational(anyhow::anyhow!( + "not authenticated for {base_url}; run `stagecraft login`" + )) + })?; + let client = ApiClient::new(base_url.clone(), Some(token), debug)?; + let identity = block_on(client.fetch_identity())??; + let payload = Whoami { + base_url, + id: identity.id, + email: identity.email, + name: identity.name, + }; + output::emit(format, &payload, || render_whoami(&payload)); + Ok(()) +} + +fn render_whoami(w: &Whoami) -> String { + match &w.name { + Some(name) => format!("{name} <{}> (id {}, {})", w.email, w.id, w.base_url), + None => format!("{} (id {}, {})", w.email, w.id, w.base_url), + } +} + +/// Resolve the effective control-plane base URL, normalized, or a usage error +/// (exit 2) naming the flag and env var that set it. +pub(crate) fn require_base_url(resolved: &ResolvedConfig) -> AppResult { + resolved + .base_url + .value + .as_deref() + .map(normalize_base_url) + .ok_or_else(|| { + AppError::Usage( + "no control-plane base URL; pass --base-url or set STAGECRAFT_BASE_URL".to_string(), + ) + }) +} + +/// Drop trailing slashes so a URL is one canonical credentials key and one +/// stable request prefix (`http://x:4000/` and `http://x:4000` are the same). +pub(crate) fn normalize_base_url(base_url: &str) -> String { + base_url.trim_end_matches('/').to_string() +} + +/// Run an async operation to completion on a fresh multi-thread runtime. The +/// CLI is otherwise synchronous; only the API verbs need async, so the runtime +/// is built per invocation rather than wrapping `main`. A build failure (thread +/// or resource exhaustion) is an operational error (exit 1), never a panic that +/// would escape the exit-code taxonomy. +pub(crate) fn block_on(future: F) -> AppResult { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|e| { + AppError::Operational(anyhow::anyhow!("failed to start async runtime: {e}")) + })?; + Ok(runtime.block_on(future)) +} + +/// Only idempotent methods are safe to retry (spec 003 §2: GETs only). +fn is_retryable_method(method: &Method) -> bool { + matches!(*method, Method::GET | Method::HEAD) +} + +/// Jittered backoff before the nth retry: `RETRY_BASE * n` plus 0-49ms of +/// jitter derived from the wall clock (no `rand` dependency). +async fn backoff(attempt: u32) { + let jitter = Duration::from_millis(jitter_ms()); + tokio::time::sleep(RETRY_BASE * attempt + jitter).await; +} + +fn jitter_ms() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| u64::from(d.subsec_nanos() % 50)) + .unwrap_or(0) +} + +/// Extract a human-facing message from an error body: the chassis `message` +/// field when present, else the trimmed body, else a placeholder. +fn server_message(body: &str) -> String { + #[derive(Deserialize)] + struct ErrBody { + message: Option, + } + if let Ok(ErrBody { + message: Some(message), + }) = serde_json::from_str::(body) + { + return message; + } + let trimmed = body.trim(); + if trimmed.is_empty() { + "(no message)".to_string() + } else { + trimmed.chars().take(200).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use httpmock::prelude::*; + use serde_json::json; + + #[test] + fn normalize_trims_trailing_slashes() { + assert_eq!(normalize_base_url("http://x:4000/"), "http://x:4000"); + assert_eq!(normalize_base_url("http://x:4000"), "http://x:4000"); + assert_eq!(normalize_base_url("http://x:4000///"), "http://x:4000"); + } + + #[test] + fn only_idempotent_methods_retry() { + assert!(is_retryable_method(&Method::GET)); + assert!(is_retryable_method(&Method::HEAD)); + assert!(!is_retryable_method(&Method::POST)); + assert!(!is_retryable_method(&Method::DELETE)); + } + + #[test] + fn server_message_prefers_json_message_field() { + assert_eq!(server_message(r#"{"code":"x","message":"boom"}"#), "boom"); + assert_eq!(server_message("plain text"), "plain text"); + assert_eq!(server_message(" "), "(no message)"); + } + + #[test] + fn unauthenticated_message_hints_login() { + assert!(ApiError::Unauthenticated + .to_string() + .contains("stagecraft login")); + } + + #[test] + fn api_errors_map_to_operational_exit_code() { + let err: AppError = ApiError::Unauthenticated.into(); + assert_eq!(err.code(), crate::error::EXIT_OPERATIONAL); + } + + #[test] + fn whoami_json_shape_is_stable() { + // The emitted envelope is API the MCP face reuses (spec 003 §2). + let payload = Whoami { + base_url: "http://localhost:4000".to_string(), + id: "u_1".to_string(), + email: "dev@example.com".to_string(), + name: Some("Dev".to_string()), + }; + let v = serde_json::to_value(&payload).unwrap(); + assert_eq!(v["base_url"], "http://localhost:4000"); + assert_eq!(v["id"], "u_1"); + assert_eq!(v["email"], "dev@example.com"); + assert_eq!(v["name"], "Dev"); + + // An absent name is omitted, never serialized as null. + let anon = Whoami { + base_url: "b".to_string(), + id: "i".to_string(), + email: "e".to_string(), + name: None, + }; + let v = serde_json::to_value(&anon).unwrap(); + assert!(v.get("name").is_none(), "name must be omitted when None"); + } + + #[test] + fn whoami_happy_path_parses_identity() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path(AUTH_ME_PATH) + .header("authorization", "Bearer good-token"); + then.status(200).json_body(json!({ + "id": "u_1", + "email": "dev@example.com", + "name": "Dev", + "roles": ["admin"] + })); + }); + + let client = ApiClient::new(server.base_url(), Some("good-token".into()), false).unwrap(); + let identity = block_on(client.fetch_identity()).unwrap().unwrap(); + + mock.assert(); + assert_eq!(identity.id, "u_1"); + assert_eq!(identity.email, "dev@example.com"); + assert_eq!(identity.name.as_deref(), Some("Dev")); + } + + #[test] + fn whoami_401_maps_to_unauthenticated() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path(AUTH_ME_PATH); + then.status(401).json_body(json!({ + "code": "unauthenticated", + "message": "missing authentication credentials" + })); + }); + + let client = ApiClient::new(server.base_url(), Some("bad".into()), false).unwrap(); + let err = block_on(client.fetch_identity()).unwrap().unwrap_err(); + assert!(matches!(err, ApiError::Unauthenticated)); + } + + #[test] + fn whoami_4xx_carries_server_message() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path(AUTH_ME_PATH); + then.status(403) + .json_body(json!({ "code": "forbidden", "message": "tenant suspended" })); + }); + + let client = ApiClient::new(server.base_url(), Some("t".into()), false).unwrap(); + let err = block_on(client.fetch_identity()).unwrap().unwrap_err(); + match err { + ApiError::Api { status, message } => { + assert_eq!(status, 403); + assert_eq!(message, "tenant suspended"); + } + other => panic!("expected Api error, got {other:?}"), + } + } + + #[test] + fn get_retries_5xx_up_to_max_attempts() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/boom"); + then.status(503); + }); + + let client = ApiClient::new(server.base_url(), None, false).unwrap(); + let (status, _) = block_on(client.send_retrying(Method::GET, "/boom")) + .unwrap() + .unwrap(); + + assert_eq!(status.as_u16(), 503); + assert_eq!(mock.hits(), MAX_ATTEMPTS as usize); + } + + #[test] + fn non_idempotent_request_is_not_retried() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/boom"); + then.status(503); + }); + + let client = ApiClient::new(server.base_url(), None, false).unwrap(); + let (status, _) = block_on(client.send_retrying(Method::POST, "/boom")) + .unwrap() + .unwrap(); + + assert_eq!(status.as_u16(), 503); + assert_eq!(mock.hits(), 1); + } +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..f624605 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,341 @@ +//! Authentication and the on-disk credentials store (spec 003 §2). +//! +//! `stagecraft login` performs the browser-assisted bearer-token handoff +//! chosen in the spec's 2026-07-14 amendment: the operator signs in through a +//! browser at the control plane, pastes the resulting session token (piped or +//! at a prompt, so a headless machine with a browser elsewhere works), the CLI +//! validates it against `/auth/me`, and stores it. Tokens live in +//! `~/.config/stagecraft/credentials.toml` at mode 0600, keyed by base URL, +//! never in the config file. The stored token is replayed as a bearer token by +//! the api module; the acquisition path can later become the RFC 8252 loopback +//! flow with no change to this store. + +use std::collections::BTreeMap; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::api::{self, ApiClient, ApiError}; +use crate::config::ResolvedConfig; +use crate::error::{AppError, AppResult}; +use crate::output::{self, OutputFormat}; + +/// The credentials file: bearer tokens keyed by control-plane base URL. +/// Multiple planes may be present; each key is a normalized base URL. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Credentials { + #[serde(default)] + pub planes: BTreeMap, +} + +/// One plane's stored secret. A struct (not a bare string) so future fields +/// (issued-at, refresh token) extend the file without breaking the shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlaneCredential { + pub token: String, +} + +/// The credentials file path: `~/.config/stagecraft/credentials.toml` on Linux, +/// alongside `config.toml` but holding only secrets. `None` without a home dir. +pub fn default_credentials_path() -> Option { + directories::ProjectDirs::from("", "", "stagecraft") + .map(|dirs| dirs.config_dir().join("credentials.toml")) +} + +/// Load the credentials file from the platform default path (empty if absent). +pub fn load() -> Result { + match default_credentials_path() { + Some(path) => load_from(&path), + None => Ok(Credentials::default()), + } +} + +/// Load and parse a credentials file. A missing file is not an error. +pub fn load_from(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(text) => toml::from_str(&text) + .with_context(|| format!("parsing credentials file {}", path.display())), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Credentials::default()), + Err(err) => { + Err(err).with_context(|| format!("reading credentials file {}", path.display())) + } + } +} + +/// The stored token for `base_url`, if any. `base_url` must be normalized (the +/// api module's `normalize_base_url`) so it matches the key `login` wrote. +pub fn load_token(base_url: &str) -> AppResult> { + let creds = load().map_err(AppError::Operational)?; + Ok(creds.planes.get(base_url).map(|c| c.token.clone())) +} + +/// Persist credentials to the platform default path (mode 0600), returning it. +pub fn save(creds: &Credentials) -> Result { + let path = default_credentials_path() + .context("no home directory: cannot locate the credentials file")?; + save_to(&path, creds)?; + Ok(path) +} + +/// Serialize and write credentials to `path` with private (0600) permissions, +/// creating parent directories as needed. +pub fn save_to(path: &Path, creds: &Credentials) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + let text = toml::to_string_pretty(creds).context("serializing credentials")?; + write_private(path, &text) +} + +/// Write `text` to `path` restricted to the owner (0600). Writes to a sibling +/// temp file, then renames over the target, so a crash cannot truncate an +/// existing file. The secret is never briefly world-readable: the mode is +/// forced to 0600 *before* any bytes are written, even if a stale temp file +/// from an earlier crash pre-existed (where `OpenOptions::mode` is a no-op +/// because it only applies on creation). +#[cfg(unix)] +fn write_private(path: &Path, text: &str) -> Result<()> { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let tmp = { + let mut raw = path.as_os_str().to_owned(); + raw.push(".tmp"); + PathBuf::from(raw) + }; + // Clear any stale temp so the create below is a real creation and `mode` is + // honored atomically; ignore a missing file. + match std::fs::remove_file(&tmp) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("clearing stale {}", tmp.display())), + } + { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&tmp) + .with_context(|| format!("creating {}", tmp.display()))?; + // Belt and suspenders against a race that recreated the temp wider: + // tighten before the token is written, not after. + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("setting permissions on {}", tmp.display()))?; + file.write_all(text.as_bytes()) + .with_context(|| format!("writing {}", tmp.display()))?; + } + std::fs::rename(&tmp, path) + .with_context(|| format!("renaming {} into place", tmp.display()))?; + Ok(()) +} + +/// Non-Unix fallback: 0600 is a Unix concept; write the file best-effort. +#[cfg(not(unix))] +fn write_private(path: &Path, text: &str) -> Result<()> { + std::fs::write(path, text).with_context(|| format!("writing {}", path.display())) +} + +/// The `login` verb: the browser-assisted bearer-token handoff (spec 003 §2 +/// amendment). Read a token, validate it against the plane, then store it. +pub fn run_login(resolved: &ResolvedConfig, format: OutputFormat, debug: bool) -> AppResult<()> { + let base_url = api::require_base_url(resolved)?; + let token = read_token(&base_url)?; + if token.is_empty() { + // A required input the operator failed to supply: usage (exit 2), like a + // missing argument clap would reject. This differs from `whoami` finding + // no stored credential, which is an operational state (exit 1): there the + // command was invoked correctly but the plane precondition is unmet. + return Err(AppError::Usage("no token was provided".to_string())); + } + + // Validate before persisting: a token that cannot name its owner is not + // worth storing. A 401 here means the paste was wrong or expired. + let client = ApiClient::new(base_url.clone(), Some(token.clone()), debug)?; + let identity = match api::block_on(client.fetch_identity())? { + Ok(identity) => identity, + Err(ApiError::Unauthenticated) => { + return Err(AppError::Operational(anyhow::anyhow!( + "the control plane rejected that token (it may be expired or for a different plane)" + ))) + } + Err(other) => return Err(other.into()), + }; + + let mut creds = load().map_err(AppError::Operational)?; + creds + .planes + .insert(base_url.clone(), PlaneCredential { token }); + let path = save(&creds).map_err(AppError::Operational)?; + + let result = LoginResult { + base_url, + email: identity.email, + credentials_path: path.display().to_string(), + }; + output::emit(format, &result, || { + format!( + "Logged in to {} as {} (credentials: {})", + result.base_url, result.email, result.credentials_path + ) + }); + Ok(()) +} + +/// The `login` confirmation payload (stable JSON under `--output json`). +#[derive(Debug, Serialize)] +struct LoginResult { + base_url: String, + email: String, + credentials_path: String, +} + +/// Read the token: from a piped stdin when not a TTY, else an interactive +/// prompt. Guidance and the prompt go to stderr so stdout stays clean for +/// `--output json`. The token is read as a single trimmed line. +/// +/// v1 does not suppress terminal echo on the interactive path: the operator is +/// pasting a token already visible in their browser devtools, and masking would +/// add a dependency. Hardening credential entry (echo-off, OS keychain) is +/// deferred alongside the keychain work the spec puts out of scope. +fn read_token(base_url: &str) -> AppResult { + use std::io::IsTerminal; + + if io::stdin().is_terminal() { + eprintln!("Sign in at {base_url} in your browser, then paste your session token."); + eprint!("Token: "); + io::stderr().flush().ok(); + let mut line = String::new(); + io::stdin() + .read_line(&mut line) + .map_err(|e| AppError::Operational(e.into()))?; + Ok(line.trim().to_string()) + } else { + let mut buf = String::new(); + io::stdin() + .read_to_string(&mut buf) + .map_err(|e| AppError::Operational(e.into()))?; + Ok(buf.trim().to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A unique scratch path per test so parallel runs never collide. + fn scratch(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("stagecraft-cred-{}-{}", std::process::id(), tag)); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("credentials.toml") + } + + #[test] + fn round_trip_preserves_tokens_per_plane() { + let path = scratch("roundtrip"); + let mut creds = Credentials::default(); + creds.planes.insert( + "http://localhost:4000".to_string(), + PlaneCredential { + token: "tok-a".to_string(), + }, + ); + creds.planes.insert( + "https://plane.example".to_string(), + PlaneCredential { + token: "tok-b".to_string(), + }, + ); + save_to(&path, &creds).unwrap(); + + let loaded = load_from(&path).unwrap(); + assert_eq!( + loaded.planes.get("http://localhost:4000").unwrap().token, + "tok-a" + ); + assert_eq!( + loaded.planes.get("https://plane.example").unwrap().token, + "tok-b" + ); + let _ = std::fs::remove_file(&path); + } + + #[cfg(unix)] + #[test] + fn saved_file_is_mode_0600() { + use std::os::unix::fs::PermissionsExt; + + let path = scratch("perms"); + let mut creds = Credentials::default(); + creds.planes.insert( + "http://localhost:4000".to_string(), + PlaneCredential { + token: "secret".to_string(), + }, + ); + save_to(&path, &creds).unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "credentials file must be owner-only"); + let _ = std::fs::remove_file(&path); + } + + #[cfg(unix)] + #[test] + fn save_tightens_permissions_on_a_prewidened_file() { + use std::os::unix::fs::PermissionsExt; + + let path = scratch("widen"); + std::fs::write(&path, "planes = {}\n").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + save_to(&path, &Credentials::default()).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + let _ = std::fs::remove_file(&path); + } + + #[cfg(unix)] + #[test] + fn save_over_a_stale_wide_temp_file_stays_0600() { + // A crash can leave `credentials.toml.tmp` behind with wide bits. The + // next save must not write the token into that file while it is still + // readable: the final file (and the temp on the way) must be 0600. + use std::os::unix::fs::PermissionsExt; + + let path = scratch("stale-tmp"); + let tmp = { + let mut raw = path.as_os_str().to_owned(); + raw.push(".tmp"); + PathBuf::from(raw) + }; + std::fs::write(&tmp, "leftover").unwrap(); + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o666)).unwrap(); + + let mut creds = Credentials::default(); + creds.planes.insert( + "http://localhost:4000".to_string(), + PlaneCredential { + token: "secret".to_string(), + }, + ); + save_to(&path, &creds).unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "a stale wide temp must not widen the result"); + assert!(!tmp.exists(), "the temp file is renamed into place"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn missing_file_loads_as_empty() { + let path = std::env::temp_dir() + .join(format!("stagecraft-cred-{}-absent", std::process::id())) + .join("credentials.toml"); + let loaded = load_from(&path).unwrap(); + assert!(loaded.planes.is_empty()); + } +} diff --git a/src/cli.rs b/src/cli.rs index 4046e5c..781bd7e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -31,6 +31,10 @@ pub struct Cli { #[arg(long, global = true, value_name = "PATH")] pub config: Option, + /// Dump request/response metadata to stderr (never credential material). + #[arg(long, global = true)] + pub debug: bool, + #[command(subcommand)] pub command: Command, } diff --git a/src/commands.rs b/src/commands.rs index 70a3045..4f31409 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -21,8 +21,8 @@ pub fn dispatch(cli: Cli) -> AppResult<()> { let format = resolved.output_format(); match &cli.command { - Command::Login => Err(AppError::not_implemented("login", "003-auth-api-client")), - Command::Whoami => Err(AppError::not_implemented("whoami", "003-auth-api-client")), + Command::Login => crate::auth::run_login(&resolved, format, cli.debug), + Command::Whoami => crate::api::run_whoami(&resolved, format, cli.debug), Command::Tenants { command } => tenants(command), Command::Stamp { command } => stamp(command), Command::Fleet { command } => fleet(command), diff --git a/src/main.rs b/src/main.rs index 406001f..f013c7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,8 @@ //! (spec 002); auth (003), governance verbs (004), and the MCP server (005) //! hang off the command tree established here. +mod api; +mod auth; mod cli; mod commands; mod config; diff --git a/tests/cli.rs b/tests/cli.rs index 29b39e1..4d07008 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -16,10 +16,9 @@ fn run(args: &[&str]) -> Output { #[test] fn stub_verb_exits_2_names_spec_and_keeps_stdout_clean() { - // One representative stub per owning spec. + // One representative stub per still-unimplemented owning spec. login and + // whoami left this list when spec 003 implemented them. for (args, spec) in [ - (["login"].as_slice(), "003"), - (["whoami"].as_slice(), "003"), (["tenants", "list"].as_slice(), "004"), (["stamp", "new"].as_slice(), "004"), (["fleet", "remove"].as_slice(), "004"), @@ -40,6 +39,39 @@ fn stub_verb_exits_2_names_spec_and_keeps_stdout_clean() { } } +#[test] +fn auth_verbs_without_base_url_are_usage_errors() { + // login and whoami need a control plane to talk to; omitting it is misuse + // (exit 2), distinct from being logged out (exit 1, below). + for args in [["login"].as_slice(), ["whoami"].as_slice()] { + let out = run(args); + assert_eq!(out.status.code(), Some(2), "args {args:?} should exit 2"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("base URL"), + "args {args:?}: stderr should name the missing base URL, got: {stderr}" + ); + assert!( + out.stdout.is_empty(), + "args {args:?}: stdout must stay clean" + ); + } +} + +#[test] +fn whoami_without_a_stored_credential_exits_1_with_login_hint() { + // A base URL that no credentials file could hold: whoami short-circuits to + // the unauthenticated error before any network call, so this is hermetic. + let out = run(&["whoami", "--base-url", "https://unconfigured.plane.invalid"]); + assert_eq!(out.status.code(), Some(1), "unauthenticated is exit 1"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("stagecraft login"), + "stderr should hint at login, got: {stderr}" + ); + assert!(out.stdout.is_empty(), "errors must not print to stdout"); +} + #[test] fn help_lists_the_full_command_tree() { let out = run(&["--help"]);