use std::net::IpAddr;
use std::path::{Component, Path, PathBuf};
use tokio::fs;
use crate::bootstrap::ironclaw_base_dir;
use crate::registry::catalog::RegistryError;
use crate::registry::manifest::{BundleDefinition, ExtensionManifest, ManifestKind, SourceSpec};
const ALLOWED_ARTIFACT_HOSTS: &[&str] = &[
"github.com",
"objects.githubusercontent.com",
"github-releases.githubusercontent.com",
"raw.githubusercontent.com",
];
fn should_attempt_source_fallback(err: &RegistryError) -> bool {
match err {
RegistryError::ChecksumMismatch { url, .. } => {
url.contains("github.com/nearai/ironclaw/releases/latest/")
}
RegistryError::AlreadyInstalled { .. } | RegistryError::InvalidManifest { .. } => false,
_ => true,
}
}
fn is_allowed_artifact_host(host: &str) -> bool {
ALLOWED_ARTIFACT_HOSTS
.iter()
.any(|allowed| host.eq_ignore_ascii_case(allowed))
|| host.ends_with(".githubusercontent.com")
}
fn validate_artifact_url(
manifest_name: &str,
field: &'static str,
url: &str,
) -> Result<(), RegistryError> {
let parsed = reqwest::Url::parse(url).map_err(|e| RegistryError::InvalidManifest {
name: manifest_name.to_string(),
field,
reason: format!("invalid URL: {}", e),
})?;
if parsed.scheme() != "https" {
return Err(RegistryError::InvalidManifest {
name: manifest_name.to_string(),
field,
reason: "URL must use https".to_string(),
});
}
let host = parsed
.host_str()
.ok_or_else(|| RegistryError::InvalidManifest {
name: manifest_name.to_string(),
field,
reason: "URL host is missing".to_string(),
})?;
if host.parse::<IpAddr>().is_ok() || !is_allowed_artifact_host(host) {
return Err(RegistryError::InvalidManifest {
name: manifest_name.to_string(),
field,
reason: format!("host '{}' is not allowed", host),
});
}
Ok(())
}
fn validate_manifest_install_inputs(manifest: &ExtensionManifest) -> Result<(), RegistryError> {
let is_valid_name = !manifest.name.is_empty()
&& manifest
.name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_');
if !is_valid_name {
return Err(RegistryError::InvalidManifest {
name: manifest.name.clone(),
field: "name",
reason: "name must contain only lowercase letters, digits, '-' or '_'".to_string(),
});
}
if manifest.kind == ManifestKind::McpServer {
return Ok(());
}
let source = match &manifest.source {
Some(s) => s,
None => {
return Err(RegistryError::InvalidManifest {
name: manifest.name.clone(),
field: "source",
reason: "WASM extensions must have a source spec".to_string(),
});
}
};
let expected_prefix = match manifest.kind {
ManifestKind::Tool => "tools-src/",
ManifestKind::Channel => "channels-src/",
ManifestKind::McpServer => unreachable!(),
};
if !source.dir.starts_with(expected_prefix) {
return Err(RegistryError::InvalidManifest {
name: manifest.name.clone(),
field: "source.dir",
reason: format!("must start with '{}'", expected_prefix),
});
}
let source_path = Path::new(&source.dir);
let has_unsafe_component = source_path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_) | Component::CurDir
)
});
if source_path.is_absolute() || has_unsafe_component {
return Err(RegistryError::InvalidManifest {
name: manifest.name.clone(),
field: "source.dir",
reason: "must be a safe relative path without traversal segments".to_string(),
});
}
let has_path_separator = source.capabilities.contains('/')
|| source.capabilities.contains('\\')
|| source.capabilities.contains("..");
if has_path_separator {
return Err(RegistryError::InvalidManifest {
name: manifest.name.clone(),
field: "source.capabilities",
reason: "must be a file name without path separators".to_string(),
});
}
Ok(())
}
fn require_source(manifest: &ExtensionManifest) -> Result<&SourceSpec, RegistryError> {
manifest
.source
.as_ref()
.ok_or_else(|| RegistryError::InvalidManifest {
name: manifest.name.clone(),
field: "source",
reason: "WASM extensions must have a source spec".to_string(),
})
}
fn download_failure_reason(error: &reqwest::Error) -> String {
if error.is_timeout() {
"request timed out".to_string()
} else if error.is_connect() {
"connection failed".to_string()
} else if error.is_request() {
"request failed".to_string()
} else {
"network error".to_string()
}
}
#[derive(Debug)]
pub struct InstallOutcome {
pub name: String,
pub kind: ManifestKind,
pub wasm_path: PathBuf,
pub has_capabilities: bool,
pub warnings: Vec<String>,
}
pub struct RegistryInstaller {
repo_root: PathBuf,
tools_dir: PathBuf,
channels_dir: PathBuf,
}
impl RegistryInstaller {
pub fn new(repo_root: PathBuf, tools_dir: PathBuf, channels_dir: PathBuf) -> Self {
Self {
repo_root,
tools_dir,
channels_dir,
}
}
pub fn with_defaults(repo_root: PathBuf) -> Self {
let base_dir = ironclaw_base_dir();
Self {
repo_root,
tools_dir: base_dir.join("tools"),
channels_dir: base_dir.join("channels"),
}
}
pub async fn install_from_source(
&self,
manifest: &ExtensionManifest,
force: bool,
) -> Result<InstallOutcome, RegistryError> {
validate_manifest_install_inputs(manifest)?;
if manifest.kind == ManifestKind::McpServer {
return Err(RegistryError::InvalidManifest {
name: manifest.name.clone(),
field: "kind",
reason: "MCP servers cannot be installed from source".to_string(),
});
}
let source = require_source(manifest)?;
let source_dir = self.repo_root.join(&source.dir);
if !source_dir.exists() {
return Err(RegistryError::ManifestRead {
path: source_dir.clone(),
reason: "source directory does not exist".to_string(),
});
}
let target_dir = match manifest.kind {
ManifestKind::Tool => &self.tools_dir,
ManifestKind::Channel => &self.channels_dir,
ManifestKind::McpServer => unreachable!(),
};
fs::create_dir_all(target_dir)
.await
.map_err(RegistryError::Io)?;
let target_wasm = target_dir.join(format!("{}.wasm", manifest.name));
if target_wasm.exists() && !force {
return Err(RegistryError::AlreadyInstalled {
name: manifest.name.clone(),
path: target_wasm,
});
}
println!(
"Building {} '{}' from {}...",
manifest.kind,
manifest.display_name,
source_dir.display()
);
let crate_name = &source.crate_name;
let wasm_path =
crate::registry::artifacts::build_wasm_component(&source_dir, crate_name, true)
.await
.map_err(|e| RegistryError::ManifestRead {
path: source_dir.clone(),
reason: format!("build failed: {}", e),
})?;
println!(" Installing to {}", target_wasm.display());
fs::copy(&wasm_path, &target_wasm)
.await
.map_err(RegistryError::Io)?;
let caps_source = source_dir.join(&source.capabilities);
let target_caps = target_dir.join(format!("{}.capabilities.json", manifest.name));
let has_capabilities = if caps_source.exists() {
fs::copy(&caps_source, &target_caps)
.await
.map_err(RegistryError::Io)?;
true
} else {
false
};
let mut warnings = Vec::new();
if !has_capabilities {
warnings.push(format!(
"No capabilities file found at {}",
caps_source.display()
));
}
Ok(InstallOutcome {
name: manifest.name.clone(),
kind: manifest.kind,
wasm_path: target_wasm,
has_capabilities,
warnings,
})
}
pub async fn install_with_source_fallback(
&self,
manifest: &ExtensionManifest,
force: bool,
) -> Result<InstallOutcome, RegistryError> {
validate_manifest_install_inputs(manifest)?;
if manifest.kind == ManifestKind::McpServer {
return Err(RegistryError::InvalidManifest {
name: manifest.name.clone(),
field: "kind",
reason: "MCP servers cannot be installed via the WASM installer".to_string(),
});
}
let source = require_source(manifest)?;
let has_artifact = manifest
.artifacts
.get("wasm32-wasip2")
.and_then(|a| a.url.as_ref())
.is_some();
if !has_artifact {
return self.install_from_source(manifest, force).await;
}
let source_dir = self.repo_root.join(&source.dir);
match self.install_from_artifact(manifest, force).await {
Ok(outcome) => Ok(outcome),
Err(artifact_err) => {
if !should_attempt_source_fallback(&artifact_err) {
return Err(artifact_err);
}
if !source_dir.is_dir() {
return Err(RegistryError::SourceFallbackUnavailable {
name: manifest.name.clone(),
source_dir,
artifact_error: Box::new(artifact_err),
});
}
tracing::warn!(
extension = %manifest.name,
error = %artifact_err,
"Artifact install failed; falling back to build-from-source"
);
match self.install_from_source(manifest, force).await {
Ok(mut outcome) => {
outcome.warnings.push(format!(
"Artifact install failed ({}); installed via source fallback.",
artifact_err
));
Ok(outcome)
}
Err(source_err) => Err(RegistryError::InstallFallbackFailed {
name: manifest.name.clone(),
artifact_error: Box::new(artifact_err),
source_error: Box::new(source_err),
}),
}
}
}
}
pub async fn install_from_artifact(
&self,
manifest: &ExtensionManifest,
force: bool,
) -> Result<InstallOutcome, RegistryError> {
validate_manifest_install_inputs(manifest)?;
let artifact = manifest.artifacts.get("wasm32-wasip2").ok_or_else(|| {
RegistryError::ExtensionNotFound(format!(
"No wasm32-wasip2 artifact for '{}'",
manifest.name
))
})?;
let url = artifact.url.as_ref().ok_or_else(|| {
RegistryError::ExtensionNotFound(format!(
"No artifact URL for '{}'. Use --build to build from source.",
manifest.name
))
})?;
validate_artifact_url(&manifest.name, "artifacts.wasm32-wasip2.url", url)?;
let expected_sha =
artifact
.sha256
.as_ref()
.ok_or_else(|| RegistryError::MissingChecksum {
name: manifest.name.clone(),
})?;
let target_dir = match manifest.kind {
ManifestKind::Tool => &self.tools_dir,
ManifestKind::Channel => &self.channels_dir,
ManifestKind::McpServer => {
return Err(RegistryError::InvalidManifest {
name: manifest.name.clone(),
field: "kind",
reason: "MCP servers cannot be installed as artifacts".to_string(),
});
}
};
fs::create_dir_all(target_dir)
.await
.map_err(RegistryError::Io)?;
let target_wasm = target_dir.join(format!("{}.wasm", manifest.name));
if target_wasm.exists() && !force {
return Err(RegistryError::AlreadyInstalled {
name: manifest.name.clone(),
path: target_wasm,
});
}
println!(
"Downloading {} '{}'...",
manifest.kind, manifest.display_name
);
let bytes = download_artifact(url).await?;
verify_sha256(&bytes, expected_sha, url)?;
let target_caps = target_dir.join(format!("{}.capabilities.json", manifest.name));
let has_capabilities = if is_gzip(&bytes) {
let extracted =
extract_tar_gz(&bytes, &manifest.name, &target_wasm, &target_caps, url)?;
extracted.has_capabilities
} else {
fs::write(&target_wasm, &bytes)
.await
.map_err(RegistryError::Io)?;
if let Some(ref caps_url) = artifact.capabilities_url {
validate_artifact_url(
&manifest.name,
"artifacts.wasm32-wasip2.capabilities_url",
caps_url,
)?;
const MAX_CAPS_SIZE: usize = 1024 * 1024; match download_artifact(caps_url).await {
Ok(caps_bytes) if caps_bytes.len() <= MAX_CAPS_SIZE => {
fs::write(&target_caps, &caps_bytes)
.await
.map_err(RegistryError::Io)?;
true
}
Ok(caps_bytes) => {
tracing::warn!(
"Capabilities file too large ({} bytes, max {}), skipping",
caps_bytes.len(),
MAX_CAPS_SIZE
);
false
}
Err(e) => {
tracing::warn!("Failed to download capabilities from {}: {}", caps_url, e);
false
}
}
} else if let Some(ref source) = manifest.source {
let caps_source = self.repo_root.join(&source.dir).join(&source.capabilities);
if caps_source.exists() {
fs::copy(&caps_source, &target_caps)
.await
.map_err(RegistryError::Io)?;
true
} else {
false
}
} else {
false
}
};
println!(" Installed to {}", target_wasm.display());
let mut warnings = Vec::new();
if !has_capabilities {
warnings.push(format!(
"No capabilities file found for '{}'. Auth and hooks may not work.",
manifest.name
));
}
Ok(InstallOutcome {
name: manifest.name.clone(),
kind: manifest.kind,
wasm_path: target_wasm,
has_capabilities,
warnings,
})
}
pub async fn install(
&self,
manifest: &ExtensionManifest,
force: bool,
prefer_build: bool,
) -> Result<InstallOutcome, RegistryError> {
let has_artifact = manifest
.artifacts
.get("wasm32-wasip2")
.and_then(|a| a.url.as_ref())
.is_some();
if prefer_build || !has_artifact {
self.install_from_source(manifest, force).await
} else {
self.install_with_source_fallback(manifest, force).await
}
}
pub async fn install_bundle(
&self,
manifests: &[&ExtensionManifest],
bundle: &BundleDefinition,
force: bool,
prefer_build: bool,
) -> (Vec<InstallOutcome>, Vec<String>) {
let mut outcomes = Vec::new();
let mut errors = Vec::new();
for manifest in manifests {
match self.install(manifest, force, prefer_build).await {
Ok(outcome) => outcomes.push(outcome),
Err(e) => errors.push(format!("{}: {}", manifest.name, e)),
}
}
let mut auth_hints = Vec::new();
if let Some(shared) = &bundle.shared_auth {
auth_hints.push(format!(
"Bundle uses shared auth '{}'. Run `ironclaw tool auth <any-member>` to authenticate all members.",
shared
));
}
let mut seen_providers = std::collections::HashSet::new();
for manifest in manifests {
if let Some(auth) = &manifest.auth_summary {
let key = auth
.shared_auth
.as_deref()
.unwrap_or(manifest.name.as_str());
if seen_providers.insert(key.to_string())
&& let Some(url) = &auth.setup_url
{
auth_hints.push(format!(
" {} ({}): {}",
auth.provider.as_deref().unwrap_or(&manifest.name),
auth.method.as_deref().unwrap_or("manual"),
url
));
}
}
}
if !errors.is_empty() {
auth_hints.push(format!(
"\nFailed to install {} extension(s):",
errors.len()
));
for err in errors {
auth_hints.push(format!(" - {}", err));
}
}
(outcomes, auth_hints)
}
}
async fn download_artifact(url: &str) -> Result<bytes::Bytes, RegistryError> {
let response = reqwest::get(url)
.await
.map_err(|e| RegistryError::DownloadFailed {
url: url.to_string(),
reason: download_failure_reason(&e),
})?;
let response = response
.error_for_status()
.map_err(|e| RegistryError::DownloadFailed {
url: url.to_string(),
reason: format!(
"http status {}",
e.status()
.map_or("unknown".to_string(), |status| status.as_u16().to_string())
),
})?;
response
.bytes()
.await
.map_err(|e| RegistryError::DownloadFailed {
url: url.to_string(),
reason: format!("failed to read response body: {}", e),
})
}
fn verify_sha256(bytes: &[u8], expected: &str, url: &str) -> Result<(), RegistryError> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
let actual = format!("{:x}", hasher.finalize());
if actual != expected {
return Err(RegistryError::ChecksumMismatch {
url: url.to_string(),
expected_sha256: expected.to_string(),
actual_sha256: actual,
});
}
Ok(())
}
fn is_gzip(bytes: &[u8]) -> bool {
bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b
}
#[derive(Debug)]
struct ExtractResult {
has_capabilities: bool,
}
fn extract_tar_gz(
bytes: &[u8],
name: &str,
target_wasm: &Path,
target_caps: &Path,
url: &str,
) -> Result<ExtractResult, RegistryError> {
use flate2::read::GzDecoder;
use tar::Archive;
use std::io::Read as _;
let decoder = GzDecoder::new(bytes);
let mut archive = Archive::new(decoder);
archive.set_preserve_permissions(false);
#[cfg(any(unix, target_os = "redox"))]
archive.set_unpack_xattrs(false);
const MAX_ENTRY_SIZE: u64 = 100 * 1024 * 1024;
let wasm_filename = format!("{}.wasm", name);
let caps_filename = format!("{}.capabilities.json", name);
let mut found_wasm = false;
let mut found_caps = false;
let entries = archive
.entries()
.map_err(|e| RegistryError::DownloadFailed {
url: url.to_string(),
reason: format!("failed to read tar.gz entries: {}", e),
})?;
for entry in entries {
let mut entry = entry.map_err(|e| RegistryError::DownloadFailed {
url: url.to_string(),
reason: format!("failed to read tar.gz entry: {}", e),
})?;
if entry.size() > MAX_ENTRY_SIZE {
return Err(RegistryError::DownloadFailed {
url: url.to_string(),
reason: format!(
"archive entry too large ({} bytes, max {} bytes)",
entry.size(),
MAX_ENTRY_SIZE
),
});
}
let entry_path = entry
.path()
.map_err(|e| RegistryError::DownloadFailed {
url: url.to_string(),
reason: format!("invalid path in tar.gz: {}", e),
})?
.to_path_buf();
let filename = entry_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if filename == wasm_filename {
let mut data = Vec::with_capacity(entry.size() as usize);
std::io::Read::read_to_end(&mut entry.by_ref().take(MAX_ENTRY_SIZE), &mut data)
.map_err(|e| RegistryError::DownloadFailed {
url: url.to_string(),
reason: format!("failed to read {} from archive: {}", wasm_filename, e),
})?;
std::fs::write(target_wasm, &data).map_err(RegistryError::Io)?;
found_wasm = true;
} else if filename == caps_filename {
let mut data = Vec::with_capacity(entry.size() as usize);
std::io::Read::read_to_end(&mut entry.by_ref().take(MAX_ENTRY_SIZE), &mut data)
.map_err(|e| RegistryError::DownloadFailed {
url: url.to_string(),
reason: format!("failed to read {} from archive: {}", caps_filename, e),
})?;
std::fs::write(target_caps, &data).map_err(RegistryError::Io)?;
found_caps = true;
}
}
if !found_wasm {
return Err(RegistryError::DownloadFailed {
url: url.to_string(),
reason: format!(
"tar.gz archive does not contain '{}'. Archive may be malformed.",
wasm_filename
),
});
}
Ok(ExtractResult {
has_capabilities: found_caps,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use crate::registry::manifest::{ArtifactSpec, SourceSpec};
fn test_manifest(
name: &str,
source_dir: &str,
artifact_url: Option<String>,
sha256: Option<&str>,
) -> ExtensionManifest {
test_manifest_with_kind(name, source_dir, artifact_url, sha256, ManifestKind::Tool)
}
fn test_manifest_with_kind(
name: &str,
source_dir: &str,
artifact_url: Option<String>,
sha256: Option<&str>,
kind: ManifestKind,
) -> ExtensionManifest {
let mut artifacts = HashMap::new();
if artifact_url.is_some() || sha256.is_some() {
artifacts.insert(
"wasm32-wasip2".to_string(),
ArtifactSpec {
url: artifact_url,
sha256: sha256.map(ToString::to_string),
capabilities_url: None,
},
);
}
ExtensionManifest {
name: name.to_string(),
display_name: name.to_string(),
kind,
version: Some("0.1.0".to_string()),
description: "test manifest".to_string(),
keywords: Vec::new(),
source: Some(SourceSpec {
dir: source_dir.to_string(),
capabilities: format!("{}.capabilities.json", name),
crate_name: name.to_string(),
}),
artifacts,
auth_summary: None,
tags: Vec::new(),
url: None,
auth: None,
}
}
#[test]
fn test_installer_creation() {
let installer = RegistryInstaller::new(
PathBuf::from("/repo"),
PathBuf::from("/home/.ironclaw/tools"),
PathBuf::from("/home/.ironclaw/channels"),
);
assert_eq!(installer.repo_root, PathBuf::from("/repo"));
}
#[test]
fn test_is_gzip() {
assert!(is_gzip(&[0x1f, 0x8b, 0x08]));
assert!(!is_gzip(&[0x00, 0x61, 0x73, 0x6d])); assert!(!is_gzip(&[0x1f])); assert!(!is_gzip(&[]));
}
#[test]
fn test_verify_sha256_valid() {
use sha2::{Digest, Sha256};
let data = b"hello world";
let mut hasher = Sha256::new();
hasher.update(data);
let hash = format!("{:x}", hasher.finalize());
assert!(verify_sha256(data, &hash, "test://url").is_ok());
}
#[test]
fn test_verify_sha256_invalid() {
let err = verify_sha256(b"data", "0000", "test://url").expect_err("checksum mismatch");
assert!(matches!(err, RegistryError::ChecksumMismatch { .. }));
}
#[tokio::test]
async fn test_install_from_source_rejects_path_traversal_name() {
let temp = tempfile::tempdir().expect("tempdir");
let installer = RegistryInstaller::new(
temp.path().to_path_buf(),
temp.path().join("tools"),
temp.path().join("channels"),
);
let manifest = test_manifest("../evil", "tools-src/evil", None, None);
let result = installer.install_from_source(&manifest, false).await;
match result {
Err(RegistryError::InvalidManifest { field, .. }) => {
assert_eq!(field, "name");
}
other => panic!("unexpected result: {:?}", other),
}
}
#[tokio::test]
async fn test_install_from_artifact_rejects_non_https_url() {
let temp = tempfile::tempdir().expect("tempdir");
let installer = RegistryInstaller::new(
temp.path().to_path_buf(),
temp.path().join("tools"),
temp.path().join("channels"),
);
let manifest = test_manifest(
"demo",
"tools-src/demo",
Some(
"http://github.com/nearai/ironclaw/releases/latest/download/demo.wasm".to_string(),
),
None,
);
let result = installer.install_from_artifact(&manifest, false).await;
match result {
Err(RegistryError::InvalidManifest { field, .. }) => {
assert_eq!(field, "artifacts.wasm32-wasip2.url");
}
other => panic!("unexpected result: {:?}", other),
}
}
#[tokio::test]
async fn test_install_from_artifact_rejects_disallowed_host() {
let temp = tempfile::tempdir().expect("tempdir");
let installer = RegistryInstaller::new(
temp.path().to_path_buf(),
temp.path().join("tools"),
temp.path().join("channels"),
);
let manifest = test_manifest(
"demo",
"tools-src/demo",
Some("https://169.254.169.254/latest/meta-data".to_string()),
None,
);
let result = installer.install_from_artifact(&manifest, false).await;
match result {
Err(RegistryError::InvalidManifest { field, .. }) => {
assert_eq!(field, "artifacts.wasm32-wasip2.url");
}
other => panic!("unexpected result: {:?}", other),
}
}
#[tokio::test]
async fn test_install_from_artifact_rejects_null_sha256() {
let temp = tempfile::tempdir().expect("tempdir");
let installer = RegistryInstaller::new(
temp.path().to_path_buf(),
temp.path().join("tools"),
temp.path().join("channels"),
);
let manifest = test_manifest(
"demo",
"tools-src/demo",
Some(
"https://github.com/nearai/ironclaw/releases/latest/download/demo-wasm32-wasip2.tar.gz".to_string(),
),
None, );
let result = installer.install_from_artifact(&manifest, false).await;
match result {
Err(RegistryError::MissingChecksum { name }) => {
assert_eq!(name, "demo");
}
other => panic!("unexpected result: {:?}", other),
}
}
#[test]
fn test_should_attempt_source_fallback_policy() {
let download = RegistryError::DownloadFailed {
url: "https://github.com/nearai/ironclaw/releases/latest/download/demo.wasm"
.to_string(),
reason: "http status 404".to_string(),
};
assert!(should_attempt_source_fallback(&download));
let already = RegistryError::AlreadyInstalled {
name: "demo".to_string(),
path: PathBuf::from("/tmp/demo.wasm"),
};
assert!(!should_attempt_source_fallback(&already));
let invalid = RegistryError::InvalidManifest {
name: "demo".to_string(),
field: "artifacts.wasm32-wasip2.url",
reason: "host not allowed".to_string(),
};
assert!(!should_attempt_source_fallback(&invalid));
let missing = RegistryError::MissingChecksum {
name: "demo".to_string(),
};
assert!(should_attempt_source_fallback(&missing));
}
#[test]
fn test_extract_tar_gz() {
use flate2::Compression;
use flate2::write::GzEncoder;
use tar::Builder;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
{
let mut builder = Builder::new(&mut encoder);
let wasm_data = b"\0asm\x01\x00\x00\x00";
let mut header = tar::Header::new_gnu();
header.set_size(wasm_data.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, "test.wasm", &wasm_data[..])
.unwrap();
let caps_data = br#"{"auth":null}"#;
let mut header = tar::Header::new_gnu();
header.set_size(caps_data.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, "test.capabilities.json", &caps_data[..])
.unwrap();
builder.finish().unwrap();
}
let gz_bytes = encoder.finish().unwrap();
let tmp = tempfile::tempdir().unwrap();
let wasm_path = tmp.path().join("test.wasm");
let caps_path = tmp.path().join("test.capabilities.json");
let result =
extract_tar_gz(&gz_bytes, "test", &wasm_path, &caps_path, "test://url").unwrap();
assert!(wasm_path.exists());
assert!(caps_path.exists());
assert!(result.has_capabilities);
}
#[tokio::test]
async fn test_install_from_source_rejects_wrong_prefix_for_channel() {
let temp = tempfile::tempdir().expect("tempdir");
let installer = RegistryInstaller::new(
temp.path().to_path_buf(),
temp.path().join("tools"),
temp.path().join("channels"),
);
let manifest = test_manifest_with_kind(
"telegram",
"tools-src/telegram",
None,
None,
ManifestKind::Channel,
);
let result = installer.install_from_source(&manifest, false).await;
match result {
Err(RegistryError::InvalidManifest { field, reason, .. }) => {
assert_eq!(field, "source.dir");
assert!(reason.contains("channels-src/"), "reason: {}", reason);
}
other => panic!("unexpected result: {:?}", other),
}
}
#[tokio::test]
async fn test_install_from_source_accepts_correct_channel_prefix() {
let temp = tempfile::tempdir().expect("tempdir");
let installer = RegistryInstaller::new(
temp.path().to_path_buf(),
temp.path().join("tools"),
temp.path().join("channels"),
);
let manifest = test_manifest_with_kind(
"telegram",
"channels-src/telegram",
None,
None,
ManifestKind::Channel,
);
let result = installer.install_from_source(&manifest, false).await;
match result {
Err(RegistryError::ManifestRead { reason, .. }) => {
assert!(
reason.contains("source directory does not exist"),
"reason: {}",
reason
);
}
other => panic!("unexpected result: {:?}", other),
}
}
#[test]
fn test_extract_tar_gz_missing_wasm() {
use flate2::Compression;
use flate2::write::GzEncoder;
use tar::Builder;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
{
let mut builder = Builder::new(&mut encoder);
let data = b"not a wasm file";
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, "wrong.wasm", &data[..])
.unwrap();
builder.finish().unwrap();
}
let gz_bytes = encoder.finish().unwrap();
let tmp = tempfile::tempdir().unwrap();
let result = extract_tar_gz(
&gz_bytes,
"test",
&tmp.path().join("test.wasm"),
&tmp.path().join("test.capabilities.json"),
"test://url",
);
assert!(result.is_err());
}
#[test]
fn test_source_fallback_on_latest_url_mismatch() {
let latest_mismatch = RegistryError::ChecksumMismatch {
url: "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz".to_string(),
expected_sha256: "aaa".to_string(),
actual_sha256: "bbb".to_string(),
};
assert!(
should_attempt_source_fallback(&latest_mismatch),
"ChecksumMismatch on releases/latest URL should allow source fallback"
);
let pinned_mismatch = RegistryError::ChecksumMismatch {
url: "https://github.com/nearai/ironclaw/releases/download/v0.7.0/github-0.2.0-wasm32-wasip2.tar.gz".to_string(),
expected_sha256: "aaa".to_string(),
actual_sha256: "bbb".to_string(),
};
assert!(
!should_attempt_source_fallback(&pinned_mismatch),
"ChecksumMismatch on version-pinned URL must remain a hard block"
);
}
fn build_test_tar_gz(wasm_name: &str, caps_name: Option<&str>) -> Vec<u8> {
use flate2::Compression;
use flate2::write::GzEncoder;
use tar::Builder;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
{
let mut builder = Builder::new(&mut encoder);
let wasm_data = b"\0asm\x01\x00\x00\x00";
let mut header = tar::Header::new_gnu();
header.set_size(wasm_data.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, wasm_name, &wasm_data[..])
.unwrap();
if let Some(caps) = caps_name {
let caps_data = br#"{"auth":null}"#;
let mut header = tar::Header::new_gnu();
header.set_size(caps_data.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, caps, &caps_data[..])
.unwrap();
}
builder.finish().unwrap();
}
encoder.finish().unwrap()
}
#[test]
fn test_extract_rejects_archive_with_wrong_wasm_name() {
let gz_bytes = build_test_tar_gz("slack.wasm", Some("slack.capabilities.json"));
let tmp = tempfile::tempdir().unwrap();
let result = extract_tar_gz(
&gz_bytes,
"slack-tool",
&tmp.path().join("slack-tool.wasm"),
&tmp.path().join("slack-tool.capabilities.json"),
"test://url",
);
let err = result.expect_err("should fail when archive has wrong wasm name");
match err {
RegistryError::DownloadFailed { reason, .. } => {
assert!(
reason.contains("slack-tool.wasm"),
"error should mention expected filename: {}",
reason
);
}
other => panic!("expected DownloadFailed, got: {:?}", other),
}
}
#[test]
fn test_extract_correct_wasm_from_tool_bundle() {
let gz_bytes = build_test_tar_gz("slack-tool.wasm", Some("slack-tool.capabilities.json"));
let tmp = tempfile::tempdir().unwrap();
let wasm_path = tmp.path().join("slack-tool.wasm");
let caps_path = tmp.path().join("slack-tool.capabilities.json");
let result = extract_tar_gz(
&gz_bytes,
"slack-tool",
&wasm_path,
&caps_path,
"test://url",
)
.unwrap();
assert!(wasm_path.exists());
assert!(caps_path.exists());
assert!(result.has_capabilities);
}
#[test]
fn test_extract_correct_wasm_from_channel_bundle() {
let gz_bytes = build_test_tar_gz("slack.wasm", Some("slack.capabilities.json"));
let tmp = tempfile::tempdir().unwrap();
let wasm_path = tmp.path().join("slack.wasm");
let caps_path = tmp.path().join("slack.capabilities.json");
let result =
extract_tar_gz(&gz_bytes, "slack", &wasm_path, &caps_path, "test://url").unwrap();
assert!(wasm_path.exists());
assert!(caps_path.exists());
assert!(result.has_capabilities);
}
#[tokio::test]
async fn test_tool_and_channel_install_to_separate_directories() {
let temp = tempfile::tempdir().expect("tempdir");
let installer = RegistryInstaller::new(
temp.path().to_path_buf(),
temp.path().join("tools"),
temp.path().join("channels"),
);
let tool_manifest = test_manifest_with_kind(
"slack-tool",
"tools-src/slack",
None,
None,
ManifestKind::Tool,
);
let channel_manifest = test_manifest_with_kind(
"slack",
"channels-src/slack",
None,
None,
ManifestKind::Channel,
);
let tool_err = installer
.install_from_source(&tool_manifest, false)
.await
.expect_err("no source dir");
let channel_err = installer
.install_from_source(&channel_manifest, false)
.await
.expect_err("no source dir");
match tool_err {
RegistryError::ManifestRead { path, .. } => {
assert!(
path.ends_with("tools-src/slack"),
"tool should resolve to tools-src/slack, got: {}",
path.display()
);
}
other => panic!("expected ManifestRead for tool, got: {:?}", other),
}
match channel_err {
RegistryError::ManifestRead { path, .. } => {
assert!(
path.ends_with("channels-src/slack"),
"channel should resolve to channels-src/slack, got: {}",
path.display()
);
}
other => panic!("expected ManifestRead for channel, got: {:?}", other),
}
}
}