feat: embedded registry catalog and WASM bundle install pipeline (#283)

* feat: embedded registry catalog and WASM bundle install pipeline

Embed registry manifests at compile time so the extension catalog is
available without network access. Add tar.gz bundle support for WASM
extension downloads (tools and channels), a /api/extensions/registry
endpoint, CI job to build and publish WASM bundles on release, and
ephemeral in-memory secrets fallback so the extension manager works
even without a persistent secrets store.

Key changes:
- build.rs: collect registry/*.json into embedded_catalog.json at compile time
- src/registry/embedded.rs + catalog.rs: load embedded or on-disk catalog
- src/extensions/manager.rs: download_and_install_wasm handles tar.gz bundles,
  bare .wasm files, and separate capabilities downloads; wasm channel install
- src/channels/web/server.rs: /api/extensions/registry endpoint + no-cache headers
- src/app.rs: ephemeral InMemorySecretsStore fallback for extension manager
- registry/*.json: populate artifact download URLs for release bundles
- .github/workflows/release.yml: build-wasm-extensions CI job
- Simplified setup wizard and CLI registry commands

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review — archive hardening, decompression bomb guard, test fix

- Add 100 MB decompressed entry size cap to tar.gz extraction in both
  manager.rs and installer.rs to prevent decompression bombs
- Add archive.set_preserve_permissions(false) and set_unpack_xattrs(false)
  for defense-in-depth against malicious archives
- Fix test assertion logic in catalog.rs (|| → || with correct negation)
- Replace silent tar fallback in CI with explicit if/else for capabilities
- Add warning when installing without SHA256 verification

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: resolve clippy warning in settings.rs and enforce zero-warnings policy

Use struct initializer with ..Default::default() instead of field
reassignment. Update CLAUDE.md to codify zero clippy warnings policy —
all warnings must be fixed before committing, including pre-existing ones.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review round 2 — build reliability, caps validation, naming

- build.rs: emit per-file rerun-if-changed for reliable content tracking;
  fix bundles fallback to match BundlesFile shape ({"bundles":{}})
- embedded.rs: parse catalog once via OnceLock instead of double-parsing
- manager.rs + installer.rs: add 1 MB size cap on capabilities_url downloads
  with proper error surfacing
- secrets/store.rs: rename misleading `pub mod testing` to `pub mod in_memory`
- server.rs: track installed extensions by (name, kind) tuple to avoid
  false positives across different extension kinds

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-21 05:43:28 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3d4c647216
commit 436066415b
40 changed files with 1406 additions and 257 deletions
+72
View File
@@ -3,6 +3,7 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::registry::embedded;
use crate::registry::manifest::{BundleDefinition, BundlesFile, ExtensionManifest, ManifestKind};
/// Error type for registry operations.
@@ -64,6 +65,69 @@ pub struct RegistryCatalog {
}
impl RegistryCatalog {
/// Find the `registry/` directory by searching relative to cwd, the executable,
/// and `CARGO_MANIFEST_DIR`. Returns `None` if the directory cannot be found
/// (non-fatal at startup).
pub fn find_dir() -> Option<PathBuf> {
// Try relative to current directory (for dev usage)
if let Ok(cwd) = std::env::current_dir() {
let candidate = cwd.join("registry");
if candidate.is_dir() {
return Some(candidate);
}
}
// Try relative to executable (covers installed binary, target/debug/, target/release/)
if let Ok(exe) = std::env::current_exe()
&& let Some(parent) = exe.parent()
{
// Walk up to 3 levels: exe dir, parent (target/release -> target), grandparent (-> repo root)
let mut dir = Some(parent);
for _ in 0..3 {
if let Some(d) = dir {
let candidate = d.join("registry");
if candidate.is_dir() {
return Some(candidate);
}
dir = d.parent();
}
}
}
// Try CARGO_MANIFEST_DIR (compile-time, works in dev builds)
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let candidate = manifest_dir.join("registry");
if candidate.is_dir() {
return Some(candidate);
}
None
}
/// Try to load from disk; if `registry/` cannot be found, fall back to
/// manifests embedded into the binary at compile time.
pub fn load_or_embedded() -> Result<Self, RegistryError> {
if let Some(dir) = Self::find_dir() {
return Self::load(&dir);
}
// Fall back to embedded catalog
let manifests = embedded::load_embedded();
let bundles = embedded::load_embedded_bundles();
tracing::info!(
"Loaded embedded registry catalog ({} extensions, {} bundles)",
manifests.len(),
bundles.len()
);
Ok(Self {
manifests,
bundles,
root: PathBuf::new(),
})
}
/// Load the catalog from a registry directory.
///
/// Expects the structure:
@@ -577,4 +641,12 @@ mod tests {
let result = RegistryCatalog::load(Path::new("/nonexistent/path"));
assert!(result.is_err());
}
#[test]
fn test_load_or_embedded_succeeds() {
// Should always succeed: either finds registry/ on disk or falls back to embedded
let catalog = RegistryCatalog::load_or_embedded().unwrap();
// At minimum, the embedded catalog from the repo should have entries
assert!(!catalog.all().is_empty() || !catalog.bundle_names().is_empty());
}
}
+97
View File
@@ -0,0 +1,97 @@
//! Embedded registry catalog compiled into the binary at build time.
//!
//! When IronClaw is distributed as a pre-built binary without a source tree,
//! the `registry/` directory is unavailable. This module provides the same
//! manifest data via `include_str!` from a JSON blob generated by `build.rs`.
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::registry::manifest::{BundleDefinition, BundlesFile, ExtensionManifest};
/// Raw JSON generated by build.rs from `registry/{tools,channels}/*.json` and `_bundles.json`.
const EMBEDDED_CATALOG: &str = include_str!(concat!(env!("OUT_DIR"), "/embedded_catalog.json"));
/// Intermediate deserialization shape matching the build.rs output.
#[derive(serde::Deserialize)]
struct EmbeddedCatalogRaw {
#[serde(default)]
tools: Vec<ExtensionManifest>,
#[serde(default)]
channels: Vec<ExtensionManifest>,
#[serde(default)]
bundles: BundlesFile,
}
/// Parsed catalog cached across calls.
struct ParsedCatalog {
manifests: HashMap<String, ExtensionManifest>,
bundles: HashMap<String, BundleDefinition>,
}
fn parsed_catalog() -> &'static ParsedCatalog {
static CACHE: OnceLock<ParsedCatalog> = OnceLock::new();
CACHE.get_or_init(|| {
let raw: EmbeddedCatalogRaw = match serde_json::from_str(EMBEDDED_CATALOG) {
Ok(v) => v,
Err(e) => {
tracing::warn!("Failed to parse embedded catalog: {}", e);
return ParsedCatalog {
manifests: HashMap::new(),
bundles: HashMap::new(),
};
}
};
let mut manifests = HashMap::new();
for m in raw.tools {
let key = format!("tools/{}", m.name);
manifests.insert(key, m);
}
for m in raw.channels {
let key = format!("channels/{}", m.name);
manifests.insert(key, m);
}
ParsedCatalog {
manifests,
bundles: raw.bundles.bundles,
}
})
}
/// Load all embedded extension manifests, keyed by `"tools/<name>"` or `"channels/<name>"`.
pub fn load_embedded() -> HashMap<String, ExtensionManifest> {
parsed_catalog().manifests.clone()
}
/// Load embedded bundle definitions.
pub fn load_embedded_bundles() -> HashMap<String, BundleDefinition> {
parsed_catalog().bundles.clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_embedded_parses() {
let manifests = load_embedded();
// Should have at least the manifests from registry/ if built from the repo
// (empty is also valid for minimal builds without registry/)
assert!(
manifests.is_empty() || manifests.contains_key("tools/github"),
"Expected either empty catalog or github tool, got {} entries",
manifests.len()
);
}
#[test]
fn test_load_embedded_bundles_parses() {
let bundles = load_embedded_bundles();
assert!(
bundles.is_empty() || bundles.contains_key("default"),
"Expected either empty bundles or 'default' bundle"
);
}
}
+325 -61
View File
@@ -137,6 +137,10 @@ impl RegistryInstaller {
}
/// Download and install a pre-built artifact.
///
/// Supports two formats:
/// - **tar.gz bundle**: Contains `{name}.wasm` + `{name}.capabilities.json`
/// - **bare .wasm file**: Just the WASM binary (capabilities fetched separately if available)
pub async fn install_from_artifact(
&self,
manifest: &ExtensionManifest,
@@ -156,13 +160,6 @@ impl RegistryInstaller {
))
})?;
let expected_sha = artifact.sha256.as_ref().ok_or_else(|| {
RegistryError::ExtensionNotFound(format!(
"No SHA256 hash for '{}'. Cannot verify download.",
manifest.name
))
})?;
let target_dir = match manifest.kind {
ManifestKind::Tool => &self.tools_dir,
ManifestKind::Channel => &self.channels_dir,
@@ -186,75 +183,90 @@ impl RegistryInstaller {
"Downloading {} '{}'...",
manifest.kind, manifest.display_name
);
let response = reqwest::get(url)
.await
.map_err(|e| RegistryError::DownloadFailed {
url: url.clone(),
reason: format!("request failed: {}", e),
})?;
let bytes = download_artifact(url).await?;
let response = response
.error_for_status()
.map_err(|e| RegistryError::DownloadFailed {
url: url.clone(),
reason: e.to_string(),
})?;
let bytes = response
.bytes()
.await
.map_err(|e| RegistryError::DownloadFailed {
url: url.clone(),
reason: format!("failed to read body: {}", e),
})?;
// Verify SHA256
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&bytes);
let actual_sha = format!("{:x}", hasher.finalize());
if actual_sha != *expected_sha {
return Err(RegistryError::DownloadFailed {
url: url.clone(),
reason: format!(
"SHA256 mismatch: expected {}, got {}",
expected_sha, actual_sha
),
});
// Verify SHA256 if provided, warn otherwise
if let Some(expected_sha) = &artifact.sha256 {
verify_sha256(&bytes, expected_sha, url)?;
} else {
println!(
"WARNING: No SHA256 checksum for '{}'; download is not cryptographically verified.",
manifest.name
);
}
// Write file
fs::write(&target_wasm, &bytes)
.await
.map_err(RegistryError::Io)?;
// Copy capabilities from source dir (still needed even for pre-built artifacts).
// NOTE: This requires the source tree to be present. When pre-built artifact
// distribution is implemented, capabilities should be bundled with the artifact
// or fetched from a separate URL.
let caps_source = self
.repo_root
.join(&manifest.source.dir)
.join(&manifest.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)
// Detect format and extract
let has_capabilities = if is_gzip(&bytes) {
// tar.gz bundle: extract {name}.wasm and {name}.capabilities.json
let extracted =
extract_tar_gz(&bytes, &manifest.name, &target_wasm, &target_caps, url)?;
extracted.has_capabilities
} else {
// Bare WASM file
fs::write(&target_wasm, &bytes)
.await
.map_err(RegistryError::Io)?;
true
} else {
false
// Try to get capabilities from:
// 1. Separate capabilities_url in the artifact
// 2. Source tree (legacy, requires repo)
if let Some(ref caps_url) = artifact.capabilities_url {
const MAX_CAPS_SIZE: usize = 1024 * 1024; // 1 MB
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 {
// Legacy fallback: try source tree
let caps_source = self
.repo_root
.join(&manifest.source.dir)
.join(&manifest.source.capabilities);
if caps_source.exists() {
fs::copy(&caps_source, &target_caps)
.await
.map_err(RegistryError::Io)?;
true
} 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: Vec::new(),
warnings,
})
}
@@ -399,6 +411,159 @@ async fn build_wasm_component(source_dir: &Path, crate_name: &str) -> anyhow::Re
)
}
/// Download an artifact from a URL.
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: format!("request failed: {}", e),
})?;
let response = response
.error_for_status()
.map_err(|e| RegistryError::DownloadFailed {
url: url.to_string(),
reason: e.to_string(),
})?;
response
.bytes()
.await
.map_err(|e| RegistryError::DownloadFailed {
url: url.to_string(),
reason: format!("failed to read body: {}", e),
})
}
/// Verify SHA256 of downloaded bytes.
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::DownloadFailed {
url: url.to_string(),
reason: format!("SHA256 mismatch: expected {}, got {}", expected, actual),
});
}
Ok(())
}
/// Check if bytes start with gzip magic number (0x1f 0x8b).
fn is_gzip(bytes: &[u8]) -> bool {
bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b
}
/// Result of extracting a tar.gz bundle.
struct ExtractResult {
has_capabilities: bool,
}
/// Extract a tar.gz archive, looking for `{name}.wasm` and `{name}.capabilities.json`.
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);
// Defense-in-depth: do not preserve permissions or extended attributes
archive.set_preserve_permissions(false);
#[cfg(any(unix, target_os = "redox"))]
archive.set_unpack_xattrs(false);
// 100 MB cap on decompressed entry size to prevent decompression bombs
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();
// Match by filename (ignoring any directory prefix in the archive)
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::*;
@@ -412,4 +577,103 @@ mod tests {
);
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])); // WASM magic
assert!(!is_gzip(&[0x1f])); // Too short
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() {
assert!(verify_sha256(b"data", "0000", "test://url").is_err());
}
#[test]
fn test_extract_tar_gz() {
use flate2::Compression;
use flate2::write::GzEncoder;
use tar::Builder;
// Create a tar.gz in memory with test.wasm and test.capabilities.json
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);
}
#[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());
}
}
+27 -5
View File
@@ -88,10 +88,17 @@ pub struct SourceSpec {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactSpec {
/// Download URL (null until release).
/// Can point to a `.wasm` file or a `.tar.gz` bundle containing both
/// `{name}.wasm` and `{name}.capabilities.json`.
pub url: Option<String>,
/// Hex SHA256 of the WASM binary (null until release).
/// Hex SHA256 of the downloaded artifact (null until release).
pub sha256: Option<String>,
/// Optional separate download URL for the capabilities file.
/// Only needed when `url` points to a bare `.wasm` file instead of a bundle.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities_url: Option<String>,
}
/// Summary of authentication requirements extracted from capabilities.
@@ -138,7 +145,7 @@ pub struct BundleDefinition {
}
/// Top-level structure of `_bundles.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BundlesFile {
pub bundles: std::collections::HashMap<String, BundleDefinition>,
}
@@ -147,9 +154,24 @@ impl ExtensionManifest {
/// Convert this manifest into a [`RegistryEntry`] for use with the in-chat
/// extension discovery system.
pub fn to_registry_entry(&self) -> RegistryEntry {
let source = ExtensionSource::WasmBuildable {
repo_url: self.source.dir.clone(),
build_dir: Some(self.source.dir.clone()),
// Prefer pre-built artifact download when a URL is available
let source = if let Some(artifact) = self.artifacts.get("wasm32-wasip2") {
if let Some(ref url) = artifact.url {
ExtensionSource::WasmDownload {
wasm_url: url.clone(),
capabilities_url: artifact.capabilities_url.clone(),
}
} else {
ExtensionSource::WasmBuildable {
repo_url: self.source.dir.clone(),
build_dir: Some(self.source.dir.clone()),
}
}
} else {
ExtensionSource::WasmBuildable {
repo_url: self.source.dir.clone(),
build_dir: Some(self.source.dir.clone()),
}
};
let auth_hint = match self.auth_summary.as_ref().and_then(|a| a.method.as_deref()) {
+1
View File
@@ -12,6 +12,7 @@
//! ```
pub mod catalog;
pub mod embedded;
pub mod installer;
pub mod manifest;