diff --git a/Cargo.lock b/Cargo.lock index 80e0f846..07033fd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,15 +21,19 @@ dependencies = [ "axum", "base64", "clap", + "flate2", "fs2", "futures-util", "getrandom 0.3.4", + "olpc-cjson", "reqwest", + "ring", "rmcp", "schemars", "serde", "serde_json", "sha2 0.10.9", + "tar", "tempfile", "tokio", "tokio-util", @@ -107,13 +111,21 @@ version = "0.1.1" dependencies = [ "a3s-acl", "a3s-use-core", + "flate2", "fs2", + "olpc-cjson", + "reqwest", + "ring", "semver", "serde", "serde_json", "sha2 0.10.9", + "tar", "tempfile", "tokio", + "tough", + "url", + "zip", ] [[package]] @@ -432,6 +444,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" @@ -565,6 +588,30 @@ dependencies = [ "arrayvec", ] +[[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" @@ -675,6 +722,16 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "built" version = "0.8.1" @@ -881,6 +938,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" @@ -1238,6 +1304,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1279,6 +1355,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 = "futures" version = "0.3.32" @@ -1455,6 +1537,19 @@ dependencies = [ "weezl", ] +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -2144,6 +2239,17 @@ dependencies = [ "autocfg", ] +[[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" @@ -2186,12 +2292,42 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[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" @@ -2215,6 +2351,12 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "png" version = "0.18.1" @@ -2723,7 +2865,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -2844,6 +2986,8 @@ version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -2868,9 +3012,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]] @@ -2991,6 +3136,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_urlencoded" version = "0.7.1" @@ -3085,6 +3239,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.6.5" @@ -3168,6 +3345,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3367,6 +3555,41 @@ dependencies = [ "tokio", ] +[[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", + "rustls", + "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" @@ -3489,6 +3712,12 @@ dependencies = [ "utf-8", ] +[[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" @@ -3507,6 +3736,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -3517,6 +3755,12 @@ dependencies = [ "subtle", ] +[[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" @@ -4039,6 +4283,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "y4m" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 8c17bb9f..cb782059 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ base64 = "0.22" clap = { version = "4", features = ["derive"] } fs2 = "0.4" futures-util = "0.3" +flate2 = "1" getrandom = "0.3" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } quick-xml = "0.38" @@ -34,10 +35,12 @@ schemars = "1.2" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +tar = "0.4" thiserror = "2" tempfile = "3" tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "process", "sync", "time"] } tokio-util = "0.7" +tough = { version = "0.22", default-features = false, features = ["http"] } url = "2" zip = { version = "2", default-features = false, features = ["deflate"] } @@ -111,5 +114,9 @@ windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_System_ [dev-dependencies] async-trait.workspace = true reqwest.workspace = true +flate2.workspace = true +olpc-cjson = "0.1" +ring = "0.17" +tar.workspace = true tempfile.workspace = true zip.workspace = true diff --git a/README.md b/README.md index 5b4916fa..9304819c 100644 --- a/README.md +++ b/README.md @@ -1631,6 +1631,7 @@ the parent TUI before source bytes leave the device. See the [OCR crate](crates/ocr/README.md) for configuration and provider boundaries. + ## External Extensions External Use domains stay behind process boundaries. A package contains an @@ -1674,11 +1675,74 @@ a3s use extension enable acme/slack --json a3s uninstall use/acme/slack ``` -The current extension source is an explicit local directory. It must pass -manifest, route, path, package-size, and executable validation, and unsigned -content requires `--allow-unsigned`. A signed remote publisher channel is -roadmap work; Use does not silently install arbitrary Homebrew, npm, Cargo, -system, or `PATH` packages. +The current extension source is an explicit local directory or a `.tar.gz`, +`.tgz`, or `.zip` archive. Archives must contain exactly one package manifest; +every entry must belong to that manifest's package root. Installation rejects +links, traversal, duplicate paths, unsupported entries, excessive expansion, +and non-portable paths before validating the manifest, route, executable, and +Skill surfaces. Unsigned content requires `--allow-unsigned`. Use does not +silently install arbitrary Homebrew, npm, Cargo, system, or `PATH` packages. + +### Signed extension registries + +Remote extensions use TUF metadata and a separately established bootstrap-root +digest. Enroll a registry with either a root file or its SHA-256, verify it, +review the immutable component plan, and apply that exact plan: + +```bash +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 +``` + +When a root file is supplied, the umbrella CLI copies it into registry-owned +configuration and records its digest. With a digest-only enrollment, Use may +fetch `/metadata/root.json`, but it caches the file only after the +bytes match the pinned SHA-256. Subsequent root rotation, timestamp, snapshot, +and targets metadata are verified by TUF with expiration and rollback +enforcement. Registry URLs require HTTPS; loopback HTTP is accepted only for +tests and local development. + +A dry-run verifies metadata but does not download the target archive. Its outer +component digest includes the exact `ResolvedRemotePackage`: registry identity, +bootstrap root, every TUF metadata version, package version and channel, +platform target, archive path, length, and SHA-256. Apply resolves again and +fails before target download if that plan changed. It then passes the resolved +package's own digest to `a3s-use`, which repeats TUF verification immediately +before downloading and activating the archive. The installed receipt records +`registry-tuf` trust and the complete signed provenance. Registry installs +reject `--allow-unsigned`; local `--from` installs cannot provide registry +options. + +Registry upgrades reuse the registry identity and channel recorded in that +signed provenance instead of searching every configured source again. A +missing registry, changed URL or bootstrap root, and semantic-version downgrade +are rejected before payload download. Plain `a3s upgrade` reports newer signed +targets, while `a3s upgrade --all` includes them in the selected batch. If the +verified target is identical to the installed target, `a3s-use` validates and +reconciles the receipt and registry snapshot without downloading or +reactivating the package. + +Publish metadata below `/metadata/` and payloads below +`/targets/`. An extension target uses this canonical path: + +```text +extensions////// +``` + +Its TUF target `custom.a3s` object must contain `schemaVersion`, `packageId`, +`version`, `channel` (`stable`, `beta`, or `nightly`), and `target` (an A3S host +target or `any`). Duplicate identities, mismatched paths, unsupported archives, +and oversized targets are rejected before payload download. Built-in and management routes are reserved. Extensions cannot shadow `browser`, `office`, `ocr`, `box`, `component`, `capability`, or other host diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml index 93f63891..d1ea839f 100644 --- a/crates/extension/Cargo.toml +++ b/crates/extension/Cargo.toml @@ -12,9 +12,19 @@ description = "ACL manifest and native surface contracts for A3S Use extensions" a3s-acl = { git = "https://github.com/A3S-Lab/ACL", rev = "6e2a6469edc0f4c61b1e588d0ace873aaf15ce22" } a3s-use-core = { version = "0.1.1", path = "../core" } fs2.workspace = true +flate2.workspace = true +reqwest.workspace = true serde.workspace = true serde_json.workspace = true semver = "1" sha2.workspace = true +tar.workspace = true tempfile.workspace = true tokio.workspace = true +tough.workspace = true +url.workspace = true +zip.workspace = true + +[dev-dependencies] +olpc-cjson = "0.1" +ring = "0.17" diff --git a/crates/extension/src/digest.rs b/crates/extension/src/digest.rs new file mode 100644 index 00000000..7f8fb87c --- /dev/null +++ b/crates/extension/src/digest.rs @@ -0,0 +1,194 @@ +use std::fs::File; +use std::io::{BufReader, Read}; +use std::path::{Path, PathBuf}; + +use a3s_use_core::{UseError, UseResult}; +use sha2::{Digest, Sha256}; + +use super::package::{io_error, MAX_PACKAGE_BYTES, MAX_PACKAGE_FILES}; +use super::source::sanitized_relative_path; + +struct PackageFile { + normalized: String, + path: PathBuf, + size: u64, +} + +pub(crate) async fn package_sha256(root: &Path) -> UseResult { + let root = root.to_path_buf(); + tokio::task::spawn_blocking(move || hash_package(&root)) + .await + .map_err(|error| { + UseError::new( + "use.extension.io", + format!("Failed to hash extension package: blocking task failed: {error}"), + ) + })? +} + +fn hash_package(root: &Path) -> UseResult { + let mut files = Vec::new(); + let mut entries = 0_usize; + let mut bytes = 0_u64; + collect_files(root, root, &mut files, &mut entries, &mut bytes)?; + files.sort_by(|left, right| left.normalized.cmp(&right.normalized)); + + let mut digest = Sha256::new(); + digest.update(b"a3s-use-expanded-package-v1\0"); + for package_file in files { + let path_bytes = package_file.normalized.as_bytes(); + digest.update((path_bytes.len() as u64).to_be_bytes()); + digest.update(path_bytes); + digest.update(package_file.size.to_be_bytes()); + + let file = File::open(&package_file.path) + .map_err(|error| io_error("open extension package file", &package_file.path, error))?; + let mut reader = BufReader::new(file); + let mut buffer = [0_u8; 64 * 1024]; + let mut read_bytes = 0_u64; + loop { + let count = reader.read(&mut buffer).map_err(|error| { + io_error("hash extension package file", &package_file.path, error) + })?; + if count == 0 { + break; + } + read_bytes = read_bytes.saturating_add(count as u64); + if read_bytes > package_file.size { + return Err(package_changed(&package_file.path)); + } + digest.update(&buffer[..count]); + } + if read_bytes != package_file.size { + return Err(package_changed(&package_file.path)); + } + } + Ok(format!("{:x}", digest.finalize())) +} + +fn collect_files( + root: &Path, + directory: &Path, + files: &mut Vec, + entries: &mut usize, + bytes: &mut u64, +) -> UseResult<()> { + let children = std::fs::read_dir(directory) + .map_err(|error| io_error("read extension package directory", directory, error))?; + for child in children { + let child = + child.map_err(|error| io_error("read extension package entry", directory, error))?; + *entries = entries.saturating_add(1); + if *entries > MAX_PACKAGE_FILES { + return Err(package_limit_error()); + } + let path = child.path(); + let metadata = std::fs::symlink_metadata(&path) + .map_err(|error| io_error("inspect extension package entry", &path, error))?; + if metadata.file_type().is_symlink() { + return Err(UseError::new( + "use.extension.package_symlink", + format!( + "Extension package entry '{}' is a symbolic link.", + path.display() + ), + )); + } + if metadata.is_dir() { + collect_files(root, &path, files, entries, bytes)?; + continue; + } + if !metadata.is_file() { + return Err(UseError::new( + "use.extension.package_entry_invalid", + format!( + "Extension package entry '{}' is not a regular file or directory.", + path.display() + ), + )); + } + *bytes = bytes.saturating_add(metadata.len()); + if *bytes > MAX_PACKAGE_BYTES { + return Err(package_limit_error()); + } + let relative = path.strip_prefix(root).map_err(|_| { + UseError::new( + "use.extension.path_escape", + format!( + "Extension package entry '{}' escapes its root.", + path.display() + ), + ) + })?; + let relative = sanitized_relative_path(relative)?.ok_or_else(|| { + UseError::new( + "use.extension.package_entry_invalid", + "Extension package contains an empty file path.", + ) + })?; + let normalized = relative + .iter() + .map(|segment| { + segment.to_str().ok_or_else(|| { + UseError::new( + "use.extension.package_entry_invalid", + format!( + "Extension package path '{}' is not valid UTF-8.", + relative.display() + ), + ) + }) + }) + .collect::>>()? + .join("/"); + files.push(PackageFile { + normalized, + path, + size: metadata.len(), + }); + } + Ok(()) +} + +fn package_changed(path: &Path) -> UseError { + UseError::new( + "use.extension.package_changed", + format!( + "Extension package file '{}' changed while it was hashed.", + path.display() + ), + ) +} + +fn package_limit_error() -> UseError { + UseError::new( + "use.extension.package_too_large", + "The extension package exceeds the local installation limits.", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn package_digest_is_order_independent_and_content_sensitive() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("first"); + let second = temp.path().join("second"); + std::fs::create_dir_all(first.join("bin")).unwrap(); + std::fs::create_dir_all(second.join("bin")).unwrap(); + std::fs::write(first.join("z.txt"), b"z").unwrap(); + std::fs::write(first.join("bin/tool"), b"tool").unwrap(); + std::fs::write(second.join("bin/tool"), b"tool").unwrap(); + std::fs::write(second.join("z.txt"), b"z").unwrap(); + + let first_digest = package_sha256(&first).await.unwrap(); + let second_digest = package_sha256(&second).await.unwrap(); + assert_eq!(first_digest, second_digest); + assert_eq!(first_digest.len(), 64); + + std::fs::write(second.join("bin/tool"), b"changed").unwrap(); + assert_ne!(first_digest, package_sha256(&second).await.unwrap()); + } +} diff --git a/crates/extension/src/lib.rs b/crates/extension/src/lib.rs index cc76e2dc..9b692c3c 100644 --- a/crates/extension/src/lib.rs +++ b/crates/extension/src/lib.rs @@ -5,11 +5,14 @@ use a3s_acl::{Block, Value}; use a3s_use_core::{RiskClass, UseError, UseResult}; use serde::{Deserialize, Serialize}; +mod digest; mod package; mod paths; mod registry; mod registry_io; +mod remote; mod route_lock; +mod source; pub use paths::ExtensionPaths; pub use registry::{ @@ -17,6 +20,10 @@ pub use registry::{ ExtensionRouteBinding, ExtensionRouteLease, ExtensionTrust, InstallOptions, InstallResult, InstalledExtension, UninstallResult, }; +pub use remote::{ + prepare_remote_package, refresh_remote_registry, DownloadedRemotePackage, + PreparedRemotePackage, ResolvedRemotePackage, TrustedRegistry, VerifiedRegistryMetadata, +}; const RESERVED_ROUTES: &[&str] = &[ "browser", diff --git a/crates/extension/src/package.rs b/crates/extension/src/package.rs index a939a430..9778ace7 100644 --- a/crates/extension/src/package.rs +++ b/crates/extension/src/package.rs @@ -12,9 +12,9 @@ use tokio::io::AsyncWriteExt; use super::registry::ExtensionReceipt; use super::{ExtensionManifest, ExtensionPaths}; -const MANIFEST_NAME: &str = "a3s-use-extension.acl"; -const MAX_PACKAGE_FILES: usize = 10_000; -const MAX_PACKAGE_BYTES: u64 = 1_073_741_824; +pub(crate) const MANIFEST_NAME: &str = "a3s-use-extension.acl"; +pub(crate) const MAX_PACKAGE_FILES: usize = 10_000; +pub(crate) const MAX_PACKAGE_BYTES: u64 = 1_073_741_824; pub(crate) async fn read_manifest(package_root: &Path) -> UseResult<(ExtensionManifest, Vec)> { let path = package_root.join(MANIFEST_NAME); diff --git a/crates/extension/src/paths.rs b/crates/extension/src/paths.rs index 62adef50..3232a610 100644 --- a/crates/extension/src/paths.rs +++ b/crates/extension/src/paths.rs @@ -93,6 +93,12 @@ impl ExtensionPaths { path.set_extension("lock"); path } + + pub fn tuf_datastore(&self, registry_name: &str) -> PathBuf { + self.state_root + .join("remote-registries") + .join(registry_name) + } } fn configured_root( @@ -163,5 +169,9 @@ mod tests { paths.registry_snapshot_path(), PathBuf::from("/state/use/registry.json") ); + assert_eq!( + paths.tuf_datastore("a3s"), + PathBuf::from("/state/use/remote-registries/a3s") + ); } } diff --git a/crates/extension/src/registry.rs b/crates/extension/src/registry.rs index 5f3c0135..05e3dc97 100644 --- a/crates/extension/src/registry.rs +++ b/crates/extension/src/registry.rs @@ -7,12 +7,15 @@ use fs2::FileExt; use serde::{Deserialize, Serialize}; use tokio::fs; +use super::digest::package_sha256; use super::package::{ copy_package, io_error, owned_package_path, read_manifest, sha256, unique_suffix, unix_timestamp, validate_surface_files, write_receipt, RegistryLock, }; use super::registry_io::{read_registry_snapshot, write_registry_snapshot}; +use super::remote::{prepare_remote_package, ResolvedRemotePackage, TrustedRegistry}; use super::route_lock::{acquire_drain_lock, deadline_after, open_route_lock}; +use super::source::prepare_package_source; use super::{ExtensionManifest, ExtensionPaths, McpTransport}; const RECEIPT_SCHEMA_VERSION: u32 = 1; @@ -24,6 +27,7 @@ const WATCH_INTERVAL: Duration = Duration::from_millis(50); #[serde(rename_all = "kebab-case")] pub enum ExtensionTrust { LocalExplicit, + RegistryTuf, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -36,7 +40,11 @@ pub struct ExtensionReceipt { pub version: String, pub package_root: PathBuf, pub manifest_sha256: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub package_sha256: Option, pub trust: ExtensionTrust, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry: Option, pub installed_at_unix: u64, #[serde(default = "enabled_by_default")] pub enabled: bool, @@ -115,6 +123,8 @@ pub struct ExtensionRouteBinding { #[serde(default)] pub package_root: PathBuf, pub manifest_sha256: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub package_sha256: Option, pub enabled: bool, pub surfaces: Vec, } @@ -368,21 +378,113 @@ impl ExtensionRegistry { .with_suggestion("Rerun the explicit install with --allow-unsigned.")); } - let source = fs::canonicalize(source) - .await - .map_err(|error| io_error("resolve extension package", source, error))?; - let source_metadata = fs::metadata(&source) - .await - .map_err(|error| io_error("inspect extension package", &source, error))?; - if !source_metadata.is_dir() { - return Err(UseError::new( - "use.extension.package_unsupported", - "The initial local installer accepts a package directory.", + let source = prepare_package_source(source).await?; + self.install_prepared( + &expected_package_id, + source.root(), + options.force, + ExtensionTrust::LocalExplicit, + None, + ) + .await + } + + /// Install an extension selected through a fully verified TUF repository. + /// + /// Metadata is resolved and the optional reviewed plan is checked before + /// the target payload is downloaded. The package manifest must repeat the + /// exact ID and version carried by the signed target metadata. + pub async fn install_remote( + &self, + expected_package_id: &str, + registry: &TrustedRegistry, + requested_version: Option<&str>, + channel: &str, + expected_plan_digest: Option<&str>, + force: bool, + ) -> UseResult { + let expected_package_id = normalize_package_id(expected_package_id)?; + let prepared = prepare_remote_package( + registry, + &expected_package_id, + requested_version, + channel, + expected_plan_digest, + ) + .await?; + if !force { + if let Some(result) = self + .converged_remote_install(&expected_package_id, prepared.resolved()) + .await? + { + return Ok(result); + } + } + let downloaded = prepared.download().await?; + let provenance = downloaded.resolved().clone(); + let source = prepare_package_source(downloaded.path()).await?; + self.install_prepared( + &expected_package_id, + source.root(), + force, + ExtensionTrust::RegistryTuf, + Some(provenance), + ) + .await + } + + async fn converged_remote_install( + &self, + expected_package_id: &str, + resolved: &ResolvedRemotePackage, + ) -> UseResult> { + let _lock = RegistryLock::acquire(&self.paths.registry_lock_path())?; + let Some(mut current) = self.get(expected_package_id).await? else { + return Ok(None); + }; + let same_target = current.receipt.trust == ExtensionTrust::RegistryTuf + && current.receipt.version == resolved.version + && registry_identity(current.receipt.registry.as_ref()) + == registry_identity(Some(resolved)); + if !same_target { + return Ok(None); + } + verify_package_integrity(¤t).await?; + if current.receipt.registry.as_ref() != Some(resolved) { + current.receipt.registry = Some(resolved.clone()); + write_receipt( + &self.paths.receipt_path(expected_package_id), + ¤t.receipt, ) - .with_suggestion("Extract the package archive and pass its directory with --from.")); + .await?; + } + let installed = self.list().await?; + self.publish_snapshot_locked(&installed).await?; + Ok(Some(InstallResult { + changed: false, + extension: current, + })) + } + + async fn install_prepared( + &self, + expected_package_id: &str, + source: &Path, + force: bool, + trust: ExtensionTrust, + registry: Option, + ) -> UseResult { + match (trust, registry.as_ref()) { + (ExtensionTrust::LocalExplicit, None) | (ExtensionTrust::RegistryTuf, Some(_)) => {} + _ => { + return Err(UseError::new( + "use.extension.trust_invalid", + "Extension installation provenance is internally inconsistent.", + )) + } } - let (manifest, manifest_bytes) = read_manifest(&source).await?; + let (manifest, manifest_bytes) = read_manifest(source).await?; if manifest.package_id != expected_package_id { return Err(UseError::new( "use.extension.identity_mismatch", @@ -392,7 +494,22 @@ impl ExtensionRegistry { ), )); } - validate_surface_files(&manifest, &source).await?; + if let Some(registry) = ®istry { + if registry.package_id != manifest.package_id || registry.version != manifest.version { + return Err(UseError::new( + "use.extension.registry_identity_mismatch", + format!( + "Signed target '{}@{}' does not match package manifest '{}@{}'.", + registry.package_id, + registry.version, + manifest.package_id, + manifest.version + ), + )); + } + } + validate_surface_files(&manifest, source).await?; + let package_digest = package_sha256(source).await?; let _lock = RegistryLock::acquire(&self.paths.registry_lock_path())?; let installed = self.list().await?; @@ -414,9 +531,17 @@ impl ExtensionRegistry { .iter() .find(|extension| extension.receipt.package_id == expected_package_id) { - if !options.force + let current_package_digest = match ¤t.receipt.package_sha256 { + Some(digest) => digest.clone(), + None => package_sha256(¤t.receipt.package_root).await?, + }; + let same_provenance = current.receipt.trust == trust + && registry_identity(current.receipt.registry.as_ref()) + == registry_identity(registry.as_ref()); + if !force && current.receipt.version == manifest.version - && current.receipt.manifest_sha256 == digest + && current_package_digest == package_digest + && same_provenance { self.publish_snapshot_locked(&installed).await?; return Ok(InstallResult { @@ -424,7 +549,10 @@ impl ExtensionRegistry { extension: current.clone(), }); } - if !options.force && current.receipt.version == manifest.version { + if !force + && current.receipt.version == manifest.version + && current_package_digest != package_digest + { return Err(UseError::new( "use.extension.version_conflict", format!( @@ -436,7 +564,7 @@ impl ExtensionRegistry { } } - let package_parent = self.paths.package_parent(&expected_package_id); + let package_parent = self.paths.package_parent(expected_package_id); fs::create_dir_all(&package_parent).await.map_err(|error| { io_error("create extension package directory", &package_parent, error) })?; @@ -446,7 +574,7 @@ impl ExtensionRegistry { .map_err(|error| { io_error("create extension staging directory", &package_parent, error) })?; - copy_package(&source, staging.path()).await?; + copy_package(source, staging.path()).await?; let (staged_manifest, staged_bytes) = read_manifest(staging.path()).await?; if staged_manifest != manifest || sha256(&staged_bytes) != digest { return Err(UseError::new( @@ -455,11 +583,17 @@ impl ExtensionRegistry { )); } validate_surface_files(&staged_manifest, staging.path()).await?; + if package_sha256(staging.path()).await? != package_digest { + return Err(UseError::new( + "use.extension.package_changed", + "The extension package changed while it was staged.", + )); + } let activation = unique_suffix(); let target = self .paths - .package_root(&expected_package_id, &manifest.version, &activation); + .package_root(expected_package_id, &manifest.version, &activation); let staging = staging.keep(); if let Err(error) = fs::rename(&staging, &target).await { let _ = fs::remove_dir_all(&staging).await; @@ -474,17 +608,19 @@ impl ExtensionRegistry { let receipt = ExtensionReceipt { schema_version: RECEIPT_SCHEMA_VERSION, - package_id: expected_package_id.clone(), + package_id: expected_package_id.to_string(), component_id: format!("use/{expected_package_id}"), route: manifest.route.clone(), version: manifest.version.clone(), package_root: target.clone(), manifest_sha256: digest, - trust: ExtensionTrust::LocalExplicit, + package_sha256: Some(package_digest), + trust, + registry, installed_at_unix: unix_timestamp(), enabled, }; - let receipt_path = self.paths.receipt_path(&expected_package_id); + let receipt_path = self.paths.receipt_path(expected_package_id); if let Err(error) = write_receipt(&receipt_path, &receipt).await { let _ = fs::remove_dir_all(&target).await; return Err(error); @@ -650,6 +786,7 @@ impl ExtensionRegistry { let _ = FileExt::unlock(&file); return Ok(None); } + verify_package_integrity(&extension).await?; Ok(Some(ExtensionRouteLease { extension, file })) } @@ -699,6 +836,46 @@ impl ExtensionRegistry { ), )); } + if receipt.package_sha256.as_deref().is_some_and(|digest| { + digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) { + return Err(UseError::new( + "use.extension.receipt_invalid", + format!( + "Extension receipt for '{}' has an invalid package digest.", + receipt.package_id + ), + )); + } + match ( + receipt.trust, + receipt.registry.as_ref(), + receipt.package_sha256.as_ref(), + ) { + (ExtensionTrust::LocalExplicit, None, _) => {} + (ExtensionTrust::RegistryTuf, Some(registry), Some(_)) => { + registry.validate_provenance()?; + if registry.package_id != receipt.package_id || registry.version != receipt.version + { + return Err(UseError::new( + "use.extension.receipt_invalid", + format!( + "Registry provenance for '{}' does not match its receipt.", + receipt.package_id + ), + )); + } + } + _ => { + return Err(UseError::new( + "use.extension.receipt_invalid", + format!( + "Extension receipt for '{}' has inconsistent trust provenance.", + receipt.package_id + ), + )) + } + } let package_id = normalize_package_id(&receipt.package_id)?; if receipt.component_id != format!("use/{package_id}") || !owned_package_path(&self.paths, &package_id, &receipt.package_root) @@ -730,6 +907,24 @@ impl ExtensionRegistry { } } +async fn verify_package_integrity(extension: &InstalledExtension) -> UseResult<()> { + let Some(expected) = extension.receipt.package_sha256.as_deref() else { + return Ok(()); + }; + let actual = package_sha256(&extension.receipt.package_root).await?; + if actual != expected { + return Err(UseError::new( + "use.extension.package_digest_mismatch", + format!( + "Installed package '{}' no longer matches its recorded digest.", + extension.receipt.package_id + ), + ) + .with_suggestion("Reinstall the extension from its trusted source.")); + } + Ok(()) +} + fn route_bindings(installed: &[InstalledExtension]) -> Vec { installed .iter() @@ -740,6 +935,7 @@ fn route_bindings(installed: &[InstalledExtension]) -> Vec UseResult { Ok(value.to_string()) } +fn registry_identity(registry: Option<&ResolvedRemotePackage>) -> Option<(&str, &str, &str, &str)> { + registry.map(|registry| { + ( + registry.registry_name.as_str(), + registry.registry_url.as_str(), + registry.root_sha256.as_str(), + registry.sha256.as_str(), + ) + }) +} + fn ensure_unique_routes(installed: &[InstalledExtension]) -> UseResult<()> { for (index, extension) in installed.iter().enumerate() { if let Some(conflict) = installed[index + 1..] diff --git a/crates/extension/src/registry_tests.rs b/crates/extension/src/registry_tests.rs index 59664552..875fd2bc 100644 --- a/crates/extension/src/registry_tests.rs +++ b/crates/extension/src/registry_tests.rs @@ -1,3 +1,5 @@ +use std::fs::File; +use std::io::Write; use std::time::Duration; #[cfg(unix)] @@ -48,6 +50,43 @@ fn registry(root: &Path) -> ExtensionRegistry { ExtensionRegistry::new(ExtensionPaths::new(root.join("data"), root.join("state"))) } +fn tar_package(source: &Path, archive: &Path) { + let file = File::create(archive).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + builder.append_dir_all("package", source).unwrap(); + builder.finish().unwrap(); +} + +fn zip_package(source: &Path, archive: &Path) { + let file = File::create(archive).unwrap(); + let mut writer = zip::ZipWriter::new(file); + for relative in [ + "a3s-use-extension.acl", + "bin/extension", + "skills/demo/SKILL.md", + ] { + let source_file = source.join(relative); + let mut options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + #[cfg(unix)] + { + let mode = std::fs::metadata(&source_file) + .unwrap() + .permissions() + .mode(); + options = options.unix_permissions(mode); + } + writer + .start_file(format!("package/{relative}"), options) + .unwrap(); + writer + .write_all(&std::fs::read(source_file).unwrap()) + .unwrap(); + } + writer.finish().unwrap(); +} + #[tokio::test] async fn installs_lists_and_uninstalls_an_explicit_local_package() { let temp = tempfile::tempdir().unwrap(); @@ -89,6 +128,63 @@ async fn installs_lists_and_uninstalls_an_explicit_local_package() { assert!(registry.list().await.unwrap().is_empty()); } +#[tokio::test] +async fn installs_and_uninstalls_a_local_tar_package() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + package(&source, "acme/slack", "slack", "1.2.0").await; + let archive = temp.path().join("acme-slack.tar.gz"); + tar_package(&source, &archive); + let registry = registry(temp.path()); + + let result = registry + .install_local( + "acme/slack", + &archive, + InstallOptions { + allow_unsigned: true, + force: false, + }, + ) + .await + .unwrap(); + assert!(result.changed); + assert_eq!(result.extension.receipt.package_id, "acme/slack"); + assert!(result.extension.cli_executable().unwrap().is_file()); + + let removed = registry.uninstall("acme/slack").await.unwrap(); + assert!(removed.changed); + assert!(registry.list().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn installs_and_uninstalls_a_local_zip_package() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + package(&source, "acme/slack", "slack", "1.2.0").await; + let archive = temp.path().join("acme-slack.zip"); + zip_package(&source, &archive); + let registry = registry(temp.path()); + + let result = registry + .install_local( + "acme/slack", + &archive, + InstallOptions { + allow_unsigned: true, + force: false, + }, + ) + .await + .unwrap(); + assert!(result.changed); + assert_eq!(result.extension.receipt.package_id, "acme/slack"); + assert!(result.extension.cli_executable().unwrap().is_file()); + + assert!(registry.uninstall("acme/slack").await.unwrap().changed); + assert!(registry.list().await.unwrap().is_empty()); +} + #[tokio::test] async fn rejects_route_conflicts_and_untrusted_installs() { let temp = tempfile::tempdir().unwrap(); @@ -198,6 +294,8 @@ async fn hot_upgrade_keeps_the_previous_package_until_inflight_routes_drain() { let second = temp.path().join("second"); package(&first, "acme/slack", "slack", "1.0.0").await; package(&second, "acme/slack", "slack", "2.0.0").await; + let second_archive = temp.path().join("second.tar.gz"); + tar_package(&second, &second_archive); let registry = registry(temp.path()); let first_install = registry @@ -217,7 +315,7 @@ async fn hot_upgrade_keeps_the_previous_package_until_inflight_routes_drain() { let second_install = registry .install_local( "acme/slack", - &second, + &second_archive, InstallOptions { allow_unsigned: true, force: false, @@ -272,6 +370,16 @@ async fn forced_reactivation_of_identical_metadata_publishes_a_new_generation() second.extension.receipt.package_root, first.extension.receipt.package_root ); + assert_eq!( + second.extension.receipt.package_sha256, + first.extension.receipt.package_sha256 + ); + assert!(second + .extension + .receipt + .package_sha256 + .as_deref() + .is_some_and(|digest| digest.len() == 64)); let second_snapshot = registry.snapshot().await.unwrap(); assert_eq!(second_snapshot.generation, 2); assert_eq!( @@ -280,6 +388,148 @@ async fn forced_reactivation_of_identical_metadata_publishes_a_new_generation() ); } +#[tokio::test] +async fn same_version_changed_executable_requires_force_and_changes_package_digest() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + package(&source, "acme/slack", "slack", "1.0.0").await; + let registry = registry(temp.path()); + + let first = registry + .install_local( + "acme/slack", + &source, + InstallOptions { + allow_unsigned: true, + force: false, + }, + ) + .await + .unwrap(); + fs::write( + source.join("bin/extension"), + "#!/bin/sh\nprintf 'changed\\n'\n", + ) + .await + .unwrap(); + + let error = registry + .install_local( + "acme/slack", + &source, + InstallOptions { + allow_unsigned: true, + force: false, + }, + ) + .await + .unwrap_err(); + assert_eq!(error.code, "use.extension.version_conflict"); + + let second = registry + .install_local( + "acme/slack", + &source, + InstallOptions { + allow_unsigned: true, + force: true, + }, + ) + .await + .unwrap(); + assert_ne!( + second.extension.receipt.package_root, + first.extension.receipt.package_root + ); + assert_ne!( + second.extension.receipt.package_sha256, + first.extension.receipt.package_sha256 + ); + assert!(second.extension.receipt.package_sha256.is_some()); + assert_eq!( + fs::read_to_string(second.extension.cli_executable().unwrap()) + .await + .unwrap(), + "#!/bin/sh\nprintf 'changed\\n'\n" + ); +} + +#[tokio::test] +async fn legacy_receipt_without_package_digest_remains_readable_and_idempotent() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + package(&source, "acme/slack", "slack", "1.0.0").await; + let registry = registry(temp.path()); + + registry + .install_local( + "acme/slack", + &source, + InstallOptions { + allow_unsigned: true, + force: false, + }, + ) + .await + .unwrap(); + + let receipt_path = registry.paths().receipt_path("acme/slack"); + let mut legacy: serde_json::Value = + serde_json::from_slice(&fs::read(&receipt_path).await.unwrap()).unwrap(); + legacy.as_object_mut().unwrap().remove("packageSha256"); + fs::write(&receipt_path, serde_json::to_vec_pretty(&legacy).unwrap()) + .await + .unwrap(); + + let installed = registry.get("acme/slack").await.unwrap().unwrap(); + assert_eq!(installed.receipt.package_sha256, None); + + let unchanged = registry + .install_local( + "acme/slack", + &source, + InstallOptions { + allow_unsigned: true, + force: false, + }, + ) + .await + .unwrap(); + assert!(!unchanged.changed); + assert_eq!(unchanged.extension.receipt.package_sha256, None); +} + +#[tokio::test] +async fn receipt_rejects_an_invalid_optional_package_digest() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + package(&source, "acme/slack", "slack", "1.0.0").await; + let registry = registry(temp.path()); + + registry + .install_local( + "acme/slack", + &source, + InstallOptions { + allow_unsigned: true, + force: false, + }, + ) + .await + .unwrap(); + + let receipt_path = registry.paths().receipt_path("acme/slack"); + let mut invalid: serde_json::Value = + serde_json::from_slice(&fs::read(&receipt_path).await.unwrap()).unwrap(); + invalid["packageSha256"] = serde_json::json!("not-a-sha256"); + fs::write(&receipt_path, serde_json::to_vec_pretty(&invalid).unwrap()) + .await + .unwrap(); + + let error = registry.get("acme/slack").await.unwrap_err(); + assert_eq!(error.code, "use.extension.receipt_invalid"); +} + #[tokio::test] async fn snapshot_reconciles_a_pre_activation_identity_binding() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/extension/src/remote.rs b/crates/extension/src/remote.rs new file mode 100644 index 00000000..8bd205b1 --- /dev/null +++ b/crates/extension/src/remote.rs @@ -0,0 +1,970 @@ +//! TUF-backed remote extension registry resolution. +//! +//! The trusted root is pinned out of band by SHA-256. Tough then verifies the +//! complete root/timestamp/snapshot/targets chain, enforces expiration, and +//! persists metadata versions in its datastore to reject rollback attacks. + +use std::collections::BTreeSet; +use std::fs::{File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use a3s_use_core::{UseError, UseResult}; +use fs2::FileExt; +use semver::Version; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tempfile::TempDir; +use tokio::fs; +use tokio::io::AsyncWriteExt; +use tough::{ExpirationEnforcement, HttpTransportBuilder, Limits, Prefix, Repository}; +use tough::{RepositoryLoader, TargetName}; +use url::Url; + +use super::package::{activate_temporary_file, io_error, sync_parent_directory, unique_suffix}; + +const ROOT_NAME: &str = "root.json"; +const ROOT_CACHE_NAME: &str = "bootstrap-root.json"; +const REGISTRY_METADATA_KEY: &str = "a3s"; +const REGISTRY_TARGET_SCHEMA_VERSION: u32 = 1; +const MAX_BOOTSTRAP_ROOT_BYTES: u64 = 1024 * 1024; +const MAX_REMOTE_ARCHIVE_BYTES: u64 = 512 * 1024 * 1024; +const MAX_ROOT_UPDATES: u64 = 64; + +/// One configured registry whose TUF root is pinned out of band. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustedRegistry { + name: String, + base_url: Url, + root_sha256: String, + trusted_root_path: Option, + datastore: PathBuf, +} + +impl TrustedRegistry { + pub fn new( + name: impl Into, + base_url: impl AsRef, + root_sha256: impl AsRef, + trusted_root_path: Option, + datastore: PathBuf, + ) -> UseResult { + let name = name.into(); + validate_registry_name(&name)?; + let base_url = normalize_registry_url(base_url.as_ref())?; + let root_sha256 = normalize_sha256(root_sha256.as_ref(), "registry trust root")?; + if !datastore.is_absolute() { + return Err(UseError::new( + "use.extension.registry_path_invalid", + "The TUF metadata datastore must be an absolute path.", + )); + } + if trusted_root_path + .as_ref() + .is_some_and(|path| !path.is_absolute()) + { + return Err(UseError::new( + "use.extension.registry_path_invalid", + "The trusted TUF root path must be absolute.", + )); + } + Ok(Self { + name, + base_url, + root_sha256, + trusted_root_path, + datastore, + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn base_url(&self) -> &Url { + &self.base_url + } + + pub fn root_sha256(&self) -> &str { + &self.root_sha256 + } + + pub fn datastore(&self) -> &Path { + &self.datastore + } + + fn metadata_url(&self) -> UseResult { + self.base_url.join("metadata/").map_err(|error| { + UseError::new( + "use.extension.registry_url_invalid", + format!("Failed to resolve the registry metadata URL: {error}"), + ) + }) + } + + fn targets_url(&self) -> UseResult { + self.base_url.join("targets/").map_err(|error| { + UseError::new( + "use.extension.registry_url_invalid", + format!("Failed to resolve the registry targets URL: {error}"), + ) + }) + } +} + +/// Exact signed target selected from a verified TUF repository. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedRemotePackage { + pub registry_name: String, + pub registry_url: String, + pub root_sha256: String, + pub root_version: u64, + pub timestamp_version: u64, + pub snapshot_version: u64, + pub targets_version: u64, + pub package_id: String, + pub version: String, + pub channel: String, + pub target: String, + pub target_name: String, + pub archive_name: String, + pub length: u64, + pub sha256: String, +} + +/// Signed metadata versions observed after a complete TUF refresh. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifiedRegistryMetadata { + pub registry_name: String, + pub registry_url: String, + pub root_sha256: String, + pub root_version: u64, + pub timestamp_version: u64, + pub snapshot_version: u64, + pub targets_version: u64, + pub package_targets: u64, +} + +impl ResolvedRemotePackage { + pub fn plan_digest(&self) -> UseResult { + let bytes = serde_json::to_vec(self).map_err(|error| { + UseError::new( + "use.extension.registry_plan_invalid", + format!("Failed to encode the resolved registry plan: {error}"), + ) + })?; + Ok(format!("{:x}", Sha256::digest(bytes))) + } + + pub fn verify_expected_plan(&self, expected: Option<&str>) -> UseResult<()> { + let Some(expected) = expected else { + return Ok(()); + }; + let expected = normalize_sha256(expected, "expected registry plan")?; + let actual = self.plan_digest()?; + if expected == actual { + return Ok(()); + } + Err(UseError::new( + "use.extension.registry_plan_mismatch", + "The signed registry target changed after review.", + ) + .with_detail("expected", expected) + .with_detail("actual", actual)) + } + + pub(crate) fn validate_provenance(&self) -> UseResult<()> { + validate_registry_name(&self.registry_name)?; + let normalized_url = normalize_registry_url(&self.registry_url)?; + if normalized_url.as_str() != self.registry_url { + return Err(UseError::new( + "use.extension.receipt_invalid", + "The registry URL in the extension receipt is not canonical.", + )); + } + normalize_sha256(&self.root_sha256, "registry trust root")?; + normalize_sha256(&self.sha256, "registry target")?; + if self.root_version == 0 + || self.timestamp_version == 0 + || self.snapshot_version == 0 + || self.targets_version == 0 + || self.length == 0 + || self.length > MAX_REMOTE_ARCHIVE_BYTES + || !super::valid_package_id(&self.package_id) + || Version::parse(&self.version).is_err() + { + return Err(UseError::new( + "use.extension.receipt_invalid", + "The registry provenance in the extension receipt is invalid.", + )); + } + validate_channel(&self.channel)?; + let host = host_target()?; + if self.target != host && self.target != "any" { + return Err(UseError::new( + "use.extension.receipt_invalid", + "The installed registry target does not match this platform.", + )); + } + let target_name = TargetName::new(self.target_name.clone()).map_err(|error| { + UseError::new( + "use.extension.receipt_invalid", + format!("The registry target name in the receipt is invalid: {error}"), + ) + })?; + validate_target_name( + &target_name, + &RegistryTargetMetadata { + schema_version: REGISTRY_TARGET_SCHEMA_VERSION, + package_id: self.package_id.clone(), + version: self.version.clone(), + channel: self.channel.clone(), + target: self.target.clone(), + }, + )?; + if target_name.raw().rsplit('/').next() != Some(self.archive_name.as_str()) { + return Err(UseError::new( + "use.extension.receipt_invalid", + "The registry archive name does not match its signed target path.", + )); + } + Ok(()) + } +} + +/// Verified repository state retained until its exact target is downloaded. +pub struct PreparedRemotePackage { + repository: Repository, + target_name: TargetName, + resolved: ResolvedRemotePackage, +} + +impl std::fmt::Debug for PreparedRemotePackage { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PreparedRemotePackage") + .field("resolved", &self.resolved) + .finish_non_exhaustive() + } +} + +impl PreparedRemotePackage { + pub fn resolved(&self) -> &ResolvedRemotePackage { + &self.resolved + } + + pub async fn download(self) -> UseResult { + let temporary = tokio::task::spawn_blocking(tempfile::tempdir) + .await + .map_err(|error| { + UseError::new( + "use.extension.registry_download_failed", + format!("Failed to create the remote package staging task: {error}"), + ) + })? + .map_err(|error| { + UseError::new( + "use.extension.registry_download_failed", + format!("Failed to create remote package staging: {error}"), + ) + })?; + self.repository + .save_target(&self.target_name, temporary.path(), Prefix::None) + .await + .map_err(|error| { + UseError::new( + "use.extension.registry_download_failed", + format!( + "Failed to download and verify TUF target '{}': {error}", + self.resolved.target_name + ), + ) + })?; + let path = temporary.path().join(self.target_name.resolved()); + let metadata = fs::metadata(&path) + .await + .map_err(|error| io_error("inspect downloaded TUF target", &path, error))?; + if !metadata.is_file() || metadata.len() != self.resolved.length { + return Err(UseError::new( + "use.extension.registry_target_invalid", + "The downloaded TUF target does not match its signed length.", + )); + } + Ok(DownloadedRemotePackage { + path, + resolved: self.resolved, + _temporary: temporary, + }) + } +} + +/// One downloaded archive kept alive through extension activation. +#[derive(Debug)] +pub struct DownloadedRemotePackage { + path: PathBuf, + resolved: ResolvedRemotePackage, + _temporary: TempDir, +} + +impl DownloadedRemotePackage { + pub fn path(&self) -> &Path { + &self.path + } + + pub fn resolved(&self) -> &ResolvedRemotePackage { + &self.resolved + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RegistryTargetMetadata { + schema_version: u32, + package_id: String, + version: String, + channel: String, + target: String, +} + +struct MetadataLock(File); + +impl Drop for MetadataLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.0); + } +} + +/// Load and verify a TUF repository, then select one exact extension target. +pub async fn prepare_remote_package( + registry: &TrustedRegistry, + package_id: &str, + requested_version: Option<&str>, + channel: &str, + expected_plan_digest: Option<&str>, +) -> UseResult { + if !super::valid_package_id(package_id) { + return Err(UseError::new( + "use.extension.id_invalid", + "Extension IDs must be '/' lowercase identifiers.", + )); + } + let requested_version = requested_version + .map(|version| { + Version::parse(version).map_err(|error| { + UseError::new( + "use.extension.version_invalid", + format!("Invalid requested extension version: {error}"), + ) + }) + }) + .transpose()?; + validate_channel(channel)?; + let repository = load_repository(registry).await?; + + let host_target = host_target()?; + let mut candidates = Vec::new(); + let mut identities = BTreeSet::new(); + for (target_name, target) in repository.all_targets() { + let Some(metadata) = target.custom.get(REGISTRY_METADATA_KEY) else { + continue; + }; + let metadata: RegistryTargetMetadata = + serde_json::from_value(metadata.clone()).map_err(|error| { + UseError::new( + "use.extension.registry_target_invalid", + format!( + "TUF target '{}' has invalid A3S metadata: {error}", + target_name.raw() + ), + ) + })?; + validate_target_metadata(target_name, target, &metadata)?; + let identity = ( + metadata.package_id.clone(), + metadata.version.clone(), + metadata.channel.clone(), + metadata.target.clone(), + ); + if !identities.insert(identity) { + return Err(UseError::new( + "use.extension.registry_target_invalid", + "The TUF repository contains duplicate A3S package targets.", + )); + } + if metadata.package_id != package_id + || metadata.channel != channel + || (metadata.target != host_target && metadata.target != "any") + { + continue; + } + let version = Version::parse(&metadata.version).map_err(|error| { + UseError::new( + "use.extension.registry_target_invalid", + format!( + "TUF target '{}' declares an invalid version: {error}", + target_name.raw() + ), + ) + })?; + if requested_version + .as_ref() + .is_some_and(|requested| requested != &version) + { + continue; + } + candidates.push((version, metadata, target_name.clone(), target.clone())); + } + candidates.sort_by(|left, right| { + left.0 + .cmp(&right.0) + .then_with(|| (left.1.target == host_target).cmp(&(right.1.target == host_target))) + .then_with(|| left.2.raw().cmp(right.2.raw())) + }); + let Some((version, metadata, target_name, target)) = candidates.pop() else { + return Err(UseError::new( + "use.extension.registry_package_missing", + format!( + "Registry '{}' has no '{}' package for channel '{}' and target '{}'.", + registry.name, package_id, channel, host_target + ), + )); + }; + if candidates.last().is_some_and(|candidate| { + candidate.0 == version + && (candidate.1.target == host_target) == (metadata.target == host_target) + }) { + return Err(UseError::new( + "use.extension.registry_target_invalid", + "The TUF repository resolves the same package version to multiple targets.", + )); + } + let archive_name = target_name + .raw() + .rsplit('/') + .next() + .unwrap_or_default() + .to_string(); + let resolved = ResolvedRemotePackage { + registry_name: registry.name.clone(), + registry_url: registry.base_url.to_string(), + root_sha256: registry.root_sha256.clone(), + root_version: repository.root().signed.version.get(), + timestamp_version: repository.timestamp().signed.version.get(), + snapshot_version: repository.snapshot().signed.version.get(), + targets_version: repository.targets().signed.version.get(), + package_id: package_id.to_string(), + version: version.to_string(), + channel: channel.to_string(), + target: metadata.target, + target_name: target_name.raw().to_string(), + archive_name, + length: target.length, + sha256: hex_lower(target.hashes.sha256.as_ref()), + }; + resolved.verify_expected_plan(expected_plan_digest)?; + Ok(PreparedRemotePackage { + repository, + target_name, + resolved, + }) +} + +/// Refresh and fully verify a registry without downloading any package target. +pub async fn refresh_remote_registry( + registry: &TrustedRegistry, +) -> UseResult { + let repository = load_repository(registry).await?; + let mut identities = BTreeSet::new(); + let mut package_targets = 0_u64; + for (target_name, target) in repository.all_targets() { + let Some(metadata) = target.custom.get(REGISTRY_METADATA_KEY) else { + continue; + }; + let metadata: RegistryTargetMetadata = + serde_json::from_value(metadata.clone()).map_err(|error| { + UseError::new( + "use.extension.registry_target_invalid", + format!( + "TUF target '{}' has invalid A3S metadata: {error}", + target_name.raw() + ), + ) + })?; + validate_target_metadata(target_name, target, &metadata)?; + let identity = ( + metadata.package_id, + metadata.version, + metadata.channel, + metadata.target, + ); + if !identities.insert(identity) { + return Err(UseError::new( + "use.extension.registry_target_invalid", + "The TUF repository contains duplicate A3S package targets.", + )); + } + package_targets = package_targets.checked_add(1).ok_or_else(|| { + UseError::new( + "use.extension.registry_target_invalid", + "The TUF repository contains too many package targets.", + ) + })?; + } + Ok(VerifiedRegistryMetadata { + registry_name: registry.name.clone(), + registry_url: registry.base_url.to_string(), + root_sha256: registry.root_sha256.clone(), + root_version: repository.root().signed.version.get(), + timestamp_version: repository.timestamp().signed.version.get(), + snapshot_version: repository.snapshot().signed.version.get(), + targets_version: repository.targets().signed.version.get(), + package_targets, + }) +} + +async fn load_repository(registry: &TrustedRegistry) -> UseResult { + ensure_metadata_directory(®istry.datastore).await?; + let lock = acquire_metadata_lock(®istry.datastore)?; + let root = load_trusted_root(registry).await?; + let metadata_url = registry.metadata_url()?; + let targets_url = registry.targets_url()?; + let transport = HttpTransportBuilder::new() + .timeout(Duration::from_secs(300)) + .connect_timeout(Duration::from_secs(15)) + .tries(3) + .build(); + let repository = RepositoryLoader::new(&root, metadata_url, targets_url) + .transport(transport) + .datastore(®istry.datastore) + .limits(Limits { + max_root_size: MAX_BOOTSTRAP_ROOT_BYTES, + max_targets_size: 10 * 1024 * 1024, + max_timestamp_size: 1024 * 1024, + max_snapshot_size: 1024 * 1024, + max_root_updates: MAX_ROOT_UPDATES, + }) + .expiration_enforcement(ExpirationEnforcement::Safe) + .load() + .await + .map_err(|error| { + UseError::new( + "use.extension.registry_untrusted", + format!( + "TUF verification failed for registry '{}': {error}", + registry.name + ), + ) + })?; + drop(lock); + Ok(repository) +} + +fn validate_target_metadata( + target_name: &TargetName, + target: &tough::schema::Target, + metadata: &RegistryTargetMetadata, +) -> UseResult<()> { + if metadata.schema_version != REGISTRY_TARGET_SCHEMA_VERSION { + return Err(UseError::new( + "use.extension.registry_target_invalid", + format!( + "TUF target '{}' uses unsupported A3S metadata schema {}.", + target_name.raw(), + metadata.schema_version + ), + )); + } + if !super::valid_package_id(&metadata.package_id) { + return Err(UseError::new( + "use.extension.registry_target_invalid", + format!( + "TUF target '{}' has an invalid package ID.", + target_name.raw() + ), + )); + } + Version::parse(&metadata.version).map_err(|error| { + UseError::new( + "use.extension.registry_target_invalid", + format!( + "TUF target '{}' has an invalid package version: {error}", + target_name.raw() + ), + ) + })?; + validate_channel(&metadata.channel)?; + validate_target_name(target_name, metadata)?; + if target.length == 0 || target.length > MAX_REMOTE_ARCHIVE_BYTES { + return Err(UseError::new( + "use.extension.registry_target_invalid", + format!( + "TUF target '{}' exceeds the supported package size.", + target_name.raw() + ), + )); + } + let digest = target.hashes.sha256.as_ref(); + if digest.len() != 32 { + return Err(UseError::new( + "use.extension.registry_target_invalid", + format!( + "TUF target '{}' does not have a valid SHA-256 digest.", + target_name.raw() + ), + )); + } + Ok(()) +} + +fn validate_target_name( + target_name: &TargetName, + metadata: &RegistryTargetMetadata, +) -> UseResult<()> { + let raw = target_name.raw(); + if raw != target_name.resolved() + || raw.starts_with('/') + || raw.contains('\\') + || raw.split('/').any(str::is_empty) + { + return Err(UseError::new( + "use.extension.registry_target_invalid", + format!("TUF target '{raw}' is not a portable package path."), + )); + } + let archive = raw.rsplit('/').next().unwrap_or_default(); + if !(archive.ends_with(".tar.gz") || archive.ends_with(".tgz") || archive.ends_with(".zip")) { + return Err(UseError::new( + "use.extension.registry_target_invalid", + format!("TUF target '{raw}' is not a supported package archive."), + )); + } + let expected_prefix = format!( + "extensions/{}/{}/{}/{}/", + metadata.package_id, metadata.version, metadata.channel, metadata.target + ); + if !raw.starts_with(&expected_prefix) { + return Err(UseError::new( + "use.extension.registry_target_invalid", + format!("TUF target '{raw}' must be published below '{expected_prefix}'."), + )); + } + Ok(()) +} + +fn validate_channel(channel: &str) -> UseResult<()> { + if matches!(channel, "stable" | "beta" | "nightly") { + Ok(()) + } else { + Err(UseError::new( + "use.extension.registry_channel_invalid", + format!("Unsupported extension release channel '{channel}'."), + )) + } +} + +fn host_target() -> UseResult { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => Ok("darwin-arm64".to_string()), + ("macos", "x86_64") => Ok("darwin-x86_64".to_string()), + ("linux", "aarch64") => Ok("linux-arm64".to_string()), + ("linux", "x86_64") => Ok("linux-x86_64".to_string()), + ("windows", "x86_64") => Ok("windows-x86_64".to_string()), + (os, arch) => Err(UseError::new( + "use.extension.registry_target_unsupported", + format!("Remote extension packages are unavailable for {os}-{arch}."), + )), + } +} + +async fn ensure_metadata_directory(path: &Path) -> UseResult<()> { + fs::create_dir_all(path) + .await + .map_err(|error| io_error("create TUF metadata datastore", path, error))?; + let metadata = fs::symlink_metadata(path) + .await + .map_err(|error| io_error("inspect TUF metadata datastore", path, error))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(UseError::new( + "use.extension.registry_path_invalid", + format!( + "The TUF metadata datastore '{}' must be a real directory.", + path.display() + ), + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .await + .map_err(|error| io_error("secure TUF metadata datastore", path, error))?; + } + Ok(()) +} + +fn acquire_metadata_lock(datastore: &Path) -> UseResult { + let path = datastore.join(".metadata.lock"); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .map_err(|error| io_error("open TUF metadata lock", &path, error))?; + file.try_lock_exclusive().map_err(|error| { + UseError::new( + "use.extension.registry_busy", + format!( + "Another process is updating registry metadata '{}': {error}", + datastore.display() + ), + ) + })?; + Ok(MetadataLock(file)) +} + +async fn load_trusted_root(registry: &TrustedRegistry) -> UseResult> { + let explicit = registry.trusted_root_path.as_deref(); + let cache = registry.datastore.join(ROOT_CACHE_NAME); + let path = explicit.unwrap_or(&cache); + let bytes = match fs::read(path).await { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound && explicit.is_none() => { + let metadata_url = registry.metadata_url()?; + let root_url = metadata_url.join(ROOT_NAME).map_err(|error| { + UseError::new( + "use.extension.registry_url_invalid", + format!("Failed to resolve the bootstrap root URL: {error}"), + ) + })?; + let bytes = download_bootstrap_root(&root_url).await?; + verify_root_digest(registry, &bytes)?; + write_bootstrap_root(&cache, &bytes).await?; + bytes + } + Err(error) => return Err(io_error("read trusted TUF root", path, error)), + }; + if bytes.len() as u64 > MAX_BOOTSTRAP_ROOT_BYTES { + return Err(UseError::new( + "use.extension.registry_root_invalid", + "The trusted TUF root exceeds the one MiB limit.", + )); + } + verify_root_digest(registry, &bytes)?; + Ok(bytes) +} + +async fn download_bootstrap_root(url: &Url) -> UseResult> { + validate_download_url(url)?; + let client = reqwest::Client::builder() + .user_agent("a3s-use-extension/0.1") + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::limited(5)) + .build() + .map_err(|error| { + UseError::new( + "use.extension.registry_download_failed", + format!("Failed to build the registry client: {error}"), + ) + })?; + let mut response = client.get(url.clone()).send().await.map_err(|error| { + UseError::new( + "use.extension.registry_download_failed", + format!("Failed to download the bootstrap TUF root: {error}"), + ) + })?; + validate_download_url(response.url())?; + if !response.status().is_success() { + return Err(UseError::new( + "use.extension.registry_download_failed", + format!( + "Bootstrap TUF root download returned HTTP {}.", + response.status() + ), + )); + } + if response + .content_length() + .is_some_and(|length| length > MAX_BOOTSTRAP_ROOT_BYTES) + { + return Err(UseError::new( + "use.extension.registry_root_invalid", + "The bootstrap TUF root exceeds the one MiB limit.", + )); + } + let mut bytes = Vec::with_capacity( + response + .content_length() + .unwrap_or_default() + .min(MAX_BOOTSTRAP_ROOT_BYTES) as usize, + ); + while let Some(chunk) = response.chunk().await.map_err(|error| { + UseError::new( + "use.extension.registry_download_failed", + format!("Failed to read the bootstrap TUF root: {error}"), + ) + })? { + if bytes.len().saturating_add(chunk.len()) as u64 > MAX_BOOTSTRAP_ROOT_BYTES { + return Err(UseError::new( + "use.extension.registry_root_invalid", + "The bootstrap TUF root exceeds the one MiB limit.", + )); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +fn verify_root_digest(registry: &TrustedRegistry, bytes: &[u8]) -> UseResult<()> { + let actual = format!("{:x}", Sha256::digest(bytes)); + if actual == registry.root_sha256 { + return Ok(()); + } + Err(UseError::new( + "use.extension.registry_root_mismatch", + format!( + "Registry '{}' bootstrap root does not match its pinned SHA-256.", + registry.name + ), + ) + .with_detail("expected", registry.root_sha256.clone()) + .with_detail("actual", actual)) +} + +async fn write_bootstrap_root(path: &Path, bytes: &[u8]) -> UseResult<()> { + let parent = path.parent().ok_or_else(|| { + UseError::new( + "use.extension.registry_path_invalid", + "The bootstrap TUF root cache has no parent directory.", + ) + })?; + let temporary = parent.join(format!(".root-{}.tmp", unique_suffix())); + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + let mut file = options + .open(&temporary) + .await + .map_err(|error| io_error("create bootstrap TUF root cache", &temporary, error))?; + if let Err(error) = file.write_all(bytes).await { + let _ = fs::remove_file(&temporary).await; + return Err(io_error( + "write bootstrap TUF root cache", + &temporary, + error, + )); + } + if let Err(error) = file.sync_all().await { + let _ = fs::remove_file(&temporary).await; + return Err(io_error("sync bootstrap TUF root cache", &temporary, error)); + } + drop(file); + if let Err(error) = activate_temporary_file( + temporary.clone(), + path.to_path_buf(), + "activate bootstrap TUF root cache", + ) + .await + { + let _ = fs::remove_file(&temporary).await; + return Err(error); + } + sync_parent_directory(parent, "TUF metadata").await +} + +fn normalize_registry_url(value: &str) -> UseResult { + let mut url = Url::parse(value).map_err(|error| { + UseError::new( + "use.extension.registry_url_invalid", + format!("Invalid registry URL: {error}"), + ) + })?; + validate_download_url(&url)?; + if !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(UseError::new( + "use.extension.registry_url_invalid", + "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 validate_download_url(url: &Url) -> UseResult<()> { + let https = url.scheme() == "https"; + let loopback_http = url.scheme() == "http" + && url.host_str().is_some_and(|host| { + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + }); + if https || loopback_http { + Ok(()) + } else { + Err(UseError::new( + "use.extension.registry_url_invalid", + "Registry downloads require HTTPS; HTTP is accepted only on loopback for local testing.", + )) + } +} + +fn validate_registry_name(name: &str) -> UseResult<()> { + let mut characters = name.chars(); + if characters + .next() + .is_some_and(|character| character.is_ascii_lowercase()) + && characters.all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) + { + Ok(()) + } else { + Err(UseError::new( + "use.extension.registry_name_invalid", + "Registry names use lowercase letters, digits, and hyphens and start with a letter.", + )) + } +} + +fn normalize_sha256(value: &str, label: &str) -> UseResult { + let value = value.strip_prefix("sha256:").unwrap_or(value); + if value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + Ok(value.to_string()) + } else { + Err(UseError::new( + "use.extension.registry_digest_invalid", + format!("The {label} must be exactly 64 lowercase hexadecimal characters."), + )) + } +} + +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 +} + +#[cfg(test)] +#[path = "tuf_test_support.rs"] +mod test_support; + +#[cfg(test)] +#[path = "remote_tests.rs"] +mod tests; diff --git a/crates/extension/src/remote_tests.rs b/crates/extension/src/remote_tests.rs new file mode 100644 index 00000000..0b538dc8 --- /dev/null +++ b/crates/extension/src/remote_tests.rs @@ -0,0 +1,310 @@ +use std::path::PathBuf; + +use super::test_support::{ + extension_archive, find_subslice, TestRepository, TestServer, EXPIRED, FUTURE, PACKAGE_VERSION, +}; +use super::*; +use crate::{ExtensionPaths, ExtensionRegistry, ExtensionTrust}; + +#[tokio::test] +async fn tuf_refresh_verifies_metadata_without_downloading_targets() { + let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 7, FUTURE); + let server = TestServer::start(repository.routes.clone()); + let temp = tempfile::tempdir().unwrap(); + let trusted = trusted_registry(&server, &repository, temp.path().join("tuf")); + + let metadata = refresh_remote_registry(&trusted).await.unwrap(); + + assert_eq!(metadata.registry_name, "fixture"); + assert_eq!(metadata.root_version, 1); + assert_eq!(metadata.timestamp_version, 7); + assert_eq!(metadata.snapshot_version, 7); + assert_eq!(metadata.targets_version, 7); + assert_eq!(metadata.package_targets, 1); + assert!(server + .requests() + .iter() + .all(|request| !request.starts_with("/targets/"))); +} + +#[tokio::test] +async fn tuf_install_records_signed_provenance_and_converges() { + let archive = extension_archive(PACKAGE_VERSION); + let repository = TestRepository::new(archive, 1, FUTURE); + let server = TestServer::start(repository.routes.clone()); + let temp = tempfile::tempdir().unwrap(); + let trusted = trusted_registry(&server, &repository, temp.path().join("tuf")); + + let prepared = prepare_remote_package(&trusted, "acme/slack", None, "stable", None) + .await + .unwrap(); + let digest = prepared.resolved().plan_digest().unwrap(); + drop(prepared); + assert!(server + .requests() + .iter() + .all(|request| !request.starts_with("/targets/"))); + + let paths = ExtensionPaths::new( + temp.path().join("data"), + temp.path().join("extension-state"), + ); + let registry = ExtensionRegistry::new(paths); + let installed = registry + .install_remote("acme/slack", &trusted, None, "stable", Some(&digest), false) + .await + .unwrap(); + assert!(installed.changed); + assert_eq!( + installed.extension.receipt.trust, + ExtensionTrust::RegistryTuf + ); + let provenance = installed.extension.receipt.registry.as_ref().unwrap(); + assert_eq!(provenance.package_id, "acme/slack"); + assert_eq!(provenance.version, PACKAGE_VERSION); + assert_eq!(provenance.sha256, repository.target_sha256); + assert!(installed.extension.cli_executable().unwrap().is_file()); + + server.clear_requests(); + let second = registry + .install_remote("acme/slack", &trusted, None, "stable", Some(&digest), false) + .await + .unwrap(); + assert!(!second.changed); + assert_eq!(registry.list().await.unwrap().len(), 1); + assert!(server + .requests() + .iter() + .all(|request| !request.starts_with("/targets/"))); +} + +#[tokio::test] +async fn tuf_convergence_refreshes_signed_provenance_without_downloading_the_target() { + let archive = extension_archive(PACKAGE_VERSION); + let first_repository = TestRepository::new(archive.clone(), 1, FUTURE); + let server = TestServer::start(first_repository.routes.clone()); + let temp = tempfile::tempdir().unwrap(); + let trusted = trusted_registry(&server, &first_repository, temp.path().join("tuf")); + let paths = ExtensionPaths::new( + temp.path().join("data"), + temp.path().join("extension-state"), + ); + let registry = ExtensionRegistry::new(paths); + registry + .install_remote("acme/slack", &trusted, None, "stable", None, false) + .await + .unwrap(); + + let second_repository = TestRepository::new(archive, 2, FUTURE); + assert_eq!( + second_repository.target_sha256, + first_repository.target_sha256 + ); + server.replace_routes(second_repository.routes); + server.clear_requests(); + + let converged = registry + .install_remote("acme/slack", &trusted, None, "stable", None, false) + .await + .unwrap(); + + assert!(!converged.changed); + let provenance = converged.extension.receipt.registry.unwrap(); + assert_eq!(provenance.timestamp_version, 2); + assert_eq!(provenance.snapshot_version, 2); + assert_eq!(provenance.targets_version, 2); + assert!(server + .requests() + .iter() + .all(|request| !request.starts_with("/targets/"))); +} + +#[tokio::test] +async fn tuf_install_rejects_modified_installed_content_before_dispatch_or_convergence() { + let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE); + let server = TestServer::start(repository.routes.clone()); + let temp = tempfile::tempdir().unwrap(); + let trusted = trusted_registry(&server, &repository, temp.path().join("tuf")); + let paths = ExtensionPaths::new( + temp.path().join("data"), + temp.path().join("extension-state"), + ); + let registry = ExtensionRegistry::new(paths); + let installed = registry + .install_remote("acme/slack", &trusted, None, "stable", None, false) + .await + .unwrap(); + std::fs::write( + installed.extension.cli_executable().unwrap(), + b"modified executable", + ) + .unwrap(); + + let dispatch_error = match registry.acquire_route("slack").await { + Err(error) => error, + Ok(_) => panic!("modified signed content must not be dispatched"), + }; + assert_eq!(dispatch_error.code, "use.extension.package_digest_mismatch"); + + server.clear_requests(); + let convergence_error = registry + .install_remote("acme/slack", &trusted, None, "stable", None, false) + .await + .unwrap_err(); + assert_eq!( + convergence_error.code, + "use.extension.package_digest_mismatch" + ); + assert!(server + .requests() + .iter() + .all(|request| !request.starts_with("/targets/"))); +} + +#[tokio::test] +async fn tuf_receipt_requires_an_expanded_package_digest() { + let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE); + let server = TestServer::start(repository.routes.clone()); + let temp = tempfile::tempdir().unwrap(); + let trusted = trusted_registry(&server, &repository, temp.path().join("tuf")); + let paths = ExtensionPaths::new( + temp.path().join("data"), + temp.path().join("extension-state"), + ); + let registry = ExtensionRegistry::new(paths); + registry + .install_remote("acme/slack", &trusted, None, "stable", None, false) + .await + .unwrap(); + + let receipt_path = registry.paths().receipt_path("acme/slack"); + let mut receipt: serde_json::Value = + serde_json::from_slice(&std::fs::read(&receipt_path).unwrap()).unwrap(); + receipt.as_object_mut().unwrap().remove("packageSha256"); + std::fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt).unwrap()).unwrap(); + + let error = registry.get("acme/slack").await.unwrap_err(); + assert_eq!(error.code, "use.extension.receipt_invalid"); +} + +#[tokio::test] +async fn reviewed_registry_plan_fails_before_target_download() { + let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE); + let server = TestServer::start(repository.routes.clone()); + let temp = tempfile::tempdir().unwrap(); + let trusted = trusted_registry(&server, &repository, temp.path().join("tuf")); + + let error = prepare_remote_package( + &trusted, + "acme/slack", + None, + "stable", + Some(&"0".repeat(64)), + ) + .await + .unwrap_err(); + + assert_eq!(error.code, "use.extension.registry_plan_mismatch"); + assert!(server + .requests() + .iter() + .all(|request| !request.starts_with("/targets/"))); +} + +#[tokio::test] +async fn tuf_rejects_wrong_root_and_tampered_target() { + let archive = extension_archive(PACKAGE_VERSION); + let repository = TestRepository::new(archive, 1, FUTURE); + let server = TestServer::start(repository.routes.clone()); + let temp = tempfile::tempdir().unwrap(); + let wrong = TrustedRegistry::new( + "fixture", + server.base_url(), + "f".repeat(64), + None, + temp.path().join("wrong-root"), + ) + .unwrap(); + let error = prepare_remote_package(&wrong, "acme/slack", None, "stable", None) + .await + .unwrap_err(); + assert_eq!(error.code, "use.extension.registry_root_mismatch"); + + let mut routes = repository.routes.clone(); + routes.insert( + format!("/targets/{}", repository.target_name), + b"tampered archive".to_vec(), + ); + let tampered_server = TestServer::start(routes); + let trusted = trusted_registry( + &tampered_server, + &repository, + temp.path().join("tampered-target"), + ); + let prepared = prepare_remote_package(&trusted, "acme/slack", None, "stable", None) + .await + .unwrap(); + let error = prepared.download().await.unwrap_err(); + assert_eq!(error.code, "use.extension.registry_download_failed"); +} + +#[tokio::test] +async fn tuf_rejects_metadata_tampering_expiration_and_rollback() { + let archive = extension_archive(PACKAGE_VERSION); + let version_two = TestRepository::new(archive.clone(), 2, FUTURE); + let server_two = TestServer::start(version_two.routes.clone()); + let temp = tempfile::tempdir().unwrap(); + let datastore = temp.path().join("rollback-state"); + let trusted_two = trusted_registry(&server_two, &version_two, datastore.clone()); + prepare_remote_package(&trusted_two, "acme/slack", None, "stable", None) + .await + .unwrap(); + + let version_one = TestRepository::new(archive.clone(), 1, FUTURE); + assert_eq!(version_one.root_sha256, version_two.root_sha256); + let server_one = TestServer::start(version_one.routes.clone()); + let trusted_one = trusted_registry(&server_one, &version_one, datastore); + let rollback = prepare_remote_package(&trusted_one, "acme/slack", None, "stable", None) + .await + .unwrap_err(); + assert_eq!(rollback.code, "use.extension.registry_untrusted"); + + let expired = TestRepository::new(archive.clone(), 1, EXPIRED); + let expired_server = TestServer::start(expired.routes.clone()); + let expired_registry = + trusted_registry(&expired_server, &expired, temp.path().join("expired-state")); + let error = prepare_remote_package(&expired_registry, "acme/slack", None, "stable", None) + .await + .unwrap_err(); + assert_eq!(error.code, "use.extension.registry_untrusted"); + + let mut tampered_routes = version_one.routes.clone(); + let targets = tampered_routes.get_mut("/metadata/targets.json").unwrap(); + let position = find_subslice(targets, b"stable").unwrap(); + targets[position..position + 6].copy_from_slice(b"nightl"); + let tampered_server = TestServer::start(tampered_routes); + let tampered_registry = trusted_registry( + &tampered_server, + &version_one, + temp.path().join("tampered-metadata"), + ); + let error = prepare_remote_package(&tampered_registry, "acme/slack", None, "stable", None) + .await + .unwrap_err(); + assert_eq!(error.code, "use.extension.registry_untrusted"); +} + +fn trusted_registry( + server: &TestServer, + repository: &TestRepository, + datastore: PathBuf, +) -> TrustedRegistry { + TrustedRegistry::new( + "fixture", + server.base_url(), + &repository.root_sha256, + None, + datastore, + ) + .unwrap() +} diff --git a/crates/extension/src/source.rs b/crates/extension/src/source.rs new file mode 100644 index 00000000..e8132daf --- /dev/null +++ b/crates/extension/src/source.rs @@ -0,0 +1,708 @@ +use std::collections::BTreeSet; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Component, Path, PathBuf}; + +use a3s_use_core::{UseError, UseResult}; +use tempfile::TempDir; +use tokio::fs; + +use super::package::{io_error, MANIFEST_NAME, MAX_PACKAGE_BYTES, MAX_PACKAGE_FILES}; + +const MAX_ARCHIVE_BYTES: u64 = 512 * 1024 * 1024; +const MAX_PATH_BYTES: usize = 4_096; +const MAX_PATH_DEPTH: usize = 32; + +#[derive(Clone, Copy)] +enum ArchiveKind { + TarGz, + Zip, +} + +struct ExtractedEntry { + relative: PathBuf, + file: bool, +} + +/// One validated local package source kept alive through installation. +#[derive(Debug)] +pub(crate) struct PreparedPackageSource { + root: PathBuf, + _temporary: Option, +} + +impl PreparedPackageSource { + pub(crate) fn root(&self) -> &Path { + &self.root + } +} + +pub(crate) async fn prepare_package_source(source: &Path) -> UseResult { + let source = fs::canonicalize(source) + .await + .map_err(|error| io_error("resolve extension package", source, error))?; + let metadata = fs::metadata(&source) + .await + .map_err(|error| io_error("inspect extension package", &source, error))?; + if metadata.is_dir() { + return Ok(PreparedPackageSource { + root: source, + _temporary: None, + }); + } + if !metadata.is_file() { + return Err(UseError::new( + "use.extension.package_unsupported", + "The local extension source must be a package directory, .tar.gz, .tgz, or .zip archive.", + )); + } + if metadata.len() > MAX_ARCHIVE_BYTES { + return Err(UseError::new( + "use.extension.package_too_large", + format!( + "The extension archive exceeds the {MAX_ARCHIVE_BYTES} byte compressed-size limit." + ), + )); + } + let kind = archive_kind(&source)?; + let temporary = tokio::task::spawn_blocking(tempfile::tempdir) + .await + .map_err(|error| { + UseError::new( + "use.extension.io", + format!("Failed to create extension archive staging task: {error}"), + ) + })? + .map_err(|error| io_error("create extension archive staging directory", &source, error))?; + let extraction_root = temporary.path().join("package"); + let blocking_source = source.clone(); + let blocking_root = extraction_root.clone(); + let package_relative = tokio::task::spawn_blocking(move || { + extract_archive(&blocking_source, &blocking_root, kind) + }) + .await + .map_err(|error| { + UseError::new( + "use.extension.package_archive_invalid", + format!("Extension archive extraction task failed: {error}"), + ) + })??; + let extraction_root = fs::canonicalize(&extraction_root).await.map_err(|error| { + io_error( + "resolve extension archive staging directory", + &extraction_root, + error, + ) + })?; + let root = extraction_root.join(package_relative); + let root = fs::canonicalize(&root) + .await + .map_err(|error| io_error("resolve extracted extension package", &root, error))?; + if !root.starts_with(&extraction_root) { + return Err(UseError::new( + "use.extension.path_escape", + "The extracted extension package root escapes its staging directory.", + )); + } + Ok(PreparedPackageSource { + root, + _temporary: Some(temporary), + }) +} + +fn archive_kind(path: &Path) -> UseResult { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + Ok(ArchiveKind::TarGz) + } else if name.ends_with(".zip") { + Ok(ArchiveKind::Zip) + } else { + Err(UseError::new( + "use.extension.package_unsupported", + "Extension package archives must use .tar.gz, .tgz, or .zip.", + )) + } +} + +fn extract_archive(source: &Path, target: &Path, kind: ArchiveKind) -> UseResult { + std::fs::create_dir_all(target) + .map_err(|error| archive_io("create extraction directory", target, error))?; + let entries = match kind { + ArchiveKind::TarGz => extract_tar_gz(source, target)?, + ArchiveKind::Zip => extract_zip(source, target)?, + }; + resolve_package_root(&entries) +} + +fn extract_tar_gz(source: &Path, target: &Path) -> UseResult> { + let file = File::open(source).map_err(|error| archive_io("open", source, error))?; + let decoder = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(decoder); + let mut extracted = Vec::new(); + let mut seen = BTreeSet::new(); + let mut extracted_bytes = 0_u64; + let entries = archive + .entries() + .map_err(|error| archive_invalid(format!("Failed to read tar entries: {error}")))?; + for (entry_count, entry) in entries.enumerate() { + if entry_count >= MAX_PACKAGE_FILES { + return Err(package_limit_error()); + } + let mut entry = + entry.map_err(|error| archive_invalid(format!("Failed to read tar entry: {error}")))?; + let entry_path = entry + .path() + .map_err(|error| archive_invalid(format!("Failed to read tar entry path: {error}")))? + .into_owned(); + let entry_type = entry.header().entry_type(); + if ignored_macos_metadata_path(&entry_path)? { + if entry_type.is_dir() { + continue; + } + if entry_type.is_file() { + let remaining = MAX_PACKAGE_BYTES.saturating_sub(extracted_bytes); + extracted_bytes = extracted_bytes.saturating_add(copy_bounded( + &mut entry, + &mut io::sink(), + remaining, + &entry_path, + )?); + continue; + } + } + let Some(relative) = sanitized_relative_path(&entry_path)? else { + if entry_type.is_dir() { + continue; + } + return Err(archive_invalid( + "The archive contains a non-directory root entry.", + )); + }; + if !seen.insert(relative.clone()) { + return Err(archive_invalid(format!( + "The archive contains duplicate entry '{}'.", + relative.display() + ))); + } + let output = target.join(&relative); + if entry_type.is_dir() { + std::fs::create_dir_all(&output) + .map_err(|error| archive_io("create archive directory", &output, error))?; + extracted.push(ExtractedEntry { + relative, + file: false, + }); + } else if entry_type.is_file() { + let remaining = MAX_PACKAGE_BYTES.saturating_sub(extracted_bytes); + if entry.size() > remaining { + return Err(package_limit_error()); + } + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| archive_io("create archive parent", parent, error))?; + } + let mut output_file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&output) + .map_err(|error| archive_io("create archive file", &output, error))?; + extracted_bytes = extracted_bytes.saturating_add(copy_bounded( + &mut entry, + &mut output_file, + remaining, + &output, + )?); + apply_unix_mode(&output, entry.header().mode().ok())?; + extracted.push(ExtractedEntry { + relative, + file: true, + }); + } else if entry_type.is_symlink() || entry_type.is_hard_link() { + return Err(UseError::new( + "use.extension.package_symlink", + format!( + "Extension archive entry '{}' is a link.", + relative.display() + ), + )); + } else { + return Err(UseError::new( + "use.extension.package_entry_invalid", + format!( + "Extension archive entry '{}' is not a regular file or directory.", + relative.display() + ), + )); + } + } + Ok(extracted) +} + +fn extract_zip(source: &Path, target: &Path) -> UseResult> { + let file = File::open(source).map_err(|error| archive_io("open", source, error))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|error| archive_invalid(format!("Failed to read ZIP archive: {error}")))?; + if archive.len() > MAX_PACKAGE_FILES { + return Err(package_limit_error()); + } + let mut extracted = Vec::new(); + let mut seen = BTreeSet::new(); + let mut extracted_bytes = 0_u64; + for index in 0..archive.len() { + let mut entry = archive.by_index(index).map_err(|error| { + archive_invalid(format!("Failed to read ZIP entry {index}: {error}")) + })?; + if entry.is_symlink() { + return Err(UseError::new( + "use.extension.package_symlink", + format!("Extension archive entry '{}' is a link.", entry.name()), + )); + } + let enclosed = entry.enclosed_name().ok_or_else(|| { + UseError::new( + "use.extension.path_escape", + format!( + "Extension archive entry '{}' escapes the package.", + entry.name() + ), + ) + })?; + if ignored_macos_metadata_path(&enclosed)? { + if entry.is_dir() { + continue; + } + if entry.is_file() { + let remaining = MAX_PACKAGE_BYTES.saturating_sub(extracted_bytes); + extracted_bytes = extracted_bytes.saturating_add(copy_bounded( + &mut entry, + &mut io::sink(), + remaining, + &enclosed, + )?); + continue; + } + } + let Some(relative) = sanitized_relative_path(&enclosed)? else { + if entry.is_dir() { + continue; + } + return Err(archive_invalid( + "The ZIP archive contains a non-directory root entry.", + )); + }; + if !seen.insert(relative.clone()) { + return Err(archive_invalid(format!( + "The ZIP archive contains duplicate entry '{}'.", + relative.display() + ))); + } + let output = target.join(&relative); + if entry.is_dir() { + std::fs::create_dir_all(&output) + .map_err(|error| archive_io("create ZIP directory", &output, error))?; + extracted.push(ExtractedEntry { + relative, + file: false, + }); + } else if entry.is_file() { + let remaining = MAX_PACKAGE_BYTES.saturating_sub(extracted_bytes); + if entry.size() > remaining { + return Err(package_limit_error()); + } + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| archive_io("create ZIP parent", parent, error))?; + } + let mut output_file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&output) + .map_err(|error| archive_io("create ZIP file", &output, error))?; + extracted_bytes = extracted_bytes.saturating_add(copy_bounded( + &mut entry, + &mut output_file, + remaining, + &output, + )?); + apply_unix_mode(&output, entry.unix_mode())?; + extracted.push(ExtractedEntry { + relative, + file: true, + }); + } else { + return Err(UseError::new( + "use.extension.package_entry_invalid", + format!("Extension ZIP entry '{}' is unsupported.", entry.name()), + )); + } + } + Ok(extracted) +} + +fn ignored_macos_metadata_path(path: &Path) -> UseResult { + if path.as_os_str().is_empty() { + return Err(archive_invalid("The archive contains an empty entry path.")); + } + let encoded = path.to_str().ok_or_else(|| { + archive_invalid("Extension archive paths must be valid UTF-8 for portability.") + })?; + if encoded.len() > MAX_PATH_BYTES { + return Err(archive_invalid(format!( + "Extension archive path '{}' is not portable.", + path.display() + ))); + } + + let mut first = None; + let mut last = None; + let mut depth = 0_usize; + for component in path.components() { + match component { + Component::Normal(segment) => { + depth += 1; + if depth > MAX_PATH_DEPTH { + return Err(archive_invalid(format!( + "Extension archive path '{}' exceeds the depth limit.", + path.display() + ))); + } + let segment = segment.to_str().ok_or_else(|| { + archive_invalid("Extension archive paths must be valid UTF-8 for portability.") + })?; + first.get_or_insert(segment); + last = Some(segment); + } + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(UseError::new( + "use.extension.path_escape", + format!( + "Extension archive path '{}' escapes the package.", + path.display() + ), + )); + } + } + } + + Ok(first == Some("__MACOSX") || last.is_some_and(|segment| segment.starts_with("._"))) +} + +pub(crate) fn sanitized_relative_path(path: &Path) -> UseResult> { + if path.as_os_str().is_empty() { + return Err(archive_invalid("The archive contains an empty entry path.")); + } + let encoded = path.to_str().ok_or_else(|| { + archive_invalid("Extension archive paths must be valid UTF-8 for portability.") + })?; + if encoded.len() > MAX_PATH_BYTES { + return Err(archive_invalid(format!( + "Extension archive path '{}' is not portable.", + path.display() + ))); + } + let mut sanitized = PathBuf::new(); + let mut depth = 0_usize; + for component in path.components() { + match component { + Component::Normal(segment) => { + depth += 1; + if depth > MAX_PATH_DEPTH { + return Err(archive_invalid(format!( + "Extension archive path '{}' exceeds the depth limit.", + path.display() + ))); + } + validate_portable_segment(segment, path)?; + sanitized.push(segment); + } + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(UseError::new( + "use.extension.path_escape", + format!( + "Extension archive path '{}' escapes the package.", + path.display() + ), + )); + } + } + } + if sanitized.as_os_str().is_empty() { + Ok(None) + } else { + Ok(Some(sanitized)) + } +} + +fn validate_portable_segment(segment: &std::ffi::OsStr, path: &Path) -> UseResult<()> { + let segment = segment.to_str().ok_or_else(|| { + archive_invalid("Extension archive paths must be valid UTF-8 for portability.") + })?; + if segment.ends_with(['.', ' ']) + || segment + .chars() + .any(|character| character.is_control() || r#"<>:"/\|?*"#.contains(character)) + { + return Err(archive_invalid(format!( + "Extension archive path '{}' is not portable.", + path.display() + ))); + } + let device = segment + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + let reserved = matches!(device.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || device + .strip_prefix("COM") + .or_else(|| device.strip_prefix("LPT")) + .is_some_and(|number| { + matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") + }); + if reserved { + return Err(archive_invalid(format!( + "Extension archive path '{}' uses a reserved device name.", + path.display() + ))); + } + Ok(()) +} + +fn copy_bounded( + reader: &mut impl Read, + writer: &mut impl Write, + remaining: u64, + path: &Path, +) -> UseResult { + let mut bounded = reader.take(remaining.saturating_add(1)); + let copied = io::copy(&mut bounded, writer) + .map_err(|error| archive_io("extract archive file", path, error))?; + if copied > remaining { + return Err(package_limit_error()); + } + Ok(copied) +} + +fn resolve_package_root(entries: &[ExtractedEntry]) -> UseResult { + let manifests = entries + .iter() + .filter(|entry| { + entry.file + && entry + .relative + .file_name() + .is_some_and(|name| name == MANIFEST_NAME) + }) + .collect::>(); + let [manifest] = manifests.as_slice() else { + return Err(UseError::new( + "use.extension.package_layout_invalid", + format!("Extension archives must contain exactly one regular {MANIFEST_NAME} file."), + )); + }; + let root = manifest + .relative + .parent() + .map(Path::to_path_buf) + .unwrap_or_default(); + if !root.as_os_str().is_empty() + && entries + .iter() + .any(|entry| !entry.relative.starts_with(&root)) + { + return Err(UseError::new( + "use.extension.package_layout_invalid", + "Extension archive entries must all belong to the directory containing its manifest.", + )); + } + Ok(root) +} + +#[cfg(unix)] +fn apply_unix_mode(path: &Path, mode: Option) -> UseResult<()> { + use std::os::unix::fs::PermissionsExt; + + if let Some(mode) = mode { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode & 0o777)) + .map_err(|error| archive_io("set archive file permissions", path, error))?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn apply_unix_mode(_path: &Path, _mode: Option) -> UseResult<()> { + Ok(()) +} + +fn archive_io(action: &str, path: &Path, error: io::Error) -> UseError { + UseError::new( + "use.extension.package_archive_invalid", + format!( + "Failed to {action} extension archive entry '{}': {error}", + path.display() + ), + ) +} + +fn archive_invalid(message: impl Into) -> UseError { + UseError::new("use.extension.package_archive_invalid", message) +} + +fn package_limit_error() -> UseError { + UseError::new( + "use.extension.package_too_large", + "The extension package exceeds the local installation limits.", + ) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use super::*; + + #[tokio::test] + async fn tar_package_accepts_an_explicit_current_directory_root() { + let temp = tempfile::tempdir().unwrap(); + let archive_path = temp.path().join("package.tar.gz"); + { + let file = File::create(&archive_path).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + + let mut root = tar::Header::new_gnu(); + root.set_path(".").unwrap(); + root.set_entry_type(tar::EntryType::Directory); + root.set_size(0); + root.set_mode(0o755); + root.set_cksum(); + builder.append(&root, io::empty()).unwrap(); + + let manifest = b"extension fixture"; + let mut header = tar::Header::new_gnu(); + header.set_path(format!("./{MANIFEST_NAME}")).unwrap(); + header.set_size(manifest.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append(&header, &manifest[..]).unwrap(); + builder.finish().unwrap(); + } + + let prepared = prepare_package_source(&archive_path).await.unwrap(); + assert_eq!( + std::fs::read(prepared.root().join(MANIFEST_NAME)).unwrap(), + b"extension fixture" + ); + } + + #[tokio::test] + async fn tar_package_ignores_bounded_macos_appledouble_metadata() { + let temp = tempfile::tempdir().unwrap(); + let archive_path = temp.path().join("package.tar.gz"); + { + let file = File::create(&archive_path).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + + let metadata = b"appledouble"; + let mut metadata_header = tar::Header::new_gnu(); + metadata_header.set_path("./._.").unwrap(); + metadata_header.set_size(metadata.len() as u64); + metadata_header.set_mode(0o644); + metadata_header.set_cksum(); + builder.append(&metadata_header, &metadata[..]).unwrap(); + + let manifest = b"extension fixture"; + let mut manifest_header = tar::Header::new_gnu(); + manifest_header + .set_path(format!("./{MANIFEST_NAME}")) + .unwrap(); + manifest_header.set_size(manifest.len() as u64); + manifest_header.set_mode(0o644); + manifest_header.set_cksum(); + builder.append(&manifest_header, &manifest[..]).unwrap(); + builder.finish().unwrap(); + } + + let prepared = prepare_package_source(&archive_path).await.unwrap(); + assert_eq!( + std::fs::read(prepared.root().join(MANIFEST_NAME)).unwrap(), + b"extension fixture" + ); + assert!(!prepared.root().join("._.").exists()); + } + + #[tokio::test] + async fn zip_package_rejects_parent_traversal() { + let temp = tempfile::tempdir().unwrap(); + let archive_path = temp.path().join("escape.zip"); + { + let file = File::create(&archive_path).unwrap(); + let mut writer = zip::ZipWriter::new(file); + writer + .start_file( + "../a3s-use-extension.acl", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + writer.write_all(b"escape").unwrap(); + writer.finish().unwrap(); + } + + let error = prepare_package_source(&archive_path).await.unwrap_err(); + assert_eq!(error.code, "use.extension.path_escape"); + } + + #[tokio::test] + async fn tar_package_rejects_symbolic_links() { + let temp = tempfile::tempdir().unwrap(); + let archive_path = temp.path().join("link.tar.gz"); + { + let file = File::create(&archive_path).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + + let manifest = b"extension fixture"; + let mut manifest_header = tar::Header::new_gnu(); + manifest_header + .set_path(format!("package/{MANIFEST_NAME}")) + .unwrap(); + manifest_header.set_size(manifest.len() as u64); + manifest_header.set_mode(0o644); + manifest_header.set_cksum(); + builder.append(&manifest_header, &manifest[..]).unwrap(); + + let mut link = tar::Header::new_gnu(); + link.set_entry_type(tar::EntryType::Symlink); + link.set_path("package/escape").unwrap(); + link.set_link_name("../../outside").unwrap(); + link.set_size(0); + link.set_cksum(); + builder.append(&link, io::empty()).unwrap(); + builder.finish().unwrap(); + } + + let error = prepare_package_source(&archive_path).await.unwrap_err(); + assert_eq!(error.code, "use.extension.package_symlink"); + } + + #[test] + fn archive_paths_reject_cross_platform_escapes_and_device_names() { + for path in ["C:/escape", "..\\escape", "package/CON", "package/name. "] { + assert!( + sanitized_relative_path(Path::new(path)).is_err(), + "accepted unsafe path {path}" + ); + } + assert_eq!( + sanitized_relative_path(Path::new("./package/bin/tool")).unwrap(), + Some(PathBuf::from("package/bin/tool")) + ); + } +} diff --git a/crates/extension/src/tuf_test_support.rs b/crates/extension/src/tuf_test_support.rs new file mode 100644 index 00000000..06767097 --- /dev/null +++ b/crates/extension/src/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-acme-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-acme-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-acme-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); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index c165d7b5..1408dc9d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -70,6 +70,14 @@ Consumers read `extension snapshot` for the current projection or long-poll `extension watch --after-generation ` for a later generation. No daemon, custom RPC protocol, `dlopen`, or restart is required. +Explicit local sources may be directories or bounded `.tar.gz`, `.tgz`, and +`.zip` archives. Archive extraction runs off the async executor, accepts one +manifest-rooted package, preserves executable permissions, and rejects links, +path traversal, duplicate entries, unsupported file types, and expansion beyond +the package limits before lifecycle activation begins. Standard bounded macOS +AppleDouble sidecars are ignored rather than installed; they still count toward +the archive entry and expanded-byte limits. + ### Unified capability projection Resident Code hosts do not need separate discovery paths for built-in and @@ -500,6 +508,11 @@ Implemented: vision adapters, standard MCP annotations/output schemas, and a release-packaged content-bound Skill that projects to `mcp__use_ocr__*` in A3S Code. +17. TUF-verified remote extension registries with pinned bootstrap roots, + expiration and rollback enforcement, exact review/apply plans, and signed + provenance receipts. Registry upgrades restore the recorded source and + channel, reject identity drift and version downgrades, and converge before + payload download when the installed signed target is already current. Next: @@ -513,5 +526,6 @@ Next: with the same runtime guarantees as macOS and Linux. Windows compilation, CLI/MCP schemas, packaged assets, and non-runtime tests remain continuously checked in CI meanwhile. -4. Signed remote extension publishers. External publisher infrastructure is - independent of the built-in Browser compatibility contract. +4. Production publication for the official A3S extension registry, including + an offline-held root-key policy and release automation. The client does not + substitute a placeholder or generated key for that operational trust root. diff --git a/src/cli.rs b/src/cli.rs index 23bad00f..42d387f0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,7 +6,8 @@ use crate::capability_registry::{ use crate::extension_cli::{ extension_capabilities, extension_disable, extension_enable, extension_inspect, extension_list, extension_snapshot, extension_watch, external_component_value, external_package_id, - install_extension, installed_extension, installed_extensions, uninstall_extension, + install_extension, install_remote_extension, installed_extension, installed_extensions, + uninstall_extension, }; use std::time::Duration; @@ -451,15 +452,78 @@ async fn component_install(args: &[String]) -> UseResult { format!("Unknown delegated component '{id}'."), )); }; - let source = option_argument(args, "--from")? - .ok_or_else(|| usage_error("external extension install requires --from "))?; - let result = install_extension( - package_id, - std::path::Path::new(source), - args.iter().any(|argument| argument == "--force"), - args.iter().any(|argument| argument == "--allow-unsigned"), - ) - .await?; + let source = option_argument(args, "--from")?; + let registry_name = option_argument(args, "--registry-name")?; + let registry_url = option_argument(args, "--registry-url")?; + let trust_root = option_argument(args, "--trust-root")?; + let trusted_root = option_argument(args, "--trusted-root")?; + let version = option_argument(args, "--version")?; + let channel = option_argument(args, "--channel")?.unwrap_or("stable"); + let expected_plan = option_argument(args, "--registry-plan-digest")?; + let force = args.iter().any(|argument| argument == "--force"); + let allow_unsigned = args.iter().any(|argument| argument == "--allow-unsigned"); + let remote_requested = registry_name.is_some() + || registry_url.is_some() + || trust_root.is_some() + || trusted_root.is_some() + || version.is_some() + || expected_plan.is_some() + || option_argument(args, "--channel")?.is_some(); + let result = if let Some(source) = source { + if remote_requested { + return Err(usage_error( + "--from cannot be combined with signed registry options", + )); + } + install_extension( + package_id, + std::path::Path::new(source), + force, + allow_unsigned, + ) + .await? + } else { + if allow_unsigned { + return Err(usage_error( + "--allow-unsigned is valid only with an explicit local --from package", + )); + } + let registry_name = registry_name + .ok_or_else(|| usage_error("remote extension install requires --registry-name"))?; + let registry_url = registry_url + .ok_or_else(|| usage_error("remote extension install requires --registry-url"))?; + let trust_root = trust_root + .ok_or_else(|| usage_error("remote extension install requires --trust-root"))?; + let trusted_root = trusted_root + .map(|path| { + let path = std::path::PathBuf::from(path); + if path.is_absolute() { + Ok(path) + } else { + std::env::current_dir() + .map(|directory| directory.join(path)) + .map_err(|error| { + UseError::new( + "use.extension.registry_path_invalid", + format!("Failed to resolve the trusted root path: {error}"), + ) + }) + } + }) + .transpose()?; + install_remote_extension( + package_id, + registry_name, + registry_url, + trust_root, + trusted_root.as_deref(), + version, + channel, + expected_plan, + force, + ) + .await? + }; Ok(CommandOutput::success( if result.changed { format!("Installed extension '{}'.", result.extension.package_id) @@ -958,9 +1022,16 @@ fn validate_component_install_options(args: &[String]) -> UseResult<()> { while index < args.len() { match args[index].as_str() { "--json" | "--force" | "--allow-unsigned" => index += 1, - "--from" => { + "--from" + | "--registry-name" + | "--registry-url" + | "--trust-root" + | "--trusted-root" + | "--version" + | "--channel" + | "--registry-plan-digest" => { if args.get(index + 1).is_none() { - return Err(usage_error("--from requires a value")); + return Err(usage_error(format!("{} requires a value", args[index]))); } index += 2; } diff --git a/src/extension_cli.rs b/src/extension_cli.rs index 4e8afb64..ee5b30e2 100644 --- a/src/extension_cli.rs +++ b/src/extension_cli.rs @@ -14,6 +14,8 @@ pub(crate) struct ExtensionView { pub enabled: bool, pub package_root: PathBuf, pub surfaces: Vec<&'static str>, + pub trust: &'static str, + pub registry: Option, pub manifest: serde_json::Value, } @@ -196,7 +198,8 @@ pub(crate) fn external_component_value( "route": extension.route, "enabled": extension.enabled, "surfaces": extension.surfaces, - "trust": "local-explicit" + "trust": extension.trust, + "registry": extension.registry }) } @@ -209,7 +212,8 @@ fn extension_value(extension: &ExtensionView) -> serde_json::Value { "enabled": extension.enabled, "packageRoot": extension.package_root, "surfaces": extension.surfaces, - "trust": "local-explicit" + "trust": extension.trust, + "registry": extension.registry }) } @@ -254,6 +258,42 @@ pub(crate) async fn install_extension( }) } +#[cfg(feature = "extensions")] +#[allow(clippy::too_many_arguments)] +pub(crate) async fn install_remote_extension( + package_id: &str, + registry_name: &str, + registry_url: &str, + trust_root: &str, + trusted_root_path: Option<&Path>, + version: Option<&str>, + channel: &str, + expected_plan_digest: Option<&str>, + force: bool, +) -> UseResult { + let paths = a3s_use_extension::ExtensionPaths::from_env()?; + let registry = a3s_use_extension::TrustedRegistry::new( + registry_name, + registry_url, + trust_root, + trusted_root_path.map(Path::to_path_buf), + paths.tuf_datastore(registry_name), + )?; + let result = crate::extension_host::install_remote( + package_id, + ®istry, + version, + channel, + expected_plan_digest, + force, + ) + .await?; + Ok(ExtensionInstallView { + changed: result.changed, + extension: extension_view(result.extension)?, + }) +} + #[cfg(not(feature = "extensions"))] pub(crate) async fn install_extension( _package_id: &str, @@ -264,6 +304,22 @@ pub(crate) async fn install_extension( Err(extensions_disabled()) } +#[cfg(not(feature = "extensions"))] +#[allow(clippy::too_many_arguments)] +pub(crate) async fn install_remote_extension( + _package_id: &str, + _registry_name: &str, + _registry_url: &str, + _trust_root: &str, + _trusted_root_path: Option<&Path>, + _version: Option<&str>, + _channel: &str, + _expected_plan_digest: Option<&str>, + _force: bool, +) -> UseResult { + Err(extensions_disabled()) +} + #[cfg(feature = "extensions")] pub(crate) async fn uninstall_extension(package_id: &str) -> UseResult { let result = crate::extension_host::uninstall(package_id).await?; @@ -368,6 +424,22 @@ async fn watch_registry( #[cfg(feature = "extensions")] fn extension_view(extension: a3s_use_extension::InstalledExtension) -> UseResult { let surfaces = extension.surfaces(); + let trust = match extension.receipt.trust { + a3s_use_extension::ExtensionTrust::LocalExplicit => "local-explicit", + a3s_use_extension::ExtensionTrust::RegistryTuf => "registry-tuf", + }; + let registry = extension + .receipt + .registry + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|error| { + UseError::new( + "use.extension.receipt_invalid", + format!("Failed to encode the extension registry provenance: {error}"), + ) + })?; let manifest = serde_json::to_value(&extension.manifest).map_err(|error| { UseError::new( "use.extension.manifest_invalid", @@ -382,6 +454,8 @@ fn extension_view(extension: a3s_use_extension::InstalledExtension) -> UseResult enabled: extension.receipt.enabled, package_root: extension.receipt.package_root, surfaces, + trust, + registry, manifest, }) } diff --git a/src/extension_host.rs b/src/extension_host.rs index b2e845e7..a2fc60bf 100644 --- a/src/extension_host.rs +++ b/src/extension_host.rs @@ -3,7 +3,7 @@ use std::path::Path; use a3s_use_core::{UseError, UseResult}; use a3s_use_extension::{ ActivationResult, ExtensionRegistry, ExtensionRegistrySnapshot, InstallOptions, InstallResult, - InstalledExtension, UninstallResult, + InstalledExtension, TrustedRegistry, UninstallResult, }; use std::time::Duration; @@ -33,6 +33,26 @@ pub async fn install( .await } +pub async fn install_remote( + package_id: &str, + registry: &TrustedRegistry, + version: Option<&str>, + channel: &str, + expected_plan_digest: Option<&str>, + force: bool, +) -> UseResult { + ExtensionRegistry::from_env()? + .install_remote( + package_id, + registry, + version, + channel, + expected_plan_digest, + force, + ) + .await +} + pub async fn uninstall(package_id: &str) -> UseResult { ExtensionRegistry::from_env()?.uninstall(package_id).await } diff --git a/tests/extension_archives.rs b/tests/extension_archives.rs new file mode 100644 index 00000000..c3d81e32 --- /dev/null +++ b/tests/extension_archives.rs @@ -0,0 +1,102 @@ +#![cfg(all(unix, feature = "extensions"))] + +use std::fs::File; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +fn binary() -> &'static str { + env!("CARGO_BIN_EXE_a3s-use") +} + +#[test] +fn archived_extension_installs_dispatches_and_uninstalls_through_the_cli() { + let temp = tempfile::tempdir().unwrap(); + let package = temp.path().join("package"); + std::fs::create_dir_all(package.join("bin")).unwrap(); + std::fs::write( + package.join("a3s-use-extension.acl"), + r#"extension "acme/slack" { + schema_version = 1 + version = "1.0.0" + route = "slack" + actions = ["read"] + + cli { + executable = "bin/a3s-use-acme-slack" + json_output = true + } +} +"#, + ) + .unwrap(); + let executable = package.join("bin/a3s-use-acme-slack"); + std::fs::write( + &executable, + "#!/bin/sh\nprintf '%s\\n' \"$A3S_USE_EXTENSION_ID\"\nprintf '%s\\n' \"$*\"\nexit 7\n", + ) + .unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let archive = temp.path().join("acme-slack.tar.gz"); + let file = File::create(&archive).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + builder.append_dir_all("package", &package).unwrap(); + builder.finish().unwrap(); + drop(builder); + + let home = temp.path().join("home"); + let installed = Command::new(binary()) + .args([ + "component", + "install", + "acme/slack", + "--from", + archive.to_str().unwrap(), + "--allow-unsigned", + "--json", + ]) + .env("A3S_USE_HOME", &home) + .output() + .unwrap(); + assert!( + installed.status.success(), + "status: {}\nstdout: {}\nstderr: {}", + installed.status, + String::from_utf8_lossy(&installed.stdout), + String::from_utf8_lossy(&installed.stderr) + ); + let installed_json: serde_json::Value = serde_json::from_slice(&installed.stdout).unwrap(); + assert_eq!(installed_json["data"]["component"]["id"], "acme/slack"); + + let delegated = Command::new(binary()) + .args(["slack", "channels", "list", "--json"]) + .env("A3S_USE_HOME", &home) + .output() + .unwrap(); + assert_eq!(delegated.status.code(), Some(7)); + assert_eq!( + String::from_utf8(delegated.stdout).unwrap(), + "acme/slack\nchannels list --json\n" + ); + + let removed = Command::new(binary()) + .args(["component", "uninstall", "acme/slack", "--json"]) + .env("A3S_USE_HOME", &home) + .output() + .unwrap(); + assert!(removed.status.success(), "{removed:?}"); + let removed_json: serde_json::Value = serde_json::from_slice(&removed.stdout).unwrap(); + assert_eq!(removed_json["data"]["changed"], true); + + let listed = Command::new(binary()) + .args(["extension", "list", "--json"]) + .env("A3S_USE_HOME", &home) + .output() + .unwrap(); + assert!(listed.status.success(), "{listed:?}"); + let listed_json: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap(); + assert_eq!(listed_json["data"]["extensions"], serde_json::json!([])); + assert!(!home.join("data/extensions/acme/slack").exists()); + assert!(!home.join("state/extensions/acme/slack.json").exists()); +} diff --git a/tests/remote_extension_cli.rs b/tests/remote_extension_cli.rs new file mode 100644 index 00000000..74167a76 --- /dev/null +++ b/tests/remote_extension_cli.rs @@ -0,0 +1,183 @@ +#![cfg(feature = "extensions")] + +use std::process::{Command, Output}; + +use a3s_use_extension::{prepare_remote_package, ResolvedRemotePackage, TrustedRegistry}; + +#[path = "../crates/extension/src/tuf_test_support.rs"] +mod tuf_test_support; + +use tuf_test_support::{extension_archive, TestRepository, TestServer, FUTURE, PACKAGE_VERSION}; + +fn binary() -> &'static str { + env!("CARGO_BIN_EXE_a3s-use") +} + +#[tokio::test] +async fn signed_registry_install_uses_reviewed_target_and_reports_tuf_provenance() { + let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE); + let server = TestServer::start(repository.routes.clone()); + let temp = tempfile::tempdir().unwrap(); + let trusted = TrustedRegistry::new( + "fixture", + server.base_url(), + &repository.root_sha256, + None, + temp.path().join("review-state"), + ) + .unwrap(); + let reviewed = prepare_remote_package(&trusted, "acme/slack", None, "stable", None) + .await + .unwrap(); + let plan_digest = reviewed.resolved().plan_digest().unwrap(); + drop(reviewed); + assert_no_target_request(&server); + + let home = temp.path().join("home"); + let installed = registry_install(&server, &repository, &home, Some(&plan_digest), &[]); + assert!(installed.status.success(), "{installed:?}"); + let installed_json = json(&installed); + assert_eq!(installed_json["data"]["changed"], true); + assert_eq!(installed_json["data"]["component"]["trust"], "registry-tuf"); + assert_eq!( + installed_json["data"]["component"]["registry"]["registryName"], + "fixture" + ); + assert_eq!( + installed_json["data"]["component"]["registry"]["sha256"], + repository.target_sha256 + ); + 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(home.join("state/extensions/acme/slack.json")).unwrap(), + ) + .unwrap(); + assert_eq!(receipt["trust"], "registry-tuf"); + let provenance: ResolvedRemotePackage = + serde_json::from_value(receipt["registry"].clone()).unwrap(); + assert_eq!(provenance.plan_digest().unwrap(), plan_digest); + + let inspected = Command::new(binary()) + .args(["extension", "inspect", "acme/slack", "--json"]) + .env("A3S_USE_HOME", &home) + .output() + .unwrap(); + assert!(inspected.status.success(), "{inspected:?}"); + let inspected = json(&inspected); + assert_eq!(inspected["data"]["extension"]["trust"], "registry-tuf"); + assert_eq!( + inspected["data"]["extension"]["registry"]["targetName"], + repository.target_name + ); + + let second = registry_install(&server, &repository, &home, Some(&plan_digest), &[]); + assert!(second.status.success(), "{second:?}"); + assert_eq!(json(&second)["data"]["changed"], false); +} + +#[test] +fn registry_plan_mismatch_fails_before_target_download() { + let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE); + let server = TestServer::start(repository.routes.clone()); + let temp = tempfile::tempdir().unwrap(); + let output = registry_install( + &server, + &repository, + &temp.path().join("home"), + Some(&"0".repeat(64)), + &[], + ); + + assert!(!output.status.success(), "{output:?}"); + assert_eq!( + json(&output)["error"]["code"], + "use.extension.registry_plan_mismatch" + ); + assert_no_target_request(&server); +} + +#[test] +fn registry_install_rejects_unsigned_and_local_source_combinations() { + let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE); + let server = TestServer::start(repository.routes.clone()); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + + let unsigned = registry_install(&server, &repository, &home, None, &["--allow-unsigned"]); + assert!(!unsigned.status.success(), "{unsigned:?}"); + assert_eq!(json(&unsigned)["error"]["code"], "use.cli.invalid_usage"); + + let local = Command::new(binary()) + .args([ + "component", + "install", + "acme/slack", + "--from", + temp.path().to_str().unwrap(), + "--allow-unsigned", + "--registry-name", + "fixture", + "--json", + ]) + .env("A3S_USE_HOME", &home) + .output() + .unwrap(); + assert!(!local.status.success(), "{local:?}"); + assert_eq!(json(&local)["error"]["code"], "use.cli.invalid_usage"); + assert!(server.requests().is_empty()); +} + +fn registry_install( + server: &TestServer, + repository: &TestRepository, + home: &std::path::Path, + plan_digest: Option<&str>, + extra: &[&str], +) -> Output { + let mut command = Command::new(binary()); + command.args([ + "component", + "install", + "acme/slack", + "--registry-name", + "fixture", + "--registry-url", + server.base_url(), + "--trust-root", + &repository.root_sha256, + ]); + if let Some(plan_digest) = plan_digest { + command.args(["--registry-plan-digest", plan_digest]); + } + command + .args(extra) + .arg("--json") + .env("A3S_USE_HOME", home) + .output() + .unwrap() +} + +fn json(output: &Output) -> serde_json::Value { + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "invalid JSON output ({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/"))); +}