diff --git a/CHANGELOG.md b/CHANGELOG.md index d0700e2d..3fa3da67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added pinned-root TUF registries for external Use packages, including full + metadata refresh, review/apply-bound install plans, signed provenance + receipts, and source-preserving upgrades. Registry upgrades query only the + recorded registry and channel, reject identity drift and version downgrades, + and converge without downloading an already installed target. +- Added deterministic component operation plans, cross-process component + locks, immutable plan digests, and preflight checks so reviewed installs, + upgrades, and removals fail before payload download or mutation when their + inputs change. - Added policy-aware A3S Use preparation to Code TUI startup. A missing Use component is installed from its verified release before terminal takeover when networking and automatic setup are allowed; offline mode and @@ -18,6 +27,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Reject symbolic-link, non-regular, replaced, or hard-linked component lock + files before truncation, and reject symbolic-link lock directories, so a + crafted runtime path cannot redirect cross-process lock writes. - Wait for the initial A3S Use MCP projection within a separate bounded startup budget, so the first model turn receives ready Use routes through `task`. Slow or broken surfaces remain non-fatal and continue converging in the diff --git a/Cargo.lock b/Cargo.lock index de542957..c6b9270e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,7 @@ dependencies = [ "a3s-search", "a3s-tui", "a3s-updater 0.3.0", + "a3s-use-extension", "anyhow", "async-trait", "axum", @@ -22,14 +23,18 @@ dependencies = [ "clap", "clap_complete", "comrak", - "futures", + "flate2", "fs2", + "futures", "http-body-util", "httpdate", "image", + "libc", + "olpc-cjson", "rand 0.8.6", "regex", "reqwest 0.11.27", + "ring", "rustls 0.23.41", "rustls-native-certs", "rustls-pemfile 2.2.0", @@ -38,6 +43,7 @@ dependencies = [ "sha2", "similar", "system-configuration 0.7.0", + "tar", "tempfile", "thiserror 2.0.18", "tokio", @@ -92,8 +98,10 @@ dependencies = [ [[package]] name = "a3s-code-core" version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "481f9350b8e65960c8cc1426a18131f26b50edd8275e034bc33ad60a6b5aa4ad" dependencies = [ - "a3s-acl 0.2.1", + "a3s-acl 0.2.2", "a3s-common", "a3s-flow", "a3s-lane", @@ -164,6 +172,8 @@ dependencies = [ [[package]] name = "a3s-flow" version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f3b680a1d4c5cb08e178ebab3d4b9c37fc3a31b9348ddb980ae9dceb48e10" dependencies = [ "async-trait", "chrono", @@ -289,7 +299,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "827c7c995e3ef1dbd55c5740619d04295965a2337e26b63fd8f7ddbcb35bd048" dependencies = [ - "a3s-use-core", + "a3s-use-core 0.1.0", "async-trait", "chromiumoxide", "fs2", @@ -316,6 +326,38 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "a3s-use-core" +version = "0.1.1" +source = "git+https://github.com/A3S-Lab/Use?rev=1b2a60d9c8930dcc9935b6841ed4a7778111f884#1b2a60d9c8930dcc9935b6841ed4a7778111f884" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "a3s-use-extension" +version = "0.1.1" +source = "git+https://github.com/A3S-Lab/Use?rev=1b2a60d9c8930dcc9935b6841ed4a7778111f884#1b2a60d9c8930dcc9935b6841ed4a7778111f884" +dependencies = [ + "a3s-acl 0.2.1", + "a3s-use-core 0.1.1", + "flate2", + "fs2", + "reqwest 0.12.28", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "tokio", + "tough", + "url", + "zip 2.4.2", +] + [[package]] name = "adler2" version = "2.0.1" @@ -576,6 +618,17 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "async-signal" version = "0.2.14" @@ -688,6 +741,30 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -905,6 +982,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -1062,6 +1141,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1488,6 +1576,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ego-tree" version = "0.10.0" @@ -1701,6 +1795,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -2484,6 +2584,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.102" @@ -2957,6 +3067,17 @@ dependencies = [ "libc", ] +[[package]] +name = "olpc-cjson" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "696183c9b5fe81a7715d074fd632e8bd46f4ccc0231a3ed7fc580a80de5f7083" +dependencies = [ + "serde", + "serde_json", + "unicode-normalization", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3100,6 +3221,16 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3211,6 +3342,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3709,7 +3860,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -3808,6 +3959,8 @@ version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -3863,7 +4016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -3872,9 +4025,10 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -3935,7 +4089,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -4059,6 +4213,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -4249,6 +4412,29 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "futures-core", + "pin-project", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "socket2" version = "0.5.10" @@ -4741,6 +4927,41 @@ dependencies = [ "winnow 0.5.40", ] +[[package]] +name = "tough" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8031cff0872dd1c6312370515a6be8098f6ea5512f1bad725016046fc725f272" +dependencies = [ + "async-recursion", + "async-trait", + "aws-lc-rs", + "bytes", + "chrono", + "dyn-clone", + "futures", + "futures-core", + "globset", + "hex", + "log", + "olpc-cjson", + "pem", + "percent-encoding", + "reqwest 0.12.28", + "rustls 0.23.41", + "serde", + "serde_json", + "serde_plain", + "snafu", + "tempfile", + "tokio", + "tokio-util", + "typed-path", + "untrusted 0.7.1", + "url", + "walkdir", +] + [[package]] name = "tower" version = "0.5.3" @@ -4924,6 +5145,12 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" +[[package]] +name = "typed-path" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82205ffd44a9697e34fc145491aa47310f9871540bb7909eaa9365e0a9a46607" + [[package]] name = "typenum" version = "1.20.1" @@ -4981,6 +5208,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -5824,3 +6057,7 @@ version = "0.2.1" [[patch.unused]] name = "a3s-boot" version = "0.1.1" + +[[patch.unused]] +name = "a3s-code-core" +version = "5.3.2" diff --git a/Cargo.toml b/Cargo.toml index 2fcd9794..9f17ca86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ a3s-tui = "=0.1.12" a3s-search = { version = "1.4.3", features = ["lightpanda"] } a3s-acl = "=0.2.2" a3s-updater = "=0.3.0" +a3s-use-extension = { git = "https://github.com/A3S-Lab/Use", rev = "1b2a60d9c8930dcc9935b6841ed4a7778111f884" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "io-std", "net", "fs", "signal"] } anyhow = "1" clap = { version = "4", features = ["derive"] } @@ -57,10 +58,17 @@ comrak = { version = "0.36", default-features = false } tempfile = "3" tracing = "0.1" +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [target.'cfg(target_os = "macos")'.dependencies] system-configuration = "0.7" [dev-dependencies] +flate2 = "1" +olpc-cjson = "0.1" +ring = "0.17" +tar = "0.4" tempfile = "3" [profile.release] diff --git a/README.md b/README.md index d39f4755..e2caf854 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,89 @@ not a compatible control-component release. Until that release exists, diagnostic that the control component is not published and do not create an installed-component record. Bench does not opt into first-use installation. +### Signed extension registries + +External Use domains can be resolved from explicitly trusted TUF registries. +The official `a3s` registry identity is listed but intentionally unavailable +until its production root is published; the CLI never invents or silently +accepts a replacement root. Add a third-party registry with a root file or a +pinned SHA-256: + +```sh +a3s registry add https://packages.example.org/a3s/ \ + --trust-root ./root.json \ + --yes +a3s registry refresh packages + +a3s --output json install use/acme/slack --dry-run +a3s --output json install use/acme/slack \ + --plan-digest + +a3s --output json upgrade use/acme/slack --dry-run +a3s --output json upgrade use/acme/slack \ + --plan-digest +``` + +Root files are copied beneath the owned `registries//root.json` path and +checked against the recorded digest whenever configuration is loaded. A +digest-only registry bootstraps from `/metadata/root.json`; the root +is cached only after its bytes match the pin. `registry refresh` performs full +TUF root, timestamp, snapshot, and targets verification, including expiration +and rollback checks. It does not use a reachability-only `HEAD` request and does +not download package targets. + +Package lookup queries configured registries in stable name order. No match is +an error, and the same package resolving from more than one trusted registry is +rejected as ambiguous. Registry installs cannot use `--allow-unsigned`; that +flag remains limited to an explicit local `--from` package. Registry URLs must +use HTTPS, except loopback HTTP used by the hermetic test suite. + +A signed-extension upgrade reads the complete source provenance from the +installed Use receipt and queries only that registry and release channel. It +refuses a removed registry, a changed URL or trust root, and a semantic-version +downgrade. `a3s upgrade --all` includes signed registry extensions but excludes +explicit local packages, whose next source must be supplied by the operator. +Plain `a3s upgrade` includes newer signed targets in its update listing. When +verified metadata resolves to the already installed target, the operation +converges without downloading the archive or publishing a new activation. + +### Reviewable component plans + +Installation, upgrade, and uninstall support an immutable review/apply guard: + +```sh +a3s --output json install box --dry-run +a3s --output json install box --plan-digest +``` + +The dry-run returns `planSchemaVersion`, a canonical SHA-256 `planDigest`, and +the ordered `plans` array. The digest covers the requested operation and flags, +target platform, current component state and normalized receipt, operation +order, local-package path and content, and every exact GitHub release version, +asset URL, asset name, and SHA-256 selected during resolution. For signed +extensions it also covers the registry name and URL, pinned root, all TUF +metadata versions, package version and channel, platform target, target path, +archive length, and SHA-256. Presentation text is deliberately excluded. + +A release dry-run reads release metadata and, when necessary, its checksum +file. It never downloads the component payload or changes component state. An +apply invocation reacquires the component locks, recomputes the full plan, and +fails before download if the supplied digest no longer matches. It uses the +exact resolved artifact from that plan instead of performing another `latest` +lookup. Local Use packages are fingerprinted deterministically, including file +paths, executable permissions, sizes, and contents. + +Signed-extension dry-runs verify metadata without downloading the archive. On +apply, the umbrella digest is checked first; `a3s` then delegates the exact +resolved package and its inner registry-plan digest to `a3s-use`. Use repeats +TUF verification before target download, so a repository change between the +outer check and payload fetch also fails without activation. + +`--plan-digest` and `--dry-run` are mutually exclusive. Homebrew plans bind the +formula, operation, flags, and current receipt, but Homebrew remains responsible +for selecting and installing its current bottle; use `--source release` when an +exact A3S release artifact must be bound by the digest. + ### Listing installed components ```sh diff --git a/src/account_providers/codex/tls.rs b/src/account_providers/codex/tls.rs index 52ba20a5..db60c8a0 100644 --- a/src/account_providers/codex/tls.rs +++ b/src/account_providers/codex/tls.rs @@ -62,9 +62,13 @@ impl TlsRoots { ) })?; } - Ok(ClientConfig::builder() - .with_root_certificates(roots) - .with_no_client_auth()) + Ok( + ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions() + .context("configure Codex WebSocket TLS protocol versions")? + .with_root_certificates(roots) + .with_no_client_auth(), + ) } pub(super) fn add_to_reqwest( diff --git a/src/cli/args.rs b/src/cli/args.rs index 979f2d4d..ec8d1053 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -554,6 +554,14 @@ pub(crate) struct InstallArgs { /// Resolve and print the operation plan without mutation. #[arg(long)] pub dry_run: bool, + /// Apply only if the newly resolved plan matches this reviewed SHA-256 digest. + #[arg( + long, + value_name = "SHA256", + conflicts_with = "dry_run", + value_parser = parse_plan_digest + )] + pub plan_digest: Option, /// Explicitly trust an unsigned local development package. #[arg(long)] pub allow_unsigned: bool, @@ -576,6 +584,14 @@ pub(crate) struct UpgradeArgs { /// Resolve and print the operation plan without mutation. #[arg(long)] pub dry_run: bool, + /// Apply only if the newly resolved plan matches this reviewed SHA-256 digest. + #[arg( + long, + value_name = "SHA256", + conflicts_with = "dry_run", + value_parser = parse_plan_digest + )] + pub plan_digest: Option, } #[derive(Clone, Debug, Default, Args)] @@ -595,6 +611,25 @@ pub(crate) struct UninstallArgs { /// Resolve and print the operation plan without mutation. #[arg(long)] pub dry_run: bool, + /// Apply only if the newly resolved plan matches this reviewed SHA-256 digest. + #[arg( + long, + value_name = "SHA256", + conflicts_with = "dry_run", + value_parser = parse_plan_digest + )] + pub plan_digest: Option, +} + +fn parse_plan_digest(value: &str) -> Result { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err("expected exactly 64 lowercase hexadecimal characters".to_string()); + } + Ok(value.to_string()) } #[derive(Debug, Args)] diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9628ff9e..9c7d26b1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -315,10 +315,12 @@ async fn dispatch(command: RootCommand, context: &InvocationContext) -> anyhow:: Ok(ExitCode::SUCCESS) } RootCommand::List(args) => { - a3s::components::run_list_with( + let registries = crate::commands::registry::store(context)?; + a3s::components::run_list_with_registries( list_argv(args, output)?, &context.component_paths, context.network.offline, + ®istries, ) .await?; Ok(ExitCode::SUCCESS) @@ -333,11 +335,13 @@ async fn dispatch(command: RootCommand, context: &InvocationContext) -> anyhow:: Ok(ExitCode::SUCCESS) } RootCommand::Install(args) => { - a3s::components::run_install_with( + let registries = crate::commands::registry::store(context)?; + a3s::components::run_install_with_registries( install_argv(args, output)?, &context.component_paths, context.network.offline, context.output.progress, + ®istries, ) .await?; Ok(ExitCode::SUCCESS) @@ -558,10 +562,12 @@ async fn run_upgrade(args: UpgradeArgs, context: &InvocationContext) -> anyhow:: updates: true, ..ListArgs::default() }; - a3s::components::run_list_with( + let registries = crate::commands::registry::store(context)?; + a3s::components::run_upgrade_list_with_registries( list_argv(list, output)?, &context.component_paths, context.network.offline, + ®istries, ) .await?; return Ok(ExitCode::SUCCESS); @@ -580,12 +586,17 @@ async fn run_upgrade(args: UpgradeArgs, context: &InvocationContext) -> anyhow:: if args.dry_run { argv.push("--dry-run".to_string()); } + if let Some(plan_digest) = args.plan_digest { + argv.push(format!("--plan-digest={plan_digest}")); + } append_json_flag(&mut argv, output)?; - a3s::components::run_update_with( + let registries = crate::commands::registry::store(context)?; + a3s::components::run_update_with_registries( argv, &context.component_paths, context.network.offline, context.output.progress, + ®istries, ) .await?; Ok(ExitCode::SUCCESS) @@ -616,11 +627,13 @@ async fn run_legacy_update( } let mut args = args; append_json_flag(&mut args, output)?; - a3s::components::run_update_with( + let registries = crate::commands::registry::store(context)?; + a3s::components::run_update_with_registries( args, &context.component_paths, context.network.offline, output == OutputMode::Human && context.output.progress, + ®istries, ) .await?; Ok(ExitCode::SUCCESS) @@ -837,6 +850,9 @@ fn install_argv(args: InstallArgs, output: OutputMode) -> anyhow::Result anyhow::Result(); let component_batch = error.downcast_ref::(); + let plan_mismatch = error.downcast_ref::(); let message = structured .map(|error| error.message.clone()) .or_else(|| component_batch.map(ToString::to_string)) + .or_else(|| plan_mismatch.map(ToString::to_string)) .unwrap_or_else(|| format!("{error:#}")); let code = structured .map(|error| error.code) @@ -82,6 +84,7 @@ pub(crate) fn render_error( } }) }) + .or_else(|| plan_mismatch.map(|_| "component.plan_mismatch")) .unwrap_or("operation.failed"); let suggestion = structured .and_then(|error| error.suggestion.as_deref()) @@ -89,10 +92,16 @@ pub(crate) fn render_error( component_batch .is_some() .then_some("Inspect each component failure and rerun only failed targets.") + }) + .or_else(|| { + plan_mismatch + .is_some() + .then_some("Rerun with --dry-run, review the new plan, and use its planDigest.") }); let details = structured .map(|error| error.details.clone()) .or_else(|| component_batch.and_then(|batch| serde_json::to_value(batch).ok())) + .or_else(|| plan_mismatch.and_then(|mismatch| serde_json::to_value(mismatch).ok())) .unwrap_or_else(|| json!({})); match mode { OutputMode::Human => { diff --git a/src/commands/registry.rs b/src/commands/registry.rs index 0a9c78b8..68720cfa 100644 --- a/src/commands/registry.rs +++ b/src/commands/registry.rs @@ -1,28 +1,13 @@ -use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; -use a3s_acl::{Block, Document, Value}; +use a3s::registry::{RegistryStore, TrustRootSource}; use anyhow::{bail, Context}; -use serde::Serialize; use serde_json::json; -use sha2::{Digest, Sha256}; use crate::cli::args::{OutputMode, RegistryArgs, RegistryCommand}; use crate::cli::context::InvocationContext; -use crate::cli::output::{coded_error, render_value, CliError, ExitClass}; - -const OFFICIAL_NAME: &str = "a3s"; -const OFFICIAL_URL: &str = "https://components.a3s.dev/"; - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct RegistryRecord { - name: String, - url: String, - trust_root: String, - built_in: bool, -} +use crate::cli::output::{coded_error, render_value, ExitClass}; pub(crate) async fn run(args: RegistryArgs, context: &InvocationContext) -> anyhow::Result<()> { match args.command { @@ -36,7 +21,7 @@ pub(crate) async fn run(args: RegistryArgs, context: &InvocationContext) -> anyh fn list(context: &InvocationContext) -> anyhow::Result<()> { let output = context.output_mode(); - let registries = registries(context)?; + let registries = store(context)?.list()?; render_value( output, "registry.list", @@ -46,6 +31,9 @@ fn list(context: &InvocationContext) -> anyhow::Result<()> { for registry in ®istries { println!("{:<24} {}", registry.name, registry.trust_root); println!(" {}", registry.url); + if !registry.configured { + println!(" unavailable: production TUF root is not configured"); + } } }, ) @@ -53,11 +41,9 @@ fn list(context: &InvocationContext) -> anyhow::Result<()> { fn show(name: &str, context: &InvocationContext) -> anyhow::Result<()> { let output = context.output_mode(); - validate_name(name)?; - let registry = registries(context)? - .into_iter() - .find(|registry| registry.name == name) - .with_context(|| format!("registry `{name}` is not configured"))?; + let registry = store(context)? + .get(name)? + .with_context(|| format!("registry '{name}' is not configured"))?; render_value( output, "registry.show", @@ -67,67 +53,68 @@ fn show(name: &str, context: &InvocationContext) -> anyhow::Result<()> { println!("url: {}", registry.url); println!("trust root: {}", registry.trust_root); println!("built in: {}", registry.built_in); + println!("configured: {}", registry.configured); + if let Some(path) = ®istry.trusted_root_path { + println!("trusted root file: {}", path.display()); + } }, ) } fn add(url: &str, trust_root: &str, yes: bool, context: &InvocationContext) -> anyhow::Result<()> { let output = context.output_mode(); - let parsed = validate_url(url)?; - let name = registry_name(&parsed)?; - if name == OFFICIAL_NAME { - bail!("registry name `a3s` is reserved for the built-in official registry"); - } - let trust_root = resolve_trust_root(trust_root, context)?; + let store = store(context)?; + let path; + let source = if trust_root.starts_with("sha256:") { + TrustRootSource::Digest(trust_root) + } else { + path = context.resolve_path(trust_root); + TrustRootSource::File(&path) + }; + let enrollment = store.prepare_enrollment(url, source)?; if !yes { confirm( - &format!("Trust registry `{name}` at {url} with root {trust_root}?"), + &format!( + "Trust registry '{}' at {} with root {}?", + enrollment.record.name, enrollment.record.url, enrollment.record.trust_root + ), context, - "registry enrollment requires `--yes` in non-interactive mode", + "registry enrollment requires '--yes' in non-interactive mode", )?; } - let path = registry_path(&name, context)?; - if path.exists() { - bail!("registry `{name}` already exists; remove it before changing its trust root"); - } - let record = RegistryRecord { - name: name.clone(), - url: parsed.to_string(), - trust_root, - built_in: false, - }; - write_registry(&path, &record)?; + store.add(&enrollment)?; + let record = enrollment.record; + let name = record.name.clone(); render_value( output, "registry.add", json!({"registry": record, "created": true}), - || println!("added trusted registry `{name}`"), + || println!("added trusted registry '{name}'"), ) } fn remove(name: &str, yes: bool, context: &InvocationContext) -> anyhow::Result<()> { let output = context.output_mode(); - validate_name(name)?; - if name == OFFICIAL_NAME { + let store = store(context)?; + let record = store + .get(name)? + .with_context(|| format!("registry '{name}' is not configured"))?; + if record.built_in { bail!("the built-in official registry cannot be removed"); } - let path = registry_path(name, context)?; - let record = - read_registry(&path)?.with_context(|| format!("registry `{name}` is not configured"))?; if !yes { confirm( - &format!("Remove trusted registry `{name}`?"), + &format!("Remove trusted registry '{name}'?"), context, - "registry removal requires `--yes` in non-interactive mode", + "registry removal requires '--yes' in non-interactive mode", )?; } - std::fs::remove_file(&path) - .with_context(|| format!("could not remove registry file {}", path.display()))?; + let record = store.remove(name)?; render_value( output, "registry.remove", json!({"registry": record, "removed": true}), - || println!("removed registry `{name}`"), + || println!("removed registry '{name}'"), ) } @@ -136,40 +123,42 @@ async fn refresh(name: Option<&str>, context: &InvocationContext) -> anyhow::Res if context.network.offline { bail!("registry refresh is unavailable in offline mode"); } - let mut selected = registries(context)?; - if let Some(name) = name { - validate_name(name)?; - selected.retain(|registry| registry.name == name); - if selected.is_empty() { - bail!("registry `{name}` is not configured"); - } - } - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build()?; + let store = store(context)?; + let mut selected = if let Some(name) = name { + vec![store + .get(name)? + .with_context(|| format!("registry '{name}' is not configured"))?] + } else { + store.list()? + }; + selected.sort_by(|left, right| left.name.cmp(&right.name)); let mut results = Vec::new(); for registry in selected { - let result = match client.head(®istry.url).send().await { - Ok(response) => json!({ - "name": registry.name, - "url": registry.url, - "reachable": response.status().is_success() || response.status().is_redirection(), - "status": response.status().as_u16(), - }), - Err(error) => json!({ + if !registry.configured { + if name.is_some() { + bail!( + "registry '{}' has no production TUF trust root configured", + registry.name + ); + } + results.push(json!({ "name": registry.name, "url": registry.url, - "reachable": false, - "error": error.to_string(), - }), - }; - results.push(result); - } - let all_reachable = results - .iter() - .all(|result| result["reachable"].as_bool() == Some(true)); - if !all_reachable { - bail!("one or more registries could not be reached"); + "configured": false, + "verified": false, + })); + continue; + } + let metadata = registry + .refresh(&context.component_paths.state_root) + .await?; + results.push(json!({ + "name": registry.name, + "url": registry.url, + "configured": true, + "verified": true, + "metadata": metadata, + })); } render_value( output, @@ -177,194 +166,42 @@ async fn refresh(name: Option<&str>, context: &InvocationContext) -> anyhow::Res json!({"registries": results}), || { for result in &results { - println!( - "ok {} ({})", - result["name"].as_str().unwrap_or_default(), - result["status"].as_u64().unwrap_or_default() - ); + if result["verified"].as_bool() == Some(true) { + println!( + "verified {} (root {}, timestamp {}, snapshot {}, targets {})", + result["name"].as_str().unwrap_or_default(), + result["metadata"]["rootVersion"] + .as_u64() + .unwrap_or_default(), + result["metadata"]["timestampVersion"] + .as_u64() + .unwrap_or_default(), + result["metadata"]["snapshotVersion"] + .as_u64() + .unwrap_or_default(), + result["metadata"]["targetsVersion"] + .as_u64() + .unwrap_or_default(), + ); + } else { + println!( + "unavailable {} (production TUF root is not configured)", + result["name"].as_str().unwrap_or_default() + ); + } } }, ) } -fn registries(context: &InvocationContext) -> anyhow::Result> { - let mut records = vec![RegistryRecord { - name: OFFICIAL_NAME.to_string(), - url: OFFICIAL_URL.to_string(), - trust_root: "built-in TUF root".to_string(), - built_in: true, - }]; - let root = registry_root(context)?; - if root.is_dir() { - for entry in std::fs::read_dir(root)? { - let path = entry?.path(); - if path.extension().and_then(|value| value.to_str()) != Some("acl") { - continue; - } - if let Some(record) = read_registry(&path)? { - records.push(record); - } - } - } - records.sort_by(|left, right| left.name.cmp(&right.name)); - Ok(records) -} - -fn write_registry(path: &Path, record: &RegistryRecord) -> anyhow::Result<()> { - let attributes = HashMap::from([ - ("url".to_string(), Value::String(record.url.clone())), - ( - "trust_root".to_string(), - Value::String(record.trust_root.clone()), - ), - ]); - let document = Document { - blocks: vec![Block { - name: "registry".to_string(), - labels: vec![record.name.clone()], - blocks: Vec::new(), - attributes, - }], - }; - let rendered = a3s_acl::generate_acl(&document); - a3s_acl::parse_acl(&rendered).context("generated registry ACL is invalid")?; - crate::api::code_web::config::persistence::write_atomic(path, rendered.as_bytes()) - .map_err(|error| anyhow::anyhow!("could not write {}: {error}", path.display())) -} - -fn read_registry(path: &Path) -> anyhow::Result> { - let source = match std::fs::read_to_string(path) { - Ok(source) => source, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error.into()), - }; - let document = a3s_acl::parse_acl(&source) - .with_context(|| format!("invalid registry ACL {}", path.display()))?; - let block = document - .blocks - .into_iter() - .find(|block| block.name == "registry") - .with_context(|| format!("registry ACL {} has no registry block", path.display()))?; - let name = block - .labels - .first() - .cloned() - .context("registry block requires one name label")?; - validate_name(&name)?; - let url = block - .attributes - .get("url") - .and_then(Value::as_str) - .context("registry URL is missing")? - .to_string(); - validate_url(&url)?; - let trust_root = block - .attributes - .get("trust_root") - .and_then(Value::as_str) - .context("registry trust_root is missing")? - .to_string(); - Ok(Some(RegistryRecord { - name, - url, - trust_root, - built_in: false, - })) -} - -fn registry_root(context: &InvocationContext) -> anyhow::Result { +pub(crate) fn registry_root(context: &InvocationContext) -> anyhow::Result { let config = crate::commands::config::active_config_path(context)?; let parent = config.parent().unwrap_or_else(|| Path::new(".")); Ok(parent.join("registries")) } -fn registry_path(name: &str, context: &InvocationContext) -> anyhow::Result { - validate_name(name)?; - Ok(registry_root(context)?.join(format!("{name}.acl"))) -} - -fn validate_url(url: &str) -> anyhow::Result { - let parsed = reqwest::Url::parse(url).map_err(|_| { - invalid_registry_url( - "registry URL is invalid", - "Use an absolute HTTPS URL without credentials, query parameters, or fragments.", - ) - })?; - if parsed.scheme() != "https" { - return Err(invalid_registry_url( - "registry URLs must use HTTPS", - "Use an https:// registry endpoint and establish trust with --trust-root.", - )); - } - if !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.query().is_some() - || parsed.fragment().is_some() - { - return Err(invalid_registry_url( - "registry URLs must not contain credentials, query parameters, or fragments", - "Move authentication out of the URL and use a stable registry identity endpoint.", - )); - } - Ok(parsed) -} - -fn invalid_registry_url(message: &str, suggestion: &str) -> anyhow::Error { - CliError::new("registry.url_invalid", message, ExitClass::Usage) - .with_suggestion(suggestion) - .with_details(json!({ - "requiredScheme": "https", - "credentialsAllowed": false, - "queryAllowed": false, - "fragmentAllowed": false, - })) - .into() -} - -fn registry_name(url: &reqwest::Url) -> anyhow::Result { - let host = url.host_str().context("registry URL requires a host")?; - let mut name = host - .trim_start_matches("www.") - .split('.') - .next() - .unwrap_or_default() - .to_ascii_lowercase(); - name.retain(|character| character.is_ascii_alphanumeric() || character == '-'); - validate_name(&name)?; - Ok(name) -} - -fn validate_name(name: &str) -> anyhow::Result<()> { - let mut characters = name.chars(); - if !characters - .next() - .is_some_and(|value| value.is_ascii_lowercase()) - || !characters - .all(|value| value.is_ascii_lowercase() || value.is_ascii_digit() || value == '-') - { - bail!("registry names use lowercase letters, digits, and hyphens and must start with a letter"); - } - Ok(()) -} - -fn resolve_trust_root(value: &str, context: &InvocationContext) -> anyhow::Result { - if let Some(digest) = value.strip_prefix("sha256:") { - if digest.len() == 64 - && digest - .chars() - .all(|character| character.is_ascii_hexdigit()) - { - return Ok(format!("sha256:{}", digest.to_ascii_lowercase())); - } - bail!("SHA-256 trust roots require exactly 64 hexadecimal characters"); - } - let path = context.resolve_path(value); - let bytes = std::fs::read(&path) - .with_context(|| format!("could not read trust-root file {}", path.display()))?; - if bytes.is_empty() { - bail!("trust-root file is empty"); - } - Ok(format!("sha256:{:x}", Sha256::digest(bytes))) +pub(crate) fn store(context: &InvocationContext) -> anyhow::Result { + Ok(RegistryStore::new(registry_root(context)?)) } fn confirm(prompt: &str, context: &InvocationContext, non_interactive: &str) -> anyhow::Result<()> { diff --git a/src/components/command.rs b/src/components/command.rs index f1ba83b7..a3a33f49 100644 --- a/src/components/command.rs +++ b/src/components/command.rs @@ -1,8 +1,9 @@ +use std::collections::BTreeMap; use std::ffi::OsString; -use std::io::{self, Write}; use std::path::PathBuf; use std::process::ExitStatus; +use crate::registry::RegistryStore; use a3s_updater::{fetch_latest_release, parse_version, InstallProvenance}; use anyhow::{bail, Context}; use serde::Serialize; @@ -11,16 +12,23 @@ use super::catalog::{self, ComponentKind, Distribution}; use super::discovery::{discover, find_state}; use super::id::ComponentId; use super::lifecycle::{ - install_component, uninstall_component, InstallRequest, InstallSource, OperationRecord, + install_component, install_component_locked, uninstall_component_locked, InstallIntent, + InstallRequest, InstallSource, OperationRecord, }; +use super::lock::ComponentOperationLock; use super::paths::ComponentPaths; +use super::plan::{ + install_plan, uninstall_plan, upgrade_plan, validate_install_plan, OperationPlanSet, +}; use super::state::{ComponentReport, Health, Presence, UpdateState}; mod options; +mod output; use options::{ DoctorOptions, InfoOptions, InstallOptions, ListOptions, UninstallOptions, UpdateOptions, }; +use output::{finish_batch, print_available, print_human_report, print_json, print_plans}; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -33,6 +41,7 @@ pub struct ComponentFailure { #[serde(rename_all = "camelCase")] pub struct ComponentBatchFailure { pub action: &'static str, + pub plan_digest: String, pub operations: Vec, pub failures: Vec, } @@ -75,6 +84,59 @@ pub async fn run_list_with( args: Vec, paths: &ComponentPaths, offline: bool, +) -> anyhow::Result<()> { + run_list_with_command(args, paths, offline, "component.list", false, None).await +} + +pub async fn run_list_with_registries( + args: Vec, + paths: &ComponentPaths, + offline: bool, + registries: &RegistryStore, +) -> anyhow::Result<()> { + run_list_with_command( + args, + paths, + offline, + "component.list", + false, + Some(registries), + ) + .await +} + +pub async fn run_upgrade_list_with( + args: Vec, + paths: &ComponentPaths, + offline: bool, +) -> anyhow::Result<()> { + run_list_with_command(args, paths, offline, "component.upgrade", true, None).await +} + +pub async fn run_upgrade_list_with_registries( + args: Vec, + paths: &ComponentPaths, + offline: bool, + registries: &RegistryStore, +) -> anyhow::Result<()> { + run_list_with_command( + args, + paths, + offline, + "component.upgrade", + true, + Some(registries), + ) + .await +} + +async fn run_list_with_command( + args: Vec, + paths: &ComponentPaths, + offline: bool, + command: &'static str, + managed_upgrades_only: bool, + registries: Option<&RegistryStore>, ) -> anyhow::Result<()> { let options = ListOptions::parse(&args)?; let mut report = discover(paths)?; @@ -82,16 +144,20 @@ pub async fn run_list_with( if offline { bail!("component update checks are unavailable in offline mode"); } - populate_updates(&mut report).await; + populate_updates(&mut report, paths, registries).await; } report.components.retain(|component| { (!options.installed || component.presence != Presence::Missing) && (!options.available || component.presence == Presence::Missing) && (!options.check_updates || component.update == UpdateState::Available) && options.kind.is_none_or(|kind| component.kind == kind) + && (!managed_upgrades_only || is_upgrade_all_candidate(component)) }); + if managed_upgrades_only { + report.external_tools.clear(); + } if options.json { - print_json("component.list", &report, true)?; + print_json(command, &report, true)?; } else { print_human_report(&report); } @@ -110,6 +176,26 @@ pub async fn run_install_with( paths: &ComponentPaths, offline: bool, progress: bool, +) -> anyhow::Result<()> { + run_install_with_registry(args, paths, offline, progress, None).await +} + +pub async fn run_install_with_registries( + args: Vec, + paths: &ComponentPaths, + offline: bool, + progress: bool, + registries: &RegistryStore, +) -> anyhow::Result<()> { + run_install_with_registry(args, paths, offline, progress, Some(registries)).await +} + +async fn run_install_with_registry( + args: Vec, + paths: &ComponentPaths, + offline: bool, + progress: bool, + registries: Option<&RegistryStore>, ) -> anyhow::Result<()> { let options = InstallOptions::parse(&args)?; if options.components.is_empty() { @@ -120,24 +206,55 @@ pub async fn run_install_with( let request = InstallRequest { version: options.version.clone(), source: options.source, + intent: InstallIntent::Install, package: options.package.clone(), force: options.force, allow_unsigned: options.allow_unsigned, progress, + resolved_releases: Default::default(), + resolved_sources: Default::default(), + resolved_registry_packages: Default::default(), }; - if options.dry_run { - let plans = options - .components - .iter() - .map(|component| install_plan(component, &request, &options, paths)) - .collect::>>()?; - return print_plans("component.install", &plans, options.json); + for component in &options.components { + validate_install_plan(component, &request)?; } + validate_registry_preflight(&options.components, &request, registries)?; enforce_install_network_policy(&options.components, &request, paths, offline)?; + let _locks = acquire_operation_locks(&options.components, paths).await?; + let mut prepared = Vec::with_capacity(options.components.len()); + for component in &options.components { + prepared.push( + install_plan( + component, + &request, + options.channel.as_str(), + options.scope.as_str(), + options.migrate, + paths, + registries, + ) + .await?, + ); + } + let plan_set = OperationPlanSet::new( + "component.install", + prepared + .iter() + .map(|prepared| prepared.plan.clone()) + .collect(), + )?; + if options.dry_run { + return print_plans("component.install", &plan_set, options.json); + } + plan_set.verify_expected(options.plan_digest.as_deref())?; let mut operations = Vec::new(); let mut failures = Vec::new(); - for component in options.components { - match install_component(&component, &request, paths).await { + for (component, prepared) in options.components.into_iter().zip(prepared) { + let mut prepared_request = request.clone(); + prepared_request.resolved_releases = prepared.resolved_releases; + prepared_request.resolved_sources = prepared.resolved_sources; + prepared_request.resolved_registry_packages = prepared.resolved_registry_packages; + match install_component_locked(&component, &prepared_request, paths).await { Ok(operation) => operations.push(operation), Err(error) => failures.push(ComponentFailure { component, @@ -148,6 +265,7 @@ pub async fn run_install_with( finish_batch( "component.install", "install", + plan_set.digest().to_string(), operations, failures, options.json, @@ -164,18 +282,21 @@ pub fn run_uninstall_with(args: Vec, paths: &ComponentPaths) -> anyhow:: if options.components.is_empty() { bail!("usage: a3s uninstall ... [--cascade] [--purge]"); } + let _locks = acquire_operation_locks_sync(&options.components, paths)?; + let plans = options + .components + .iter() + .map(|component| uninstall_plan(component, options.cascade, options.purge, paths)) + .collect::>>()?; + let plan_set = OperationPlanSet::new("component.uninstall", plans)?; if options.dry_run { - let plans = options - .components - .iter() - .map(|component| uninstall_plan(component, options.purge, paths)) - .collect::>>()?; - return print_plans("component.uninstall", &plans, options.json); + return print_plans("component.uninstall", &plan_set, options.json); } + plan_set.verify_expected(options.plan_digest.as_deref())?; let mut operations = Vec::new(); let mut failures = Vec::new(); for component in options.components { - match uninstall_component(&component, options.cascade, options.purge, paths) { + match uninstall_component_locked(&component, options.cascade, options.purge, paths) { Ok(operation) => operations.push(operation), Err(error) => failures.push(ComponentFailure { component, @@ -186,6 +307,7 @@ pub fn run_uninstall_with(args: Vec, paths: &ComponentPaths) -> anyhow:: finish_batch( "component.uninstall", "uninstall", + plan_set.digest().to_string(), operations, failures, options.json, @@ -204,15 +326,33 @@ pub async fn run_update_with( paths: &ComponentPaths, offline: bool, progress: bool, +) -> anyhow::Result<()> { + run_update_with_registry(args, paths, offline, progress, None).await +} + +pub async fn run_update_with_registries( + args: Vec, + paths: &ComponentPaths, + offline: bool, + progress: bool, + registries: &RegistryStore, +) -> anyhow::Result<()> { + run_update_with_registry(args, paths, offline, progress, Some(registries)).await +} + +async fn run_update_with_registry( + args: Vec, + paths: &ComponentPaths, + offline: bool, + progress: bool, + registries: Option<&RegistryStore>, ) -> anyhow::Result<()> { let options = UpdateOptions::parse(&args)?; let components = if options.all { discover(paths)? .components .into_iter() - .filter(|component| { - component.presence == Presence::Managed && component.kind != ComponentKind::BuiltIn - }) + .filter(is_upgrade_all_candidate) .map(|component| component.id) .collect::>() } else { @@ -221,19 +361,28 @@ pub async fn run_update_with( if components.is_empty() { bail!("no managed components were selected for update"); } - if options.dry_run { - let plans = components - .iter() - .map(|component| upgrade_plan(component, paths)) - .collect::>>()?; - return print_plans("component.upgrade", &plans, options.json); - } if offline { bail!("component upgrades require network access and are unavailable in offline mode"); } + let _locks = acquire_operation_locks(&components, paths).await?; + let mut prepared = Vec::with_capacity(components.len()); + for component in &components { + prepared.push(upgrade_plan(component, paths, registries).await?); + } + let plan_set = OperationPlanSet::new( + "component.upgrade", + prepared + .iter() + .map(|prepared| prepared.plan.clone()) + .collect(), + )?; + if options.dry_run { + return print_plans("component.upgrade", &plan_set, options.json); + } + plan_set.verify_expected(options.plan_digest.as_deref())?; let mut operations = Vec::new(); let mut failures = Vec::new(); - for component in components { + for (component, prepared) in components.into_iter().zip(prepared) { let operation = async { let state = find_state(&component, paths)?; if state.presence != Presence::Managed { @@ -242,18 +391,27 @@ pub async fn run_update_with( component ); } - let source = if state.provenance == Some(InstallProvenance::Homebrew) { - InstallSource::Homebrew - } else { - InstallSource::Release - }; + if state.provenance == Some(InstallProvenance::Delegated) + && catalog::find(&component).is_none() + && prepared.resolved_registry_packages.is_empty() + { + bail!( + "local extension '{}' has no recorded upgrade source; install the new package explicitly with 'a3s install {} --from --force --allow-unsigned'", + component, + component + ); + } let request = InstallRequest { - source, - force: true, + source: InstallSource::Auto, + intent: InstallIntent::Upgrade, + force: prepared.apply_force, progress, + resolved_releases: prepared.resolved_releases, + resolved_sources: prepared.resolved_sources, + resolved_registry_packages: prepared.resolved_registry_packages, ..InstallRequest::default() }; - install_component(&component, &request, paths).await + install_component_locked(&component, &request, paths).await } .await; match operation { @@ -267,12 +425,20 @@ pub async fn run_update_with( finish_batch( "component.upgrade", "upgrade", + plan_set.digest().to_string(), operations, failures, options.json, ) } +fn is_upgrade_all_candidate(component: &super::state::ComponentState) -> bool { + component.presence == Presence::Managed + && (component.kind == ComponentKind::Product + || (component.kind == ComponentKind::Extension + && component.trust == super::state::Trust::RegistryTuf)) +} + pub async fn run_info(args: Vec) -> anyhow::Result<()> { let paths = ComponentPaths::from_env()?; let offline = environment_flag("A3S_OFFLINE"); @@ -488,8 +654,54 @@ pub async fn resolve_or_install_with( .context("resolved component has no executable path") } -async fn populate_updates(report: &mut ComponentReport) { +async fn populate_updates( + report: &mut ComponentReport, + paths: &ComponentPaths, + registries: Option<&RegistryStore>, +) { for component in &mut report.components { + if component.presence != Presence::Managed { + continue; + } + if component.kind == ComponentKind::Extension + && component.trust == super::state::Trust::RegistryTuf + { + let result = async { + let registries = registries.context( + "signed extension update checks require the umbrella registry configuration", + )?; + let installed = + super::discovery::extension_registry_provenance(&component.id, paths)? + .context("signed extension has no registry provenance")?; + let resolved = registries + .resolve_upgrade(&paths.state_root, &installed) + .await?; + let current = parse_version(&installed.version)?; + let latest = parse_version(&resolved.package.version)?; + if latest < current { + bail!( + "registry '{}' attempted to downgrade from {} to {}", + installed.registry_name, + installed.version, + resolved.package.version + ); + } + Ok::<_, anyhow::Error>( + if latest > current || resolved.package.sha256 != installed.sha256 { + UpdateState::Available + } else { + UpdateState::Current + }, + ) + } + .await; + match result { + Ok(update) => component.update = update, + Err(error) => component.message = Some(format!("Update check failed: {error:#}")), + } + continue; + } + let Some(spec) = catalog::find(&component.id) else { continue; }; @@ -520,313 +732,101 @@ async fn populate_updates(report: &mut ComponentReport) { } } -fn print_human_report(report: &ComponentReport) { - println!( - "{:<18} {:<11} {:<17} {:<12} SOURCE", - "COMPONENT", "TYPE", "STATUS", "VERSION" - ); - for component in &report.components { - let status = match (component.presence, component.health) { - (Presence::Missing, _) => "missing".to_string(), - (_, Health::Broken) => "broken".to_string(), - (Presence::System, Health::Ready) => "ready (system)".to_string(), - (Presence::External, Health::Ready) => "ready (external)".to_string(), - (_, Health::Ready) => "ready".to_string(), - _ => "unknown".to_string(), - }; - let source = component - .provenance - .map(|value| format!("{value:?}").to_ascii_lowercase()) - .unwrap_or_else(|| "-".to_string()); - println!( - "{:<18} {:<11} {:<17} {:<12} {}", - component.id, - format!("{:?}", component.kind).to_ascii_lowercase(), - status, - component.version.as_deref().unwrap_or("-"), - source - ); - if let Some(message) = &component.message { - println!(" note: {message}"); - } +fn enforce_install_network_policy( + components: &[ComponentId], + request: &InstallRequest, + paths: &ComponentPaths, + offline: bool, +) -> anyhow::Result<()> { + if !offline { + return Ok(()); } - if !report.external_tools.is_empty() { - println!(); - println!("EXTERNAL TOOLS (discovered, never activated automatically)"); - for tool in &report.external_tools { - println!( - " {:<16} {:<20} {}", - tool.command, - tool.binary, - tool.path.display() + for component in components { + if request.package.is_none() && is_external_use_extension(component) { + bail!( + "signed registry package '{}' cannot be resolved in offline mode", + component ); } - } -} - -fn print_available(json: bool) -> anyhow::Result<()> { - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - struct Available<'a> { - schema_version: u32, - components: Vec>, - } - #[derive(Serialize)] - struct AvailableItem<'a> { - id: &'a str, - kind: ComponentKind, - description: &'a str, - } - let output = Available { - schema_version: 1, - components: catalog::all() - .iter() - .map(|component| AvailableItem { - id: component.id, - kind: component.kind, - description: component.description, - }) - .collect(), - }; - if json { - print_json("component.install", &output, true)?; - } else { - println!("Available A3S components:"); - for component in catalog::all() { - println!(" {:<18} {}", component.id, component.description); + if request.package.is_some() && is_external_use_extension(component) { + let parent = ComponentId::parse("use")?; + if find_state(&parent, paths)?.is_ready() { + continue; + } + bail!( + "component '{}' has a local package, but its parent '{}' is unavailable in offline mode", + component, + parent + ); } - } - Ok(()) -} - -fn print_operations( - command: &'static str, - operations: &[OperationRecord], - json: bool, -) -> anyhow::Result<()> { - if json { - print_json( - command, - &serde_json::json!({"operations": operations}), - true, - )?; - } else { - for operation in operations { - let marker = if operation.changed { "✓" } else { "=" }; - println!("{marker} {}", operation.message); + if !find_state(component, paths)?.is_ready() { + bail!( + "component '{}' is not available from an installed or explicit local source in offline mode", + component + ); } } Ok(()) } -fn finish_batch( - command: &'static str, - action: &'static str, - operations: Vec, - failures: Vec, - json: bool, +fn validate_registry_preflight( + components: &[ComponentId], + request: &InstallRequest, + registries: Option<&RegistryStore>, ) -> anyhow::Result<()> { - if failures.is_empty() { - return print_operations(command, &operations, json); - } - if !json && !operations.is_empty() { - print_operations(command, &operations, false)?; + if request.package.is_some() || !components.iter().any(is_external_use_extension) { + return Ok(()); } - Err(ComponentBatchFailure { - action, - operations, - failures, + let registries = registries + .context("signed extension installation requires the umbrella registry configuration")?; + if registries + .list()? + .iter() + .any(|registry| registry.configured) + { + Ok(()) + } else { + bail!( + "no package registry has a production TUF trust root; add one with 'a3s registry add'" + ) } - .into()) -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct OperationPlan { - schema_version: u32, - component: ComponentId, - action: &'static str, - source: String, - #[serde(skip_serializing_if = "Option::is_none")] - channel: Option<&'static str>, - #[serde(skip_serializing_if = "Option::is_none")] - scope: Option<&'static str>, - #[serde(skip_serializing_if = "Option::is_none")] - migration: Option, - target: String, - ownership: String, - mutates: bool, - message: String, -} - -fn install_plan( - id: &ComponentId, - request: &InstallRequest, - options: &InstallOptions, - paths: &ComponentPaths, -) -> anyhow::Result { - let state = find_state(id, paths).or_else(|_| { - let use_id = ComponentId::parse("use")?; - if id.is_child_of(&use_id) && id.as_str().split('/').count() >= 3 { - find_state(&use_id, paths) - } else { - bail!("component '{}' is not registered", id) - } - })?; - let source = match request.source { - InstallSource::Auto => state - .provenance - .map(|value| format!("existing:{value:?}").to_ascii_lowercase()) - .unwrap_or_else(|| "auto".to_string()), - InstallSource::Homebrew => "homebrew".to_string(), - InstallSource::Release => "release".to_string(), - }; - Ok(OperationPlan { - schema_version: 1, - component: id.clone(), - action: "install", - source, - channel: Some(options.channel.as_str()), - scope: Some(options.scope.as_str()), - migration: Some(options.migrate), - target: format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH), - ownership: "resolved-at-apply".to_string(), - mutates: !state.is_ready() || request.force, - message: if state.is_ready() && !request.force { - "Already healthy; apply would be a no-op.".to_string() - } else { - "Apply would resolve, verify, stage, activate, and health-check the component." - .to_string() - }, - }) } -fn uninstall_plan( - id: &ComponentId, - purge: bool, +async fn acquire_operation_locks( + components: &[ComponentId], paths: &ComponentPaths, -) -> anyhow::Result { - let state = find_state(id, paths)?; - if matches!( - state.presence, - Presence::External | Presence::System | Presence::Bundled - ) { - bail!( - "component '{}' is not owned by A3S and cannot be uninstalled", - id - ); +) -> anyhow::Result> { + let targets = operation_lock_targets(components, paths); + let mut locks = Vec::with_capacity(targets.len()); + for (path, component) in targets { + locks.push(ComponentOperationLock::acquire(path, &component).await?); } - Ok(OperationPlan { - schema_version: 1, - component: id.clone(), - action: "uninstall", - source: state - .provenance - .map(|value| format!("{value:?}").to_ascii_lowercase()) - .unwrap_or_else(|| "unknown".to_string()), - channel: None, - scope: None, - migration: None, - target: format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH), - ownership: "receipt-or-parent-owned".to_string(), - mutates: state.presence != Presence::Missing, - message: if purge { - "Apply would remove owned files and component-owned recreatable cache.".to_string() - } else { - "Apply would remove only receipt-owned files or delegate to the owning parent." - .to_string() - }, - }) + Ok(locks) } -fn upgrade_plan(id: &ComponentId, paths: &ComponentPaths) -> anyhow::Result { - let state = find_state(id, paths)?; - if state.presence != Presence::Managed { - bail!("component '{}' is not managed by A3S", id); - } - Ok(OperationPlan { - schema_version: 1, - component: id.clone(), - action: "upgrade", - source: state - .provenance - .map(|value| format!("{value:?}").to_ascii_lowercase()) - .unwrap_or_else(|| "existing-provenance".to_string()), - channel: None, - scope: None, - migration: None, - target: format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH), - ownership: "existing-provenance".to_string(), - mutates: true, - message: "Apply would query and install through the existing provenance.".to_string(), - }) -} - -fn print_plans(command: &'static str, plans: &[OperationPlan], json: bool) -> anyhow::Result<()> { - if json { - print_json( - command, - &serde_json::json!({"dryRun": true, "plans": plans}), - true, - )?; - } else { - for plan in plans { - println!("component: {}", plan.component); - println!("action: {}", plan.action); - println!("source: {}", plan.source); - if let Some(channel) = plan.channel { - println!("channel: {channel}"); - } - if let Some(scope) = plan.scope { - println!("scope: {scope}"); - } - if let Some(migration) = plan.migration { - println!("migration: {migration}"); - } - println!("target: {}", plan.target); - println!("ownership: {}", plan.ownership); - println!("mutates: {}", plan.mutates); - println!("{}", plan.message); - } +fn acquire_operation_locks_sync( + components: &[ComponentId], + paths: &ComponentPaths, +) -> anyhow::Result> { + let targets = operation_lock_targets(components, paths); + let mut locks = Vec::with_capacity(targets.len()); + for (path, component) in targets { + locks.push(ComponentOperationLock::acquire_sync(&path, &component)?); } - Ok(()) + Ok(locks) } -fn print_json(command: &'static str, data: &impl Serialize, ok: bool) -> anyhow::Result<()> { - let value = serde_json::json!({ - "schemaVersion": 1, - "command": command, - "ok": ok, - "data": data, - "warnings": [], - }); - let bytes = serde_json::to_vec_pretty(&value)?; - let stdout = io::stdout(); - let mut stdout = stdout.lock(); - stdout.write_all(&bytes)?; - stdout.write_all(b"\n")?; - stdout.flush()?; - Ok(()) -} - -fn enforce_install_network_policy( +fn operation_lock_targets( components: &[ComponentId], - request: &InstallRequest, paths: &ComponentPaths, - offline: bool, -) -> anyhow::Result<()> { - if !offline || request.package.is_some() { - return Ok(()); - } +) -> BTreeMap { + let mut targets = BTreeMap::new(); for component in components { - if !find_state(component, paths)?.is_ready() { - bail!( - "component '{}' is not available from an installed or explicit local source in offline mode", - component - ); - } + targets + .entry(paths.operation_lock_path(component)) + .or_insert_with(|| component.clone()); } - Ok(()) + targets } fn environment_flag(name: &str) -> bool { @@ -876,10 +876,21 @@ impl InstallScope { } fn validate_supported_install_policy(options: &InstallOptions) -> anyhow::Result<()> { - if options.version.is_some() && options.channel != ReleaseChannel::Stable { + let signed_registry_only = + options.package.is_none() && options.components.iter().all(is_external_use_extension); + if let Some(version) = options.version.as_deref() { + parse_version(version).with_context(|| format!("invalid component version '{version}'"))?; + if options.source == InstallSource::Homebrew { + bail!("--version requires --source release because Homebrew does not support exact component version selection"); + } + } + if options.version.is_some() + && options.channel != ReleaseChannel::Stable + && !signed_registry_only + { bail!("--version cannot be combined with a beta or nightly release channel"); } - if options.channel != ReleaseChannel::Stable { + if options.channel != ReleaseChannel::Stable && !signed_registry_only { bail!( "the '{}' channel is not declared by the selected component sources", options.channel.as_str() @@ -893,6 +904,19 @@ fn validate_supported_install_policy(options: &InstallOptions) -> anyhow::Result Ok(()) } +fn is_external_use_extension(id: &ComponentId) -> bool { + let mut segments = id.as_str().split('/'); + matches!( + ( + segments.next(), + segments.next(), + segments.next(), + segments.next() + ), + (Some("use"), Some(_), Some(_), None) + ) +} + fn enforce_provenance_policy( options: &InstallOptions, paths: &ComponentPaths, @@ -947,33 +971,4 @@ fn required_value<'a>(args: &'a [String], index: usize, option: &str) -> anyhow: } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_install_options_in_any_order() { - let args = [ - "use/acme/slack", - "--from", - "./package", - "--allow-unsigned", - "--json", - "--force", - ] - .map(str::to_string); - let options = InstallOptions::parse(&args).unwrap(); - assert_eq!(options.components[0].as_str(), "use/acme/slack"); - assert_eq!(options.package, Some(PathBuf::from("./package"))); - assert!(options.allow_unsigned); - assert!(options.force); - assert!(options.json); - } - - #[test] - fn rejects_conflicting_list_and_update_options() { - assert!( - ListOptions::parse(&["--installed".to_string(), "--available".to_string()]).is_err() - ); - assert!(UpdateOptions::parse(&["--all".to_string(), "use".to_string()]).is_err()); - } -} +mod tests; diff --git a/src/components/command/options.rs b/src/components/command/options.rs index 8b82e0a0..c2bce0e9 100644 --- a/src/components/command/options.rs +++ b/src/components/command/options.rs @@ -52,6 +52,7 @@ pub(super) struct InstallOptions { pub(super) allow_unsigned: bool, pub(super) json: bool, pub(super) dry_run: bool, + pub(super) plan_digest: Option, } impl InstallOptions { @@ -99,17 +100,31 @@ impl InstallOptions { value => bail!("unsupported install scope '{value}'"), }; } + "--plan-digest" => { + index += 1; + options.plan_digest = Some(validate_plan_digest(required_value( + args, + index, + "--plan-digest", + )?)?); + } "--force" => options.force = true, "--migrate" => options.migrate = true, "--allow-unsigned" => options.allow_unsigned = true, "--yes" => {} "--dry-run" => options.dry_run = true, "--json" => options.json = true, + value if value.starts_with("--plan-digest=") => { + options.plan_digest = Some(validate_plan_digest( + value.trim_start_matches("--plan-digest="), + )?); + } value if value.starts_with('-') => bail!("unknown install option '{value}'"), value => options.components.push(ComponentId::parse(value)?), } index += 1; } + validate_plan_mode(options.dry_run, options.plan_digest.as_deref())?; Ok(options) } } @@ -121,24 +136,41 @@ pub(super) struct UninstallOptions { pub(super) purge: bool, pub(super) json: bool, pub(super) dry_run: bool, + pub(super) plan_digest: Option, } impl UninstallOptions { pub(super) fn parse(args: &[String]) -> anyhow::Result { let mut options = Self::default(); - for argument in args { - match argument.as_str() { + let mut index = 0; + while index < args.len() { + match args[index].as_str() { "--cascade" => options.cascade = true, "--purge" => options.purge = true, + "--plan-digest" => { + index += 1; + options.plan_digest = Some(validate_plan_digest(required_value( + args, + index, + "--plan-digest", + )?)?); + } "--yes" => {} "--json" => options.json = true, "--dry-run" => options.dry_run = true, + value if value.starts_with("--plan-digest=") => { + options.plan_digest = Some(validate_plan_digest( + value.trim_start_matches("--plan-digest="), + )?); + } value if value.starts_with('-') => { bail!("unknown uninstall option '{value}'") } value => options.components.push(ComponentId::parse(value)?), } + index += 1; } + validate_plan_mode(options.dry_run, options.plan_digest.as_deref())?; Ok(options) } } @@ -149,28 +181,63 @@ pub(super) struct UpdateOptions { pub(super) all: bool, pub(super) json: bool, pub(super) dry_run: bool, + pub(super) plan_digest: Option, } impl UpdateOptions { pub(super) fn parse(args: &[String]) -> anyhow::Result { let mut options = Self::default(); - for argument in args { - match argument.as_str() { + let mut index = 0; + while index < args.len() { + match args[index].as_str() { "--all" => options.all = true, + "--plan-digest" => { + index += 1; + options.plan_digest = Some(validate_plan_digest(required_value( + args, + index, + "--plan-digest", + )?)?); + } "--yes" => {} "--json" => options.json = true, "--dry-run" => options.dry_run = true, + value if value.starts_with("--plan-digest=") => { + options.plan_digest = Some(validate_plan_digest( + value.trim_start_matches("--plan-digest="), + )?); + } value if value.starts_with('-') => bail!("unknown update option '{value}'"), value => options.components.push(ComponentId::parse(value)?), } + index += 1; } if options.all && !options.components.is_empty() { bail!("--all cannot be combined with component IDs"); } + validate_plan_mode(options.dry_run, options.plan_digest.as_deref())?; Ok(options) } } +fn validate_plan_digest(value: &str) -> anyhow::Result { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + bail!("--plan-digest requires exactly 64 lowercase hexadecimal characters"); + } + Ok(value.to_string()) +} + +fn validate_plan_mode(dry_run: bool, plan_digest: Option<&str>) -> anyhow::Result<()> { + if dry_run && plan_digest.is_some() { + bail!("--plan-digest cannot be combined with --dry-run"); + } + Ok(()) +} + #[derive(Debug)] pub(super) struct InfoOptions { pub(super) component: String, diff --git a/src/components/command/output.rs b/src/components/command/output.rs new file mode 100644 index 00000000..358b5f8d --- /dev/null +++ b/src/components/command/output.rs @@ -0,0 +1,177 @@ +use std::io::{self, Write}; + +use serde::Serialize; + +use super::super::catalog::{self, ComponentKind}; +use super::super::lifecycle::OperationRecord; +use super::super::plan::OperationPlanSet; +use super::super::state::{ComponentReport, Health, Presence}; +use super::{ComponentBatchFailure, ComponentFailure}; + +pub(super) fn print_human_report(report: &ComponentReport) { + println!( + "{:<18} {:<11} {:<17} {:<12} SOURCE", + "COMPONENT", "TYPE", "STATUS", "VERSION" + ); + for component in &report.components { + let status = match (component.presence, component.health) { + (Presence::Missing, _) => "missing".to_string(), + (_, Health::Broken) => "broken".to_string(), + (Presence::System, Health::Ready) => "ready (system)".to_string(), + (Presence::External, Health::Ready) => "ready (external)".to_string(), + (_, Health::Ready) => "ready".to_string(), + _ => "unknown".to_string(), + }; + let source = component + .provenance + .map(|value| format!("{value:?}").to_ascii_lowercase()) + .unwrap_or_else(|| "-".to_string()); + println!( + "{:<18} {:<11} {:<17} {:<12} {}", + component.id, + format!("{:?}", component.kind).to_ascii_lowercase(), + status, + component.version.as_deref().unwrap_or("-"), + source + ); + if let Some(message) = &component.message { + println!(" note: {message}"); + } + } + if !report.external_tools.is_empty() { + println!(); + println!("EXTERNAL TOOLS (discovered, never activated automatically)"); + for tool in &report.external_tools { + println!( + " {:<16} {:<20} {}", + tool.command, + tool.binary, + tool.path.display() + ); + } + } +} + +pub(super) fn print_available(json: bool) -> anyhow::Result<()> { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Available<'a> { + schema_version: u32, + components: Vec>, + } + #[derive(Serialize)] + struct AvailableItem<'a> { + id: &'a str, + kind: ComponentKind, + description: &'a str, + } + let output = Available { + schema_version: 1, + components: catalog::all() + .iter() + .map(|component| AvailableItem { + id: component.id, + kind: component.kind, + description: component.description, + }) + .collect(), + }; + if json { + print_json("component.install", &output, true)?; + } else { + println!("Available A3S components:"); + for component in catalog::all() { + println!(" {:<18} {}", component.id, component.description); + } + } + Ok(()) +} + +fn print_operations( + command: &'static str, + plan_digest: &str, + operations: &[OperationRecord], + json: bool, +) -> anyhow::Result<()> { + if json { + print_json( + command, + &serde_json::json!({"planDigest": plan_digest, "operations": operations}), + true, + )?; + } else { + println!("plan digest: {plan_digest}"); + for operation in operations { + let marker = if operation.changed { "✓" } else { "=" }; + println!("{marker} {}", operation.message); + } + } + Ok(()) +} + +pub(super) fn finish_batch( + command: &'static str, + action: &'static str, + plan_digest: String, + operations: Vec, + failures: Vec, + json: bool, +) -> anyhow::Result<()> { + if failures.is_empty() { + return print_operations(command, &plan_digest, &operations, json); + } + if !json && !operations.is_empty() { + print_operations(command, &plan_digest, &operations, false)?; + } + Err(ComponentBatchFailure { + action, + plan_digest, + operations, + failures, + } + .into()) +} + +pub(super) fn print_plans( + command: &'static str, + plan_set: &OperationPlanSet, + json: bool, +) -> anyhow::Result<()> { + if json { + print_json( + command, + &serde_json::json!({ + "dryRun": true, + "planSchemaVersion": plan_set.plan_schema_version, + "planCommand": plan_set.plan_command, + "planDigest": plan_set.plan_digest, + "plans": plan_set.plans, + }), + true, + )?; + } else { + plan_set.print_human(); + } + Ok(()) +} + +pub(super) fn print_json( + command: &'static str, + data: &impl Serialize, + ok: bool, +) -> anyhow::Result<()> { + let value = serde_json::json!({ + "schemaVersion": 1, + "command": command, + "ok": ok, + "data": data, + "warnings": [], + }); + let bytes = serde_json::to_vec_pretty(&value)?; + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + stdout.write_all(&bytes)?; + stdout.write_all(b"\n")?; + stdout.flush()?; + Ok(()) +} diff --git a/src/components/command/tests.rs b/src/components/command/tests.rs new file mode 100644 index 00000000..c0f93c61 --- /dev/null +++ b/src/components/command/tests.rs @@ -0,0 +1,84 @@ +use super::super::state::{ComponentState, Trust}; +use super::*; + +#[test] +fn parses_install_options_in_any_order() { + let args = [ + "use/acme/slack", + "--from", + "./package", + "--allow-unsigned", + "--json", + "--force", + ] + .map(str::to_string); + let options = InstallOptions::parse(&args).unwrap(); + assert_eq!(options.components[0].as_str(), "use/acme/slack"); + assert_eq!(options.package, Some(PathBuf::from("./package"))); + assert!(options.allow_unsigned); + assert!(options.force); + assert!(options.json); +} + +#[test] +fn rejects_conflicting_list_and_update_options() { + assert!(ListOptions::parse(&["--installed".to_string(), "--available".to_string()]).is_err()); + assert!(UpdateOptions::parse(&["--all".to_string(), "use".to_string()]).is_err()); + assert!(InstallOptions::parse(&[ + "box".to_string(), + "--dry-run".to_string(), + format!("--plan-digest={}", "a".repeat(64)), + ]) + .is_err()); + assert!(UninstallOptions::parse(&[ + "box".to_string(), + format!("--plan-digest={}", "A".repeat(64)), + ]) + .is_err()); +} + +#[test] +fn upgrade_all_selects_only_managed_products() { + let state = |id: &str, kind, presence| ComponentState { + id: ComponentId::parse(id).unwrap(), + kind, + description: String::new(), + presence, + health: Health::Ready, + update: UpdateState::Unknown, + trust: Trust::FirstParty, + provenance: Some(InstallProvenance::GithubRelease), + version: Some("1.0.0".to_string()), + path: Some(PathBuf::from("/tmp/component")), + message: None, + }; + + assert!(is_upgrade_all_candidate(&state( + "use", + ComponentKind::Product, + Presence::Managed, + ))); + assert!(!is_upgrade_all_candidate(&state( + "use/browser", + ComponentKind::Capability, + Presence::Managed, + ))); + let mut registry_extension = state( + "use/acme/slack", + ComponentKind::Extension, + Presence::Managed, + ); + registry_extension.trust = Trust::RegistryTuf; + assert!(is_upgrade_all_candidate(®istry_extension)); + let local_extension = state( + "use/acme/local", + ComponentKind::Extension, + Presence::Managed, + ); + assert!(!is_upgrade_all_candidate(&local_extension)); + assert!(!is_upgrade_all_candidate(&state( + "search", + ComponentKind::Product, + Presence::External, + ))); +} diff --git a/src/components/discovery.rs b/src/components/discovery.rs index bbc67afd..9e33b106 100644 --- a/src/components/discovery.rs +++ b/src/components/discovery.rs @@ -3,6 +3,7 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use a3s_updater::{ComponentReceipt, InstallProvenance}; +use a3s_use_extension::ResolvedRemotePackage; use anyhow::{bail, Context}; use super::catalog::{self, ComponentKind, ComponentSpec, Distribution, ReleaseSpec}; @@ -135,6 +136,7 @@ fn discover_dynamic_use_extensions(parent_binary: &Path) -> anyhow::Result Trust::LocalExplicit, + Some("registry-tuf") => Trust::RegistryTuf, _ => Trust::Untrusted, }, provenance: Some(InstallProvenance::Delegated), @@ -152,6 +154,77 @@ fn discover_dynamic_use_extensions(parent_binary: &Path) -> anyhow::Result anyhow::Result> { + let use_id = ComponentId::parse("use")?; + let package_id = id + .relative_to(&use_id) + .filter(|value| value.split('/').count() == 2) + .with_context(|| format!("component '{}' is not an external Use extension", id))?; + let parent = find_state(&use_id, paths)?; + let parent_binary = parent + .is_ready() + .then_some(parent.path.as_deref()) + .flatten() + .with_context(|| "parent component 'use' is not ready")?; + let output = run_bounded( + parent_binary.as_os_str(), + &[ + OsString::from("component"), + OsString::from("status"), + OsString::from(package_id), + OsString::from("--json"), + ], + )?; + if !output.success { + bail!("Use component status exited unsuccessfully"); + } + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).context("invalid Use component status JSON")?; + let component = value + .get("component") + .or_else(|| value.get("data").and_then(|data| data.get("component"))) + .context("Use component status has no component object")?; + let returned_id = component + .get("id") + .and_then(serde_json::Value::as_str) + .context("Use component status has no component ID")?; + if returned_id != package_id && returned_id != id.as_str() { + bail!("Use component status returned mismatched ID '{returned_id}'"); + } + + match component.get("trust").and_then(serde_json::Value::as_str) { + Some("local-explicit") => Ok(None), + Some("registry-tuf") => { + let registry = component + .get("registry") + .filter(|value| !value.is_null()) + .context("signed extension status has no registry provenance")?; + let resolved: ResolvedRemotePackage = serde_json::from_value(registry.clone()) + .context("signed extension status has invalid registry provenance")?; + if resolved.package_id != package_id { + bail!( + "signed extension provenance package '{}' does not match '{}'", + resolved.package_id, + package_id + ); + } + if component + .get("version") + .and_then(serde_json::Value::as_str) + .is_some_and(|version| version != resolved.version) + { + bail!("signed extension status version does not match its registry provenance"); + } + Ok(Some(resolved)) + } + Some(trust) => bail!("unsupported installed extension trust source '{trust}'"), + None => bail!("installed extension status has no trust source"), + } +} + pub fn find_state(id: &ComponentId, paths: &ComponentPaths) -> anyhow::Result { if let Some(spec) = catalog::find(id) { let receipt = paths.receipt_store().read(id.as_str())?; diff --git a/src/components/lifecycle.rs b/src/components/lifecycle.rs index dbf6832c..2b4f20a8 100644 --- a/src/components/lifecycle.rs +++ b/src/components/lifecycle.rs @@ -3,7 +3,8 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use a3s_updater::{ - uninstall_owned_files, ComponentReceipt, InstallProvenance, RECEIPT_SCHEMA_VERSION, + parse_version, uninstall_owned_files, ComponentReceipt, InstallProvenance, + RECEIPT_SCHEMA_VERSION, }; use anyhow::{bail, Context}; use serde::Serialize; @@ -11,10 +12,12 @@ use serde::Serialize; use super::catalog::{self, ComponentSpec, Distribution, ReleaseSpec}; use super::discovery::find_state; use super::id::ComponentId; +use super::lock::ComponentOperationLock; use super::paths::ComponentPaths; use super::probe::probe_version; -use super::release_install::install_release; +use super::release_install::{install_release, ResolvedRelease}; use super::state::{ComponentState, Health, Presence}; +use crate::registry::ResolvedRegistryPackage; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum InstallSource { @@ -24,14 +27,34 @@ pub enum InstallSource { Release, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum InstallIntent { + #[default] + Install, + Upgrade, +} + +impl InstallIntent { + pub fn action(self) -> &'static str { + match self { + Self::Install => "install", + Self::Upgrade => "upgrade", + } + } +} + #[derive(Debug, Clone)] pub struct InstallRequest { pub version: Option, pub source: InstallSource, + pub intent: InstallIntent, pub package: Option, pub force: bool, pub allow_unsigned: bool, pub progress: bool, + pub resolved_releases: BTreeMap, + pub resolved_sources: BTreeMap, + pub resolved_registry_packages: BTreeMap, } impl Default for InstallRequest { @@ -39,10 +62,14 @@ impl Default for InstallRequest { Self { version: None, source: InstallSource::Auto, + intent: InstallIntent::Install, package: None, force: false, allow_unsigned: false, progress: true, + resolved_releases: BTreeMap::new(), + resolved_sources: BTreeMap::new(), + resolved_registry_packages: BTreeMap::new(), } } } @@ -66,8 +93,37 @@ pub async fn install_component( id: &ComponentId, request: &InstallRequest, paths: &ComponentPaths, +) -> anyhow::Result { + let _lock = ComponentOperationLock::acquire(paths.operation_lock_path(id), id).await?; + install_component_locked(id, request, paths).await +} + +pub(super) async fn install_component_locked( + id: &ComponentId, + request: &InstallRequest, + paths: &ComponentPaths, ) -> anyhow::Result { if let Some(spec) = catalog::find(id) { + if request.package.is_some() { + bail!("--from is valid only for external Use extensions"); + } + if request.allow_unsigned { + bail!("--allow-unsigned is valid only for external Use extensions"); + } + if request.version.is_some() && !matches!(spec.distribution, Distribution::Release(_)) { + bail!( + "component '{}' does not own a versioned release; --version is not supported", + id + ); + } + if request.source != InstallSource::Auto + && !matches!(spec.distribution, Distribution::Release(_)) + { + bail!( + "component '{}' does not own an install source; --source is not supported", + id + ); + } return match spec.distribution { Distribution::Bundled => install_bundled(id, spec, paths), Distribution::Release(release) => { @@ -78,7 +134,7 @@ pub async fn install_component( } Distribution::Delegated { parent } => { let parent = ComponentId::parse(parent)?; - let parent_path = ensure_parent(&parent, paths, request.progress).await?; + let parent_path = ensure_parent(&parent, paths, request).await?; delegate_install(id, &parent, &parent_path, request) } }; @@ -88,21 +144,60 @@ pub async fn install_component( if !id.is_child_of(&use_id) || id.as_str().split('/').count() < 3 { bail!("component '{}' is not registered", id); } - if request.package.is_none() { + if request.source != InstallSource::Auto { bail!( - "external component '{}' requires an explicit --from package", + "external component '{}' is resolved through its package source; --source is not supported", id ); } - let parent_path = ensure_parent(&use_id, paths, request.progress).await?; + if request.package.is_some() { + if !request.allow_unsigned { + bail!( + "external component '{}' uses an unsigned local package; rerun with --allow-unsigned", + id + ); + } + if request.version.is_some() { + bail!( + "external component '{}' derives its version from the local package manifest; --version is not supported", + id + ); + } + } else { + if request.allow_unsigned { + bail!("--allow-unsigned is valid only with an explicit local --from package"); + } + let resolved = request + .resolved_registry_packages + .get(id.as_str()) + .with_context(|| { + format!( + "external component '{}' has no reviewed signed-registry resolution", + id + ) + })?; + validate_registry_resolution(id, resolved)?; + } + let parent_path = ensure_parent(&use_id, paths, request).await?; delegate_install(id, &use_id, &parent_path, request) } +#[cfg(test)] pub fn uninstall_component( id: &ComponentId, cascade: bool, purge: bool, paths: &ComponentPaths, +) -> anyhow::Result { + let _lock = ComponentOperationLock::acquire_sync(&paths.operation_lock_path(id), id)?; + uninstall_component_locked(id, cascade, purge, paths) +} + +pub(super) fn uninstall_component_locked( + id: &ComponentId, + cascade: bool, + purge: bool, + paths: &ComponentPaths, ) -> anyhow::Result { if let Some(spec) = catalog::find(id) { if !spec.removable { @@ -146,7 +241,7 @@ pub fn uninstall_component( if cascade { for child in children.into_iter().rev() { let child_id = ComponentId::parse(&child.component_id)?; - uninstall_component(&child_id, true, purge, paths)?; + uninstall_component_locked(&child_id, true, purge, paths)?; } } @@ -180,7 +275,7 @@ pub fn uninstall_component( async fn ensure_parent( parent: &ComponentId, paths: &ComponentPaths, - progress: bool, + request: &InstallRequest, ) -> anyhow::Result { let state = find_state(parent, paths)?; if state.is_ready() { @@ -190,11 +285,13 @@ async fn ensure_parent( .with_context(|| format!("parent component '{}' is not registered", parent))?; let release = catalog::release(spec) .with_context(|| format!("parent component '{}' is not installable", parent))?; - let request = InstallRequest { - progress, + let parent_request = InstallRequest { + progress: request.progress, + resolved_releases: request.resolved_releases.clone(), + resolved_sources: request.resolved_sources.clone(), ..InstallRequest::default() }; - let record = install_product(parent, spec, release, &request, paths).await?; + let record = install_product(parent, spec, release, &parent_request, paths).await?; record .path .context("installed parent did not report an executable path") @@ -228,7 +325,13 @@ async fn install_product( paths: &ComponentPaths, ) -> anyhow::Result { let state = find_state(id, paths)?; - if state.is_ready() && !request.force { + let requested_version_is_ready = request.version.as_deref().is_none_or(|requested| { + state + .version + .as_deref() + .is_some_and(|installed| parse_version(installed).ok() == parse_version(requested).ok()) + }); + if state.is_ready() && requested_version_is_ready && !request.force { return Ok(OperationRecord { component: id.clone(), action: "install", @@ -246,41 +349,83 @@ async fn install_product( ); } - let source = match request.source { - InstallSource::Auto if release.homebrew_formula.is_some() && a3s_is_homebrew_managed() => { - InstallSource::Homebrew - } - InstallSource::Auto => InstallSource::Release, - explicit => explicit, - }; + let source = resolve_install_source(id, release, request)?; match source { - InstallSource::Homebrew => install_homebrew(id, release, paths, request.progress), - InstallSource::Release => install_release(id, release, request, paths).await, + InstallSource::Homebrew => install_homebrew(id, release, request, paths), + InstallSource::Release => { + install_release( + id, + release, + request, + request.resolved_releases.get(id.as_str()), + paths, + ) + .await + } InstallSource::Auto => { bail!("automatic install source was not resolved") } } } +pub(super) fn resolve_install_source( + id: &ComponentId, + release: ReleaseSpec, + request: &InstallRequest, +) -> anyhow::Result { + if let Some(resolved) = request.resolved_sources.get(id.as_str()).copied() { + if request.source != InstallSource::Auto && request.source != resolved { + bail!( + "reviewed install source {:?} does not match requested source {:?}", + resolved, + request.source + ); + } + return Ok(resolved); + } + Ok(match request.source { + InstallSource::Auto + if request.version.is_none() + && release.homebrew_formula.is_some() + && a3s_is_homebrew_managed() => + { + InstallSource::Homebrew + } + InstallSource::Auto => InstallSource::Release, + explicit => explicit, + }) +} + fn install_homebrew( id: &ComponentId, release: ReleaseSpec, + request: &InstallRequest, paths: &ComponentPaths, - progress: bool, ) -> anyhow::Result { + if request.version.is_some() { + bail!( + "component '{}' cannot select an exact version through Homebrew; use --source release", + id + ); + } let formula = release .homebrew_formula .with_context(|| format!("component '{}' has no Homebrew formula", id))?; + let verb = match (request.intent, request.force) { + (InstallIntent::Upgrade, _) => "upgrade", + (InstallIntent::Install, true) => "reinstall", + (InstallIntent::Install, false) => "install", + }; super::progress( - progress, - format!("a3s: installing '{}' with Homebrew...", id), + request.progress, + format!("a3s: {} '{}' with Homebrew...", verb, id), ); let status = Command::new("brew") - .args(["install", formula]) + .args([verb, formula]) .status() .context("failed to run Homebrew")?; if !status.success() { - bail!("Homebrew failed to install '{}'", id); + bail!("Homebrew failed to {verb} '{}'", id); } let prefix_output = Command::new("brew") .args(["--prefix", formula]) @@ -307,12 +452,12 @@ fn install_homebrew( paths.receipt_store().write(&receipt)?; Ok(OperationRecord { component: id.clone(), - action: "install", + action: request.intent.action(), changed: true, version: Some(version), provenance: Some(InstallProvenance::Homebrew), path: Some(executable), - message: format!("Installed component '{}' with Homebrew.", id), + message: format!("Homebrew completed {verb} for component '{}'.", id), }) } @@ -336,6 +481,26 @@ fn delegate_install( if request.allow_unsigned { command.arg("--allow-unsigned"); } + if let Some(resolved) = request.resolved_registry_packages.get(id.as_str()) { + validate_registry_resolution(id, resolved)?; + let plan_digest = resolved.package.plan_digest().map_err(anyhow::Error::new)?; + command + .arg("--registry-name") + .arg(&resolved.registry.name) + .arg("--registry-url") + .arg(&resolved.registry.url) + .arg("--trust-root") + .arg(&resolved.registry.trust_root) + .arg("--version") + .arg(&resolved.package.version) + .arg("--channel") + .arg(&resolved.package.channel) + .arg("--registry-plan-digest") + .arg(plan_digest); + if let Some(path) = &resolved.registry.trusted_root_path { + command.arg("--trusted-root").arg(path); + } + } let output = command.output().with_context(|| { format!( "failed to delegate install to parent component '{}'", @@ -352,7 +517,7 @@ fn delegate_install( let component = data.get("component"); Ok(OperationRecord { component: id.clone(), - action: "install", + action: request.intent.action(), changed: data .get("changed") .and_then(serde_json::Value::as_bool) @@ -370,6 +535,31 @@ fn delegate_install( }) } +fn validate_registry_resolution( + id: &ComponentId, + resolved: &ResolvedRegistryPackage, +) -> anyhow::Result<()> { + let parent = ComponentId::parse("use")?; + let package_id = id + .relative_to(&parent) + .context("signed registry package is outside the Use namespace")?; + if resolved.package.package_id != package_id { + bail!( + "reviewed registry package '{}' does not match component '{}'", + resolved.package.package_id, + id + ); + } + if resolved.package.registry_name != resolved.registry.name + || resolved.package.registry_url != resolved.registry.url + || resolved.package.root_sha256 + != resolved.registry.trust_root.trim_start_matches("sha256:") + { + bail!("reviewed registry package provenance is internally inconsistent"); + } + Ok(()) +} + fn delegate_uninstall( id: &ComponentId, parent: &ComponentId, diff --git a/src/components/lock.rs b/src/components/lock.rs new file mode 100644 index 00000000..e608bc86 --- /dev/null +++ b/src/components/lock.rs @@ -0,0 +1,237 @@ +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context}; +use fs2::FileExt; + +use super::id::ComponentId; + +/// Cross-process guard for one top-level component family. +/// +/// Delegated children share their parent's lock (for example every `use/*` +/// operation uses the `use` lock). This keeps parent installation and child +/// lifecycle calls in one deterministic order and avoids parent/child +/// deadlocks during cascade operations. +pub struct ComponentOperationLock { + file: File, +} + +impl ComponentOperationLock { + pub async fn acquire(path: PathBuf, component: &ComponentId) -> anyhow::Result { + let component = component.to_string(); + tokio::task::spawn_blocking(move || Self::acquire_blocking(&path, &component)) + .await + .context("component lock task failed")? + } + + pub fn acquire_sync(path: &Path, component: &ComponentId) -> anyhow::Result { + Self::acquire_blocking(path, component.as_str()) + } + + fn acquire_blocking(path: &Path, component: &str) -> anyhow::Result { + let parent = path + .parent() + .context("component operation lock has no parent directory")?; + ensure_real_directory(parent)?; + inspect_lock_path(path)?; + + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + options.custom_flags(libc::O_NOFOLLOW); + } + let mut file = options + .open(path) + .with_context(|| format!("failed to open component lock {}", path.display()))?; + file.lock_exclusive() + .with_context(|| format!("failed to acquire component lock {}", path.display()))?; + validate_open_lock(&file, path)?; + file.set_len(0) + .with_context(|| format!("failed to truncate component lock {}", path.display()))?; + writeln!(file, "pid={} component={component}", std::process::id()) + .with_context(|| format!("failed to write component lock {}", path.display()))?; + Ok(Self { file }) + } +} + +fn ensure_real_directory(path: &Path) -> anyhow::Result<()> { + std::fs::create_dir_all(path).with_context(|| { + format!( + "failed to create component lock directory {}", + path.display() + ) + })?; + let metadata = std::fs::symlink_metadata(path).with_context(|| { + format!( + "failed to inspect component lock directory {}", + path.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!( + "component lock directory '{}' must be a real directory", + path.display() + ); + } + Ok(()) +} + +fn inspect_lock_path(path: &Path) -> anyhow::Result> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + bail!( + "component operation lock '{}' must not be a symbolic link", + path.display() + ) + } + Ok(metadata) if !metadata.is_file() => { + bail!( + "component operation lock '{}' must be a regular file", + path.display() + ) + } + Ok(metadata) => Ok(Some(metadata)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| { + format!( + "failed to inspect component operation lock {}", + path.display() + ) + }), + } +} + +fn validate_open_lock(file: &File, path: &Path) -> anyhow::Result<()> { + let path_metadata = inspect_lock_path(path)? + .with_context(|| format!("component operation lock '{}' disappeared", path.display()))?; + let file_metadata = file + .metadata() + .with_context(|| format!("failed to inspect open component lock {}", path.display()))?; + if !file_metadata.is_file() { + bail!( + "open component operation lock '{}' is not a regular file", + path.display() + ); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + + if path_metadata.dev() != file_metadata.dev() || path_metadata.ino() != file_metadata.ino() + { + bail!( + "component operation lock '{}' changed while it was being acquired", + path.display() + ); + } + if file_metadata.nlink() != 1 { + bail!( + "component operation lock '{}' must not have hard links", + path.display() + ); + } + } + #[cfg(not(unix))] + let _ = path_metadata; + Ok(()) +} + +impl Drop for ComponentOperationLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.file); + } +} + +#[cfg(test)] +mod tests { + use std::sync::mpsc; + use std::time::Duration; + + use super::*; + + #[test] + fn concurrent_operations_wait_for_the_existing_lock() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("locks/use.lock"); + let first = ComponentOperationLock::acquire_blocking(&path, "use").unwrap(); + let (started_tx, started_rx) = mpsc::channel(); + let (acquired_tx, acquired_rx) = mpsc::channel(); + let thread_path = path.clone(); + let thread = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + let _second = + ComponentOperationLock::acquire_blocking(&thread_path, "use/browser").unwrap(); + acquired_tx.send(()).unwrap(); + }); + + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(acquired_rx + .recv_timeout(Duration::from_millis(100)) + .is_err()); + drop(first); + acquired_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + thread.join().unwrap(); + } + + #[cfg(unix)] + #[test] + fn symbolic_link_lock_is_rejected_without_modifying_its_target() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("do-not-truncate"); + let lock_path = temp.path().join("locks/use.lock"); + std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap(); + std::fs::write(&target, b"preserve me").unwrap(); + symlink(&target, &lock_path).unwrap(); + + let Err(error) = ComponentOperationLock::acquire_blocking(&lock_path, "use") else { + panic!("symbolic-link lock should be rejected"); + }; + + assert!(error.to_string().contains("symbolic link"), "{error:#}"); + assert_eq!(std::fs::read(&target).unwrap(), b"preserve me"); + } + + #[cfg(unix)] + #[test] + fn symbolic_link_lock_directory_is_rejected() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("redirected-locks"); + let lock_directory = temp.path().join("locks"); + std::fs::create_dir(&target).unwrap(); + symlink(&target, &lock_directory).unwrap(); + + let error = + ComponentOperationLock::acquire_blocking(&lock_directory.join("use.lock"), "use") + .err() + .expect("symbolic-link lock directory should be rejected"); + + assert!(error.to_string().contains("real directory"), "{error:#}"); + assert!(!target.join("use.lock").exists()); + } + + #[cfg(unix)] + #[test] + fn hard_link_lock_is_rejected_without_modifying_its_target() { + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("do-not-truncate"); + let lock_path = temp.path().join("locks/use.lock"); + std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap(); + std::fs::write(&target, b"preserve me").unwrap(); + std::fs::hard_link(&target, &lock_path).unwrap(); + + let error = ComponentOperationLock::acquire_blocking(&lock_path, "use") + .err() + .expect("hard-link lock should be rejected"); + + assert!(error.to_string().contains("hard links"), "{error:#}"); + assert_eq!(std::fs::read(&target).unwrap(), b"preserve me"); + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index e405fab5..2ef08998 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -5,19 +5,23 @@ mod command; mod discovery; mod id; mod lifecycle; +mod lock; mod paths; +mod plan; mod probe; mod release_install; mod state; pub use command::{ find_ready_executable_with, resolve_or_install, resolve_or_install_with, run_doctor, - run_doctor_with, run_info, run_info_with, run_install, run_install_with, run_list, - run_list_with, run_proxy, run_uninstall, run_uninstall_with, run_update, run_update_with, - ComponentBatchFailure, + run_doctor_with, run_info, run_info_with, run_install, run_install_with, + run_install_with_registries, run_list, run_list_with, run_list_with_registries, run_proxy, + run_uninstall, run_uninstall_with, run_update, run_update_with, run_update_with_registries, + run_upgrade_list_with, run_upgrade_list_with_registries, ComponentBatchFailure, }; pub use id::ComponentId; pub use paths::ComponentPaths; +pub use plan::ComponentPlanMismatch; fn progress(enabled: bool, message: impl std::fmt::Display) { if enabled { diff --git a/src/components/paths.rs b/src/components/paths.rs index c09a8a3b..44d724d8 100644 --- a/src/components/paths.rs +++ b/src/components/paths.rs @@ -13,6 +13,7 @@ pub struct ComponentPaths { pub data_root: PathBuf, pub state_root: PathBuf, pub cache_root: PathBuf, + pub runtime_root: PathBuf, pub current_exe: PathBuf, pub path_env: Option, pub home: Option, @@ -53,6 +54,8 @@ impl ComponentPaths { home.as_deref(), local_app_data.as_deref(), )?; + let runtime_root = + configured_runtime_root(directory, &state_root, local_app_data.as_deref()); let current_exe = std::env::current_exe().context("failed to determine current a3s executable")?; @@ -60,6 +63,7 @@ impl ComponentPaths { data_root, state_root, cache_root, + runtime_root, current_exe, path_env: std::env::var_os("PATH"), home, @@ -95,6 +99,13 @@ impl ComponentPaths { append_id(self.cache_root.join("components"), id) } + pub fn operation_lock_path(&self, id: &ComponentId) -> PathBuf { + let family = id.as_str().split('/').next().unwrap_or(id.as_str()); + self.runtime_root + .join("locks") + .join(format!("{family}.lock")) + } + pub fn configured_binary(&self, release: ReleaseSpec) -> Option { self.install_overrides .get(release.install_dir_env) @@ -119,6 +130,7 @@ impl ComponentPaths { data_root: root.join("data"), state_root: root.join("state"), cache_root: root.join("cache"), + runtime_root: root.join("runtime"), current_exe: root.join("bin/a3s"), path_env: None, home: Some(root.join("home")), @@ -133,6 +145,31 @@ impl ComponentPaths { } } +fn configured_runtime_root( + directory: &Path, + state_root: &Path, + local_app_data: Option<&Path>, +) -> PathBuf { + if let Some(value) = std::env::var_os("A3S_RUNTIME_HOME").filter(|value| !value.is_empty()) { + return absolute_from(PathBuf::from(value), directory); + } + + #[cfg(unix)] + if let Some(value) = std::env::var_os("XDG_RUNTIME_DIR").filter(|value| !value.is_empty()) { + return absolute_from(PathBuf::from(value), directory).join("a3s"); + } + + #[cfg(windows)] + if let Some(local_app_data) = local_app_data { + return local_app_data.join("A3S/Runtime"); + } + + #[cfg(not(windows))] + let _ = local_app_data; + + state_root.join("runtime") +} + fn host_binary_name(binary: &str) -> String { if cfg!(windows) && !binary.ends_with(".exe") { format!("{binary}.exe") @@ -264,6 +301,10 @@ mod tests { paths.cache_dir(&id), temp.path().join("cache/components/use/browser") ); + assert_eq!( + paths.operation_lock_path(&id), + temp.path().join("runtime/locks/use.lock") + ); paths.set_install_override("A3S_USE_INSTALL_DIR", temp.path().join("use-bin")); let use_spec = crate::components::catalog::find(&ComponentId::parse("use").unwrap()) .and_then(crate::components::catalog::release) diff --git a/src/components/plan.rs b/src/components/plan.rs new file mode 100644 index 00000000..42707f49 --- /dev/null +++ b/src/components/plan.rs @@ -0,0 +1,941 @@ +use std::collections::BTreeMap; + +use a3s_updater::{parse_version, ComponentReceipt, InstallProvenance}; +use a3s_use_extension::ResolvedRemotePackage; +use anyhow::{bail, Context}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use super::catalog::{self, Distribution}; +use super::discovery::{discover, extension_registry_provenance}; +use super::id::ComponentId; +use super::lifecycle::{resolve_install_source, InstallRequest, InstallSource}; +use super::paths::ComponentPaths; +use super::release_install::{resolve_release, ResolvedRelease}; +use super::state::{ComponentState, Health, Presence}; +use crate::registry::{RegistryStore, ResolvedRegistryPackage}; + +mod local_package; + +use local_package::fingerprint_local_package; + +const PLAN_SCHEMA_VERSION: u32 = 1; +const PLAN_DIGEST_DOMAIN: &[u8] = b"a3s-component-plan-v1\0"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct PlannedPath { + display: String, + identity: String, +} + +impl PlannedPath { + fn new(path: &std::path::Path) -> Self { + Self { + display: path.to_string_lossy().into_owned(), + identity: path_identity(path), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct PlannedLocalPackage { + path: PlannedPath, + kind: &'static str, + sha256: String, + file_count: u64, + byte_count: u64, +} + +#[derive(Debug, Clone)] +pub(super) struct PreparedOperationPlan { + pub(super) plan: OperationPlan, + pub(super) resolved_releases: BTreeMap, + pub(super) resolved_sources: BTreeMap, + pub(super) resolved_registry_packages: BTreeMap, + pub(super) apply_force: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct PlannedCurrentState { + presence: Presence, + health: Health, + #[serde(skip_serializing_if = "Option::is_none")] + provenance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + receipt: Option, +} + +impl From<&ComponentState> for PlannedCurrentState { + fn from(state: &ComponentState) -> Self { + Self { + presence: state.presence, + health: state.health, + provenance: state.provenance, + version: state.version.clone(), + path: state.path.as_deref().map(PlannedPath::new), + receipt: None, + } + } +} + +impl PlannedCurrentState { + fn with_receipt(state: &ComponentState, paths: &ComponentPaths) -> anyhow::Result { + let mut planned = Self::from(state); + planned.receipt = paths + .receipt_store() + .read(state.id.as_str())? + .as_ref() + .map(PlannedReceipt::from); + Ok(planned) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct PlannedReceipt { + schema_version: u32, + component_id: String, + version: String, + provenance: InstallProvenance, + install_root: PlannedPath, + #[serde(skip_serializing_if = "Option::is_none")] + executable_path: Option, + owned_paths: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + source: Option, + artifact_checksums: BTreeMap, +} + +impl From<&ComponentReceipt> for PlannedReceipt { + fn from(receipt: &ComponentReceipt) -> Self { + let mut owned_paths = receipt + .owned_paths + .iter() + .map(|path| PlannedPath::new(path)) + .collect::>(); + owned_paths.sort_by(|left, right| left.identity.cmp(&right.identity)); + Self { + schema_version: receipt.schema_version, + component_id: receipt.component_id.clone(), + version: receipt.version.clone(), + provenance: receipt.provenance, + install_root: PlannedPath::new(&receipt.install_root), + executable_path: receipt.executable_path.as_deref().map(PlannedPath::new), + owned_paths, + source: receipt.source.clone(), + artifact_checksums: receipt.artifact_checksums.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct OperationPlan { + schema_version: u32, + component: ComponentId, + action: &'static str, + source: String, + #[serde(skip_serializing_if = "Option::is_none")] + requested_source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + channel: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + migration: Option, + target: String, + ownership: String, + mutates: bool, + #[serde(skip_serializing_if = "Option::is_none")] + requested_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + local_package: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + resolved_sources: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + resolved_releases: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + resolved_registry_packages: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + prerequisites: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + force: Option, + #[serde(skip_serializing_if = "Option::is_none")] + allow_unsigned: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cascade: Option, + #[serde(skip_serializing_if = "Option::is_none")] + purge: Option, + #[serde(skip_serializing_if = "Option::is_none")] + current: Option, + message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct OperationPlanSet { + pub(super) plan_schema_version: u32, + pub(super) plan_command: &'static str, + pub(super) plan_digest: String, + pub(super) plans: Vec, +} + +impl OperationPlanSet { + pub(super) fn new(command: &'static str, plans: Vec) -> anyhow::Result { + let plan_digest = plan_digest(command, &plans)?; + Ok(Self { + plan_schema_version: PLAN_SCHEMA_VERSION, + plan_command: command, + plan_digest, + plans, + }) + } + + pub(super) fn print_human(&self) { + println!("plan digest: {}", self.plan_digest); + for plan in &self.plans { + println!(); + println!("component: {}", plan.component); + println!("action: {}", plan.action); + println!("source: {}", plan.source); + if let Some(source) = &plan.requested_source { + println!("requested source: {source}"); + } + if let Some(channel) = &plan.channel { + println!("channel: {channel}"); + } + if let Some(scope) = &plan.scope { + println!("scope: {scope}"); + } + if let Some(migration) = plan.migration { + println!("migration: {migration}"); + } + if let Some(version) = &plan.requested_version { + println!("requested version: {version}"); + } + if let Some(package) = &plan.local_package { + println!("local package: {}", package.path.display); + println!("local package sha256: {}", package.sha256); + } + for (component, release) in &plan.resolved_releases { + println!( + "resolved release: {component} {} {} {}", + release.version, release.archive_name, release.sha256 + ); + } + for (component, package) in &plan.resolved_registry_packages { + println!( + "resolved registry package: {component} {} {} {} {}", + package.registry_name, package.version, package.target_name, package.sha256 + ); + } + for (component, state) in &plan.prerequisites { + println!( + "prerequisite: {component} {:?} {:?}", + state.presence, state.health + ); + } + println!("target: {}", plan.target); + println!("ownership: {}", plan.ownership); + println!("mutates: {}", plan.mutates); + println!("{}", plan.message); + } + } + + pub(super) fn digest(&self) -> &str { + &self.plan_digest + } + + pub(super) fn verify_expected(&self, expected: Option<&str>) -> anyhow::Result<()> { + let Some(expected) = expected else { + return Ok(()); + }; + if expected == self.plan_digest { + return Ok(()); + } + Err(ComponentPlanMismatch { + expected: expected.to_string(), + actual: self.plan_digest.clone(), + } + .into()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentPlanMismatch { + pub expected: String, + pub actual: String, +} + +impl std::fmt::Display for ComponentPlanMismatch { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "component plan changed after review: expected {}, resolved {}", + self.expected, self.actual + ) + } +} + +impl std::error::Error for ComponentPlanMismatch {} + +pub(super) fn validate_install_plan( + id: &ComponentId, + request: &InstallRequest, +) -> anyhow::Result<()> { + let spec = catalog::find(id); + let external = spec.is_none() && is_external_use_extension(id); + if spec.is_none() && !external { + bail!("component '{}' is not registered", id); + } + if external { + if request.source != InstallSource::Auto { + bail!( + "external component '{}' is resolved through its package source; --source is not supported", + id + ); + } + if request.package.is_some() { + if !request.allow_unsigned { + bail!( + "external component '{}' uses an unsigned local package; rerun with --allow-unsigned", + id + ); + } + if request.version.is_some() { + bail!( + "external component '{}' derives its version from the local package manifest; --version is not supported", + id + ); + } + } else if request.allow_unsigned { + bail!("--allow-unsigned is valid only with an explicit local --from package"); + } + return Ok(()); + } + if request.package.is_some() { + bail!("--from is valid only for external Use extensions"); + } + if request.allow_unsigned { + bail!("--allow-unsigned is valid only for external Use extensions"); + } + if request.version.is_some() + && !matches!( + spec.map(|spec| spec.distribution), + Some(Distribution::Release(_)) + ) + { + bail!( + "component '{}' does not own a versioned release; --version is not supported", + id + ); + } + if request.source != InstallSource::Auto + && !matches!( + spec.map(|spec| spec.distribution), + Some(Distribution::Release(_)) + ) + { + bail!( + "component '{}' does not own an install source; --source is not supported", + id + ); + } + Ok(()) +} + +pub(super) async fn install_plan( + id: &ComponentId, + request: &InstallRequest, + channel: &str, + scope: &str, + migrate: bool, + paths: &ComponentPaths, + registries: Option<&RegistryStore>, +) -> anyhow::Result { + validate_install_plan(id, request)?; + let state = discover(paths)? + .components + .into_iter() + .find(|component| &component.id == id); + let spec = catalog::find(id); + let external = spec.is_none() && is_external_use_extension(id); + + let requested_version_is_ready = request.version.as_deref().is_none_or(|requested| { + state + .as_ref() + .and_then(|state| state.version.as_deref()) + .is_some_and(|installed| parse_version(installed).ok() == parse_version(requested).ok()) + }); + let already_ready = state.as_ref().is_some_and(ComponentState::is_ready) + && requested_version_is_ready + && !request.force + && !external; + let local_package = match request.package.as_deref() { + Some(path) => Some(fingerprint_local_package(path).await?), + None => None, + }; + let mut resolved_releases = BTreeMap::new(); + let mut resolved_sources = BTreeMap::new(); + let mut prerequisites = BTreeMap::new(); + let mut resolved_registry_packages = BTreeMap::new(); + let (source, ownership) = if external { + prepare_parent_release( + &ComponentId::parse("use")?, + paths, + &mut resolved_sources, + &mut resolved_releases, + &mut prerequisites, + ) + .await?; + if request.package.is_some() { + ("local-package".to_string(), "parent:use".to_string()) + } else { + let registry_store = registries.context( + "signed extension installation requires the umbrella registry configuration", + )?; + let package_id = id + .relative_to(&ComponentId::parse("use")?) + .context("external extension is outside the Use namespace")?; + let resolved = registry_store + .resolve_package( + &paths.state_root, + package_id, + request.version.as_deref(), + channel, + ) + .await?; + let source = format!("registry:{}", resolved.registry.name); + resolved_registry_packages.insert(id.to_string(), resolved); + (source, "parent:use".to_string()) + } + } else { + match spec.map(|spec| spec.distribution) { + Some(Distribution::Bundled) => ("bundled".to_string(), "bundled".to_string()), + Some(Distribution::Delegated { parent }) => { + prepare_parent_release( + &ComponentId::parse(parent)?, + paths, + &mut resolved_sources, + &mut resolved_releases, + &mut prerequisites, + ) + .await?; + (format!("delegated:{parent}"), format!("parent:{parent}")) + } + Some(Distribution::Release(_)) if already_ready => ( + state + .as_ref() + .and_then(|state| state.provenance) + .map(|value| format!("existing:{value:?}").to_ascii_lowercase()) + .unwrap_or_else(|| "existing".to_string()), + state + .as_ref() + .and_then(|state| state.provenance) + .map(|value| match value { + InstallProvenance::Homebrew => "package-manager:homebrew".to_string(), + _ => "a3s".to_string(), + }) + .unwrap_or_else(|| "a3s".to_string()), + ), + Some(Distribution::Release(release)) => { + let selected = resolve_install_source(id, release, request)?; + resolved_sources.insert(id.to_string(), selected); + match selected { + InstallSource::Release => { + let resolved = + resolve_release(id, release, request.version.as_deref()).await?; + resolved_releases.insert(id.to_string(), resolved); + ("github-release".to_string(), "a3s".to_string()) + } + InstallSource::Homebrew => { + let formula = release.homebrew_formula.with_context(|| { + format!("component '{}' has no Homebrew formula", id) + })?; + ( + format!("homebrew:{formula}"), + "package-manager:homebrew".to_string(), + ) + } + InstallSource::Auto => bail!("automatic install source was not resolved"), + } + } + None => bail!("component '{}' has no install ownership", id), + } + }; + let current = state + .as_ref() + .map(|state| PlannedCurrentState::with_receipt(state, paths)) + .transpose()?; + + let plan = OperationPlan { + schema_version: PLAN_SCHEMA_VERSION, + component: id.clone(), + action: "install", + source, + requested_source: Some(install_source_name(request.source).to_string()), + channel: Some(channel.to_string()), + scope: Some(scope.to_string()), + migration: Some(migrate), + target: host_target(), + ownership, + mutates: !already_ready, + requested_version: request.version.clone(), + local_package, + resolved_sources: planned_sources(&resolved_sources)?, + resolved_releases: resolved_releases.clone(), + resolved_registry_packages: resolved_registry_packages + .iter() + .map(|(component, resolved)| (component.clone(), resolved.package.clone())) + .collect(), + prerequisites, + force: Some(request.force), + allow_unsigned: Some(request.allow_unsigned), + cascade: None, + purge: None, + current, + message: if already_ready { + "Already healthy; apply would be a no-op.".to_string() + } else { + "Apply would resolve, verify, stage, activate, and health-check the component." + .to_string() + }, + }; + Ok(PreparedOperationPlan { + plan, + resolved_releases, + resolved_sources, + resolved_registry_packages, + apply_force: request.force, + }) +} + +pub(super) fn uninstall_plan( + id: &ComponentId, + cascade: bool, + purge: bool, + paths: &ComponentPaths, +) -> anyhow::Result { + let state = super::discovery::find_state(id, paths)?; + if matches!( + state.presence, + Presence::External | Presence::System | Presence::Bundled + ) { + bail!( + "component '{}' is not owned by A3S and cannot be uninstalled", + id + ); + } + let mut prerequisites = BTreeMap::new(); + let parent = match catalog::find(id).map(|spec| spec.distribution) { + Some(Distribution::Delegated { parent }) => Some(ComponentId::parse(parent)?), + None if is_external_use_extension(id) => Some(ComponentId::parse("use")?), + _ => None, + }; + if let Some(parent) = parent { + let parent_state = super::discovery::find_state(&parent, paths)?; + if !parent_state.is_ready() { + bail!("parent component '{}' is not ready", parent); + } + prerequisites.insert( + parent.to_string(), + PlannedCurrentState::with_receipt(&parent_state, paths)?, + ); + } + Ok(OperationPlan { + schema_version: PLAN_SCHEMA_VERSION, + component: id.clone(), + action: "uninstall", + source: state + .provenance + .map(|value| format!("{value:?}").to_ascii_lowercase()) + .unwrap_or_else(|| "unknown".to_string()), + requested_source: None, + channel: None, + scope: None, + migration: None, + target: host_target(), + ownership: "receipt-or-parent-owned".to_string(), + mutates: state.presence != Presence::Missing, + requested_version: None, + local_package: None, + resolved_sources: BTreeMap::new(), + resolved_releases: BTreeMap::new(), + resolved_registry_packages: BTreeMap::new(), + prerequisites, + force: None, + allow_unsigned: None, + cascade: Some(cascade), + purge: Some(purge), + current: Some(PlannedCurrentState::with_receipt(&state, paths)?), + message: if purge { + "Apply would remove owned files and component-owned recreatable cache.".to_string() + } else { + "Apply would remove only receipt-owned files or delegate to the owning parent." + .to_string() + }, + }) +} + +pub(super) async fn upgrade_plan( + id: &ComponentId, + paths: &ComponentPaths, + registries: Option<&RegistryStore>, +) -> anyhow::Result { + let state = super::discovery::find_state(id, paths)?; + if state.presence != Presence::Managed { + bail!("component '{}' is not managed by A3S", id); + } + let Some(spec) = catalog::find(id) else { + if !is_external_use_extension(id) { + bail!("component '{}' is not registered", id); + } + return registry_extension_upgrade_plan(id, state, paths, registries).await; + }; + let release = catalog::release(spec) + .with_context(|| format!("component '{}' has no managed release", id))?; + let selected = match state.provenance { + Some(InstallProvenance::Homebrew) => InstallSource::Homebrew, + Some(InstallProvenance::GithubRelease) => InstallSource::Release, + Some(provenance) => bail!( + "component '{}' cannot be upgraded from {:?} provenance", + id, + provenance + ), + None => bail!("component '{}' has no recorded upgrade provenance", id), + }; + let resolved_sources = BTreeMap::from([(id.to_string(), selected)]); + let mut resolved_releases = BTreeMap::new(); + let source = match selected { + InstallSource::Release => { + resolved_releases.insert(id.to_string(), resolve_release(id, release, None).await?); + "github-release".to_string() + } + InstallSource::Homebrew => { + let formula = release + .homebrew_formula + .with_context(|| format!("component '{}' has no Homebrew formula", id))?; + format!("homebrew:{formula}") + } + InstallSource::Auto => bail!("automatic install source was not resolved"), + }; + let plan = OperationPlan { + schema_version: PLAN_SCHEMA_VERSION, + component: id.clone(), + action: "upgrade", + source, + requested_source: None, + channel: None, + scope: None, + migration: None, + target: host_target(), + ownership: "existing-provenance".to_string(), + mutates: true, + requested_version: None, + local_package: None, + resolved_sources: planned_sources(&resolved_sources)?, + resolved_releases: resolved_releases.clone(), + resolved_registry_packages: BTreeMap::new(), + prerequisites: BTreeMap::new(), + force: Some(true), + allow_unsigned: None, + cascade: None, + purge: None, + current: Some(PlannedCurrentState::with_receipt(&state, paths)?), + message: "Apply would install the exact resolved artifact through the existing provenance." + .to_string(), + }; + Ok(PreparedOperationPlan { + plan, + resolved_releases, + resolved_sources, + resolved_registry_packages: BTreeMap::new(), + apply_force: true, + }) +} + +async fn registry_extension_upgrade_plan( + id: &ComponentId, + state: ComponentState, + paths: &ComponentPaths, + registries: Option<&RegistryStore>, +) -> anyhow::Result { + let installed = extension_registry_provenance(id, paths)?.with_context(|| { + format!( + "local extension '{}' has no recorded signed upgrade source; install the new package explicitly with 'a3s install {} --from --force --allow-unsigned'", + id, id + ) + })?; + let registries = registries + .context("signed extension upgrade requires the umbrella registry configuration")?; + let resolved = registries + .resolve_upgrade(&paths.state_root, &installed) + .await?; + let installed_version = parse_version(&installed.version) + .with_context(|| format!("installed extension '{}' has an invalid version", id))?; + let resolved_version = parse_version(&resolved.package.version).with_context(|| { + format!( + "registry returned an invalid version for extension '{}'", + id + ) + })?; + if resolved_version < installed_version { + bail!( + "registry '{}' attempted to downgrade extension '{}' from {} to {}", + installed.registry_name, + id, + installed.version, + resolved.package.version + ); + } + + let mutates = installed.version != resolved.package.version + || installed.sha256 != resolved.package.sha256; + let apply_force = mutates; + let source = format!("registry:{}", resolved.registry.name); + let channel = installed.channel.clone(); + let resolved_registry_packages = BTreeMap::from([(id.to_string(), resolved.clone())]); + let plan = OperationPlan { + schema_version: PLAN_SCHEMA_VERSION, + component: id.clone(), + action: "upgrade", + source, + requested_source: None, + channel: Some(channel), + scope: None, + migration: None, + target: host_target(), + ownership: "parent:use".to_string(), + mutates, + requested_version: None, + local_package: None, + resolved_sources: BTreeMap::new(), + resolved_releases: BTreeMap::new(), + resolved_registry_packages: BTreeMap::from([(id.to_string(), resolved.package.clone())]), + prerequisites: BTreeMap::new(), + force: Some(apply_force), + allow_unsigned: Some(false), + cascade: None, + purge: None, + current: Some(PlannedCurrentState::with_receipt(&state, paths)?), + message: if mutates { + "Apply would install the exact signed target from the extension's recorded registry." + .to_string() + } else { + "The recorded registry resolves to the installed target; apply would be a no-op." + .to_string() + }, + }; + Ok(PreparedOperationPlan { + plan, + resolved_releases: BTreeMap::new(), + resolved_sources: BTreeMap::new(), + resolved_registry_packages, + apply_force, + }) +} + +async fn prepare_parent_release( + parent: &ComponentId, + paths: &ComponentPaths, + resolved_sources: &mut BTreeMap, + resolved_releases: &mut BTreeMap, + prerequisites: &mut BTreeMap, +) -> anyhow::Result<()> { + let state = super::discovery::find_state(parent, paths)?; + prerequisites.insert( + parent.to_string(), + PlannedCurrentState::with_receipt(&state, paths)?, + ); + if state.is_ready() { + return Ok(()); + } + let spec = catalog::find(parent) + .with_context(|| format!("parent component '{}' is not registered", parent))?; + let release = catalog::release(spec) + .with_context(|| format!("parent component '{}' is not installable", parent))?; + let request = InstallRequest { + progress: false, + ..InstallRequest::default() + }; + let source = resolve_install_source(parent, release, &request)?; + resolved_sources.insert(parent.to_string(), source); + if source == InstallSource::Release { + resolved_releases.insert( + parent.to_string(), + resolve_release(parent, release, None).await?, + ); + } + Ok(()) +} + +fn planned_sources( + sources: &BTreeMap, +) -> anyhow::Result> { + sources + .iter() + .map(|(component, source)| { + let id = ComponentId::parse(component)?; + let release = catalog::find(&id).and_then(catalog::release); + let source = match (source, release) { + (InstallSource::Auto, _) => "auto".to_string(), + (InstallSource::Homebrew, Some(release)) => format!( + "homebrew:{}", + release.homebrew_formula.with_context(|| { + format!("component '{}' has no Homebrew formula", component) + })? + ), + (InstallSource::Release, Some(release)) => format!( + "github-release:{}/{}", + release.github_owner, release.github_repo + ), + (_, None) => bail!("component '{}' has no resolved install source", component), + }; + Ok((component.clone(), source)) + }) + .collect() +} + +fn install_source_name(source: InstallSource) -> &'static str { + match source { + InstallSource::Auto => "auto", + InstallSource::Homebrew => "homebrew", + InstallSource::Release => "release", + } +} + +fn is_external_use_extension(id: &ComponentId) -> bool { + let mut segments = id.as_str().split('/'); + matches!( + ( + segments.next(), + segments.next(), + segments.next(), + segments.next() + ), + (Some("use"), Some(_), Some(_), None) + ) +} + +fn host_target() -> String { + format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH) +} + +#[cfg(unix)] +fn path_identity(path: &std::path::Path) -> String { + use std::os::unix::ffi::OsStrExt; + + format!("unix-hex:{}", encode_hex(path.as_os_str().as_bytes())) +} + +#[cfg(windows)] +fn path_identity(path: &std::path::Path) -> String { + use std::os::windows::ffi::OsStrExt; + + let words = path.as_os_str().encode_wide().collect::>(); + let mut bytes = Vec::with_capacity(words.len() * 2); + for word in words { + bytes.extend_from_slice(&word.to_le_bytes()); + } + format!("windows-utf16le-hex:{}", encode_hex(&bytes)) +} + +#[cfg(not(any(unix, windows)))] +fn path_identity(path: &std::path::Path) -> String { + format!("display:{}", path.to_string_lossy()) +} + +fn encode_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn plan_digest(command: &'static str, plans: &[OperationPlan]) -> anyhow::Result { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct CanonicalPlanSet<'a> { + schema_version: u32, + command: &'a str, + plans: Vec>, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct CanonicalOperationPlan<'a> { + schema_version: u32, + component: &'a ComponentId, + action: &'a str, + source: &'a str, + requested_source: &'a Option, + channel: &'a Option, + scope: &'a Option, + migration: Option, + target: &'a str, + ownership: &'a str, + mutates: bool, + requested_version: &'a Option, + local_package: &'a Option, + resolved_sources: &'a BTreeMap, + resolved_releases: &'a BTreeMap, + resolved_registry_packages: &'a BTreeMap, + prerequisites: &'a BTreeMap, + force: Option, + allow_unsigned: Option, + cascade: Option, + purge: Option, + current: &'a Option, + } + + let canonical = CanonicalPlanSet { + schema_version: PLAN_SCHEMA_VERSION, + command, + plans: plans + .iter() + .map(|plan| CanonicalOperationPlan { + schema_version: plan.schema_version, + component: &plan.component, + action: plan.action, + source: &plan.source, + requested_source: &plan.requested_source, + channel: &plan.channel, + scope: &plan.scope, + migration: plan.migration, + target: &plan.target, + ownership: &plan.ownership, + mutates: plan.mutates, + requested_version: &plan.requested_version, + local_package: &plan.local_package, + resolved_sources: &plan.resolved_sources, + resolved_releases: &plan.resolved_releases, + resolved_registry_packages: &plan.resolved_registry_packages, + prerequisites: &plan.prerequisites, + force: plan.force, + allow_unsigned: plan.allow_unsigned, + cascade: plan.cascade, + purge: plan.purge, + current: &plan.current, + }) + .collect(), + }; + let bytes = serde_json::to_vec(&canonical).context("failed to encode component plan")?; + let mut digest = Sha256::new(); + digest.update(PLAN_DIGEST_DOMAIN); + digest.update(bytes); + Ok(format!("{:x}", digest.finalize())) +} + +#[cfg(test)] +mod tests; diff --git a/src/components/plan/local_package.rs b/src/components/plan/local_package.rs new file mode 100644 index 00000000..3ef5f17a --- /dev/null +++ b/src/components/plan/local_package.rs @@ -0,0 +1,185 @@ +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context}; +use sha2::{Digest, Sha256}; + +use super::{path_identity, PlannedLocalPackage, PlannedPath}; + +const LOCAL_PACKAGE_DIGEST_DOMAIN: &[u8] = b"a3s-component-local-package-v1\0"; +const MAX_LOCAL_ARCHIVE_BYTES: u64 = 512 * 1024 * 1024; +const MAX_LOCAL_PACKAGE_FILES: u64 = 10_000; +const MAX_LOCAL_PACKAGE_BYTES: u64 = 1_073_741_824; + +pub(super) async fn fingerprint_local_package(path: &Path) -> anyhow::Result { + let path = path.to_path_buf(); + tokio::task::spawn_blocking(move || fingerprint_local_package_blocking(&path)) + .await + .context("local package fingerprint task failed")? +} + +fn fingerprint_local_package_blocking(path: &Path) -> anyhow::Result { + let metadata = std::fs::symlink_metadata(path) + .with_context(|| format!("failed to inspect local package {}", path.display()))?; + if metadata.file_type().is_symlink() { + bail!( + "local package source '{}' is a symbolic link", + path.display() + ); + } + + let mut digest = Sha256::new(); + digest.update(LOCAL_PACKAGE_DIGEST_DOMAIN); + let (kind, file_count, byte_count) = if metadata.is_file() { + if metadata.len() > MAX_LOCAL_ARCHIVE_BYTES { + bail!( + "local package archive exceeds the {} byte compressed-size limit", + MAX_LOCAL_ARCHIVE_BYTES + ); + } + hash_file(&mut digest, path, "root", &metadata)?; + ("file", 1, metadata.len()) + } else if metadata.is_dir() { + let mut entries = Vec::new(); + collect_local_entries(path, path, &mut entries)?; + entries.sort_by_key(|entry| path_identity(&entry.0)); + let mut file_count = 0_u64; + let mut byte_count = 0_u64; + for (relative, absolute, metadata) in entries { + let identity = path_identity(&relative); + if metadata.is_dir() { + hash_field(&mut digest, b"directory", identity.as_bytes()); + } else if metadata.is_file() { + hash_file(&mut digest, &absolute, &identity, &metadata)?; + file_count = file_count + .checked_add(1) + .context("local package file count overflow")?; + byte_count = byte_count + .checked_add(metadata.len()) + .context("local package byte count overflow")?; + if file_count > MAX_LOCAL_PACKAGE_FILES || byte_count > MAX_LOCAL_PACKAGE_BYTES { + bail!( + "local package exceeds the {} file or {} byte limit", + MAX_LOCAL_PACKAGE_FILES, + MAX_LOCAL_PACKAGE_BYTES + ); + } + } else { + bail!( + "local package entry '{}' is not a regular file or directory", + absolute.display() + ); + } + } + ("directory", file_count, byte_count) + } else { + bail!( + "local package source '{}' is not a regular file or directory", + path.display() + ); + }; + + Ok(PlannedLocalPackage { + path: PlannedPath::new(path), + kind, + sha256: format!("{:x}", digest.finalize()), + file_count, + byte_count, + }) +} + +fn collect_local_entries( + root: &Path, + directory: &Path, + output: &mut Vec<(PathBuf, PathBuf, std::fs::Metadata)>, +) -> anyhow::Result<()> { + for entry in std::fs::read_dir(directory).with_context(|| { + format!( + "failed to read local package directory {}", + directory.display() + ) + })? { + let entry = entry.with_context(|| { + format!( + "failed to read local package entry in {}", + directory.display() + ) + })?; + let absolute = entry.path(); + let metadata = std::fs::symlink_metadata(&absolute).with_context(|| { + format!( + "failed to inspect local package entry {}", + absolute.display() + ) + })?; + if metadata.file_type().is_symlink() { + bail!( + "local package entry '{}' is a symbolic link", + absolute.display() + ); + } + let relative = absolute + .strip_prefix(root) + .context("local package entry escaped its source root")? + .to_path_buf(); + output.push((relative, absolute.clone(), metadata.clone())); + if metadata.is_dir() { + collect_local_entries(root, &absolute, output)?; + } + } + Ok(()) +} + +fn hash_file( + digest: &mut Sha256, + path: &Path, + identity: &str, + metadata: &std::fs::Metadata, +) -> anyhow::Result<()> { + hash_field(digest, b"file", identity.as_bytes()); + hash_field(digest, b"size", &metadata.len().to_le_bytes()); + hash_field(digest, b"mode", &file_mode(metadata).to_le_bytes()); + let mut file = File::open(path) + .with_context(|| format!("failed to open local package file {}", path.display()))?; + let mut buffer = [0_u8; 64 * 1024]; + let mut bytes_read = 0_u64; + loop { + let count = file + .read(&mut buffer) + .with_context(|| format!("failed to read local package file {}", path.display()))?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + bytes_read = bytes_read + .checked_add(count as u64) + .context("local package file size overflow")?; + } + if bytes_read != metadata.len() { + bail!( + "local package file '{}' changed while its plan was computed", + path.display() + ); + } + Ok(()) +} + +fn hash_field(digest: &mut Sha256, label: &[u8], value: &[u8]) { + digest.update((label.len() as u64).to_le_bytes()); + digest.update(label); + digest.update((value.len() as u64).to_le_bytes()); + digest.update(value); +} + +#[cfg(unix)] +fn file_mode(metadata: &std::fs::Metadata) -> u32 { + use std::os::unix::fs::PermissionsExt; + + metadata.permissions().mode() & 0o777 +} + +#[cfg(not(unix))] +fn file_mode(metadata: &std::fs::Metadata) -> u32 { + u32::from(metadata.permissions().readonly()) +} diff --git a/src/components/plan/tests.rs b/src/components/plan/tests.rs new file mode 100644 index 00000000..4929ac99 --- /dev/null +++ b/src/components/plan/tests.rs @@ -0,0 +1,193 @@ +use std::path::{Path, PathBuf}; + +use super::super::catalog::ComponentKind; +use super::super::state::{Trust, UpdateState}; +use super::*; + +fn fixture(message: &str) -> OperationPlan { + OperationPlan { + schema_version: PLAN_SCHEMA_VERSION, + component: ComponentId::parse("box").unwrap(), + action: "install", + source: "release".to_string(), + requested_source: Some("release".to_string()), + channel: Some("stable".to_string()), + scope: Some("user".to_string()), + migration: Some(false), + target: "linux-x86_64".to_string(), + ownership: "a3s".to_string(), + mutates: true, + requested_version: Some("1.2.3".to_string()), + local_package: None, + resolved_sources: BTreeMap::from([("box".to_string(), "github-release".to_string())]), + resolved_releases: BTreeMap::new(), + resolved_registry_packages: BTreeMap::new(), + prerequisites: BTreeMap::new(), + force: Some(false), + allow_unsigned: Some(false), + cascade: None, + purge: None, + current: None, + message: message.to_string(), + } +} + +#[test] +fn digest_is_stable_and_excludes_presentation_text() { + let first = OperationPlanSet::new("component.install", vec![fixture("first")]).unwrap(); + let second = OperationPlanSet::new("component.install", vec![fixture("second")]).unwrap(); + assert_eq!(first.plan_digest, second.plan_digest); + assert_eq!(first.plan_digest.len(), 64); + assert!(first + .plan_digest + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())); +} + +#[test] +fn digest_changes_with_semantics_or_operation_order() { + let first = fixture("same"); + let mut forced = first.clone(); + forced.force = Some(true); + let mut different_version = first.clone(); + different_version.requested_version = Some("2.0.0".to_string()); + let mut different_source = first.clone(); + different_source.source = "homebrew:a3s-lab/tap/a3s-box".to_string(); + different_source.requested_source = Some("homebrew".to_string()); + let mut purged = first.clone(); + purged.purge = Some(true); + assert_ne!( + plan_digest("component.install", std::slice::from_ref(&first)).unwrap(), + plan_digest("component.install", &[forced.clone()]).unwrap() + ); + for changed in [different_version, different_source, purged] { + assert_ne!( + plan_digest("component.install", std::slice::from_ref(&first)).unwrap(), + plan_digest("component.install", &[changed]).unwrap() + ); + } + assert_ne!( + plan_digest("component.install", &[first.clone(), forced.clone()]).unwrap(), + plan_digest("component.install", &[forced, first]).unwrap() + ); +} + +#[test] +fn current_state_is_part_of_the_digest() { + let mut first = fixture("same"); + first.current = Some(PlannedCurrentState::from(&ComponentState { + id: ComponentId::parse("box").unwrap(), + kind: ComponentKind::Product, + description: String::new(), + presence: Presence::Managed, + health: Health::Ready, + update: UpdateState::Unknown, + trust: Trust::FirstParty, + provenance: Some(InstallProvenance::GithubRelease), + version: Some("1.0.0".to_string()), + path: Some(PathBuf::from("/components/box")), + message: None, + })); + let mut second = first.clone(); + second.current.as_mut().unwrap().version = Some("2.0.0".to_string()); + assert_ne!( + plan_digest("component.install", &[first]).unwrap(), + plan_digest("component.install", &[second]).unwrap() + ); +} + +#[test] +fn receipt_ownership_and_checksums_are_part_of_the_digest() { + let mut first = fixture("same"); + first.current = Some(PlannedCurrentState { + presence: Presence::Managed, + health: Health::Ready, + provenance: Some(InstallProvenance::GithubRelease), + version: Some("1.0.0".to_string()), + path: Some(PlannedPath::new(Path::new("/components/box/a3s-box"))), + receipt: Some(PlannedReceipt { + schema_version: 1, + component_id: "box".to_string(), + version: "1.0.0".to_string(), + provenance: InstallProvenance::GithubRelease, + install_root: PlannedPath::new(Path::new("/components/box")), + executable_path: Some(PlannedPath::new(Path::new("/components/box/a3s-box"))), + owned_paths: vec![PlannedPath::new(Path::new("/components/box"))], + source: Some("https://example.invalid/releases/v1.0.0".to_string()), + artifact_checksums: BTreeMap::from([("box.tar.gz".to_string(), "a".repeat(64))]), + }), + }); + let mut second = first.clone(); + second + .current + .as_mut() + .unwrap() + .receipt + .as_mut() + .unwrap() + .artifact_checksums + .insert("box.tar.gz".to_string(), "b".repeat(64)); + assert_ne!( + plan_digest("component.uninstall", &[first]).unwrap(), + plan_digest("component.uninstall", &[second]).unwrap() + ); +} + +#[test] +fn expected_digest_rejects_a_changed_plan() { + let plan = OperationPlanSet::new("component.install", vec![fixture("same")]).unwrap(); + plan.verify_expected(Some(plan.digest())).unwrap(); + let error = plan.verify_expected(Some(&"0".repeat(64))).unwrap_err(); + let mismatch = error.downcast_ref::().unwrap(); + assert_eq!(mismatch.expected, "0".repeat(64)); + assert_eq!(mismatch.actual, plan.digest()); +} + +#[test] +fn resolved_release_is_part_of_the_digest() { + let mut first = fixture("same"); + first.resolved_releases.insert( + "box".to_string(), + ResolvedRelease { + version: "1.2.3".to_string(), + tag: "v1.2.3".to_string(), + target: "linux-x86_64".to_string(), + archive_name: "a3s-box-v1.2.3-linux-x86_64.tar.gz".to_string(), + asset_url: "https://example.invalid/box.tar.gz".to_string(), + sha256: "a".repeat(64), + }, + ); + let mut second = first.clone(); + second.resolved_releases.get_mut("box").unwrap().sha256 = "b".repeat(64); + assert_ne!( + plan_digest("component.install", &[first]).unwrap(), + plan_digest("component.install", &[second]).unwrap() + ); +} + +#[tokio::test] +async fn local_package_fingerprint_is_stable_and_tracks_content() { + let temp = tempfile::tempdir().unwrap(); + let package = temp.path().join("package"); + std::fs::create_dir_all(package.join("nested")).unwrap(); + std::fs::write(package.join("nested/tool"), b"first").unwrap(); + + let first = fingerprint_local_package(&package).await.unwrap(); + let repeated = fingerprint_local_package(&package).await.unwrap(); + assert_eq!(first, repeated); + assert_eq!(first.file_count, 1); + assert_eq!(first.byte_count, 5); + + std::fs::write(package.join("nested/tool"), b"other").unwrap(); + let changed = fingerprint_local_package(&package).await.unwrap(); + assert_ne!(first.sha256, changed.sha256); + + let mut first_plan = fixture("same"); + first_plan.local_package = Some(first); + let mut changed_plan = first_plan.clone(); + changed_plan.local_package = Some(changed); + assert_ne!( + plan_digest("component.install", &[first_plan]).unwrap(), + plan_digest("component.install", &[changed_plan]).unwrap() + ); +} diff --git a/src/components/release_install.rs b/src/components/release_install.rs index e518c860..c02b89ae 100644 --- a/src/components/release_install.rs +++ b/src/components/release_install.rs @@ -7,6 +7,7 @@ use a3s_updater::{ RECEIPT_SCHEMA_VERSION, }; use anyhow::{bail, Context}; +use serde::{Deserialize, Serialize}; use super::catalog::ReleaseSpec; use super::id::ComponentId; @@ -14,12 +15,26 @@ use super::lifecycle::{InstallRequest, OperationRecord}; use super::paths::ComponentPaths; use super::probe::probe_version; -pub async fn install_release( +/// One exact release artifact resolved before a component plan is approved. +/// +/// Passing this value into the installer prevents a second `latest` lookup +/// from selecting a different release after the plan digest was checked. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedRelease { + pub version: String, + pub tag: String, + pub target: String, + pub archive_name: String, + pub asset_url: String, + pub sha256: String, +} + +pub async fn resolve_release( id: &ComponentId, release_spec: ReleaseSpec, - request: &InstallRequest, - paths: &ComponentPaths, -) -> anyhow::Result { + requested_version: Option<&str>, +) -> anyhow::Result { let target = release_spec.asset_family.target().with_context(|| { format!( "component '{}' has no release for {}-{}", @@ -28,14 +43,10 @@ pub async fn install_release( std::env::consts::ARCH ) })?; - super::progress( - request.progress, - format!("a3s: resolving release for '{}'...", id), - ); let release = fetch_release( release_spec.github_owner, release_spec.github_repo, - request.version.as_deref(), + requested_version, ) .await?; let version = parse_version(&release.tag_name)?.to_string(); @@ -54,13 +65,46 @@ pub async fn install_release( version, archive_name, id ) })?; - let checksum = release_checksum(&release, &asset).await?; + let sha256 = release_checksum(&release, &asset).await?; + Ok(ResolvedRelease { + version, + tag: release.tag_name, + target: target.to_string(), + archive_name, + asset_url: asset.browser_download_url, + sha256, + }) +} + +pub async fn install_release( + id: &ComponentId, + release_spec: ReleaseSpec, + request: &InstallRequest, + resolved: Option<&ResolvedRelease>, + paths: &ComponentPaths, +) -> anyhow::Result { + let resolved = match resolved { + Some(resolved) => { + validate_resolved_release(id, release_spec, request, resolved)?; + resolved.clone() + } + None => { + super::progress( + request.progress, + format!("a3s: resolving release for '{}'...", id), + ); + resolve_release(id, release_spec, request.version.as_deref()).await? + } + }; super::progress( request.progress, - format!("a3s: downloading '{}' {} for {}...", id, version, target), + format!( + "a3s: downloading '{}' {} for {}...", + id, resolved.version, resolved.target + ), ); - let archive = download_asset(&asset.browser_download_url).await?; - verify_sha256(&archive, &checksum)?; + let archive = download_asset(&resolved.asset_url).await?; + verify_sha256(&archive, &resolved.sha256)?; let component_root = paths.component_root(id); std::fs::create_dir_all(&component_root)?; @@ -68,38 +112,41 @@ pub async fn install_release( .prefix(".staging-") .tempdir_in(&component_root)?; let unpacked = staging.path().join("unpacked"); - extract_release_archive(&archive, &unpacked, &archive_name)?; + extract_release_archive(&archive, &unpacked, &resolved.archive_name)?; let executable_name = release_spec .asset_family - .executable_name(release_spec.binary, target); + .executable_name(release_spec.binary, &resolved.target); let staged_executable = find_unique_file(&unpacked, &executable_name)?; let actual_version = probe_version(&staged_executable)?; - if parse_version(&actual_version)? != parse_version(&version)? { + if parse_version(&actual_version)? != parse_version(&resolved.version)? { bail!( "downloaded '{}' reported version {}, expected {}", id, actual_version, - version + resolved.version ); } let relative_executable = staged_executable.strip_prefix(&unpacked)?.to_path_buf(); - let active = paths.version_root(id, &version); + let active = paths.version_root(id, &resolved.version); let old_receipt = paths.receipt_store().read(id.as_str())?; let activation = DirectoryActivation::activate(&unpacked, &active)?; let executable = active.join(relative_executable); let receipt = ComponentReceipt { schema_version: RECEIPT_SCHEMA_VERSION, component_id: id.to_string(), - version: version.clone(), + version: resolved.version.clone(), provenance: InstallProvenance::GithubRelease, install_root: active.clone(), executable_path: Some(executable.clone()), owned_paths: vec![active.clone()], source: Some(format!( "https://github.com/{}/{}/releases/tag/{}", - release_spec.github_owner, release_spec.github_repo, release.tag_name + release_spec.github_owner, release_spec.github_repo, resolved.tag )), - artifact_checksums: BTreeMap::from([(archive_name, checksum)]), + artifact_checksums: BTreeMap::from([( + resolved.archive_name.clone(), + resolved.sha256.clone(), + )]), installed_at: chrono::Utc::now().to_rfc3339(), }; paths.receipt_store().write(&receipt)?; @@ -112,15 +159,83 @@ pub async fn install_release( } Ok(OperationRecord { component: id.clone(), - action: "install", + action: request.intent.action(), changed: true, - version: Some(version), + version: Some(resolved.version), provenance: Some(InstallProvenance::GithubRelease), path: Some(executable), - message: format!("Installed component '{}' from its verified release.", id), + message: format!( + "Completed {} for component '{}' from its verified release.", + request.intent.action(), + id + ), }) } +fn validate_resolved_release( + id: &ComponentId, + release_spec: ReleaseSpec, + request: &InstallRequest, + resolved: &ResolvedRelease, +) -> anyhow::Result<()> { + let target = release_spec.asset_family.target().with_context(|| { + format!( + "component '{}' has no release for {}-{}", + id, + std::env::consts::OS, + std::env::consts::ARCH + ) + })?; + if resolved.target != target { + bail!( + "reviewed release target '{}' does not match current target '{}'", + resolved.target, + target + ); + } + let version = parse_version(&resolved.version)?; + if parse_version(&resolved.tag)? != version { + bail!( + "reviewed release tag '{}' does not match version '{}'", + resolved.tag, + resolved.version + ); + } + if let Some(requested) = request.version.as_deref() { + if parse_version(requested)? != version { + bail!( + "reviewed release version '{}' does not match requested version '{}'", + resolved.version, + requested + ); + } + } + let expected_archive = release_spec.asset_family.archive_name( + release_spec.binary, + &resolved.version, + &resolved.target, + ); + if resolved.archive_name != expected_archive { + bail!( + "reviewed release asset '{}' does not match expected asset '{}'", + resolved.archive_name, + expected_archive + ); + } + if resolved.asset_url.trim().is_empty() { + bail!("reviewed release asset URL cannot be empty"); + } + if resolved.sha256.len() != 64 + || !resolved + .sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + bail!("reviewed release SHA-256 digest is invalid"); + } + Ok(()) +} + async fn release_checksum( release: &a3s_updater::Release, asset: &a3s_updater::Asset, diff --git a/src/components/state.rs b/src/components/state.rs index 6468f952..d4e41ff7 100644 --- a/src/components/state.rs +++ b/src/components/state.rs @@ -37,6 +37,7 @@ pub enum UpdateState { pub enum Trust { FirstParty, LocalExplicit, + RegistryTuf, Untrusted, NotApplicable, } diff --git a/src/components/tests.rs b/src/components/tests.rs index e9e86dc2..bed30de1 100644 --- a/src/components/tests.rs +++ b/src/components/tests.rs @@ -100,7 +100,7 @@ exit 2 let arguments = std::fs::read_to_string(args_log).unwrap(); assert!(arguments.contains("component\ninstall\nacme/slack\n--json\n")); assert!(arguments.contains("--from\n")); - assert!(arguments.contains(package.to_string_lossy().as_ref())); + assert!(arguments.contains(&*package.to_string_lossy())); assert!(arguments.contains("--allow-unsigned\n")); assert!(!arguments.to_ascii_lowercase().contains("jsonrpc")); } diff --git a/src/lib.rs b/src/lib.rs index f4821145..4e833b8d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ //! Reusable infrastructure for the umbrella A3S CLI. pub mod components; +pub mod registry; // Compile the process adapter in the library test target as well, so its // hermetic fake-process contract tests do not depend on the TUI test graph. diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 00000000..0d7b34d6 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,671 @@ +//! Trusted extension-registry configuration and TUF package resolution. + +use std::collections::HashMap; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use a3s_acl::{Block, Document, Value}; +use a3s_use_extension::{ + prepare_remote_package, refresh_remote_registry, ResolvedRemotePackage, TrustedRegistry, + VerifiedRegistryMetadata, +}; +use anyhow::{bail, Context}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +pub const OFFICIAL_NAME: &str = "a3s"; +pub const OFFICIAL_URL: &str = "https://components.a3s.dev/"; +const OFFICIAL_TRUST_PLACEHOLDER: &str = "built-in TUF root"; +const MAX_TRUSTED_ROOT_BYTES: u64 = 1024 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegistryRecord { + pub name: String, + pub url: String, + pub trust_root: String, + pub built_in: bool, + pub configured: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub trusted_root_path: Option, +} + +impl RegistryRecord { + pub fn trusted_registry(&self, state_root: &Path) -> anyhow::Result { + if !self.configured { + bail!( + "registry '{}' has no production TUF trust root configured", + self.name + ); + } + TrustedRegistry::new( + &self.name, + &self.url, + &self.trust_root, + self.trusted_root_path.clone(), + tuf_datastore(state_root, &self.name), + ) + .map_err(anyhow::Error::new) + } + + pub async fn refresh(&self, state_root: &Path) -> anyhow::Result { + let registry = self.trusted_registry(state_root)?; + refresh_remote_registry(®istry) + .await + .map_err(|error| registry_error(self, error)) + } +} + +#[derive(Debug)] +pub enum TrustRootSource<'a> { + Digest(&'a str), + File(&'a Path), +} + +#[derive(Debug)] +pub struct RegistryEnrollment { + pub record: RegistryRecord, + root_bytes: Option>, +} + +#[derive(Clone, Debug)] +pub struct ResolvedRegistryPackage { + pub registry: RegistryRecord, + pub package: ResolvedRemotePackage, +} + +#[derive(Clone, Debug)] +pub struct RegistryStore { + root: PathBuf, +} + +impl RegistryStore { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn list(&self) -> anyhow::Result> { + let mut records = vec![official_registry()]; + if self.root.is_dir() { + for entry in std::fs::read_dir(&self.root) + .with_context(|| format!("could not read registry root {}", self.root.display()))? + { + let path = entry?.path(); + if path.extension().and_then(|value| value.to_str()) != Some("acl") { + continue; + } + if let Some(record) = self.read_path(&path)? { + records.push(record); + } + } + } + records.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(records) + } + + pub fn get(&self, name: &str) -> anyhow::Result> { + validate_name(name)?; + if name == OFFICIAL_NAME { + return Ok(Some(official_registry())); + } + self.read_path(&self.registry_path(name)) + } + + pub fn prepare_enrollment( + &self, + url: &str, + source: TrustRootSource<'_>, + ) -> anyhow::Result { + let url = normalize_url(url)?; + let name = registry_name(&url)?; + if name == OFFICIAL_NAME { + bail!("registry name 'a3s' is reserved for the built-in official registry"); + } + let trusted_root_path = self.trusted_root_path(&name); + let (trust_root, root_bytes, trusted_root_path) = match source { + TrustRootSource::Digest(value) => (normalize_digest(value)?, None, None), + TrustRootSource::File(path) => { + let metadata = std::fs::symlink_metadata(path).with_context(|| { + format!("could not inspect trust-root file {}", path.display()) + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!( + "trust-root path '{}' must be a regular file", + path.display() + ); + } + if metadata.len() == 0 || metadata.len() > MAX_TRUSTED_ROOT_BYTES { + bail!( + "trust-root file must contain between 1 and {} bytes", + MAX_TRUSTED_ROOT_BYTES + ); + } + let bytes = std::fs::read(path).with_context(|| { + format!("could not read trust-root file {}", path.display()) + })?; + ( + format!("sha256:{:x}", Sha256::digest(&bytes)), + Some(bytes), + Some(trusted_root_path), + ) + } + }; + Ok(RegistryEnrollment { + record: RegistryRecord { + name, + url: url.to_string(), + trust_root, + built_in: false, + configured: true, + trusted_root_path, + }, + root_bytes, + }) + } + + pub fn add(&self, enrollment: &RegistryEnrollment) -> anyhow::Result<()> { + let name = &enrollment.record.name; + validate_name(name)?; + if name == OFFICIAL_NAME { + bail!("the built-in official registry cannot be replaced"); + } + let path = self.registry_path(name); + if path.exists() { + bail!("registry '{name}' already exists; remove it before changing its trust root"); + } + if let Some(bytes) = &enrollment.root_bytes { + let root_path = enrollment + .record + .trusted_root_path + .as_deref() + .context("trusted root bytes have no destination path")?; + ensure_real_directory( + root_path + .parent() + .context("trusted root destination has no parent")?, + )?; + write_atomic(root_path, bytes)?; + } + write_registry(&path, &enrollment.record) + } + + pub fn remove(&self, name: &str) -> anyhow::Result { + validate_name(name)?; + if name == OFFICIAL_NAME { + bail!("the built-in official registry cannot be removed"); + } + let path = self.registry_path(name); + let record = self + .read_path(&path)? + .with_context(|| format!("registry '{name}' is not configured"))?; + std::fs::remove_file(&path) + .with_context(|| format!("could not remove registry file {}", path.display()))?; + let trusted_root_directory = self.root.join(name); + match std::fs::symlink_metadata(&trusted_root_directory) { + Ok(metadata) if metadata.file_type().is_symlink() => { + std::fs::remove_file(&trusted_root_directory).with_context(|| { + format!( + "could not remove registry root link {}", + trusted_root_directory.display() + ) + })?; + } + Ok(metadata) if metadata.is_dir() => { + std::fs::remove_dir_all(&trusted_root_directory).with_context(|| { + format!( + "could not remove registry root directory {}", + trusted_root_directory.display() + ) + })?; + } + Ok(_) => { + std::fs::remove_file(&trusted_root_directory).with_context(|| { + format!( + "could not remove registry root file {}", + trusted_root_directory.display() + ) + })?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(record) + } + + pub async fn resolve_package( + &self, + state_root: &Path, + package_id: &str, + version: Option<&str>, + channel: &str, + ) -> anyhow::Result { + let registries = self + .list()? + .into_iter() + .filter(|registry| registry.configured) + .collect::>(); + if registries.is_empty() { + bail!( + "no package registry has a production TUF trust root; add one with 'a3s registry add'" + ); + } + let mut matches = Vec::new(); + for record in registries { + let registry = record.trusted_registry(state_root)?; + match prepare_remote_package(®istry, package_id, version, channel, None).await { + Ok(prepared) => matches.push(ResolvedRegistryPackage { + registry: record, + package: prepared.resolved().clone(), + }), + Err(error) if error.code == "use.extension.registry_package_missing" => {} + Err(error) => return Err(registry_error(&record, error)), + } + } + match matches.len() { + 0 => bail!( + "no trusted registry contains package '{}' for channel '{}'", + package_id, + channel + ), + 1 => matches + .pop() + .context("one resolved registry package unexpectedly disappeared"), + _ => { + let names = matches + .iter() + .map(|resolved| resolved.registry.name.as_str()) + .collect::>() + .join(", "); + bail!( + "package '{}' is ambiguous across trusted registries: {}; remove the duplicate source", + package_id, + names + ) + } + } + } + + pub async fn resolve_upgrade( + &self, + state_root: &Path, + installed: &ResolvedRemotePackage, + ) -> anyhow::Result { + let record = self.get(&installed.registry_name)?.with_context(|| { + format!( + "installed package source registry '{}' is no longer configured", + installed.registry_name + ) + })?; + if !record.configured { + bail!( + "installed package source registry '{}' has no production TUF trust root configured", + installed.registry_name + ); + } + let configured_root = record.trust_root.trim_start_matches("sha256:"); + if record.url != installed.registry_url || configured_root != installed.root_sha256 { + bail!( + "installed package source registry '{}' no longer matches its recorded URL and trust root; restore the original registry or reinstall with an explicit source migration", + installed.registry_name + ); + } + + let registry = record.trusted_registry(state_root)?; + let prepared = prepare_remote_package( + ®istry, + &installed.package_id, + None, + &installed.channel, + None, + ) + .await + .map_err(|error| registry_error(&record, error))?; + Ok(ResolvedRegistryPackage { + registry: record, + package: prepared.resolved().clone(), + }) + } + + fn read_path(&self, path: &Path) -> anyhow::Result> { + let source = match std::fs::read_to_string(path) { + Ok(source) => source, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let document = a3s_acl::parse_acl(&source) + .with_context(|| format!("invalid registry ACL {}", path.display()))?; + let block = document + .blocks + .into_iter() + .find(|block| block.name == "registry") + .with_context(|| format!("registry ACL {} has no registry block", path.display()))?; + if block.labels.len() != 1 { + bail!("registry ACL {} requires one name label", path.display()); + } + let name = block.labels[0].clone(); + validate_name(&name)?; + if name == OFFICIAL_NAME { + bail!( + "registry ACL {} uses the reserved name 'a3s'", + path.display() + ); + } + if path.file_stem().and_then(|value| value.to_str()) != Some(name.as_str()) { + bail!( + "registry ACL filename '{}' does not match registry name '{}'", + path.display(), + name + ); + } + let url = block + .attributes + .get("url") + .and_then(Value::as_str) + .context("registry URL is missing")?; + let url = normalize_url(url)?.to_string(); + let trust_root = normalize_digest( + block + .attributes + .get("trust_root") + .and_then(Value::as_str) + .context("registry trust_root is missing")?, + )?; + let trusted_root_path = self.trusted_root_path(&name); + let trusted_root_path = match std::fs::symlink_metadata(&trusted_root_path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!( + "trusted root '{}' must be a regular file", + trusted_root_path.display() + ); + } + let bytes = std::fs::read(&trusted_root_path).with_context(|| { + format!( + "could not read trusted root {}", + trusted_root_path.display() + ) + })?; + let actual = format!("sha256:{:x}", Sha256::digest(bytes)); + if actual != trust_root { + bail!( + "trusted root '{}' does not match the configured SHA-256 digest", + trusted_root_path.display() + ); + } + Some(trusted_root_path) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + Ok(Some(RegistryRecord { + name, + url, + trust_root, + built_in: false, + configured: true, + trusted_root_path, + })) + } + + fn registry_path(&self, name: &str) -> PathBuf { + self.root.join(format!("{name}.acl")) + } + + fn trusted_root_path(&self, name: &str) -> PathBuf { + self.root.join(name).join("root.json") + } +} + +fn official_registry() -> RegistryRecord { + RegistryRecord { + name: OFFICIAL_NAME.to_string(), + url: OFFICIAL_URL.to_string(), + trust_root: OFFICIAL_TRUST_PLACEHOLDER.to_string(), + built_in: true, + configured: false, + trusted_root_path: None, + } +} + +fn write_registry(path: &Path, record: &RegistryRecord) -> anyhow::Result<()> { + let attributes = HashMap::from([ + ("url".to_string(), Value::String(record.url.clone())), + ( + "trust_root".to_string(), + Value::String(record.trust_root.clone()), + ), + ]); + let document = Document { + blocks: vec![Block { + name: "registry".to_string(), + labels: vec![record.name.clone()], + blocks: Vec::new(), + attributes, + }], + }; + let rendered = a3s_acl::generate_acl(&document); + a3s_acl::parse_acl(&rendered).context("generated registry ACL is invalid")?; + write_atomic(path, rendered.as_bytes()) +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { + let parent = path + .parent() + .with_context(|| format!("path '{}' has no parent", path.display()))?; + ensure_real_directory(parent)?; + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("could not create a temporary file in {}", parent.display()))?; + temporary + .write_all(bytes) + .with_context(|| format!("could not write temporary file for {}", path.display()))?; + temporary + .as_file() + .sync_all() + .with_context(|| format!("could not sync temporary file for {}", path.display()))?; + temporary + .persist(path) + .map_err(|error| error.error) + .with_context(|| format!("could not atomically write {}", path.display()))?; + Ok(()) +} + +fn ensure_real_directory(path: &Path) -> anyhow::Result<()> { + std::fs::create_dir_all(path) + .with_context(|| format!("could not create directory {}", path.display()))?; + let metadata = std::fs::symlink_metadata(path) + .with_context(|| format!("could not inspect directory {}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!( + "registry path '{}' must be a real directory", + path.display() + ); + } + Ok(()) +} + +fn normalize_url(value: &str) -> anyhow::Result { + let mut url = reqwest::Url::parse(value).context("registry URL is invalid")?; + let loopback_http = url.scheme() == "http" + && url.host_str().is_some_and(|host| { + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()) + }); + if url.scheme() != "https" && !loopback_http { + bail!("registry URLs must use HTTPS"); + } + if !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + bail!("registry URLs must not contain credentials, query parameters, or fragments"); + } + if !url.path().ends_with('/') { + let path = format!("{}/", url.path()); + url.set_path(&path); + } + Ok(url) +} + +fn registry_name(url: &reqwest::Url) -> anyhow::Result { + let host = url.host_str().context("registry URL requires a host")?; + let mut name = host + .trim_start_matches("www.") + .split('.') + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + name.retain(|character| character.is_ascii_alphanumeric() || character == '-'); + validate_name(&name)?; + Ok(name) +} + +fn validate_name(name: &str) -> anyhow::Result<()> { + let mut characters = name.chars(); + if !characters + .next() + .is_some_and(|value| value.is_ascii_lowercase()) + || !characters + .all(|value| value.is_ascii_lowercase() || value.is_ascii_digit() || value == '-') + { + bail!( + "registry names use lowercase letters, digits, and hyphens and must start with a letter" + ); + } + Ok(()) +} + +fn normalize_digest(value: &str) -> anyhow::Result { + let digest = value.strip_prefix("sha256:").unwrap_or(value); + if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + bail!("SHA-256 trust roots require exactly 64 hexadecimal characters"); + } + Ok(format!("sha256:{}", digest.to_ascii_lowercase())) +} + +fn tuf_datastore(state_root: &Path, name: &str) -> PathBuf { + state_root.join("use").join("remote-registries").join(name) +} + +fn registry_error( + registry: &RegistryRecord, + error: impl std::error::Error + Send + Sync + 'static, +) -> anyhow::Error { + anyhow::Error::new(error).context(format!( + "registry '{}' failed TUF verification", + registry.name + )) +} + +#[cfg(test)] +#[path = "../tests/support/tuf_test_support.rs"] +mod tuf_test_support; + +#[cfg(test)] +mod tests { + use super::tuf_test_support::{ + extension_archive, TestRepository, TestServer, FUTURE, PACKAGE_VERSION, + }; + use super::*; + + #[tokio::test] + async fn duplicate_package_sources_are_rejected_as_ambiguous() { + let temp = tempfile::tempdir().unwrap(); + let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE); + let server = TestServer::start(repository.routes.clone()); + let store = RegistryStore::new(temp.path().join("registries")); + for name in ["alpha", "beta"] { + let record = RegistryRecord { + name: name.to_string(), + url: server.base_url().to_string(), + trust_root: format!("sha256:{}", repository.root_sha256), + built_in: false, + configured: true, + trusted_root_path: None, + }; + write_registry(&store.registry_path(name), &record).unwrap(); + } + + let error = store + .resolve_package(&temp.path().join("state"), "acme/slack", None, "stable") + .await + .unwrap_err(); + + let message = error.to_string(); + assert!(message.contains("ambiguous"), "{message}"); + assert!(message.contains("alpha, beta"), "{message}"); + assert!(server + .requests() + .iter() + .all(|request| !request.starts_with("/targets/"))); + } + + #[tokio::test] + async fn upgrade_uses_only_the_recorded_registry_and_rejects_identity_drift() { + let temp = tempfile::tempdir().unwrap(); + let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE); + let server = TestServer::start(repository.routes.clone()); + let store = RegistryStore::new(temp.path().join("registries")); + for name in ["alpha", "duplicate"] { + let record = RegistryRecord { + name: name.to_string(), + url: server.base_url().to_string(), + trust_root: format!("sha256:{}", repository.root_sha256), + built_in: false, + configured: true, + trusted_root_path: None, + }; + write_registry(&store.registry_path(name), &record).unwrap(); + } + + let alpha = store.get("alpha").unwrap().unwrap(); + let installed = prepare_remote_package( + &alpha + .trusted_registry(&temp.path().join("initial-state")) + .unwrap(), + "acme/slack", + None, + "stable", + None, + ) + .await + .unwrap() + .resolved() + .clone(); + server.clear_requests(); + + let resolved = store + .resolve_upgrade(&temp.path().join("upgrade-state"), &installed) + .await + .unwrap(); + assert_eq!(resolved.registry.name, "alpha"); + assert_eq!(resolved.package.sha256, repository.target_sha256); + assert!(server + .requests() + .iter() + .all(|request| !request.starts_with("/targets/"))); + + let changed = RegistryRecord { + name: "alpha".to_string(), + url: server.base_url().to_string(), + trust_root: format!("sha256:{}", "f".repeat(64)), + built_in: false, + configured: true, + trusted_root_path: None, + }; + write_registry(&store.registry_path("alpha"), &changed).unwrap(); + server.clear_requests(); + let error = store + .resolve_upgrade(&temp.path().join("changed-state"), &installed) + .await + .unwrap_err(); + assert!(error.to_string().contains("no longer matches"), "{error:#}"); + assert!(server.requests().is_empty()); + } +} diff --git a/tests/administration_contract.rs b/tests/administration_contract.rs index 5e5b0f89..e79a7d9f 100644 --- a/tests/administration_contract.rs +++ b/tests/administration_contract.rs @@ -111,6 +111,55 @@ fn registry_rejects_urls_that_can_leak_secrets_or_change_identity() { assert!(!temp.path("config/registries").exists()); } +#[test] +fn registry_file_trust_root_is_copied_into_owned_configuration() { + let temp = TempWorkspace::new("registry-root-copy"); + let config = temp.path("config/config.acl"); + let source = temp.path("bootstrap-root.json"); + let root_bytes = br#"{"signed":{"_type":"root","version":1}}"#; + std::fs::write(&source, root_bytes).unwrap(); + + let mut add = Command::new(a3s_bin()); + configure_component_env(&mut add, &temp); + let output = add + .arg("--config") + .arg(&config) + .args([ + "--output", + "json", + "registry", + "add", + "https://files.example/components/", + "--trust-root", + source.to_str().unwrap(), + "--yes", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + + let owned = temp.path("config/registries/files/root.json"); + assert_eq!(std::fs::read(&owned).unwrap(), root_bytes); + std::fs::write(&source, b"changed outside registry ownership").unwrap(); + assert_eq!(std::fs::read(&owned).unwrap(), root_bytes); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + result["data"]["registry"]["trustedRootPath"], + owned.to_string_lossy().to_string() + ); + + let mut remove = Command::new(a3s_bin()); + configure_component_env(&mut remove, &temp); + let output = remove + .arg("--config") + .arg(&config) + .args(["registry", "remove", "files", "--yes"]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert!(!owned.exists()); +} + #[test] fn cache_dry_run_and_clean_stay_inside_the_owned_root() { let temp = TempWorkspace::new("cache-boundary"); diff --git a/tests/component_commands.rs b/tests/component_commands.rs index 8da6871e..62fdeb66 100644 --- a/tests/component_commands.rs +++ b/tests/component_commands.rs @@ -97,11 +97,89 @@ fn unsafe_component_ids_fail_at_the_parser_boundary() { } #[test] -fn install_dry_run_plans_without_network_or_mutation() { +fn install_dry_run_resolves_a_stable_artifact_without_downloading_or_mutating() { + if support::box_release_target().is_none() { + eprintln!("skipping release plan test on unsupported host target"); + return; + } let temp = TempWorkspace::new("component-install-dry-run"); - let mut command = Command::new(a3s_bin()); - configure_component_env(&mut command, &temp); - let output = command + let server = support::start_fake_box_release(&temp, "2.5.2", None); + let run = || { + let mut command = Command::new(a3s_bin()); + configure_component_env(&mut command, &temp); + command + .args([ + "install", + "box", + "--source", + "release", + "--dry-run", + "--json", + ]) + .env("A3S_UPDATER_GITHUB_API_BASE", server.api_base()) + .output() + .unwrap() + }; + let output = run(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["command"], "component.install"); + assert_eq!(result["data"]["dryRun"], true); + assert_eq!(result["data"]["plans"][0]["component"], "box"); + assert_eq!(result["data"]["planSchemaVersion"], 1); + assert_eq!(result["data"]["planCommand"], "component.install"); + let digest = result["data"]["planDigest"].as_str().unwrap(); + assert_eq!(digest.len(), 64); + assert_eq!( + result["data"]["plans"][0]["resolvedReleases"]["box"]["version"], + "2.5.2" + ); + assert_eq!( + result["data"]["plans"][0]["resolvedSources"]["box"], + "github-release:A3S-Lab/Box" + ); + assert_eq!( + result["data"]["plans"][0]["resolvedReleases"]["box"]["archiveName"], + format!( + "a3s-box-v2.5.2-{}.tar.gz", + support::box_release_target().unwrap() + ) + ); + let repeated: serde_json::Value = serde_json::from_slice(&run().stdout).unwrap(); + assert_eq!(repeated["data"]["planDigest"], digest); + let requests = server.requests(); + assert!( + requests + .iter() + .filter(|path| path.ends_with("/releases/latest")) + .count() + >= 2 + ); + assert!( + requests.iter().all(|path| !path.starts_with("/assets/")), + "dry-run downloaded a release asset: {requests:?}" + ); + assert!(!temp.path("state/components").exists()); + assert!(!temp.path("data/components").exists()); +} + +#[test] +fn reviewed_release_plan_rejects_changes_before_download_and_applies_exact_artifact() { + if support::box_release_target().is_none() { + eprintln!("skipping reviewed release plan test on unsupported host target"); + return; + } + let temp = TempWorkspace::new("component-reviewed-release-plan"); + let server = support::start_fake_box_release(&temp, "2.5.2", None); + + let mut review = Command::new(a3s_bin()); + configure_component_env(&mut review, &temp); + let output = review .args([ "install", "box", @@ -110,21 +188,87 @@ fn install_dry_run_plans_without_network_or_mutation() { "--dry-run", "--json", ]) - .env("A3S_UPDATER_GITHUB_API_BASE", "http://127.0.0.1:1") + .env("A3S_UPDATER_GITHUB_API_BASE", server.api_base()) .output() .unwrap(); + assert!(output.status.success(), "{output:?}"); + let review: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let digest = review["data"]["planDigest"].as_str().unwrap().to_string(); + let mut changed = Command::new(a3s_bin()); + configure_component_env(&mut changed, &temp); + let output = changed + .args([ + "install", + "box", + "--source", + "release", + "--force", + "--plan-digest", + &digest, + "--json", + ]) + .env("A3S_UPDATER_GITHUB_API_BASE", server.api_base()) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(output.stderr.is_empty()); + let error: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(error["error"]["code"], "component.plan_mismatch"); + assert_eq!(error["error"]["details"]["expected"], digest); + assert!( + server + .requests() + .iter() + .all(|path| !path.starts_with("/assets/")), + "a mismatched plan downloaded an artifact" + ); + assert!(!temp.path("state/components/box.json").exists()); + assert!(!temp.path("data/components/box").exists()); + + let mut apply = Command::new(a3s_bin()); + configure_component_env(&mut apply, &temp); + let output = apply + .args([ + "install", + "box", + "--source", + "release", + "--plan-digest", + &digest, + "--json", + ]) + .env("A3S_UPDATER_GITHUB_API_BASE", server.api_base()) + .output() + .unwrap(); assert!( output.status.success(), - "{}", + "status: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(result["command"], "component.install"); - assert_eq!(result["data"]["dryRun"], true); - assert_eq!(result["data"]["plans"][0]["component"], "box"); - assert!(!temp.path("state/components").exists()); - assert!(!temp.path("data/components").exists()); + let applied: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(applied["data"]["planDigest"], digest); + assert_eq!(applied["data"]["operations"][0]["version"], "2.5.2"); + assert_eq!( + server + .requests() + .iter() + .filter(|path| path.starts_with("/assets/")) + .count(), + 1, + "reviewed apply must download exactly one artifact" + ); + assert_eq!( + server + .requests() + .iter() + .filter(|path| path.ends_with("/releases/latest")) + .count(), + 3, + "the installer must reuse the plan resolution instead of resolving latest again" + ); } #[test] @@ -250,27 +394,69 @@ exit 2 } #[test] -fn multi_component_partial_failure_preserves_every_outcome() { - let temp = TempWorkspace::new("component-partial"); +fn multi_component_preflight_failure_prevents_network_and_every_mutation() { + if support::box_release_target().is_none() { + eprintln!("skipping component preflight test on unsupported host target"); + return; + } + let temp = TempWorkspace::new("component-preflight"); + let server = support::start_fake_box_release(&temp, "2.5.2", None); let mut command = Command::new(a3s_bin()); configure_component_env(&mut command, &temp); let output = command - .args(["install", "code", "use/acme/slack", "--json"]) + .args(["install", "box", "use/acme/slack", "--json"]) + .env("A3S_UPDATER_GITHUB_API_BASE", server.api_base()) .output() .unwrap(); - assert_eq!(output.status.code(), Some(3)); + assert!(!output.status.success()); let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(result["command"], "component.install"); assert_eq!(result["ok"], false); + assert_eq!(result["error"]["code"], "operation.failed"); + assert!(result["error"]["message"] + .as_str() + .unwrap() + .contains("no package registry has a production TUF trust root")); + assert!(server.requests().is_empty()); + assert!(!temp.path("state/components").exists()); + assert!(!temp.path("data/components").exists()); +} + +#[test] +fn multi_component_runtime_failure_preserves_each_completed_outcome() { + let temp = TempWorkspace::new("component-runtime-partial"); + let bin = temp.path("use-bin"); + make_executable( + &bin.join("a3s-use"), + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then printf 'a3s-use 0.1.0\\n'; exit 0; fi\nif [ \"$1\" = \"component\" ] && [ \"$2\" = \"list\" ]; then printf '{\"schemaVersion\":1,\"ok\":true,\"data\":{\"components\":[]}}\\n'; exit 0; fi\nif [ \"$1\" = \"component\" ] && [ \"$2\" = \"status\" ]; then printf '{\"schemaVersion\":1,\"ok\":true,\"data\":{\"component\":{\"id\":\"%s\",\"presence\":\"missing\",\"health\":\"unknown\"}}}\\n' \"$3\"; exit 0; fi\nif [ \"$1\" = \"component\" ] && [ \"$2\" = \"install\" ] && [ \"$3\" = \"browser\" ]; then printf '{\"schemaVersion\":1,\"ok\":true,\"data\":{\"changed\":true,\"component\":{\"id\":\"browser\",\"version\":\"1.0.0\"}}}\\n'; exit 0; fi\nif [ \"$1\" = \"component\" ] && [ \"$2\" = \"install\" ] && [ \"$3\" = \"office\" ]; then printf 'office fixture failed\\n' >&2; exit 7; fi\nexit 2\n", + ); + + let mut command = Command::new(a3s_bin()); + configure_component_env(&mut command, &temp); + let output = command + .args(["install", "use/browser", "use/office", "--json"]) + .env("A3S_USE_INSTALL_DIR", &bin) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(3)); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(result["error"]["code"], "component.partial"); assert_eq!( result["error"]["details"]["operations"][0]["component"], - "code" + "use/browser" ); assert_eq!( result["error"]["details"]["failures"][0]["component"], - "use/acme/slack" + "use/office" + ); + assert_eq!( + result["error"]["details"]["planDigest"] + .as_str() + .unwrap() + .len(), + 64 ); } @@ -507,6 +693,103 @@ fn external_use_install_uses_cli_json_not_custom_rpc() { assert!(!arguments.to_ascii_lowercase().contains("jsonrpc")); } +#[test] +fn local_package_plan_digest_changes_when_package_content_changes() { + let temp = TempWorkspace::new("extension-content-plan"); + let bin = temp.path("use-bin"); + let install_log = temp.path("extension-install.log"); + make_executable( + &bin.join("a3s-use"), + &format!( + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then printf 'a3s-use 0.1.0\\n'; exit 0; fi\nif [ \"$1\" = \"component\" ] && [ \"$2\" = \"list\" ]; then printf '{{\"schemaVersion\":1,\"ok\":true,\"data\":{{\"components\":[]}}}}\\n'; exit 0; fi\nif [ \"$1\" = \"component\" ] && [ \"$2\" = \"install\" ]; then printf '%s\\n' \"$@\" > {}; printf '{{\"schemaVersion\":1,\"ok\":true,\"data\":{{\"changed\":true,\"component\":{{\"id\":\"%s\",\"version\":\"1.0.0\"}}}}}}\\n' \"$3\"; exit 0; fi\nexit 2\n", + sh_quote(&install_log) + ), + ); + let package = temp.path("package"); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("a3s-use-extension.acl"), b"first").unwrap(); + + let run = || { + let mut command = Command::new(a3s_bin()); + configure_component_env(&mut command, &temp); + command + .args([ + "install", + "use/acme/slack", + "--from", + package.to_str().unwrap(), + "--allow-unsigned", + "--dry-run", + "--json", + ]) + .env("A3S_USE_INSTALL_DIR", &bin) + .output() + .unwrap() + }; + + let first = run(); + assert!(first.status.success(), "{first:?}"); + let first: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap(); + let first_digest = first["data"]["planDigest"].as_str().unwrap().to_string(); + let first_package_digest = first["data"]["plans"][0]["localPackage"]["sha256"] + .as_str() + .unwrap() + .to_string(); + + std::fs::write(package.join("a3s-use-extension.acl"), b"other").unwrap(); + let changed = run(); + assert!(changed.status.success(), "{changed:?}"); + let changed: serde_json::Value = serde_json::from_slice(&changed.stdout).unwrap(); + assert_ne!(changed["data"]["planDigest"], first_digest); + assert_ne!( + changed["data"]["plans"][0]["localPackage"]["sha256"], + first_package_digest + ); + + let mut stale_apply = Command::new(a3s_bin()); + configure_component_env(&mut stale_apply, &temp); + let output = stale_apply + .args([ + "install", + "use/acme/slack", + "--from", + package.to_str().unwrap(), + "--allow-unsigned", + "--plan-digest", + &first_digest, + "--json", + ]) + .env("A3S_USE_INSTALL_DIR", &bin) + .output() + .unwrap(); + assert!(!output.status.success()); + let error: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(error["error"]["code"], "component.plan_mismatch"); + assert!(!install_log.exists()); + + std::fs::write(package.join("a3s-use-extension.acl"), b"first").unwrap(); + let mut reviewed_apply = Command::new(a3s_bin()); + configure_component_env(&mut reviewed_apply, &temp); + let output = reviewed_apply + .args([ + "install", + "use/acme/slack", + "--from", + package.to_str().unwrap(), + "--allow-unsigned", + "--plan-digest", + &first_digest, + "--json", + ]) + .env("A3S_USE_INSTALL_DIR", &bin) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + let applied: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(applied["data"]["planDigest"], first_digest); + assert!(install_log.exists()); +} + #[test] fn built_in_use_runtime_lifecycle_delegates_native_component_commands() { let temp = TempWorkspace::new("use-runtime-lifecycle"); diff --git a/tests/component_lifecycle.rs b/tests/component_lifecycle.rs index 07ce78a9..c273d93e 100644 --- a/tests/component_lifecycle.rs +++ b/tests/component_lifecycle.rs @@ -2,11 +2,16 @@ mod support; +use std::fs::OpenOptions; use std::path::PathBuf; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use fs2::FileExt; use support::{ - a3s_bin, box_release_target, configure_component_env, start_fake_box_release, TempWorkspace, + a3s_bin, box_release_target, configure_component_env, make_executable, sh_quote, + start_fake_box_release, TempWorkspace, }; #[test] @@ -88,3 +93,274 @@ fn managed_component_installs_updates_and_uninstalls_by_receipt() { assert!(!receipt_path.exists()); assert!(!temp.path("data/components/box/2.6.0").exists()); } + +#[test] +fn explicit_version_replaces_a_different_healthy_release_without_force() { + if box_release_target().is_none() { + eprintln!("skipping explicit version test on unsupported host target"); + return; + } + let temp = TempWorkspace::new("managed-explicit-version"); + let first_release = start_fake_box_release(&temp, "2.5.2", None); + let mut install = Command::new(a3s_bin()); + configure_component_env(&mut install, &temp); + let output = install + .args(["install", "box", "--source", "release", "--json"]) + .env("A3S_UPDATER_GITHUB_API_BASE", first_release.api_base()) + .output() + .unwrap(); + assert!( + output.status.success(), + "status: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + drop(first_release); + + let requested_release = start_fake_box_release(&temp, "2.6.0", None); + let mut replace = Command::new(a3s_bin()); + configure_component_env(&mut replace, &temp); + let output = replace + .args([ + "install", + "box", + "--source", + "release", + "--version", + "2.6.0", + "--json", + ]) + .env("A3S_UPDATER_GITHUB_API_BASE", requested_release.api_base()) + .output() + .unwrap(); + assert!( + output.status.success(), + "status: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["data"]["operations"][0]["changed"], true); + assert_eq!(result["data"]["operations"][0]["version"], "2.6.0"); + assert!(requested_release + .requests() + .iter() + .any(|path| path.ends_with("/releases/tags/v2.6.0"))); + assert!(!temp.path("data/components/box/2.5.2").exists()); +} + +#[test] +fn upgrade_without_components_lists_available_managed_upgrades_without_mutation() { + if box_release_target().is_none() { + eprintln!("skipping upgrade listing test on unsupported host target"); + return; + } + let temp = TempWorkspace::new("managed-upgrade-list"); + let installed_release = start_fake_box_release(&temp, "2.5.2", None); + let mut install = Command::new(a3s_bin()); + configure_component_env(&mut install, &temp); + let output = install + .args(["install", "box", "--source", "release", "--json"]) + .env("A3S_UPDATER_GITHUB_API_BASE", installed_release.api_base()) + .output() + .unwrap(); + assert!( + output.status.success(), + "status: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + drop(installed_release); + + let receipt_path = temp.path("state/components/box.json"); + let receipt_before = std::fs::read(&receipt_path).unwrap(); + let available_release = start_fake_box_release(&temp, "2.6.0", None); + let mut upgrade = Command::new(a3s_bin()); + configure_component_env(&mut upgrade, &temp); + let output = upgrade + .args(["upgrade", "--json"]) + .env("A3S_UPDATER_GITHUB_API_BASE", available_release.api_base()) + .output() + .unwrap(); + assert!( + output.status.success(), + "status: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["command"], "component.upgrade"); + assert_eq!(result["data"]["components"].as_array().unwrap().len(), 1); + assert_eq!(result["data"]["components"][0]["id"], "box"); + assert_eq!(result["data"]["components"][0]["update"], "available"); + assert_eq!(std::fs::read(&receipt_path).unwrap(), receipt_before); + assert!(!temp.path("data/components/box/2.6.0").exists()); + let requests = available_release.requests(); + assert!( + requests + .iter() + .any(|path| path.ends_with("/releases/latest")), + "upgrade listing did not check the latest release: {requests:?}" + ); + assert!( + requests.iter().all(|path| !path.starts_with("/assets/")), + "upgrade listing downloaded a release asset: {requests:?}" + ); +} + +#[test] +fn upgrade_listing_excludes_unmanaged_products_and_external_tools() { + if box_release_target().is_none() { + eprintln!("skipping unmanaged upgrade listing test on unsupported host target"); + return; + } + let temp = TempWorkspace::new("unmanaged-upgrade-list"); + let bin = temp.path("bin"); + make_executable( + &bin.join("a3s-box"), + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then printf 'a3s-box 2.5.2\\n'; exit 0; fi\nexit 0\n", + ); + make_executable( + &bin.join("a3s-unregistered"), + "#!/bin/sh\nprintf 'external tool\\n'\n", + ); + let available_release = start_fake_box_release(&temp, "2.6.0", None); + let mut upgrade = Command::new(a3s_bin()); + configure_component_env(&mut upgrade, &temp); + let output = upgrade + .args(["upgrade", "--json"]) + .env("PATH", &bin) + .env("A3S_UPDATER_GITHUB_API_BASE", available_release.api_base()) + .output() + .unwrap(); + assert!( + output.status.success(), + "status: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["command"], "component.upgrade"); + assert_eq!(result["data"]["components"], serde_json::json!([])); + assert_eq!(result["data"]["externalTools"], serde_json::json!([])); +} + +#[test] +fn homebrew_component_upgrade_uses_the_upgrade_verb() { + let temp = TempWorkspace::new("homebrew-component-upgrade"); + let bin = temp.path("bin"); + let prefix = temp.path("brew-prefix"); + let log = temp.path("brew.log"); + make_executable( + &prefix.join("bin/a3s-box"), + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then printf 'a3s-box 2.6.0\\n'; exit 0; fi\nexit 0\n", + ); + make_executable( + &bin.join("brew"), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\nif [ \"$1\" = \"--prefix\" ]; then printf '%s\\n' {}; exit 0; fi\ncase \"$1\" in install|upgrade|reinstall|uninstall) exit 0;; esac\nexit 2\n", + sh_quote(&log), + sh_quote(&prefix), + ), + ); + + let mut install = Command::new(a3s_bin()); + configure_component_env(&mut install, &temp); + let output = install + .args(["install", "box", "--source", "homebrew", "--json"]) + .env("PATH", &bin) + .output() + .unwrap(); + assert!( + output.status.success(), + "status: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + std::fs::write(&log, "").unwrap(); + + let mut upgrade = Command::new(a3s_bin()); + configure_component_env(&mut upgrade, &temp); + let output = upgrade + .args(["upgrade", "box", "--json"]) + .env("PATH", &bin) + .output() + .unwrap(); + assert!( + output.status.success(), + "status: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let commands = std::fs::read_to_string(&log).unwrap(); + assert!( + commands + .lines() + .any(|line| line == "upgrade a3s-lab/tap/a3s-box"), + "Homebrew commands were:\n{commands}" + ); + assert!( + !commands + .lines() + .any(|line| line == "install a3s-lab/tap/a3s-box"), + "upgrade unexpectedly reused brew install:\n{commands}" + ); +} + +#[test] +fn component_mutation_waits_for_the_existing_cross_process_lock() { + if box_release_target().is_none() { + eprintln!("skipping component lock test on unsupported host target"); + return; + } + let temp = TempWorkspace::new("component-operation-lock"); + let lock_path = temp.path("runtime/locks/box.lock"); + std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap(); + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .unwrap(); + lock.lock_exclusive().unwrap(); + let release = start_fake_box_release(&temp, "2.5.2", None); + + let mut install = Command::new(a3s_bin()); + configure_component_env(&mut install, &temp); + let mut child = install + .args(["install", "box", "--source", "release", "--json"]) + .env("A3S_UPDATER_GITHUB_API_BASE", release.api_base()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + std::thread::sleep(Duration::from_millis(200)); + assert!( + child.try_wait().unwrap().is_none(), + "component command did not wait for its operation lock" + ); + assert!( + release.requests().is_empty(), + "component command accessed the network before acquiring its lock" + ); + + FileExt::unlock(&lock).unwrap(); + drop(lock); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "status: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/remote_registry_components.rs b/tests/remote_registry_components.rs new file mode 100644 index 00000000..19d63624 --- /dev/null +++ b/tests/remote_registry_components.rs @@ -0,0 +1,621 @@ +#![cfg(unix)] + +mod support; + +use std::process::{Command, Output}; + +use a3s_use_extension::ResolvedRemotePackage; +use support::{a3s_bin, configure_component_env, make_executable, sh_quote, TempWorkspace}; + +#[path = "support/tuf_test_support.rs"] +mod tuf_test_support; + +use tuf_test_support::{ + extension_archive, TestRepository, TestServer, EXPIRED, FUTURE, PACKAGE_VERSION, +}; + +#[test] +fn signed_registry_plan_is_bound_before_delegating_to_use() { + let temp = TempWorkspace::new("signed-registry-install"); + let version_one = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE); + let server = TestServer::start(version_one.routes.clone()); + let registry_url = localhost_url(&server); + let config = temp.path("config/config.acl"); + let use_bin = temp.path("use-bin"); + let install_log = temp.path("remote-install.log"); + make_use_fixture(&use_bin, &install_log); + add_registry( + &temp, + &config, + &use_bin, + ®istry_url, + &format!("sha256:{}", version_one.root_sha256), + ); + + server.clear_requests(); + let refreshed = run( + &temp, + &config, + &use_bin, + &["registry", "refresh", "localhost"], + ); + assert!(refreshed.status.success(), "{refreshed:?}"); + let refreshed = json(&refreshed); + assert_eq!( + refreshed["data"]["registries"][0]["metadata"]["targetsVersion"], + 1 + ); + assert_no_target_request(&server); + + server.clear_requests(); + let first_plan = run( + &temp, + &config, + &use_bin, + &["install", "use/acme/slack", "--dry-run"], + ); + assert!(first_plan.status.success(), "{first_plan:?}"); + let first_plan = json(&first_plan); + let first_digest = first_plan["data"]["planDigest"] + .as_str() + .unwrap() + .to_string(); + let first_package = + &first_plan["data"]["plans"][0]["resolvedRegistryPackages"]["use/acme/slack"]; + assert_eq!(first_package["registryName"], "localhost"); + assert_eq!(first_package["targetsVersion"], 1); + assert_eq!(first_package["sha256"], version_one.target_sha256); + assert_no_target_request(&server); + assert!(!install_log.exists()); + + let version_two = TestRepository::new(extension_archive(PACKAGE_VERSION), 2, FUTURE); + assert_eq!(version_one.root_sha256, version_two.root_sha256); + server.replace_routes(version_two.routes.clone()); + server.clear_requests(); + let stale = run( + &temp, + &config, + &use_bin, + &["install", "use/acme/slack", "--plan-digest", &first_digest], + ); + assert!(!stale.status.success(), "{stale:?}"); + assert_eq!(json(&stale)["error"]["code"], "component.plan_mismatch"); + assert_no_target_request(&server); + assert!(!install_log.exists()); + + let current_plan = run( + &temp, + &config, + &use_bin, + &["install", "use/acme/slack", "--dry-run"], + ); + assert!(current_plan.status.success(), "{current_plan:?}"); + let current_plan = json(¤t_plan); + let current_digest = current_plan["data"]["planDigest"].as_str().unwrap(); + let package: ResolvedRemotePackage = serde_json::from_value( + current_plan["data"]["plans"][0]["resolvedRegistryPackages"]["use/acme/slack"].clone(), + ) + .unwrap(); + assert_eq!(package.targets_version, 2); + let registry_plan_digest = package.plan_digest().unwrap(); + + server.clear_requests(); + let applied = run( + &temp, + &config, + &use_bin, + &["install", "use/acme/slack", "--plan-digest", current_digest], + ); + assert!(applied.status.success(), "{applied:?}"); + assert_eq!(json(&applied)["data"]["planDigest"], current_digest); + assert_no_target_request(&server); + let arguments = std::fs::read_to_string(&install_log).unwrap(); + assert!(arguments.contains("component\ninstall\nacme/slack\n--json\n")); + assert!(arguments.contains("--registry-name\nlocalhost\n")); + assert!(arguments.contains(&format!("--registry-url\n{registry_url}\n"))); + assert!(arguments.contains(&format!( + "--trust-root\nsha256:{}\n", + version_two.root_sha256 + ))); + assert!(arguments.contains(&format!("--registry-plan-digest\n{registry_plan_digest}\n"))); + assert!(arguments.contains(&format!("--version\n{PACKAGE_VERSION}\n"))); + assert!(arguments.contains("--channel\nstable\n")); + assert!(!arguments.contains("--allow-unsigned")); + + server.clear_requests(); + std::fs::remove_file(&install_log).unwrap(); + let unsigned = run( + &temp, + &config, + &use_bin, + &["install", "use/acme/slack", "--allow-unsigned"], + ); + assert!(!unsigned.status.success(), "{unsigned:?}"); + assert!(json(&unsigned)["error"]["message"] + .as_str() + .unwrap() + .contains("explicit local --from package")); + assert!(server.requests().is_empty()); + assert!(!install_log.exists()); +} + +#[test] +fn signed_registry_upgrade_restores_the_recorded_source_and_binds_the_new_target() { + const NEXT_VERSION: &str = "0.2.0"; + + let temp = TempWorkspace::new("signed-registry-upgrade"); + let version_one = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE); + let server = TestServer::start(version_one.routes.clone()); + let registry_url = localhost_url(&server); + let config = temp.path("config/config.acl"); + let use_bin = temp.path("use-bin"); + let install_log = temp.path("remote-upgrade.log"); + make_use_fixture(&use_bin, &install_log); + add_registry( + &temp, + &config, + &use_bin, + ®istry_url, + &format!("sha256:{}", version_one.root_sha256), + ); + + let initial = run( + &temp, + &config, + &use_bin, + &["install", "use/acme/slack", "--dry-run"], + ); + assert!(initial.status.success(), "{initial:?}"); + let initial = json(&initial); + let installed: ResolvedRemotePackage = serde_json::from_value( + initial["data"]["plans"][0]["resolvedRegistryPackages"]["use/acme/slack"].clone(), + ) + .unwrap(); + make_installed_use_fixture(&use_bin, &install_log, &installed); + + let version_two = TestRepository::with_package_version( + extension_archive(NEXT_VERSION), + NEXT_VERSION, + 2, + FUTURE, + ); + assert_eq!(version_one.root_sha256, version_two.root_sha256); + server.replace_routes(version_two.routes.clone()); + server.clear_requests(); + let available = run(&temp, &config, &use_bin, &["upgrade"]); + assert!(available.status.success(), "{available:?}"); + let available = json(&available); + let components = available["data"]["components"].as_array().unwrap(); + let slack = components + .iter() + .find(|component| component["id"] == "use/acme/slack") + .expect("signed extension should be listed as upgradeable"); + assert_eq!(slack["update"], "available"); + assert_no_target_request(&server); + + server.clear_requests(); + let all_plan = run(&temp, &config, &use_bin, &["upgrade", "--all", "--dry-run"]); + assert!(all_plan.status.success(), "{all_plan:?}"); + let all_plan = json(&all_plan); + assert_eq!(all_plan["data"]["plans"].as_array().unwrap().len(), 1); + assert_eq!(all_plan["data"]["plans"][0]["component"], "use/acme/slack"); + assert_no_target_request(&server); + + server.clear_requests(); + let first_plan = run( + &temp, + &config, + &use_bin, + &["upgrade", "use/acme/slack", "--dry-run"], + ); + assert!(first_plan.status.success(), "{first_plan:?}"); + let first_plan = json(&first_plan); + let first_digest = first_plan["data"]["planDigest"] + .as_str() + .unwrap() + .to_string(); + let operation = &first_plan["data"]["plans"][0]; + assert_eq!(operation["action"], "upgrade"); + assert_eq!(operation["source"], "registry:localhost"); + assert_eq!(operation["channel"], "stable"); + assert_eq!(operation["mutates"], true); + assert_eq!(operation["force"], true); + assert_eq!( + operation["resolvedRegistryPackages"]["use/acme/slack"]["version"], + NEXT_VERSION + ); + assert_no_target_request(&server); + assert!(!install_log.exists()); + + let version_three = TestRepository::with_package_version( + extension_archive(NEXT_VERSION), + NEXT_VERSION, + 3, + FUTURE, + ); + server.replace_routes(version_three.routes.clone()); + server.clear_requests(); + let stale = run( + &temp, + &config, + &use_bin, + &["upgrade", "use/acme/slack", "--plan-digest", &first_digest], + ); + assert!(!stale.status.success(), "{stale:?}"); + assert_eq!(json(&stale)["error"]["code"], "component.plan_mismatch"); + assert_no_target_request(&server); + assert!(!install_log.exists()); + + let current = run( + &temp, + &config, + &use_bin, + &["upgrade", "use/acme/slack", "--dry-run"], + ); + assert!(current.status.success(), "{current:?}"); + let current = json(¤t); + let current_digest = current["data"]["planDigest"].as_str().unwrap(); + let package: ResolvedRemotePackage = serde_json::from_value( + current["data"]["plans"][0]["resolvedRegistryPackages"]["use/acme/slack"].clone(), + ) + .unwrap(); + let registry_plan_digest = package.plan_digest().unwrap(); + + server.clear_requests(); + let applied = run( + &temp, + &config, + &use_bin, + &["upgrade", "use/acme/slack", "--plan-digest", current_digest], + ); + assert!(applied.status.success(), "{applied:?}"); + assert_eq!(json(&applied)["data"]["operations"][0]["action"], "upgrade"); + assert_no_target_request(&server); + let arguments = std::fs::read_to_string(&install_log).unwrap(); + assert!(arguments.contains("component\ninstall\nacme/slack\n--json\n")); + assert!(arguments.contains("--force\n")); + assert!(arguments.contains("--registry-name\nlocalhost\n")); + assert!(arguments.contains(&format!("--registry-url\n{registry_url}\n"))); + assert!(arguments.contains(&format!("--version\n{NEXT_VERSION}\n"))); + assert!(arguments.contains("--channel\nstable\n")); + assert!(arguments.contains(&format!("--registry-plan-digest\n{registry_plan_digest}\n"))); + assert!(!arguments.contains("--allow-unsigned")); + + make_installed_use_fixture(&use_bin, &install_log, &package); + std::fs::remove_file(&install_log).unwrap(); + server.clear_requests(); + let converged = run( + &temp, + &config, + &use_bin, + &["upgrade", "use/acme/slack", "--dry-run"], + ); + assert!(converged.status.success(), "{converged:?}"); + let converged = json(&converged); + assert_eq!(converged["data"]["plans"][0]["mutates"], false); + assert_eq!(converged["data"]["plans"][0]["force"], false); + assert_no_target_request(&server); + assert!(!install_log.exists()); + + let downgrade = TestRepository::with_package_version( + extension_archive(PACKAGE_VERSION), + PACKAGE_VERSION, + 4, + FUTURE, + ); + server.replace_routes(downgrade.routes.clone()); + server.clear_requests(); + let rejected = run( + &temp, + &config, + &use_bin, + &["upgrade", "use/acme/slack", "--dry-run"], + ); + assert!(!rejected.status.success(), "{rejected:?}"); + assert!(json(&rejected)["error"]["message"] + .as_str() + .unwrap() + .contains("attempted to downgrade")); + assert_no_target_request(&server); + assert!(!install_log.exists()); +} + +#[test] +fn registry_refresh_rejects_wrong_roots_and_expired_metadata() { + let wrong_temp = TempWorkspace::new("registry-wrong-root"); + let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE); + let server = TestServer::start(repository.routes.clone()); + let registry_url = localhost_url(&server); + let config = wrong_temp.path("config/config.acl"); + let use_bin = wrong_temp.path("use-bin"); + make_use_fixture(&use_bin, &wrong_temp.path("unused.log")); + add_registry( + &wrong_temp, + &config, + &use_bin, + ®istry_url, + &format!("sha256:{}", "f".repeat(64)), + ); + server.clear_requests(); + let wrong = run( + &wrong_temp, + &config, + &use_bin, + &["registry", "refresh", "localhost"], + ); + assert!(!wrong.status.success(), "{wrong:?}"); + assert_no_target_request(&server); + + let expired_temp = TempWorkspace::new("registry-expired"); + let expired = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, EXPIRED); + let expired_server = TestServer::start(expired.routes.clone()); + let expired_url = localhost_url(&expired_server); + let expired_config = expired_temp.path("config/config.acl"); + let expired_use_bin = expired_temp.path("use-bin"); + make_use_fixture(&expired_use_bin, &expired_temp.path("unused.log")); + add_registry( + &expired_temp, + &expired_config, + &expired_use_bin, + &expired_url, + &format!("sha256:{}", expired.root_sha256), + ); + expired_server.clear_requests(); + let output = run( + &expired_temp, + &expired_config, + &expired_use_bin, + &["registry", "refresh", "localhost"], + ); + assert!(!output.status.success(), "{output:?}"); + assert_no_target_request(&expired_server); +} + +#[test] +#[ignore = "requires A3S_USE_E2E_BIN pointing to a real a3s-use binary"] +fn full_stack_registry_install_and_upgrade_activate_only_reviewed_targets() { + const NEXT_VERSION: &str = "0.2.0"; + + let use_executable = std::path::PathBuf::from( + std::env::var_os("A3S_USE_E2E_BIN") + .expect("A3S_USE_E2E_BIN must point to the real a3s-use binary"), + ); + assert!(use_executable.is_file(), "{}", use_executable.display()); + let use_bin = use_executable.parent().unwrap(); + let temp = TempWorkspace::new("signed-registry-full-stack"); + let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE); + let server = TestServer::start(repository.routes.clone()); + let registry_url = localhost_url(&server); + let config = temp.path("config/config.acl"); + add_registry( + &temp, + &config, + use_bin, + ®istry_url, + &format!("sha256:{}", repository.root_sha256), + ); + + server.clear_requests(); + let plan = run( + &temp, + &config, + use_bin, + &["install", "use/acme/slack", "--dry-run"], + ); + assert!(plan.status.success(), "{plan:?}"); + let plan = json(&plan); + let digest = plan["data"]["planDigest"].as_str().unwrap(); + assert_no_target_request(&server); + + let installed = run( + &temp, + &config, + use_bin, + &["install", "use/acme/slack", "--plan-digest", digest], + ); + assert!(installed.status.success(), "{installed:?}"); + assert_eq!( + server + .requests() + .iter() + .filter(|request| request.starts_with("/targets/")) + .count(), + 1 + ); + let receipt: serde_json::Value = serde_json::from_slice( + &std::fs::read(temp.path("state/use/extensions/acme/slack.json")).unwrap(), + ) + .unwrap(); + assert_eq!(receipt["trust"], "registry-tuf"); + assert_eq!(receipt["registry"]["sha256"], repository.target_sha256); + + let upgraded_repository = TestRepository::with_package_version( + extension_archive(NEXT_VERSION), + NEXT_VERSION, + 2, + FUTURE, + ); + assert_eq!(repository.root_sha256, upgraded_repository.root_sha256); + server.replace_routes(upgraded_repository.routes.clone()); + server.clear_requests(); + let upgrade_plan = run( + &temp, + &config, + use_bin, + &["upgrade", "use/acme/slack", "--dry-run"], + ); + assert!(upgrade_plan.status.success(), "{upgrade_plan:?}"); + let upgrade_plan = json(&upgrade_plan); + let upgrade_digest = upgrade_plan["data"]["planDigest"].as_str().unwrap(); + assert_eq!(upgrade_plan["data"]["plans"][0]["action"], "upgrade"); + assert_eq!( + upgrade_plan["data"]["plans"][0]["resolvedRegistryPackages"]["use/acme/slack"]["version"], + NEXT_VERSION + ); + assert_no_target_request(&server); + + let upgraded = run( + &temp, + &config, + use_bin, + &["upgrade", "use/acme/slack", "--plan-digest", upgrade_digest], + ); + assert!(upgraded.status.success(), "{upgraded:?}"); + assert_eq!( + server + .requests() + .iter() + .filter(|request| request.starts_with("/targets/")) + .count(), + 1 + ); + let upgraded_receipt: serde_json::Value = serde_json::from_slice( + &std::fs::read(temp.path("state/use/extensions/acme/slack.json")).unwrap(), + ) + .unwrap(); + assert_eq!(upgraded_receipt["version"], NEXT_VERSION); + assert_eq!( + upgraded_receipt["registry"]["sha256"], + upgraded_repository.target_sha256 + ); + + server.clear_requests(); + let converged_plan = run( + &temp, + &config, + use_bin, + &["upgrade", "use/acme/slack", "--dry-run"], + ); + assert!(converged_plan.status.success(), "{converged_plan:?}"); + let converged_plan = json(&converged_plan); + assert_eq!(converged_plan["data"]["plans"][0]["mutates"], false); + let converged_digest = converged_plan["data"]["planDigest"].as_str().unwrap(); + assert_no_target_request(&server); + let converged = run( + &temp, + &config, + use_bin, + &[ + "upgrade", + "use/acme/slack", + "--plan-digest", + converged_digest, + ], + ); + assert!(converged.status.success(), "{converged:?}"); + assert_eq!(converged_plan["data"]["plans"][0]["force"], false); + assert_no_target_request(&server); +} + +fn add_registry( + temp: &TempWorkspace, + config: &std::path::Path, + use_bin: &std::path::Path, + url: &str, + trust_root: &str, +) { + let output = run( + temp, + config, + use_bin, + &["registry", "add", url, "--trust-root", trust_root, "--yes"], + ); + assert!(output.status.success(), "{output:?}"); +} + +fn run( + temp: &TempWorkspace, + config: &std::path::Path, + use_bin: &std::path::Path, + args: &[&str], +) -> Output { + let mut command = Command::new(a3s_bin()); + configure_component_env(&mut command, temp); + command + .arg("--config") + .arg(config) + .args(["--output", "json"]) + .args(args) + .env("A3S_USE_INSTALL_DIR", use_bin) + .output() + .unwrap() +} + +fn make_use_fixture(directory: &std::path::Path, install_log: &std::path::Path) { + make_executable( + &directory.join("a3s-use"), + &format!( + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then printf 'a3s-use 0.1.1\\n'; exit 0; fi\nif [ \"$1\" = \"component\" ] && [ \"$2\" = \"list\" ]; then printf '{{\"schemaVersion\":1,\"ok\":true,\"data\":{{\"components\":[]}}}}\\n'; exit 0; fi\nif [ \"$1\" = \"component\" ] && [ \"$2\" = \"status\" ]; then printf '{{\"schemaVersion\":1,\"ok\":true,\"data\":{{\"component\":{{\"id\":\"%s\",\"presence\":\"missing\",\"health\":\"unknown\"}}}}}}\\n' \"$3\"; exit 0; fi\nif [ \"$1\" = \"component\" ] && [ \"$2\" = \"install\" ]; then printf '%s\\n' \"$@\" > {}; printf '{{\"schemaVersion\":1,\"ok\":true,\"data\":{{\"changed\":true,\"component\":{{\"id\":\"%s\",\"version\":\"{PACKAGE_VERSION}\",\"trust\":\"registry-tuf\"}}}}}}\\n' \"$3\"; exit 0; fi\nexit 2\n", + sh_quote(install_log) + ), + ); +} + +fn make_installed_use_fixture( + directory: &std::path::Path, + install_log: &std::path::Path, + installed: &ResolvedRemotePackage, +) { + let component = serde_json::json!({ + "id": "acme/slack", + "description": "Installed signed slack extension", + "presence": "managed", + "health": "ready", + "version": installed.version, + "path": "/tmp/a3s-use-slack", + "trust": "registry-tuf", + "registry": installed + }); + let list_path = directory.join("component-list.json"); + let status_path = directory.join("component-status.json"); + std::fs::write( + &list_path, + serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 1, + "ok": true, + "data": {"components": [component.clone()]} + })) + .unwrap(), + ) + .unwrap(); + std::fs::write( + &status_path, + serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 1, + "ok": true, + "data": {"component": component} + })) + .unwrap(), + ) + .unwrap(); + make_executable( + &directory.join("a3s-use"), + &format!( + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then printf 'a3s-use 0.1.1\\n'; exit 0; fi\nif [ \"$1\" = \"component\" ] && [ \"$2\" = \"list\" ]; then /bin/cat {}; exit 0; fi\nif [ \"$1\" = \"component\" ] && [ \"$2\" = \"status\" ]; then /bin/cat {}; exit 0; fi\nif [ \"$1\" = \"component\" ] && [ \"$2\" = \"install\" ]; then printf '%s\\n' \"$@\" > {}; printf '{{\"schemaVersion\":1,\"ok\":true,\"data\":{{\"changed\":true,\"component\":{{\"id\":\"%s\",\"version\":\"0.2.0\",\"trust\":\"registry-tuf\"}}}}}}\\n' \"$3\"; exit 0; fi\nexit 2\n", + sh_quote(&list_path), + sh_quote(&status_path), + sh_quote(install_log) + ), + ); +} + +fn localhost_url(server: &TestServer) -> String { + server.base_url().replacen("127.0.0.1", "localhost", 1) +} + +fn json(output: &Output) -> serde_json::Value { + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "invalid JSON ({error}): stdout={:?}, stderr={:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }) +} + +fn assert_no_target_request(server: &TestServer) { + assert!(server + .requests() + .iter() + .all(|request| !request.starts_with("/targets/"))); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index f12235a5..053844b6 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -50,6 +50,7 @@ pub fn configure_component_env(command: &mut std::process::Command, workspace: & .env("A3S_DATA_HOME", workspace.path("data")) .env("A3S_STATE_HOME", workspace.path("state")) .env("A3S_CACHE_HOME", workspace.path("cache")) + .env("A3S_RUNTIME_HOME", workspace.path("runtime")) .env("HOME", workspace.path("home")) .env("PATH", ""); } @@ -161,6 +162,7 @@ impl FakeReleaseServer { let thread_stop = Arc::clone(&stop); let asset_path = format!("/assets/{asset_name}"); let latest_path = format!("/repos/A3S-Lab/{repository}/releases/latest"); + let tag_path = format!("/repos/A3S-Lab/{repository}/releases/tags/v{version}"); let thread = std::thread::spawn(move || { while !thread_stop.load(Ordering::Relaxed) { match listener.accept() { @@ -176,6 +178,7 @@ impl FakeReleaseServer { let release = release.clone(); let asset_path = asset_path.clone(); let latest_path = latest_path.clone(); + let tag_path = tag_path.clone(); let archive = archive.clone(); let requests = Arc::clone(&thread_requests); std::thread::spawn(move || { @@ -184,6 +187,7 @@ impl FakeReleaseServer { &release, &asset_path, &latest_path, + &tag_path, &archive, &requests, ); @@ -227,6 +231,7 @@ fn serve_request( release: &[u8], asset_path: &str, latest_path: &str, + tag_path: &str, archive: &[u8], requests: &Arc>>, ) { @@ -245,7 +250,7 @@ fn serve_request( requests.lock().unwrap().push(path.clone()); let (status, content_type, body) = if path == asset_path { ("200 OK", "application/gzip", archive) - } else if path == latest_path { + } else if path == latest_path || path == tag_path { ("200 OK", "application/json", release) } else { ("404 Not Found", "text/plain", b"not found".as_slice()) diff --git a/tests/support/tuf_test_support.rs b/tests/support/tuf_test_support.rs new file mode 100644 index 00000000..51d24ebc --- /dev/null +++ b/tests/support/tuf_test_support.rs @@ -0,0 +1,324 @@ +#![allow(dead_code)] + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{Shutdown, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::Duration; + +use olpc_cjson::CanonicalFormatter; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use serde::Serialize; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +pub(crate) const FUTURE: &str = "2999-01-01T00:00:00Z"; +pub(crate) const EXPIRED: &str = "2000-01-01T00:00:00Z"; +pub(crate) const PACKAGE_VERSION: &str = "0.1.1"; + +pub(crate) struct TestRepository { + pub(crate) routes: HashMap>, + pub(crate) root_sha256: String, + pub(crate) target_name: String, + pub(crate) target_sha256: String, +} + +impl TestRepository { + pub(crate) fn new(archive: Vec, metadata_version: u64, expires: &str) -> Self { + Self::with_package_version(archive, PACKAGE_VERSION, metadata_version, expires) + } + + pub(crate) fn with_package_version( + archive: Vec, + package_version: &str, + metadata_version: u64, + expires: &str, + ) -> Self { + let key = Ed25519KeyPair::from_seed_unchecked(&[7_u8; 32]).unwrap(); + let public = hex_lower(key.public_key().as_ref()); + let key_value = json!({ + "keytype": "ed25519", + "scheme": "ed25519", + "keyval": {"public": public} + }); + let key_id = sha256(&canonical(&key_value)); + let role = json!({"keyids": [key_id.clone()], "threshold": 1}); + let mut keys = Map::new(); + keys.insert(key_id.clone(), key_value); + let root_signed = json!({ + "_type": "root", + "spec_version": "1.0.0", + "consistent_snapshot": false, + "version": 1, + "expires": FUTURE, + "keys": keys, + "roles": { + "root": role.clone(), + "snapshot": role.clone(), + "targets": role.clone(), + "timestamp": role + } + }); + let root = signed_document(&key, &key_id, root_signed); + let root_sha256 = sha256(&root); + + let target = host_target(); + let archive_name = format!("a3s-use-slack-{package_version}-{target}.tar.gz"); + let target_name = + format!("extensions/acme/slack/{package_version}/stable/{target}/{archive_name}"); + let target_sha256 = sha256(&archive); + let mut targets_map = Map::new(); + targets_map.insert( + target_name.clone(), + json!({ + "length": archive.len(), + "hashes": {"sha256": target_sha256}, + "custom": { + "a3s": { + "schemaVersion": 1, + "packageId": "acme/slack", + "version": package_version, + "channel": "stable", + "target": target + } + } + }), + ); + let targets_signed = json!({ + "_type": "targets", + "spec_version": "1.0.0", + "version": metadata_version, + "expires": expires, + "targets": targets_map + }); + let targets = signed_document(&key, &key_id, targets_signed); + let snapshot_signed = json!({ + "_type": "snapshot", + "spec_version": "1.0.0", + "version": metadata_version, + "expires": expires, + "meta": { + "targets.json": { + "version": metadata_version, + "length": targets.len(), + "hashes": {"sha256": sha256(&targets)} + } + } + }); + let snapshot = signed_document(&key, &key_id, snapshot_signed); + let timestamp_signed = json!({ + "_type": "timestamp", + "spec_version": "1.0.0", + "version": metadata_version, + "expires": expires, + "meta": { + "snapshot.json": { + "version": metadata_version, + "length": snapshot.len(), + "hashes": {"sha256": sha256(&snapshot)} + } + } + }); + let timestamp = signed_document(&key, &key_id, timestamp_signed); + + let routes = HashMap::from([ + ("/metadata/root.json".to_string(), root), + ("/metadata/timestamp.json".to_string(), timestamp), + ("/metadata/snapshot.json".to_string(), snapshot), + ("/metadata/targets.json".to_string(), targets), + (format!("/targets/{target_name}"), archive), + ]); + Self { + routes, + root_sha256, + target_name, + target_sha256, + } + } +} + +fn signed_document(key: &Ed25519KeyPair, key_id: &str, signed: Value) -> Vec { + let signature = key.sign(&canonical(&signed)); + serde_json::to_vec(&json!({ + "signatures": [{"keyid": key_id, "sig": hex_lower(signature.as_ref())}], + "signed": signed + })) + .unwrap() +} + +fn canonical(value: &Value) -> Vec { + let mut bytes = Vec::new(); + let mut serializer = + serde_json::Serializer::with_formatter(&mut bytes, CanonicalFormatter::new()); + value.serialize(&mut serializer).unwrap(); + bytes +} + +fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn hex_lower(bytes: &[u8]) -> String { + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + let _ = write!(output, "{byte:02x}"); + } + output +} + +pub(crate) fn extension_archive(version: &str) -> Vec { + let manifest = format!( + "extension \"acme/slack\" {{\n schema_version = 1\n version = \"{version}\"\n route = \"slack\"\n actions = [\"read\"]\n\n cli {{\n executable = \"bin/a3s-use-slack\"\n json_output = true\n }}\n}}\n" + ); + let mut bytes = Vec::new(); + { + let encoder = flate2::write::GzEncoder::new(&mut bytes, flate2::Compression::default()); + let mut archive = tar::Builder::new(encoder); + append_tar_file( + &mut archive, + "package/a3s-use-extension.acl", + 0o644, + manifest.as_bytes(), + ); + append_tar_file( + &mut archive, + "package/bin/a3s-use-slack", + 0o755, + b"#!/bin/sh\nprintf 'slack fixture\\n'\n", + ); + archive.finish().unwrap(); + } + bytes +} + +fn append_tar_file(archive: &mut tar::Builder, path: &str, mode: u32, body: &[u8]) { + let mut header = tar::Header::new_gnu(); + header.set_path(path).unwrap(); + header.set_size(body.len() as u64); + header.set_mode(mode); + header.set_cksum(); + archive.append(&header, body).unwrap(); +} + +pub(crate) fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn host_target() -> &'static str { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => "darwin-arm64", + ("macos", "x86_64") => "darwin-x86_64", + ("linux", "aarch64") => "linux-arm64", + ("linux", "x86_64") => "linux-x86_64", + ("windows", "x86_64") => "windows-x86_64", + (os, arch) => panic!("unsupported TUF test target {os}-{arch}"), + } +} + +pub(crate) struct TestServer { + base_url: String, + routes: Arc>>>, + requests: Arc>>, + stop: Arc, + thread: Option>, +} + +impl TestServer { + pub(crate) fn start(routes: HashMap>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let base_url = format!("http://{}/", listener.local_addr().unwrap()); + let routes = Arc::new(Mutex::new(routes)); + let requests = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + let thread_routes = Arc::clone(&routes); + let thread_requests = Arc::clone(&requests); + let thread_stop = Arc::clone(&stop); + let thread = std::thread::spawn(move || { + while !thread_stop.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).unwrap(); + let routes = Arc::clone(&thread_routes); + let requests = Arc::clone(&thread_requests); + std::thread::spawn(move || serve(stream, &routes, &requests)); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(_) => break, + } + } + }); + Self { + base_url, + routes, + requests, + stop, + thread: Some(thread), + } + } + + pub(crate) fn base_url(&self) -> &str { + &self.base_url + } + + pub(crate) fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + + pub(crate) fn clear_requests(&self) { + self.requests.lock().unwrap().clear(); + } + + pub(crate) fn replace_routes(&self, routes: HashMap>) { + *self.routes.lock().unwrap() = routes; + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn serve( + mut stream: TcpStream, + routes: &Mutex>>, + requests: &Mutex>, +) { + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + let mut buffer = [0_u8; 8192]; + let Ok(size) = stream.read(&mut buffer) else { + return; + }; + let request = String::from_utf8_lossy(&buffer[..size]); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/") + .to_string(); + requests.lock().unwrap().push(path.clone()); + let body = routes.lock().unwrap().get(&path).cloned(); + let (status, body) = body + .as_deref() + .map(|body| ("200 OK", body)) + .unwrap_or(("404 Not Found", b"not found")); + let header = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + if stream.write_all(header.as_bytes()).is_ok() && stream.write_all(body).is_ok() { + let _ = stream.flush(); + let _ = stream.shutdown(Shutdown::Write); + } +}