diff --git a/Cargo.lock b/Cargo.lock index 19fd5257..b69a1f8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -191,6 +191,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "a3s-use-registry-tools" +version = "0.2.9" +dependencies = [ + "a3s-acl", + "a3s-use-core", + "a3s-use-extension", + "flate2", + "olpc-cjson", + "ring", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "tokio", + "tough", + "url", +] + [[package]] name = "ab_glyph" version = "0.2.32" diff --git a/Cargo.toml b/Cargo.toml index a232c323..bdde8aba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/core", "crates/extension", "crates/mcp-release-fixture", + "crates/registry-tools", ] resolver = "2" diff --git a/crates/extension/src/digest.rs b/crates/extension/src/digest.rs index b630f2f6..3c7026d5 100644 --- a/crates/extension/src/digest.rs +++ b/crates/extension/src/digest.rs @@ -14,18 +14,24 @@ struct PackageFile { size: u64, } +/// Canonical expanded-package fingerprint over one package root directory. +/// +/// This is the same domain-separated digest the Artifact Store uses at +/// admission, so registry assembly and install-time verification agree on +/// package content identity by construction. #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct PackageFingerprint { - pub(crate) sha256: String, - pub(crate) file_count: u64, - pub(crate) byte_count: u64, +pub struct PackageFingerprint { + pub sha256: String, + pub file_count: u64, + pub byte_count: u64, } pub(crate) async fn package_sha256(root: &Path) -> UseResult { Ok(package_fingerprint(root).await?.sha256) } -pub(crate) async fn package_fingerprint(root: &Path) -> UseResult { +/// Hash one expanded package root with the canonical package fingerprint. +pub async fn package_fingerprint(root: &Path) -> UseResult { let root = root.to_path_buf(); tokio::task::spawn_blocking(move || hash_package(&root)) .await diff --git a/crates/extension/src/lib.rs b/crates/extension/src/lib.rs index b22141de..2035f66a 100644 --- a/crates/extension/src/lib.rs +++ b/crates/extension/src/lib.rs @@ -33,6 +33,7 @@ mod remote; mod source; mod state_maintenance; mod surface_files; +mod tuf_authoring; mod workspace_grant; mod workspace_grant_io; mod workspace_grant_lifecycle; @@ -75,6 +76,7 @@ pub use capability_description_verifier::{ CapabilityDescriptionTrustKey, CapabilityDescriptionTrustStore, VerifiedCapabilityDescription, CAPABILITY_DESCRIPTION_TRUST_KEY_SCHEMA_V1, CAPABILITY_DESCRIPTION_TRUST_STORE_SCHEMA_V1, }; +pub use digest::{package_fingerprint, PackageFingerprint}; pub use paths::{ExtensionPaths, UsePaths}; pub use plugin_manifest::{ PluginFlowEngine, PluginFlowRuntime, PluginFlowSurface, PluginMcpLaunch, PluginMcpSurface, @@ -136,6 +138,9 @@ pub use surface_files::{ inspect_tool_surface_files, inspect_ui_surface_files, load_okf_bundle_files, PluginSurfaceFileEvidence, }; +pub use tuf_authoring::{ + canonical_json, ed25519_key_id, ed25519_key_value, hex_lower, sha256_hex, sign_tuf_document, +}; pub use workspace_grant::{ StoredWorkspaceGrant, WorkspaceGrantReceipt, WorkspaceGrantRevocation, WorkspaceGrantStore, WORKSPACE_GRANT_RECEIPT_SCHEMA, WORKSPACE_GRANT_REVOCATION_SCHEMA, diff --git a/crates/extension/src/tuf_authoring.rs b/crates/extension/src/tuf_authoring.rs new file mode 100644 index 00000000..c8f8268d --- /dev/null +++ b/crates/extension/src/tuf_authoring.rs @@ -0,0 +1,107 @@ +//! Production TUF signing primitives for A3S Use registries. +//! +//! The bytes produced here are the signing half of the registry contract the +//! client in [`crate::remote`] verifies with `tough`. Test support and the +//! registry tooling share this module so a signed document can only be +//! produced one canonical way: canonical JSON (olpc-cjson) over the signed +//! role object, one Ed25519 signature, hex-encoded, wrapped in the standard +//! `{"signatures": [...], "signed": ...}` envelope. + +use olpc_cjson::CanonicalFormatter; +use ring::signature::Ed25519KeyPair; +use serde::Serialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +/// SHA-256 over arbitrary bytes, formatted as lowercase hex. +pub fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +/// Lowercase hex encoding. +pub 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 +} + +/// Canonical (RFC 8785 subset used by TUF) JSON serialization. +pub fn canonical_json(value: &Value) -> Vec { + let mut bytes = Vec::new(); + let mut serializer = + serde_json::Serializer::with_formatter(&mut bytes, CanonicalFormatter::new()); + value.serialize(&mut serializer).expect( + "canonical JSON serialization of a serde_json::Value is infallible for this formatter", + ); + bytes +} + +/// The TUF key object for one Ed25519 public key. +pub fn ed25519_key_value(public: &[u8]) -> Value { + json!({ + "keytype": "ed25519", + "scheme": "ed25519", + "keyval": {"public": hex_lower(public)} + }) +} + +/// The TUF key id: SHA-256 over the canonical form of the key object. +pub fn ed25519_key_id(public: &[u8]) -> String { + sha256_hex(&canonical_json(&ed25519_key_value(public))) +} + +/// Sign one TUF role document and wrap it in the standard envelope. +/// +/// The signature covers only the canonical serialization of `signed`; the +/// envelope itself is serialized compactly, matching the layout the client +/// and the frozen registry fixtures expect. +pub fn sign_tuf_document(key: &Ed25519KeyPair, key_id: &str, signed: Value) -> Vec { + let signature = key.sign(&canonical_json(&signed)); + serde_json::to_vec(&json!({ + "signatures": [{"keyid": key_id, "sig": hex_lower(signature.as_ref())}], + "signed": signed + })) + .expect("serializing a TUF signature envelope cannot fail") +} + +#[cfg(test)] +mod tests { + use super::*; + use ring::signature::KeyPair; + + #[test] + fn canonical_json_orders_object_keys_deterministically() { + let value = json!({"b": 1, "a": 2}); + assert_eq!(canonical_json(&value), br#"{"a":2,"b":1}"#); + } + + #[test] + fn key_id_is_stable_for_a_fixed_public_key() { + let key = Ed25519KeyPair::from_seed_unchecked(&[7_u8; 32]).unwrap(); + let public = key.public_key().as_ref().to_vec(); + let first = ed25519_key_id(&public); + assert_eq!(first.len(), 64); + assert_eq!(first, ed25519_key_id(&public)); + assert_ne!(first, ed25519_key_id(&[0_u8; 32])); + } + + #[test] + fn signed_documents_cover_their_canonical_bytes_deterministically() { + let key = Ed25519KeyPair::from_seed_unchecked(&[7_u8; 32]).unwrap(); + let key_id = ed25519_key_id(key.public_key().as_ref()); + let signed = json!({"_type": "targets", "version": 3}); + let document = sign_tuf_document(&key, &key_id, signed.clone()); + let parsed: Value = serde_json::from_slice(&document).unwrap(); + assert_eq!(parsed["signed"], signed); + // Ed25519 signatures are deterministic, so an independent signature + // over the canonical bytes must equal the envelope's signature. + let signature = parsed["signatures"][0]["sig"].as_str().unwrap(); + assert_eq!( + signature, + &hex_lower(key.sign(&canonical_json(&signed)).as_ref()) + ); + } +} diff --git a/crates/extension/src/tuf_test_support.rs b/crates/extension/src/tuf_test_support.rs index 445a7626..f13c3ce7 100644 --- a/crates/extension/src/tuf_test_support.rs +++ b/crates/extension/src/tuf_test_support.rs @@ -9,12 +9,14 @@ use a3s_use_core::{ PluginPermissionCeiling, PluginReleaseChannel, PluginSurfaceKind, PLUGIN_CATALOG_SCHEMA_V3, PLUGIN_PERMISSION_SCHEMA, }; -use olpc_cjson::CanonicalFormatter; use ring::signature::{Ed25519KeyPair, KeyPair}; -use serde::Serialize; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; +use a3s_use_extension::{ + ed25519_key_id, ed25519_key_value, sha256_hex as sha256, sign_tuf_document as signed_document, +}; + #[path = "tuf_test_support/server.rs"] mod server; @@ -155,13 +157,8 @@ impl TestRepository { ) -> Self { assert!(!targets.is_empty(), "a TUF test repository needs a target"); 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 key_value = ed25519_key_value(key.public_key().as_ref()); + let key_id = ed25519_key_id(key.public_key().as_ref()); let role = json!({"keyids": [key_id.clone()], "threshold": 1}); let mut keys = Map::new(); keys.insert(key_id.clone(), key_value); @@ -326,36 +323,6 @@ fn collect_fixture_files(root: &Path, directory: &Path, output: &mut Vec<(String } } -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 \"a3s/science\" {{\n schema_version = 3\n version = \"{version}\"\n route = \"science\"\n requires_use = \">=0.3.0, <0.4.0\"\n actions = [\"read\"]\n\n repository {{\n url = \"https://github.com/A3S-Lab/Science\"\n revision = \"0123456789abcdef0123456789abcdef01234567\"\n }}\n\n skill \"science\" {{\n path = \"skills/science/SKILL.md\"\n requires_tool = []\n requires_mcp = []\n optional = false\n }}\n}}\n" diff --git a/crates/registry-tools/Cargo.toml b/crates/registry-tools/Cargo.toml new file mode 100644 index 00000000..ed602cf6 --- /dev/null +++ b/crates/registry-tools/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "a3s-use-registry-tools" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "Registry assembly, signing, and verification tools for A3S Use registries" +publish = false + +[dependencies] +a3s-acl = "=0.2.2" +a3s-use-core = { version = "0.2.9", path = "../core" } +a3s-use-extension = { version = "0.3.11", path = "../extension" } +flate2.workspace = true +olpc-cjson.workspace = true +ring.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tar.workspace = true +tokio.workspace = true +tough.workspace = true +url.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/registry-tools/src/admission.rs b/crates/registry-tools/src/admission.rs new file mode 100644 index 00000000..b9430bbe --- /dev/null +++ b/crates/registry-tools/src/admission.rs @@ -0,0 +1,188 @@ +//! Reviewed admission records: the registry-side metadata one package +//! contributes on top of its own manifest. + +use std::path::{Path, PathBuf}; + +use a3s_acl::{Block, Value}; +use a3s_use_core::{PluginReleaseChannel, UseResult}; +use serde_json::json; + +use crate::tools_error; + +/// One admission block from the registry's admission ACL. +#[derive(Debug, Clone)] +pub(crate) struct Admission { + pub(crate) package_id: String, + pub(crate) package_directory: PathBuf, + pub(crate) channel: PluginReleaseChannel, + pub(crate) target: String, + pub(crate) archive_name: Option, + pub(crate) display_name: String, + pub(crate) description: String, + pub(crate) license: String, + pub(crate) repository: Option, + pub(crate) keywords: Vec, + pub(crate) categories: Vec, + pub(crate) permission_ceiling: Option, +} + +/// Load every admission block from one ACL file. +/// +/// `package_directory` and `permission_ceiling` resolve relative to the +/// admission file's directory so the registry repository stays portable. +pub(crate) fn load_admissions(path: &Path) -> UseResult> { + let input = std::fs::read_to_string(path).map_err(|error| { + tools_error( + "registry_tools.admission_read_failed", + &format!( + "Failed to read admissions file '{}': {error}", + path.display() + ), + ) + })?; + let document = a3s_acl::parse_acl(&input).map_err(|error| { + tools_error( + "registry_tools.admission_invalid", + &format!("Failed to parse the admission ACL: {error}"), + ) + })?; + let base = path.parent().unwrap_or_else(|| Path::new(".")); + let mut admissions = Vec::new(); + for block in &document.blocks { + if block.name != "admission" { + return Err(tools_error( + "registry_tools.admission_invalid", + &format!( + "The admission file contains an unexpected '{}' block.", + block.name + ), + )); + } + admissions.push(parse_admission(block, base)?); + } + if admissions.is_empty() { + return Err(tools_error( + "registry_tools.admission_invalid", + "The admission file contains no admission blocks.", + )); + } + Ok(admissions) +} + +fn parse_admission(block: &Block, base: &Path) -> UseResult { + let label = block.labels.first().cloned().unwrap_or_default(); + let attributes = &block.attributes; + let require_string = |name: &str| -> UseResult { + match attributes.get(name) { + Some(Value::String(value)) => Ok(value.clone()), + Some(_) => Err(tools_error( + "registry_tools.admission_invalid", + &format!("Admission '{label}' attribute {name} must be a string."), + )), + None => Err(tools_error( + "registry_tools.admission_invalid", + &format!("Admission '{label}' is missing required attribute {name}."), + )), + } + }; + let optional_string = |name: &str| -> UseResult> { + match attributes.get(name) { + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(tools_error( + "registry_tools.admission_invalid", + &format!("Admission '{label}' attribute {name} must be a string."), + )), + None => Ok(None), + } + }; + let string_list = |name: &str| -> UseResult> { + match attributes.get(name) { + Some(Value::List(items)) => { + let mut values = Vec::new(); + for item in items { + match item { + Value::String(value) => values.push(value.clone()), + _ => { + return Err(tools_error( + "registry_tools.admission_invalid", + &format!( + "Admission '{label}' attribute {name} must be a string list." + ), + )); + } + } + } + Ok(values) + } + Some(_) => Err(tools_error( + "registry_tools.admission_invalid", + &format!("Admission '{label}' attribute {name} must be a string list."), + )), + None => Err(tools_error( + "registry_tools.admission_invalid", + &format!("Admission '{label}' is missing required attribute {name}."), + )), + } + }; + + let channel = match require_string("channel")?.as_str() { + "stable" => PluginReleaseChannel::Stable, + "beta" => PluginReleaseChannel::Beta, + "nightly" => PluginReleaseChannel::Nightly, + other => { + return Err(tools_error( + "registry_tools.admission_invalid", + &format!( + "Admission '{label}' has unsupported channel '{other}'; use stable, beta, or nightly." + ), + )); + } + }; + let package_directory = PathBuf::from(require_string("package_directory")?); + if package_directory.is_absolute() { + return Err(tools_error( + "registry_tools.admission_invalid", + &format!( + "Admission '{label}' package_directory must be relative to the admission file." + ), + )); + } + let permission_ceiling = optional_string("permission_ceiling")? + .map(|value| { + let path = PathBuf::from(value); + if path.is_absolute() { + return Err(tools_error( + "registry_tools.admission_invalid", + &format!( + "Admission '{label}' permission_ceiling must be relative to the admission file." + ), + )); + } + Ok(base.join(path)) + }) + .transpose()?; + Ok(Admission { + package_id: label.clone(), + package_directory: base.join(package_directory), + channel, + target: require_string("target")?, + archive_name: optional_string("archive_name")?, + display_name: require_string("display_name")?, + description: require_string("description")?, + license: require_string("license")?, + repository: optional_string("repository")?, + keywords: string_list("keywords")?, + categories: string_list("categories")?, + permission_ceiling, + }) +} + +/// The `custom.a3s` catalog metadata value for one target. +pub(crate) fn catalog_custom(record: &serde_json::Value) -> serde_json::Value { + json!({"a3s": record}) +} + +/// The `custom` value marking a separately signed planning target. +pub(crate) fn planning_custom() -> serde_json::Value { + json!({"a3sPlanning": {"schema": "a3s.use.plugin-planning-target.v1"}}) +} diff --git a/crates/registry-tools/src/assemble.rs b/crates/registry-tools/src/assemble.rs new file mode 100644 index 00000000..4ad1cb6f --- /dev/null +++ b/crates/registry-tools/src/assemble.rs @@ -0,0 +1,290 @@ +//! Assemble and sign the complete static registry tree. + +use std::path::Path; + +use a3s_use_core::UseResult; +use serde_json::{json, Map, Value}; + +use crate::admission::{catalog_custom, load_admissions, planning_custom}; +use crate::catalog_build::assemble_admission; +use crate::keys::{role_key_value, KeySet}; +use crate::tools_error; + +/// Result evidence for one assembled publication. +pub(crate) struct AssembleOutcome { + pub(crate) root_sha256: String, + pub(crate) metadata_version: u64, + pub(crate) target_names: Vec, +} + +/// CLI entry: build `metadata/` and `targets/` under `--out-root`. +pub(crate) async fn assemble_command(options: &crate::Options) -> UseResult<()> { + let keys_dir = options.require("keys-dir")?; + let admissions_path = options.require("admissions")?; + let out_root = Path::new(&options.require("out-root")?).to_path_buf(); + let metadata_version: u64 = options + .optional("metadata-version") + .map(|value| { + value.parse().map_err(|_| { + tools_error( + "registry_tools.arguments_invalid", + "--metadata-version must be a positive integer.", + ) + }) + }) + .transpose()? + .unwrap_or(1); + if metadata_version == 0 { + return Err(tools_error( + "registry_tools.arguments_invalid", + "--metadata-version must be a positive integer.", + )); + } + let root_expires = options + .optional("root-expires") + .unwrap_or_else(|| default_expiry(365)); + let metadata_expires = options + .optional("metadata-expires") + .unwrap_or_else(|| default_expiry(30)); + + let outcome = assemble_registry( + &KeySet::load(Path::new(&keys_dir))?, + Path::new(&admissions_path), + &out_root, + metadata_version, + &root_expires, + &metadata_expires, + ) + .await?; + println!( + "{}", + serde_json::json!({ + "rootSha256": format!("sha256:{}", outcome.root_sha256), + "metadataVersion": outcome.metadata_version, + "targetNames": outcome.target_names, + "outRoot": out_root.display().to_string(), + }) + ); + Ok(()) +} + +fn default_expiry(days: u64) -> String { + let seconds = (days * 24 * 60 * 60) as i64; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|value| value.as_secs() as i64) + .unwrap_or(0); + let expiry = now + seconds; + // RFC 3339 without pulling a datetime dependency: epoch-day arithmetic. + format_epoch_as_rfc3339(expiry) +} + +fn format_epoch_as_rfc3339(epoch_seconds: i64) -> String { + let days = epoch_seconds.div_euclid(86_400); + let seconds_of_day = epoch_seconds.rem_euclid(86_400); + let (year, month, day) = civil_from_days(days); + let hour = seconds_of_day / 3_600; + let minute = (seconds_of_day % 3_600) / 60; + let second = seconds_of_day % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + +/// Howard Hinnant's civil-from-days algorithm. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let shifted = days + 719_468; + let era = shifted.div_euclid(146_097); + let day_of_era = shifted.rem_euclid(146_097); + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let shifted_month = (5 * day_of_year + 2) / 153; + let day = (day_of_year - (153 * shifted_month + 2) / 5 + 1) as u32; + let month = if shifted_month < 10 { + shifted_month + 3 + } else { + shifted_month - 9 + } as u32; + (if month <= 2 { year + 1 } else { year }, month, day) +} + +/// Sign and write the complete tree for the admitted package set. +pub(crate) async fn assemble_registry( + keys: &KeySet, + admissions_path: &Path, + out_root: &Path, + metadata_version: u64, + root_expires: &str, + metadata_expires: &str, +) -> UseResult { + let admissions = load_admissions(admissions_path)?; + let mut assembled = Vec::new(); + for admission in &admissions { + assembled.push(assemble_admission(admission).await?); + } + + let mut tuf_keys = Map::new(); + let mut roles = Map::new(); + for key in [&keys.root, &keys.targets, &keys.snapshot, &keys.timestamp] { + tuf_keys.insert(key.key_id.clone(), role_key_value(key)); + roles.insert( + key.role.to_string(), + json!({"keyids": [key.key_id.clone()], "threshold": 1}), + ); + } + let root_signed = json!({ + "_type": "root", + "spec_version": "1.0.0", + "consistent_snapshot": false, + "version": 1, + "expires": root_expires, + "keys": Value::Object(tuf_keys), + "roles": Value::Object(roles), + }); + let root = + a3s_use_extension::sign_tuf_document(&keys.root.pair, &keys.root.key_id, root_signed); + let root_sha256 = a3s_use_extension::sha256_hex(&root); + + let mut targets_map = Map::new(); + let mut target_writes = Vec::new(); + let mut target_names = Vec::new(); + for entry in &assembled { + let record: Value = serde_json::from_slice(&entry.record_json).map_err(|error| { + tools_error( + "registry_tools.assemble_failed", + &format!( + "The catalog record for '{}' cannot be re-encoded: {error}", + entry.record.package_id + ), + ) + })?; + targets_map.insert( + entry.archive_target_name.clone(), + json!({ + "length": entry.archive.len(), + "hashes": {"sha256": a3s_use_extension::sha256_hex(&entry.archive)}, + "custom": catalog_custom(&record), + }), + ); + target_writes.push((entry.archive_target_name.clone(), entry.archive.clone())); + target_names.push(entry.archive_target_name.clone()); + if let Some(planning) = &entry.planning { + targets_map.insert( + planning.target_name.clone(), + json!({ + "length": planning.bytes.len(), + "hashes": {"sha256": a3s_use_extension::sha256_hex(&planning.bytes)}, + "custom": planning_custom(), + }), + ); + target_writes.push((planning.target_name.clone(), planning.bytes.clone())); + target_names.push(planning.target_name.clone()); + } + } + let targets_signed = json!({ + "_type": "targets", + "spec_version": "1.0.0", + "version": metadata_version, + "expires": metadata_expires, + "targets": Value::Object(targets_map), + }); + let targets = a3s_use_extension::sign_tuf_document( + &keys.targets.pair, + &keys.targets.key_id, + targets_signed, + ); + let snapshot_signed = json!({ + "_type": "snapshot", + "spec_version": "1.0.0", + "version": metadata_version, + "expires": metadata_expires, + "meta": { + "targets.json": { + "version": metadata_version, + "length": targets.len(), + "hashes": {"sha256": a3s_use_extension::sha256_hex(&targets)}, + } + } + }); + let snapshot = a3s_use_extension::sign_tuf_document( + &keys.snapshot.pair, + &keys.snapshot.key_id, + snapshot_signed, + ); + let timestamp_signed = json!({ + "_type": "timestamp", + "spec_version": "1.0.0", + "version": metadata_version, + "expires": metadata_expires, + "meta": { + "snapshot.json": { + "version": metadata_version, + "length": snapshot.len(), + "hashes": {"sha256": a3s_use_extension::sha256_hex(&snapshot)}, + } + } + }); + let timestamp = a3s_use_extension::sign_tuf_document( + &keys.timestamp.pair, + &keys.timestamp.key_id, + timestamp_signed, + ); + + let metadata_directory = out_root.join("metadata"); + let targets_directory = out_root.join("targets"); + // The published tree is fully derived from the admissions: every + // assembly starts from a clean state so superseded target bytes can + // never linger beside a catalog that no longer advertises them. + for derived in [&metadata_directory, &targets_directory] { + if derived.exists() { + std::fs::remove_dir_all(derived).map_err(|error| { + tools_error( + "registry_tools.assemble_failed", + &format!( + "Failed to clear the derived directory '{}': {error}", + derived.display() + ), + ) + })?; + } + } + std::fs::create_dir_all(&metadata_directory).map_err(|error| { + tools_error( + "registry_tools.assemble_failed", + &format!( + "Failed to create '{}': {error}", + metadata_directory.display() + ), + ) + })?; + write_bytes(&metadata_directory.join("root.json"), &root)?; + write_bytes(&metadata_directory.join("timestamp.json"), ×tamp)?; + write_bytes(&metadata_directory.join("snapshot.json"), &snapshot)?; + write_bytes(&metadata_directory.join("targets.json"), &targets)?; + for (target_name, bytes) in target_writes { + let path = targets_directory.join(&target_name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + tools_error( + "registry_tools.assemble_failed", + &format!("Failed to create '{}': {error}", parent.display()), + ) + })?; + } + write_bytes(&path, &bytes)?; + } + Ok(AssembleOutcome { + root_sha256, + metadata_version, + target_names, + }) +} + +fn write_bytes(path: &Path, bytes: &[u8]) -> UseResult<()> { + std::fs::write(path, bytes).map_err(|error| { + tools_error( + "registry_tools.assemble_failed", + &format!("Failed to write '{}': {error}", path.display()), + ) + }) +} diff --git a/crates/registry-tools/src/catalog_build.rs b/crates/registry-tools/src/catalog_build.rs new file mode 100644 index 00000000..2e8e1c53 --- /dev/null +++ b/crates/registry-tools/src/catalog_build.rs @@ -0,0 +1,488 @@ +//! Derive catalog-v3 records and planning bundles from package manifests +//! plus reviewed admission metadata. + +use std::path::Path; + +use a3s_use_core::{ + CatalogArchive, CatalogAvailability, CatalogMcpTransport, CatalogPackage, CatalogSurface, + ExecutablePlanningSurface, PlanningSurfaceActivation, PluginCatalogRecord, + PluginPermissionCeiling, PluginPlanningBundle, PluginReleaseChannel, PluginSurfaceKind, + PluginSurfaceRef, ToolWorkloadClass, UseResult, PLUGIN_CATALOG_SCHEMA_V3, + PLUGIN_PERMISSION_SCHEMA, PLUGIN_PLANNING_BUNDLE_SCHEMA, +}; +use a3s_use_extension::{ + ExtensionManifest, PackageFingerprint, PluginMcpLaunch, SurfaceActivation, ToolWorkload, +}; + +use crate::admission::Admission; +use crate::package_build::{deterministic_package_archive, read_manifest}; +use crate::tools_error; + +/// Everything one admission contributes to the registry tree. +pub(crate) struct AssembledAdmission { + pub(crate) record: PluginCatalogRecord, + pub(crate) record_json: Vec, + pub(crate) archive: Vec, + pub(crate) archive_target_name: String, + pub(crate) planning: Option, +} + +/// The separately signed `planning-v1.json` target for executable packages. +pub(crate) struct PlanningTarget { + pub(crate) target_name: String, + pub(crate) bytes: Vec, +} + +/// Pack the package and derive its complete signed-metadata payload. +pub(crate) async fn assemble_admission(admission: &Admission) -> UseResult { + let manifest = read_manifest(&admission.package_directory)?; + if manifest.package_id != admission.package_id { + return Err(tools_error( + "registry_tools.admission_invalid", + &format!( + "Admission '{}' does not match the package manifest identity '{}'.", + admission.package_id, manifest.package_id + ), + )); + } + let requires_use = manifest.requires_use.clone().ok_or_else(|| { + tools_error( + "registry_tools.package_invalid", + &format!( + "Package '{}' must declare requires_use before it can be admitted.", + manifest.package_id + ), + ) + })?; + let repository = admission.repository.clone().or_else(|| { + manifest + .repository + .as_ref() + .map(|repository| repository.url.clone()) + }) + .ok_or_else(|| { + tools_error( + "registry_tools.admission_invalid", + &format!( + "Admission '{}' needs a repository URL: declare one in the admission or the package manifest.", + admission.package_id + ), + ) + })?; + + let archive = deterministic_package_archive(&admission.package_directory)?; + let archive_sha256 = a3s_use_extension::sha256_hex(&archive); + let fingerprint: PackageFingerprint = + a3s_use_extension::package_fingerprint(&admission.package_directory).await?; + let manifest_bytes = std::fs::read(admission.package_directory.join("a3s-use-extension.acl")) + .map_err(|error| { + tools_error( + "registry_tools.package_read_failed", + &format!( + "Failed to re-read the manifest of '{}': {error}", + admission.package_id + ), + ) + })?; + let manifest_sha256 = a3s_use_extension::sha256_hex(&manifest_bytes); + + let archive_name = admission.archive_name.clone().unwrap_or_else(|| { + format!( + "{}-{}-{}.tar.gz", + admission.package_id.replace('/', "-"), + manifest.version, + admission.target + ) + }); + let archive_target_name = format!( + "extensions/{}/{}/{}/{}/{}", + admission.package_id, + manifest.version, + channel_segment(admission.channel), + admission.target, + archive_name + ); + + let surfaces = catalog_surfaces(&manifest)?; + let permission_ceiling = permission_ceiling(admission, &surfaces)?; + let permission_ceiling_digest = permission_ceiling.descriptor_digest()?; + + let planning = planning_target( + admission, + &manifest, + &archive_sha256, + &fingerprint.sha256, + &manifest_sha256, + &permission_ceiling_digest, + &surfaces, + )?; + + let has_executable = surfaces.iter().any(|surface| { + matches!( + surface.kind, + PluginSurfaceKind::Tool | PluginSurfaceKind::Mcp + ) + }); + if has_executable != planning.is_some() { + return Err(tools_error( + "registry_tools.planning_invalid", + &format!( + "Package '{}' carries executable surfaces exactly when it carries a planning target.", + admission.package_id + ), + )); + } + + let record = PluginCatalogRecord { + schema: PLUGIN_CATALOG_SCHEMA_V3.to_string(), + package_id: manifest.package_id.clone(), + display_name: admission.display_name.clone(), + description: admission.description.clone(), + publisher: admission + .package_id + .split('/') + .next() + .unwrap_or_default() + .to_string(), + keywords: sorted_unique(admission.keywords.clone()), + categories: sorted_unique(admission.categories.clone()), + version: manifest.version.clone(), + channel: admission.channel, + requires_use, + dependencies: manifest.dependencies.clone(), + target: admission.target.clone(), + surfaces, + permission_ceiling_digest, + permission_ceiling, + planning: planning.as_ref().map(|planning| { + use a3s_use_core::CatalogPlanningTarget; + CatalogPlanningTarget { + target_name: planning.target_name.clone(), + length: planning.bytes.len() as u64, + sha256: format!("sha256:{}", a3s_use_extension::sha256_hex(&planning.bytes)), + } + }), + archive: CatalogArchive { + target_name: archive_target_name.clone(), + length: archive.len() as u64, + sha256: format!("sha256:{archive_sha256}"), + }, + package: CatalogPackage { + expanded_bytes: fingerprint.byte_count, + file_count: fingerprint.file_count, + sha256: Some(format!("sha256:{}", fingerprint.sha256)), + manifest_sha256: Some(format!("sha256:{manifest_sha256}")), + }, + license: admission.license.clone(), + repository, + availability: CatalogAvailability::Available, + }; + record.validate()?; + let record_json = record.canonical_bytes()?; + Ok(AssembledAdmission { + record, + record_json, + archive, + archive_target_name, + planning, + }) +} + +fn channel_segment(channel: PluginReleaseChannel) -> &'static str { + match channel { + PluginReleaseChannel::Stable => "stable", + PluginReleaseChannel::Beta => "beta", + PluginReleaseChannel::Nightly => "nightly", + } +} + +fn sorted_unique(mut values: Vec) -> Vec { + values.sort(); + values.dedup(); + values +} + +fn catalog_surfaces(manifest: &ExtensionManifest) -> UseResult> { + let mut surfaces = Vec::new(); + for tool in &manifest.tools { + surfaces.push(CatalogSurface { + kind: PluginSurfaceKind::Tool, + id: tool.id.clone(), + optional: tool.optional, + workload: Some(match tool.workload { + ToolWorkload::Task(_) => ToolWorkloadClass::Task, + ToolWorkload::Service(_) => ToolWorkloadClass::Service, + }), + mcp_transport: None, + mcp_tool_count: None, + okf_bundle: None, + requires: Vec::new(), + }); + } + for mcp in &manifest.mcp_servers { + surfaces.push(CatalogSurface { + kind: PluginSurfaceKind::Mcp, + id: mcp.id.clone(), + optional: mcp.optional, + workload: None, + mcp_transport: Some(match mcp.launch { + PluginMcpLaunch::Stdio { .. } => CatalogMcpTransport::Stdio, + PluginMcpLaunch::StreamableHttp { .. } => CatalogMcpTransport::StreamableHttp, + }), + mcp_tool_count: None, + okf_bundle: None, + requires: Vec::new(), + }); + } + for okf in &manifest.okf { + surfaces.push(CatalogSurface { + kind: PluginSurfaceKind::Okf, + id: okf.id.clone(), + optional: okf.optional, + workload: None, + mcp_transport: None, + mcp_tool_count: None, + okf_bundle: Some(okf.bundle.clone()), + requires: Vec::new(), + }); + } + for flow in &manifest.flows { + surfaces.push(CatalogSurface { + kind: PluginSurfaceKind::Flow, + id: flow.id.clone(), + optional: flow.optional, + workload: None, + mcp_transport: None, + mcp_tool_count: None, + okf_bundle: None, + requires: Vec::new(), + }); + } + for skill in &manifest.skills { + let mut requires = Vec::new(); + for dependency in &skill.requires_tools { + requires.push(PluginSurfaceRef { + kind: PluginSurfaceKind::Tool, + id: dependency.clone(), + }); + } + for dependency in &skill.requires_mcp { + requires.push(PluginSurfaceRef { + kind: PluginSurfaceKind::Mcp, + id: dependency.clone(), + }); + } + for dependency in &skill.requires_okf { + requires.push(PluginSurfaceRef { + kind: PluginSurfaceKind::Okf, + id: dependency.clone(), + }); + } + for dependency in &skill.requires_flows { + requires.push(PluginSurfaceRef { + kind: PluginSurfaceKind::Flow, + id: dependency.clone(), + }); + } + requires.sort(); + surfaces.push(CatalogSurface { + kind: PluginSurfaceKind::Skill, + id: skill.id.clone(), + optional: skill.optional, + workload: None, + mcp_transport: None, + mcp_tool_count: None, + okf_bundle: None, + requires, + }); + } + for ui in &manifest.ui { + surfaces.push(CatalogSurface { + kind: PluginSurfaceKind::Ui, + id: ui.id.clone(), + optional: ui.optional, + workload: None, + mcp_transport: None, + mcp_tool_count: None, + okf_bundle: None, + requires: Vec::new(), + }); + } + surfaces.sort_by(|left, right| { + left.kind + .cmp(&right.kind) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(surfaces) +} + +fn permission_ceiling( + admission: &Admission, + surfaces: &[CatalogSurface], +) -> UseResult { + let executable = surfaces.iter().any(|surface| { + matches!( + surface.kind, + PluginSurfaceKind::Tool | PluginSurfaceKind::Mcp + ) + }); + if !executable { + return Ok(PluginPermissionCeiling { + schema: PLUGIN_PERMISSION_SCHEMA.to_string(), + surfaces: Vec::new(), + }); + } + let Some(path) = &admission.permission_ceiling else { + return Err(tools_error( + "registry_tools.admission_invalid", + &format!( + "Admission '{}' has executable surfaces and must declare permission_ceiling.", + admission.package_id + ), + )); + }; + let bytes = std::fs::read(path).map_err(|error| { + tools_error( + "registry_tools.admission_read_failed", + &format!( + "Failed to read the permission ceiling '{:?}': {error}", + path + ), + ) + })?; + let ceiling: PluginPermissionCeiling = serde_json::from_slice(&bytes).map_err(|error| { + tools_error( + "registry_tools.admission_invalid", + &format!("The permission ceiling is not valid JSON: {error}"), + ) + })?; + ceiling.validate()?; + Ok(ceiling) +} + +fn planning_target( + admission: &Admission, + manifest: &ExtensionManifest, + archive_sha256: &str, + package_sha256: &str, + manifest_digest: &str, + permission_ceiling_digest: &str, + surfaces: &[CatalogSurface], +) -> UseResult> { + let has_executable = surfaces.iter().any(|surface| { + matches!( + surface.kind, + PluginSurfaceKind::Tool | PluginSurfaceKind::Mcp + ) + }); + if !has_executable { + return Ok(None); + } + let mut planning_surfaces = Vec::new(); + for tool in &manifest.tools { + let activation = planning_activation(tool.activation); + planning_surfaces.push(match &tool.workload { + ToolWorkload::Task(task) => match &task.source { + a3s_use_extension::ToolTaskSource::Executable { executable } => { + ExecutablePlanningSurface::ToolTaskNative { + id: tool.id.clone(), + activation, + executable: portable_path(executable)?, + command: task.command.clone(), + json_output: task.json_output, + timeout_ms: task.timeout_ms, + } + } + a3s_use_extension::ToolTaskSource::Release { .. } => { + return Err(unsupported_release_surface( + &admission.package_id, + "release-backed Tool Task", + )); + } + }, + ToolWorkload::Service(_) => { + return Err(unsupported_release_surface( + &admission.package_id, + "Tool Service", + )); + } + }); + } + for mcp in &manifest.mcp_servers { + let activation = planning_activation(mcp.activation); + planning_surfaces.push(match &mcp.launch { + PluginMcpLaunch::Stdio { executable, args } => ExecutablePlanningSurface::McpStdio { + id: mcp.id.clone(), + activation, + executable: portable_path(executable)?, + args: args.clone(), + }, + PluginMcpLaunch::StreamableHttp { .. } => { + return Err(unsupported_release_surface( + &admission.package_id, + "Streamable HTTP MCP", + )); + } + }); + } + let target_name = format!( + "extensions/{}/{}/{}/{}/planning-v1.json", + admission.package_id, + manifest.version, + channel_segment(admission.channel), + admission.target + ); + let bundle = PluginPlanningBundle { + schema: PLUGIN_PLANNING_BUNDLE_SCHEMA.to_string(), + package_id: admission.package_id.clone(), + version: manifest.version.clone(), + channel: admission.channel, + target: admission.target.clone(), + archive_sha256: format!("sha256:{archive_sha256}"), + package_sha256: format!("sha256:{package_sha256}"), + manifest_sha256: format!("sha256:{manifest_digest}"), + permission_ceiling_digest: permission_ceiling_digest.to_string(), + surfaces: planning_surfaces, + }; + Ok(Some(PlanningTarget { + target_name, + bytes: bundle.canonical_bytes()?, + })) +} + +fn unsupported_release_surface(package_id: &str, kind: &str) -> a3s_use_core::UseError { + tools_error( + "registry_tools.planning_invalid", + &format!( + "Package '{package_id}' declares a {kind} surface. Release-backed surfaces need \ + descriptor assembly that registry-tools does not implement yet; admit the package \ + through package-local executable surfaces first." + ), + ) + .with_suggestion( + "Convert the surface to a package-local executable, or extend registry-tools with \ + release-descriptor assembly before admitting this package.", + ) +} + +fn planning_activation(activation: SurfaceActivation) -> PlanningSurfaceActivation { + match activation { + SurfaceActivation::Eager => PlanningSurfaceActivation::Eager, + SurfaceActivation::Lazy => PlanningSurfaceActivation::Lazy, + } +} + +fn portable_path(path: &Path) -> UseResult { + let segments = path + .iter() + .map(|segment| segment.to_str().unwrap_or_default()) + .collect::>() + .join("/"); + if segments.starts_with('/') || segments.contains("..") || segments.contains('\\') { + return Err(tools_error( + "registry_tools.planning_invalid", + &format!("Executable path '{segments}' must be a relative package-local path."), + )); + } + Ok(segments) +} diff --git a/crates/registry-tools/src/keys.rs b/crates/registry-tools/src/keys.rs new file mode 100644 index 00000000..f7e59aea --- /dev/null +++ b/crates/registry-tools/src/keys.rs @@ -0,0 +1,286 @@ +//! Ed25519 custody material for the four TUF roles. + +use std::fs; +use std::path::{Path, PathBuf}; + +use a3s_use_core::{UseError, UseResult}; +use ring::rand::{SecureRandom, SystemRandom}; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::tools_error; + +const ROLES: [&str; 4] = ["root", "targets", "snapshot", "timestamp"]; + +/// One role key with its derived TUF key id and custody seed. +pub(crate) struct RoleKey { + pub(crate) role: &'static str, + pub(crate) pair: Ed25519KeyPair, + pub(crate) key_id: String, + seed: [u8; 32], +} + +impl RoleKey { + fn from_seed(role: &'static str, seed: [u8; 32]) -> UseResult { + let pair = Ed25519KeyPair::from_seed_unchecked(seed.as_slice()).map_err(|error| { + tools_error( + "registry_tools.keygen_failed", + &format!("The {role} seed is unusable: {error}"), + ) + })?; + let key_id = a3s_use_extension::ed25519_key_id(pair.public_key().as_ref()); + Ok(Self { + role, + pair, + key_id, + seed, + }) + } + + fn file_stem(&self) -> String { + format!("{}.key", self.role) + } +} + +/// The complete signing key set for one registry. +pub(crate) struct KeySet { + pub(crate) root: RoleKey, + pub(crate) targets: RoleKey, + pub(crate) snapshot: RoleKey, + pub(crate) timestamp: RoleKey, +} + +#[derive(Serialize, Deserialize)] +struct StoredKey { + role: String, + key_type: String, + scheme: String, + key_id: String, + public_key: String, + seed: String, +} + +impl KeySet { + /// Generate four fresh independent role keys. + pub(crate) fn generate() -> UseResult { + Ok(Self { + root: generate_role("root")?, + targets: generate_role("targets")?, + snapshot: generate_role("snapshot")?, + timestamp: generate_role("timestamp")?, + }) + } + + /// Persist one seed file per role with 0600 permissions. + /// + /// Writing into an existing key file is refused so an accidental rerun + /// cannot silently rotate a registry's identity. + pub(crate) fn save(&self, directory: &Path) -> UseResult> { + if !directory.exists() { + fs::create_dir_all(directory).map_err(|error| { + tools_error( + "registry_tools.keys_write_failed", + &format!( + "Failed to create keys directory '{}': {error}", + directory.display() + ), + ) + })?; + } + let mut written = Vec::new(); + for key in [&self.root, &self.targets, &self.snapshot, &self.timestamp] { + let path = directory.join(key.file_stem()); + if path.exists() { + return Err(tools_error( + "registry_tools.keys_write_failed", + &format!( + "Refusing to overwrite existing key file '{}'; move it aside to rotate.", + path.display() + ), + )); + } + let stored = StoredKey { + role: key.role.to_string(), + key_type: "ed25519".to_string(), + scheme: "ed25519".to_string(), + key_id: key.key_id.clone(), + public_key: a3s_use_extension::hex_lower(key.pair.public_key().as_ref()), + seed: a3s_use_extension::hex_lower(&key.seed), + }; + let bytes = serde_json::to_vec_pretty(&stored).map_err(|error| { + tools_error( + "registry_tools.keys_write_failed", + &format!("Failed to serialize key material: {error}"), + ) + })?; + write_private(&path, &bytes)?; + written.push(path); + } + Ok(written) + } + + /// Load a previously generated key set. + pub(crate) fn load(directory: &Path) -> UseResult { + Ok(Self { + root: load_role(directory, "root")?, + targets: load_role(directory, "targets")?, + snapshot: load_role(directory, "snapshot")?, + timestamp: load_role(directory, "timestamp")?, + }) + } +} + +fn generate_role(role: &'static str) -> UseResult { + let mut seed = [0_u8; 32]; + SystemRandom::new().fill(&mut seed).map_err(|error| { + tools_error( + "registry_tools.keygen_failed", + &format!("Failed to generate the {role} key seed: {error}"), + ) + })?; + RoleKey::from_seed(role, seed) +} + +fn load_role(directory: &Path, role: &str) -> UseResult { + let path = directory.join(format!("{role}.key")); + let bytes = fs::read(&path).map_err(|error| { + tools_error( + "registry_tools.keys_read_failed", + &format!("Failed to read key file '{}': {error}", path.display()), + ) + })?; + let stored: StoredKey = serde_json::from_slice(&bytes).map_err(|error| { + tools_error( + "registry_tools.keys_read_failed", + &format!("Key file '{}' is not valid JSON: {error}", path.display()), + ) + })?; + if stored.role != role || stored.key_type != "ed25519" || stored.scheme != "ed25519" { + return Err(tools_error( + "registry_tools.keys_read_failed", + &format!( + "Key file '{}' does not describe the {role} role.", + path.display() + ), + )); + } + let seed = decode_hex(&stored.seed).ok_or_else(|| { + tools_error( + "registry_tools.keys_read_failed", + &format!("Key file '{}' has an invalid seed.", path.display()), + ) + })?; + let seed: [u8; 32] = seed.try_into().map_err(|_| { + tools_error( + "registry_tools.keys_read_failed", + &format!("Key file '{}' seed must be 32 bytes.", path.display()), + ) + })?; + let key = RoleKey::from_seed( + ROLES + .iter() + .find(|candidate| **candidate == role) + .copied() + .expect("the role name was validated against the fixed role list before construction"), + seed, + ) + .map_err(|error| { + tools_error( + "registry_tools.keys_read_failed", + &format!( + "Key file '{}' seed is unusable: {}", + path.display(), + error.message + ), + ) + })?; + if key.key_id != stored.key_id { + return Err(tools_error( + "registry_tools.keys_read_failed", + &format!( + "Key file '{}' declares key id {} but its seed derives {}.", + path.display(), + stored.key_id, + key.key_id + ), + )); + } + Ok(key) +} + +fn write_private(path: &Path, bytes: &[u8]) -> UseResult<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(error) = fs::write(path, bytes) + .and_then(|()| fs::set_permissions(path, fs::Permissions::from_mode(0o600))) + { + return Err(private_write_error(path, &error.to_string())); + } + Ok(()) + } + #[cfg(not(unix))] + { + fs::write(path, bytes).map_err(|error| private_write_error(path, &error.to_string())) + } +} + +#[cfg(unix)] +fn private_write_error(path: &Path, error: &str) -> UseError { + tools_error( + "registry_tools.keys_write_failed", + &format!( + "Failed to write '{}' with 0600 permissions: {error}", + path.display() + ), + ) +} + +#[cfg(not(unix))] +fn private_write_error(path: &Path, error: &str) -> UseError { + tools_error( + "registry_tools.keys_write_failed", + &format!("Failed to write '{}': {error}", path.display()), + ) +} + +fn decode_hex(input: &str) -> Option> { + if input.len() % 2 != 0 { + return None; + } + (0..input.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&input[index..index + 2], 16).ok()) + .collect() +} + +/// CLI entry: generate and persist one independent key per TUF role. +pub(crate) fn keygen_command(options: &crate::Options) -> UseResult<()> { + let directory = PathBuf::from(options.require("keys-dir")?); + let key_set = KeySet::generate()?; + let written = key_set.save(&directory)?; + println!( + "{}", + serde_json::json!({ + "keysDir": directory.display().to_string(), + "written": written.iter().map(|path| path.display().to_string()).collect::>(), + "roleKeyIds": { + "root": key_set.root.key_id, + "targets": key_set.targets.key_id, + "snapshot": key_set.snapshot.key_id, + "timestamp": key_set.timestamp.key_id, + }, + }) + ); + Ok(()) +} + +/// The public key object for one role, in TUF wire form. +pub(crate) fn role_key_value(key: &RoleKey) -> serde_json::Value { + json!({ + "keytype": "ed25519", + "scheme": "ed25519", + "keyval": {"public": a3s_use_extension::hex_lower(key.pair.public_key().as_ref())} + }) +} diff --git a/crates/registry-tools/src/main.rs b/crates/registry-tools/src/main.rs new file mode 100644 index 00000000..ed42b967 --- /dev/null +++ b/crates/registry-tools/src/main.rs @@ -0,0 +1,148 @@ +//! `a3s-use-registry-tools` assembles, signs, and verifies the static TUF +//! tree an A3S Use Registry publishes. +//! +//! The tool is operator-facing: it never talks to the network, never touches +//! an installation, and writes only the directories it is given. Package +//! identity comes from each package's `a3s-use-extension.acl` manifest; +//! presentation and review metadata come from a separate admission ACL so +//! the two authorities stay distinct. + +mod admission; +mod assemble; +mod catalog_build; +mod keys; +mod package_build; +mod verify; + +use std::process::ExitCode; + +use a3s_use_core::{UseError, UseResult}; + +const USAGE: &str = "\ +a3s-use-registry-tools — assemble, sign, and verify A3S Use registries + +USAGE: + a3s-use-registry-tools keygen --keys-dir + a3s-use-registry-tools pack --package-dir --out + a3s-use-registry-tools assemble --keys-dir --admissions --out-root + [--metadata-version ] [--root-expires ] [--metadata-expires ] + a3s-use-registry-tools verify --registry [--expected-root-sha256 <64-hex>] + +keygen writes one Ed25519 seed file per TUF role into --keys-dir with 0600 +permissions. Seeds are custody material: keep them outside every published +repository and back them up offline. + +assemble reads the admission ACL, packs each admitted package directory into +a deterministic archive, derives catalog-v3 records from the package +manifests, signs the four TUF roles, and writes metadata/ plus targets/ under +--out-root. It prints the bootstrap root SHA-256 clients pin with +--trust-root. + +verify re-opens a assembled tree with the same tough loader a released +client uses, decodes and validates every catalog record and planning bundle, +and re-hashes every on-disk target against its signed digest. +"; + +fn main() -> ExitCode { + let arguments: Vec = std::env::args().skip(1).collect(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("the registry tools need a Tokio runtime"); + match runtime.block_on(run(&arguments)) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: [{}] {}", error.code, error.message); + if let Some(suggestion) = error.suggestion { + eprintln!(" suggestion: {suggestion}"); + } + ExitCode::FAILURE + } + } +} + +async fn run(arguments: &[String]) -> UseResult<()> { + let Some(command) = arguments.first() else { + print_usage_error("a subcommand is required"); + return Ok(()); + }; + let options = Options::parse(&arguments[1..])?; + match command.as_str() { + "keygen" => keys::keygen_command(&options), + "pack" => package_build::pack_command(&options), + "assemble" => assemble::assemble_command(&options).await, + "verify" => verify::verify_command(&options).await, + "--help" | "-h" | "help" => { + print!("{USAGE}"); + Ok(()) + } + other => { + print_usage_error(&format!("unknown subcommand '{other}'")); + Ok(()) + } + } +} + +fn print_usage_error(message: &str) { + eprintln!("error: {message}\n\n{USAGE}"); +} + +/// Flat `--name value` option bag shared by every subcommand. +pub(crate) struct Options { + values: Vec<(String, String)>, +} + +impl Options { + pub(crate) fn parse(arguments: &[String]) -> UseResult { + let mut values = Vec::new(); + let mut index = 0; + while index < arguments.len() { + let name = arguments[index].as_str(); + if !name.starts_with("--") { + return Err(tools_error( + "arguments_invalid", + "Options must start with --.", + )); + } + let Some(value) = arguments.get(index + 1) else { + return Err(tools_error( + "arguments_invalid", + &format!("Option {name} requires a value."), + )); + }; + if value.starts_with("--") { + return Err(tools_error( + "arguments_invalid", + &format!("Option {name} requires a value, found another option."), + )); + } + values.push((name.trim_start_matches("--").to_string(), value.clone())); + index += 2; + } + Ok(Self { values }) + } + + pub(crate) fn require(&self, name: &str) -> UseResult { + self.values + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.clone()) + .ok_or_else(|| { + tools_error( + "arguments_invalid", + &format!("--{name} is required for this subcommand."), + ) + }) + } + + pub(crate) fn optional(&self, name: &str) -> Option { + self.values + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.clone()) + } +} + +pub(crate) fn tools_error(code: &str, message: &str) -> UseError { + UseError::new(code, message) +} diff --git a/crates/registry-tools/src/package_build.rs b/crates/registry-tools/src/package_build.rs new file mode 100644 index 00000000..1a3d5ce6 --- /dev/null +++ b/crates/registry-tools/src/package_build.rs @@ -0,0 +1,181 @@ +//! Deterministic package packing and fingerprinting. + +use std::path::Path; + +use a3s_use_core::{UseError, UseResult}; +use a3s_use_extension::ExtensionManifest; + +use crate::tools_error; + +/// Read and parse one package's `a3s-use-extension.acl` manifest. +pub(crate) fn read_manifest(package_directory: &Path) -> UseResult { + let manifest_path = package_directory.join("a3s-use-extension.acl"); + let input = std::fs::read_to_string(&manifest_path).map_err(|error| { + tools_error( + "registry_tools.package_read_failed", + &format!( + "Failed to read package manifest '{}': {error}", + manifest_path.display() + ), + ) + })?; + ExtensionManifest::parse_acl(&input) +} + +/// Build the deterministic `package/`-prefixed tar.gz for one package root. +/// +/// Byte-for-byte reproducible for identical content: fixed zero timestamps, +/// zero uid/gid, sorted paths, best compression, and mode derived only from +/// the source executable bit. +pub(crate) fn deterministic_package_archive(root: &Path) -> UseResult> { + let mut files = Vec::new(); + collect_files(root, root, &mut files)?; + files.sort_by(|left, right| left.0.cmp(&right.0)); + + let encoder = flate2::GzBuilder::new() + .mtime(0) + .operating_system(255) + .write(Vec::new(), flate2::Compression::best()); + let mut archive = tar::Builder::new(encoder); + for (relative, path) in files { + let body = std::fs::read(&path).map_err(|error| read_error(&path, &error.to_string()))?; + #[cfg(unix)] + let executable = { + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::symlink_metadata(&path) + .map_err(|error| read_error(&path, &error.to_string()))?; + metadata.permissions().mode() & 0o111 != 0 + }; + #[cfg(not(unix))] + let executable = false; + let mode = if executable { 0o755 } else { 0o644 }; + let archive_path = format!("package/{relative}"); + let mut header = tar::Header::new_gnu(); + header.set_path(&archive_path).map_err(|error| { + tools_error( + "registry_tools.package_invalid", + &format!("Package path '{archive_path}' cannot be encoded: {error}"), + ) + })?; + header.set_size(body.len() as u64); + header.set_mode(mode); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_entry_type(tar::EntryType::Regular); + header.set_cksum(); + archive.append(&header, body.as_slice()).map_err(|error| { + tools_error( + "registry_tools.pack_failed", + &format!("Failed to append '{archive_path}' to the archive: {error}"), + ) + })?; + } + archive + .into_inner() + .map_err(|error| { + tools_error( + "registry_tools.pack_failed", + &format!("Failed to finish the package archive: {error}"), + ) + })? + .finish() + .map_err(|error| { + tools_error( + "registry_tools.pack_failed", + &format!("Failed to compress the package archive: {error}"), + ) + }) +} + +fn read_error(path: &Path, error: &str) -> UseError { + tools_error( + "registry_tools.package_read_failed", + &format!("Failed to read '{}': {error}", path.display()), + ) +} + +fn collect_files<'a>( + root: &'a Path, + directory: &'a Path, + output: &mut Vec<(String, std::path::PathBuf)>, +) -> UseResult<()> { + let entries = std::fs::read_dir(directory).map_err(|error| { + tools_error( + "registry_tools.package_read_failed", + &format!( + "Failed to read package directory '{}': {error}", + directory.display() + ), + ) + })?; + for entry in entries { + let entry = entry.map_err(|error| read_error(directory, &error.to_string()))?; + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path) + .map_err(|error| read_error(&path, &error.to_string()))?; + if metadata.is_dir() { + collect_files(root, &path, output)?; + continue; + } + if !metadata.is_file() { + return Err(tools_error( + "registry_tools.package_invalid", + &format!( + "Package entry '{}' is not a regular file or directory.", + path.display() + ), + )); + } + let relative = path + .strip_prefix(root) + .map_err(|_| { + tools_error( + "registry_tools.package_invalid", + &format!("Package entry '{}' escapes its root.", path.display()), + ) + })? + .iter() + .map(|segment| segment.to_str().unwrap_or_default()) + .collect::>() + .join("/"); + if relative.is_empty() { + return Err(tools_error( + "registry_tools.package_invalid", + "The package root itself cannot be a file.", + )); + } + output.push((relative, path)); + } + Ok(()) +} + +/// CLI entry: pack one package directory and report its digest evidence. +pub(crate) fn pack_command(options: &crate::Options) -> UseResult<()> { + let package_directory = Path::new(&options.require("package-dir")?) + .canonicalize() + .map_err(|error| { + tools_error( + "registry_tools.package_read_failed", + &format!("Failed to resolve the package directory: {error}"), + ) + })?; + let output = options.require("out")?; + read_manifest(&package_directory)?; + let archive = deterministic_package_archive(&package_directory)?; + std::fs::write(&output, &archive).map_err(|error| { + tools_error( + "registry_tools.pack_failed", + &format!("Failed to write archive '{}': {error}", output), + ) + })?; + println!( + "{}", + serde_json::json!({ + "archive": output, + "length": archive.len(), + "sha256": format!("sha256:{}", a3s_use_extension::sha256_hex(&archive)), + }) + ); + Ok(()) +} diff --git a/crates/registry-tools/src/verify.rs b/crates/registry-tools/src/verify.rs new file mode 100644 index 00000000..f35193a8 --- /dev/null +++ b/crates/registry-tools/src/verify.rs @@ -0,0 +1,233 @@ +//! Re-open an assembled registry exactly the way a released client does. + +use std::collections::BTreeSet; +use std::path::Path; + +use a3s_use_core::{ + PluginCatalogRecord, PluginPlanningBundle, PluginReleaseChannel, UseError, UseResult, +}; +use tough::{ExpirationEnforcement, Limits, RepositoryLoader}; +use url::Url; + +use crate::tools_error; + +/// Summary of one verification pass. +pub(crate) struct VerifyReport { + pub(crate) root_sha256: String, + pub(crate) packages: Vec, + pub(crate) targets_checked: u64, +} + +/// CLI entry: verify one registry tree. +pub(crate) async fn verify_command(options: &crate::Options) -> UseResult<()> { + let registry = Path::new(&options.require("registry")?) + .canonicalize() + .map_err(|error| { + tools_error( + "registry_tools.verify_failed", + &format!("Failed to resolve the registry directory: {error}"), + ) + })?; + let expected = options.optional("expected-root-sha256"); + let report = verify_registry(®istry, expected.as_deref()).await?; + println!( + "{}", + serde_json::json!({ + "rootSha256": format!("sha256:{}", report.root_sha256), + "packages": report.packages, + "targetsChecked": report.targets_checked, + "registry": registry.display().to_string(), + }) + ); + Ok(()) +} + +/// Verify the metadata chain, every catalog record, and every target digest. +pub(crate) async fn verify_registry( + registry_root: &Path, + expected_root_sha256: Option<&str>, +) -> UseResult { + let metadata_directory = registry_root.join("metadata"); + let targets_directory = registry_root.join("targets"); + let root_bytes = std::fs::read(metadata_directory.join("root.json")).map_err(|error| { + tools_error( + "registry_tools.verify_failed", + &format!( + "Failed to read '{}': {error}", + metadata_directory.join("root.json").display() + ), + ) + })?; + let root_sha256 = a3s_use_extension::sha256_hex(&root_bytes); + if let Some(expected) = expected_root_sha256 { + let normalized = expected.trim_start_matches("sha256:").to_ascii_lowercase(); + if normalized != root_sha256 { + return Err(tools_error( + "registry_tools.verify_failed", + &format!("The registry root digest is {root_sha256}, but {normalized} was pinned."), + )); + } + } + a3s_use_extension::inspect_bootstrap_root(&root_bytes).map_err(|error| { + tools_error( + "registry_tools.verify_failed", + &format!( + "The root role is not valid signed TUF JSON: {}", + error.message + ), + ) + })?; + let metadata_url = directory_url(&metadata_directory)?; + let targets_url = directory_url(&targets_directory)?; + let repository = RepositoryLoader::new(&root_bytes, metadata_url, targets_url) + .transport(tough::FilesystemTransport) + .limits(Limits { + max_root_size: 1024 * 1024, + max_targets_size: 10 * 1024 * 1024, + max_timestamp_size: 1024 * 1024, + max_snapshot_size: 1024 * 1024, + max_root_updates: 64, + }) + .expiration_enforcement(ExpirationEnforcement::Safe) + .load() + .await + .map_err(|error| { + tools_error( + "registry_tools.verify_failed", + &format!("The tough client rejected this registry: {error}"), + ) + })?; + + let mut packages = Vec::new(); + let mut expected_planning: BTreeSet = BTreeSet::new(); + let mut seen_planning: BTreeSet = BTreeSet::new(); + let mut targets_checked = 0_u64; + for (target_name, target) in repository.all_targets() { + let name = target_name.raw(); + let signed_digest = serde_json::to_value(&target.hashes) + .ok() + .and_then(|value| value.get("sha256").cloned()) + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or_else(|| { + tools_error( + "registry_tools.verify_failed", + &format!("Target '{name}' has no readable sha256 digest."), + ) + })?; + let bytes = read_target(&targets_directory, name)?; + let actual = a3s_use_extension::sha256_hex(&bytes); + if actual != signed_digest || target.length != bytes.len() as u64 { + return Err(tools_error( + "registry_tools.verify_failed", + &format!("Target '{name}' on disk does not match its signed digest or length."), + )); + } + targets_checked += 1; + if let Some(metadata) = target.custom.get("a3s") { + let record = PluginCatalogRecord::from_json(metadata.to_string().as_bytes())?; + record.validate()?; + validate_target_metadata(name, &record)?; + let expected_planning_target = format!("{}planning-v1.json", archive_prefix(name)); + if record.planning.is_some() { + expected_planning.insert(expected_planning_target); + } + packages.push(format!( + "{}@{} {}", + record.package_id, record.version, record.target + )); + } else if target.custom.contains_key("a3sPlanning") { + PluginPlanningBundle::from_json(&bytes)?; + seen_planning.insert(name.to_string()); + } + } + if packages.is_empty() { + return Err(tools_error( + "registry_tools.verify_failed", + "The registry contains no catalog package targets.", + )); + } + if seen_planning != expected_planning { + return Err(tools_error( + "registry_tools.verify_failed", + &format!( + "Planning targets {seen_planning:?} do not match the catalog expectations {expected_planning:?}." + ), + )); + } + Ok(VerifyReport { + root_sha256, + packages, + targets_checked, + }) +} + +/// The directory prefix one archive target name must carry. +fn archive_prefix(target_name: &str) -> &str { + match target_name.rfind('/') { + Some(index) => &target_name[..=index], + None => "", + } +} + +fn read_target(targets_directory: &Path, target_name: &str) -> UseResult> { + if target_name.contains("..") || target_name.starts_with('/') || target_name.contains('\\') { + return Err(tools_error( + "registry_tools.verify_failed", + &format!("Target name '{target_name}' is not a safe relative path."), + )); + } + std::fs::read(targets_directory.join(target_name)).map_err(|error| { + tools_error( + "registry_tools.verify_failed", + &format!("Failed to read target '{target_name}': {error}"), + ) + }) +} + +fn directory_url(path: &Path) -> Result { + Url::from_directory_path(path).map_err(|()| { + tools_error( + "registry_tools.verify_failed", + &format!( + "The path '{}' cannot be represented as a URL.", + path.display() + ), + ) + }) +} + +fn validate_target_metadata(target_name: &str, record: &PluginCatalogRecord) -> UseResult<()> { + let expected_prefix = format!( + "extensions/{}/{}/{}/{}/", + record.package_id, + record.version, + channel_segment(record.channel), + record.target + ); + if !target_name.starts_with(&expected_prefix) { + return Err(tools_error( + "registry_tools.verify_failed", + &format!( + "Target '{target_name}' does not live under its catalog identity prefix '{expected_prefix}'." + ), + )); + } + if record.archive.target_name != target_name { + return Err(tools_error( + "registry_tools.verify_failed", + &format!( + "Catalog record for '{}' names archive '{}' but is attached to '{target_name}'.", + record.package_id, record.archive.target_name + ), + )); + } + Ok(()) +} + +fn channel_segment(channel: PluginReleaseChannel) -> &'static str { + match channel { + PluginReleaseChannel::Stable => "stable", + PluginReleaseChannel::Beta => "beta", + PluginReleaseChannel::Nightly => "nightly", + } +} diff --git a/crates/registry-tools/tests/registry_tools.rs b/crates/registry-tools/tests/registry_tools.rs new file mode 100644 index 00000000..4fd5a82b --- /dev/null +++ b/crates/registry-tools/tests/registry_tools.rs @@ -0,0 +1,434 @@ +//! End-to-end tests for the registry tools binary: keygen → pack → +//! assemble → verify, plus the failure paths that must stay closed. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::Value; + +const MANIFEST: &str = "\ +extension \"a3s/registry-demo\" { + schema_version = 3 + version = \"0.1.0\" + route = \"registry-demo\" + requires_use = \">=0.3.0, <0.4.0\" + actions = [\"read\"] + + repository { + url = \"https://github.com/A3S-Lab/Use-Registry\" + revision = \"0123456789abcdef0123456789abcdef01234567\" + } + + skill \"demo\" { + path = \"skills/demo/SKILL.md\" + requires_tool = [] + requires_mcp = [] + optional = false + } +} +"; + +const TOOL_MANIFEST: &str = "\ +extension \"a3s/registry-echo\" { + schema_version = 3 + version = \"0.1.0\" + requires_use = \">=0.3.0, <0.4.0\" + actions = [\"read\", \"execute\"] + + repository { + url = \"https://github.com/A3S-Lab/Use-Registry\" + revision = \"0123456789abcdef0123456789abcdef01234567\" + } + + tool \"echo\" { + workload = \"task\" + interface = \"cli\" + executable = \"tools/echo\" + command = \"registry-echo\" + json_output = true + interactive = false + timeout_ms = 120000 + activation = \"lazy\" + optional = false + } +} +"; + +const PERMISSION_CEILING: &str = r#"{ + "schema": "a3s.use.plugin-permissions.v1", + "surfaces": [ + { + "surface": {"kind": "tool", "id": "echo"}, + "nativeExecution": true, + "childProcess": false, + "filesystem": [], + "networkEgress": [], + "privateService": false, + "secrets": [], + "resources": { + "cpuMillis": 1000, + "memoryBytes": 268435456, + "pids": 64, + "ephemeralStorageBytes": 1073741824, + "taskTimeoutMs": 120000, + "maxStdoutBytes": 1048576, + "maxStderrBytes": 1048576 + }, + "uiHttp": [] + } + ] +}"#; + +fn binary() -> Command { + Command::new(env!("CARGO_BIN_EXE_a3s-use-registry-tools")) +} + +fn run(command: &mut Command) -> (String, String, bool) { + let output = command + .output() + .expect("the registry tools binary must run"); + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + output.status.success(), + ) +} + +fn write_skill_package(root: &Path) -> PathBuf { + let package = root.join("packages/registry-demo"); + fs::create_dir_all(package.join("skills/demo")).unwrap(); + fs::write(package.join("a3s-use-extension.acl"), MANIFEST).unwrap(); + fs::write( + package.join("README.md"), + "# Registry demo\n\nSkill-only admission fixture.\n", + ) + .unwrap(); + fs::write( + package.join("skills/demo/SKILL.md"), + "---\nname: registry-demo\ndescription: Registry admission demo skill\n---\n# Demo\n", + ) + .unwrap(); + package +} + +fn write_tool_package(root: &Path) -> PathBuf { + let package = root.join("packages/registry-echo"); + fs::create_dir_all(package.join("tools")).unwrap(); + fs::write(package.join("a3s-use-extension.acl"), TOOL_MANIFEST).unwrap(); + fs::write( + package.join("README.md"), + "# Registry echo\n\nNative executable admission fixture.\n", + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::write(package.join("tools/echo"), "#!/bin/sh\nexec cat\n").unwrap(); + fs::set_permissions( + package.join("tools/echo"), + fs::Permissions::from_mode(0o755), + ) + .unwrap(); + } + #[cfg(not(unix))] + fs::write(package.join("tools/echo"), "echo").unwrap(); + package +} + +fn write_admissions(root: &Path, entries: &[(&str, &str, &str)]) -> PathBuf { + let mut acl = String::new(); + for (package_id, directory, extra) in entries { + acl.push_str(&format!( + "admission \"{package_id}\" {{\n package_directory = \"{directory}\"\n channel = \"stable\"\n target = \"any\"\n display_name = \"{package_id} demo\"\n description = \"Admission fixture for {package_id}.\"\n license = \"Apache-2.0\"\n keywords = [\"demo\"]\n categories = [\"testing\"]\n{extra}}}\n\n" + )); + } + let path = root.join("admissions.acl"); + fs::write(&path, acl).unwrap(); + path +} + +fn setup_registry(root: &Path, entries: &[(&str, &str, &str)]) -> (PathBuf, String) { + let keys = root.join("keys"); + let (_stdout, stderr, ok) = + run(binary().args(["keygen", "--keys-dir", keys.to_str().unwrap()])); + assert!(ok, "keygen failed: {stderr}"); + assert!(keys.join("root.key").exists()); + + let admissions = write_admissions(root, entries); + let out_root = root.join("registry"); + let (stdout, stderr, ok) = run(binary().args([ + "assemble", + "--keys-dir", + keys.to_str().unwrap(), + "--admissions", + admissions.to_str().unwrap(), + "--out-root", + out_root.to_str().unwrap(), + ])); + assert!(ok, "assemble failed: {stderr}"); + let outcome: Value = serde_json::from_str(&stdout).expect("assemble prints JSON"); + let root_sha256 = outcome["rootSha256"] + .as_str() + .expect("assemble reports the root digest") + .to_owned(); + (out_root, root_sha256) +} + +#[test] +fn skill_package_assembles_and_verifies() { + let root = tempfile::tempdir().unwrap(); + write_skill_package(root.path()); + let (registry, root_sha256) = setup_registry( + root.path(), + &[("a3s/registry-demo", "packages/registry-demo", "")], + ); + + assert!(registry.join("metadata/root.json").exists()); + assert!(registry.join("metadata/targets.json").exists()); + assert!(registry.join("metadata/snapshot.json").exists()); + assert!(registry.join("metadata/timestamp.json").exists()); + let archive = registry.join( + "targets/extensions/a3s/registry-demo/0.1.0/stable/any/a3s-registry-demo-0.1.0-any.tar.gz", + ); + assert!(archive.exists(), "the archive target must be published"); + + let (stdout, stderr, ok) = run(binary().args([ + "verify", + "--registry", + registry.to_str().unwrap(), + "--expected-root-sha256", + &root_sha256, + ])); + assert!(ok, "verify failed: {stderr}"); + let report: Value = serde_json::from_str(&stdout).expect("verify prints JSON"); + assert_eq!(report["targetsChecked"], 1); + assert!(report["packages"] + .as_array() + .unwrap() + .iter() + .any(|value| value + .as_str() + .unwrap() + .starts_with("a3s/registry-demo@0.1.0"))); +} + +#[test] +fn tool_package_carries_a_planning_target() { + let root = tempfile::tempdir().unwrap(); + write_tool_package(root.path()); + fs::write(root.path().join("ceiling.json"), PERMISSION_CEILING).unwrap(); + let (registry, _root_sha256) = setup_registry( + root.path(), + &[( + "a3s/registry-echo", + "packages/registry-echo", + " permission_ceiling = \"ceiling.json\"\n", + )], + ); + + let planning = + registry.join("targets/extensions/a3s/registry-echo/0.1.0/stable/any/planning-v1.json"); + assert!( + planning.exists(), + "executable packages need a planning target" + ); + let bundle: Value = serde_json::from_str(&fs::read_to_string(&planning).unwrap()).unwrap(); + assert_eq!(bundle["schema"], "a3s.use.plugin-planning-bundle.v1"); + assert_eq!(bundle["packageId"], "a3s/registry-echo"); + assert_eq!(bundle["surfaces"][0]["kind"], "tool-task-native"); + + let (stdout, stderr, ok) = + run(binary().args(["verify", "--registry", registry.to_str().unwrap()])); + assert!(ok, "verify failed: {stderr}"); + let report: Value = serde_json::from_str(&stdout).expect("verify prints JSON"); + assert_eq!(report["targetsChecked"], 2); +} + +#[test] +fn tampered_target_fails_verification() { + let root = tempfile::tempdir().unwrap(); + write_skill_package(root.path()); + let (registry, root_sha256) = setup_registry( + root.path(), + &[("a3s/registry-demo", "packages/registry-demo", "")], + ); + let archive = registry.join( + "targets/extensions/a3s/registry-demo/0.1.0/stable/any/a3s-registry-demo-0.1.0-any.tar.gz", + ); + let bytes = fs::read(&archive).unwrap(); + let mut tampered = bytes.clone(); + let last = tampered.len() - 1; + tampered[last] ^= 0xff; + fs::write(&archive, tampered).unwrap(); + + let (_stdout, stderr, ok) = run(binary().args([ + "verify", + "--registry", + registry.to_str().unwrap(), + "--expected-root-sha256", + &root_sha256, + ])); + assert!(!ok, "a tampered target must fail verification"); + assert!(stderr.contains("does not match its signed digest")); +} + +#[test] +fn admission_identity_mismatch_fails_assembly() { + let root = tempfile::tempdir().unwrap(); + write_skill_package(root.path()); + let admissions = write_admissions( + root.path(), + &[("a3s/someone-else", "packages/registry-demo", "")], + ); + let keys = root.path().join("keys"); + run(binary().args(["keygen", "--keys-dir", keys.to_str().unwrap()])); + + let (_stdout, stderr, ok) = run(binary().args([ + "assemble", + "--keys-dir", + keys.to_str().unwrap(), + "--admissions", + admissions.to_str().unwrap(), + "--out-root", + root.path().join("registry").to_str().unwrap(), + ])); + assert!( + !ok, + "an admission must not adopt another package's identity" + ); + assert!(stderr.contains("does not match the package manifest identity")); +} + +#[test] +fn executable_package_without_ceiling_fails_assembly() { + let root = tempfile::tempdir().unwrap(); + write_tool_package(root.path()); + let admissions = write_admissions( + root.path(), + &[("a3s/registry-echo", "packages/registry-echo", "")], + ); + let keys = root.path().join("keys"); + run(binary().args(["keygen", "--keys-dir", keys.to_str().unwrap()])); + + let (_stdout, stderr, ok) = run(binary().args([ + "assemble", + "--keys-dir", + keys.to_str().unwrap(), + "--admissions", + admissions.to_str().unwrap(), + "--out-root", + root.path().join("registry").to_str().unwrap(), + ])); + assert!( + !ok, + "executable surfaces require an explicit permission ceiling" + ); + assert!(stderr.contains("must declare permission_ceiling")); +} + +#[test] +fn keygen_refuses_to_overwrite_existing_keys() { + let root = tempfile::tempdir().unwrap(); + let keys = root.path().join("keys"); + let (_stdout, _stderr, ok) = + run(binary().args(["keygen", "--keys-dir", keys.to_str().unwrap()])); + assert!(ok); + let (_stdout, stderr, ok) = + run(binary().args(["keygen", "--keys-dir", keys.to_str().unwrap()])); + assert!( + !ok, + "a rerun must not silently rotate the registry identity" + ); + assert!(stderr.contains("Refusing to overwrite")); +} + +#[test] +fn republication_advances_the_metadata_version_the_client_accepts() { + let root = tempfile::tempdir().unwrap(); + write_skill_package(root.path()); + let admissions = write_admissions( + root.path(), + &[("a3s/registry-demo", "packages/registry-demo", "")], + ); + let keys = root.path().join("keys"); + run(binary().args(["keygen", "--keys-dir", keys.to_str().unwrap()])); + + let registry = root.path().join("registry"); + for version in [1_u64, 2, 3] { + let (stdout, stderr, ok) = run(binary().args([ + "assemble", + "--keys-dir", + keys.to_str().unwrap(), + "--admissions", + admissions.to_str().unwrap(), + "--out-root", + registry.to_str().unwrap(), + "--metadata-version", + &version.to_string(), + ])); + assert!(ok, "assemble v{version} failed: {stderr}"); + let outcome: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!( + outcome["metadataVersion"], version, + "assembly reports the version" + ); + let (_stdout, stderr, ok) = + run(binary().args(["verify", "--registry", registry.to_str().unwrap()])); + assert!(ok, "verify v{version} failed: {stderr}"); + let targets: Value = serde_json::from_str( + &fs::read_to_string(registry.join("metadata/targets.json")).unwrap(), + ) + .unwrap(); + assert_eq!(targets["signed"]["version"], version); + } +} + +#[test] +fn republication_with_a_new_package_version_keeps_one_identity_per_target() { + let root = tempfile::tempdir().unwrap(); + write_skill_package(root.path()); + let admissions = write_admissions( + root.path(), + &[("a3s/registry-demo", "packages/registry-demo", "")], + ); + let keys = root.path().join("keys"); + run(binary().args(["keygen", "--keys-dir", keys.to_str().unwrap()])); + let registry = root.path().join("registry"); + let assemble = |version: u64| { + let (_stdout, stderr, ok) = run(binary().args([ + "assemble", + "--keys-dir", + keys.to_str().unwrap(), + "--admissions", + admissions.to_str().unwrap(), + "--out-root", + registry.to_str().unwrap(), + "--metadata-version", + &version.to_string(), + ])); + assert!(ok, "assemble failed: {stderr}"); + }; + assemble(1); + + // Publish 0.2.0 while the 0.1.0 tree stays on disk: the stale archive + // is gone from the signed catalog, and the new identity verifies. + let manifest_path = root + .path() + .join("packages/registry-demo/a3s-use-extension.acl"); + let manifest = fs::read_to_string(&manifest_path).unwrap(); + fs::write(&manifest_path, manifest.replace("0.1.0", "0.2.0")).unwrap(); + assemble(2); + let (_stdout, stderr, ok) = + run(binary().args(["verify", "--registry", registry.to_str().unwrap()])); + assert!(ok, "verify after version bump failed: {stderr}"); + assert!( + !registry + .join("targets/extensions/a3s/registry-demo/0.1.0") + .exists(), + "the superseded version directory must not stay published", + ); + assert!(registry + .join("targets/extensions/a3s/registry-demo/0.2.0/stable/any/a3s-registry-demo-0.2.0-any.tar.gz") + .exists()); +} diff --git a/src/capability_gateway.rs b/src/capability_gateway.rs index 639d7a95..b9530232 100644 --- a/src/capability_gateway.rs +++ b/src/capability_gateway.rs @@ -1075,6 +1075,7 @@ impl CapabilityGatewayMcpServer { /// Return whether this server retains any complete generation lease. /// Session replacement uses this to prevent an accidentally unleased /// server from replacing a leased endpoint (or vice versa). + #[cfg(test)] pub(crate) fn has_generation_lease(&self) -> bool { self.generation_lease_mode() != CapabilityGatewayGenerationLeaseMode::None } diff --git a/src/control_store/aggregate/read/effects.rs b/src/control_store/aggregate/read/effects.rs index 9c486641..b37b8366 100644 --- a/src/control_store/aggregate/read/effects.rs +++ b/src/control_store/aggregate/read/effects.rs @@ -1,7 +1,5 @@ use std::collections::{btree_map::Entry, BTreeMap}; -use rusqlite::OptionalExtension as _; - use crate::control_store::model::{MAX_CONTROL_EFFECTS, MAX_CONTROL_EFFECT_PAYLOAD_TOTAL_BYTES}; use super::*; @@ -301,14 +299,12 @@ pub(in crate::control_store::aggregate) fn read_effect_by_key( installation: &InstallationId, idempotency_key: &str, ) -> UseResult> { - let operation_id = connection - .query_row( - "SELECT operation_id FROM effect_outbox WHERE idempotency_key = ?1", - [idempotency_key], - |row| row.get::<_, String>(0), - ) - .optional() - .map_err(|error| schema::sqlite_error("locate Control Store effect", error))?; + let operation_id = rusqlite::OptionalExtension::optional(connection.query_row( + "SELECT operation_id FROM effect_outbox WHERE idempotency_key = ?1", + [idempotency_key], + |row| row.get::<_, String>(0), + )) + .map_err(|error| schema::sqlite_error("locate Control Store effect", error))?; let Some(operation_id) = operation_id else { return Ok(None); }; diff --git a/src/control_store/capability_plane_effect_tests.rs b/src/control_store/capability_plane_effect_tests.rs index 73297385..2416637b 100644 --- a/src/control_store/capability_plane_effect_tests.rs +++ b/src/control_store/capability_plane_effect_tests.rs @@ -2436,18 +2436,22 @@ async fn assert_dispatch( }) .await .unwrap(); - assert!(matches!( - result, + match &result { ControlEffectDispatchResult::Observed { sequence: observed_sequence, attempt: observed_attempt, outcome, observation_changed: true, .. - } if observed_sequence == sequence - && observed_attempt == attempt - && outcome == expected_outcome - )); + } if *observed_sequence == sequence + && *observed_attempt == attempt + && *outcome == expected_outcome => {} + other => panic!( + "capability-plane dispatch mismatch for claim '{claim_token}': \ + expected Observed(sequence={sequence}, attempt={attempt}, \ + outcome={expected_outcome:?}, observation_changed=true), got {other:?}" + ), + } } fn exact_resource_descriptor(authority: &ControlCapabilityEffectAuthority) -> CapabilityDescriptor { diff --git a/src/control_store/effect_owner/capability_plane/descriptor_snapshot_store.rs b/src/control_store/effect_owner/capability_plane/descriptor_snapshot_store.rs index 8ec8ef0d..d169d42d 100644 --- a/src/control_store/effect_owner/capability_plane/descriptor_snapshot_store.rs +++ b/src/control_store/effect_owner/capability_plane/descriptor_snapshot_store.rs @@ -1075,7 +1075,6 @@ fn metadata_is_link(metadata: &std::fs::Metadata) -> bool { fn configure_no_follow(options: &mut fs::OpenOptions) { #[cfg(unix)] { - use std::os::unix::fs::OpenOptionsExt as _; options.custom_flags(libc::O_NOFOLLOW); } #[cfg(windows)] diff --git a/src/control_store/effect_owner/capability_plane/payload_retention_journal.rs b/src/control_store/effect_owner/capability_plane/payload_retention_journal.rs index bf5eb1c6..387ef72f 100644 --- a/src/control_store/effect_owner/capability_plane/payload_retention_journal.rs +++ b/src/control_store/effect_owner/capability_plane/payload_retention_journal.rs @@ -485,7 +485,6 @@ async fn create_journal(path: &Path, bytes: &[u8]) -> super::UseResult<()> { configure_no_follow(&mut options); #[cfg(unix)] { - use std::os::unix::fs::OpenOptionsExt as _; options.mode(0o600); } let mut file = options.open(path).await.map_err(|error| { @@ -645,7 +644,6 @@ fn journal_io(action: &str, path: &Path, error: io::Error) -> super::UseError { fn configure_no_follow(options: &mut fs::OpenOptions) { #[cfg(unix)] { - use std::os::unix::fs::OpenOptionsExt as _; options.custom_flags(libc::O_NOFOLLOW); } #[cfg(windows)]