From b494b0643cae3354a79ece03a67f635ce102f467 Mon Sep 17 00:00:00 2001 From: roylin Date: Mon, 7 Sep 2026 13:20:52 +0800 Subject: [PATCH 1/6] feat: add registry assembly, signing, and verification tools Extract the TUF signing primitives from test support into a production tuf_authoring module (canonical JSON, Ed25519 key ids, signed role documents) and expose the canonical expanded-package fingerprint. Build a3s-use-registry-tools on top: keygen with offline custody seeds, deterministic package packing, admission-driven catalog-v3 record and planning-bundle derivation, four-role TUF tree assembly, and a verifier that reloads the tree through the same tough loader a released client uses. The first published tree this tooling produced is verified end to end: source add with a pinned root, search, inspect, reviewed plan/apply, capability snapshot, and uninstall all pass against the bootstrapped Use-Registry dev-preview. --- Cargo.lock | 20 + Cargo.toml | 1 + crates/extension/src/digest.rs | 16 +- crates/extension/src/lib.rs | 5 + crates/extension/src/tuf_authoring.rs | 107 ++++ crates/extension/src/tuf_test_support.rs | 45 +- crates/registry-tools/Cargo.toml | 28 + crates/registry-tools/src/admission.rs | 188 +++++++ crates/registry-tools/src/assemble.rs | 274 ++++++++++ crates/registry-tools/src/catalog_build.rs | 488 ++++++++++++++++++ crates/registry-tools/src/keys.rs | 286 ++++++++++ crates/registry-tools/src/main.rs | 148 ++++++ crates/registry-tools/src/package_build.rs | 181 +++++++ crates/registry-tools/src/verify.rs | 233 +++++++++ crates/registry-tools/tests/registry_tools.rs | 344 ++++++++++++ 15 files changed, 2320 insertions(+), 44 deletions(-) create mode 100644 crates/extension/src/tuf_authoring.rs create mode 100644 crates/registry-tools/Cargo.toml create mode 100644 crates/registry-tools/src/admission.rs create mode 100644 crates/registry-tools/src/assemble.rs create mode 100644 crates/registry-tools/src/catalog_build.rs create mode 100644 crates/registry-tools/src/keys.rs create mode 100644 crates/registry-tools/src/main.rs create mode 100644 crates/registry-tools/src/package_build.rs create mode 100644 crates/registry-tools/src/verify.rs create mode 100644 crates/registry-tools/tests/registry_tools.rs 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..e5f918c5 --- /dev/null +++ b/crates/registry-tools/src/assemble.rs @@ -0,0 +1,274 @@ +//! 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"); + 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..f87478d2 --- /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()))?; + let metadata = std::fs::symlink_metadata(&path) + .map_err(|error| read_error(&path, &error.to_string()))?; + #[cfg(unix)] + let executable = { + use std::os::unix::fs::PermissionsExt; + 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..b921a445 --- /dev/null +++ b/crates/registry-tools/tests/registry_tools.rs @@ -0,0 +1,344 @@ +//! 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")); +} From a235d8a79b7b2704292297cb5c8e79103101270d Mon Sep 17 00:00:00 2001 From: roylin Date: Mon, 7 Sep 2026 13:34:51 +0800 Subject: [PATCH 2/6] fix: assemble from a clean derived tree and prove republication Assembly now clears the metadata/ and targets/ directories it owns before writing, so a superseded version can never linger beside a catalog that no longer advertises it. Two integration tests cover the ongoing publication workflow: metadata versions 1..3 assemble and verify in place, and a 0.1.0 -> 0.2.0 republication removes the stale target directory while the new identity verifies. The full client path was exercised end to end against a loopback server: install at metadata v1, republication at v2 with a package version bump, client metadata refresh, reviewed upgrade apply to capability generation 2, and clean uninstall. --- crates/registry-tools/src/assemble.rs | 16 ++++ crates/registry-tools/tests/registry_tools.rs | 96 +++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/crates/registry-tools/src/assemble.rs b/crates/registry-tools/src/assemble.rs index e5f918c5..4ad1cb6f 100644 --- a/crates/registry-tools/src/assemble.rs +++ b/crates/registry-tools/src/assemble.rs @@ -232,6 +232,22 @@ pub(crate) async fn assemble_registry( 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", diff --git a/crates/registry-tools/tests/registry_tools.rs b/crates/registry-tools/tests/registry_tools.rs index b921a445..2ed0519c 100644 --- a/crates/registry-tools/tests/registry_tools.rs +++ b/crates/registry-tools/tests/registry_tools.rs @@ -342,3 +342,99 @@ fn keygen_refuses_to_overwrite_existing_keys() { ); 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") + .replace("skills/demo/SKILL.md", "skills/demo/SKILL.md"), + ) + .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()); +} From 03a6c14eb436d80b199473118ae04c8311b5b34c Mon Sep 17 00:00:00 2001 From: roylin Date: Mon, 7 Sep 2026 13:36:31 +0800 Subject: [PATCH 3/6] chore: clear clippy warnings in the republication tests --- crates/registry-tools/tests/registry_tools.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/crates/registry-tools/tests/registry_tools.rs b/crates/registry-tools/tests/registry_tools.rs index 2ed0519c..4fd5a82b 100644 --- a/crates/registry-tools/tests/registry_tools.rs +++ b/crates/registry-tools/tests/registry_tools.rs @@ -373,7 +373,7 @@ fn republication_advances_the_metadata_version_the_client_accepts() { outcome["metadataVersion"], version, "assembly reports the version" ); - let (stdout, stderr, ok) = + 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( @@ -417,13 +417,7 @@ fn republication_with_a_new_package_version_keeps_one_identity_per_target() { .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") - .replace("skills/demo/SKILL.md", "skills/demo/SKILL.md"), - ) - .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()])); From 3c6caeb2d13beab37dec244c12912551bb4af74f Mon Sep 17 00:00:00 2001 From: roylin Date: Mon, 7 Sep 2026 14:08:44 +0800 Subject: [PATCH 4/6] fix: restore strict clippy on the facade main has been failing the test job since the gateway lease series: tokio's OpenOptions has inherent custom_flags/mode methods, so the shadowed std::os::unix::fs::OpenOptionsExt imports in the capability plane stores were never used, and has_generation_lease is exercised only by tests, so the lib target reported it as dead code. Drop the redundant imports and gate the test-only accessor with #[cfg(test)]. cargo clippy --workspace --all-targets --all-features --locked -- -D warnings passes and the full workspace suite is green (1421 passed). --- src/capability_gateway.rs | 1 + .../effect_owner/capability_plane/descriptor_snapshot_store.rs | 1 - .../effect_owner/capability_plane/payload_retention_journal.rs | 2 -- 3 files changed, 1 insertion(+), 3 deletions(-) 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/effect_owner/capability_plane/descriptor_snapshot_store.rs b/src/control_store/effect_owner/capability_plane/descriptor_snapshot_store.rs index 13725d65..ed21cb46 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 @@ -966,7 +966,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)] From fcd1521f157591c72ffef3b8383a4ad2f80919c6 Mon Sep 17 00:00:00 2001 From: roylin Date: Mon, 7 Sep 2026 14:35:01 +0800 Subject: [PATCH 5/6] fix: call OptionalExtension::optional fully qualified rustc 1.98 reports the file-level 'use rusqlite::OptionalExtension as _' as unused in this module while 1.97 does not, which fails the CI clippy gate on current stable. Call the adapter fully qualified instead so the lint cannot disagree between toolchain versions; the exact CI command now passes under both 1.97.1 and 1.98.1. --- src/control_store/aggregate/read/effects.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) 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); }; From 7356bc5832afd21d86bd0b648a34f960936a14d8 Mon Sep 17 00:00:00 2001 From: roylin Date: Mon, 7 Sep 2026 15:52:19 +0800 Subject: [PATCH 6/6] fix: silence Windows unused metadata and clarify dispatch asserts Keep symlink metadata reads Unix-only in registry packing so Windows builds stay warning-clean, and surface the actual Control effect dispatch result when capability-plane fixture setup mismatches. Co-authored-by: Cursor --- crates/registry-tools/src/package_build.rs | 4 ++-- .../capability_plane_effect_tests.rs | 16 ++++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/registry-tools/src/package_build.rs b/crates/registry-tools/src/package_build.rs index f87478d2..1a3d5ce6 100644 --- a/crates/registry-tools/src/package_build.rs +++ b/crates/registry-tools/src/package_build.rs @@ -39,11 +39,11 @@ pub(crate) fn deterministic_package_archive(root: &Path) -> UseResult> { 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()))?; - let metadata = std::fs::symlink_metadata(&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))] 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 {