use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use sha2::{Digest, Sha256};
use crate::manifest::{self, BrickManifest};
pub struct ResolvedBrick {
pub manifest: BrickManifest,
pub wasm_bytes: Vec<u8>,
pub manifest_path: PathBuf,
pub wasm_path: PathBuf,
}
pub struct BrickMap {
pub base_dir: PathBuf,
pub map: HashMap<String, PathBuf>,
}
impl BrickMap {
pub fn resolve(&self, brick_id: &str) -> Option<PathBuf> {
self.map.get(brick_id).map(|p| {
if p.is_relative() {
self.base_dir.join(p)
} else {
p.clone()
}
})
}
}
pub fn load_brick_map(path: &Path) -> Result<BrickMap> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("reading brick-map '{}'", path.display()))?;
let map: HashMap<String, PathBuf> = match path.extension().and_then(|e| e.to_str()) {
Some("json") => serde_json::from_str(&contents)
.with_context(|| format!("parsing brick-map as JSON '{}'", path.display()))?,
_ => serde_yaml::from_str(&contents)
.with_context(|| format!("parsing brick-map as YAML '{}'", path.display()))?,
};
let base_dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
Ok(BrickMap { base_dir, map })
}
fn resolve_brick_dir(
brick_id: &str,
brick_dir: &Path,
brick_map: &Option<BrickMap>,
) -> Result<PathBuf> {
if let Some(map) = brick_map {
if let Some(dir) = map.resolve(brick_id) {
return Ok(dir);
}
}
let short_name = brick_id.rsplit('.').next().unwrap_or(brick_id);
let dir = brick_dir.join(short_name);
if !dir.is_dir() {
bail!(
"cannot resolve brick '{}': directory '{}' does not exist",
brick_id,
dir.display()
);
}
Ok(dir)
}
fn select_wasm(brick_id: &str, dir: &Path, artifact_path: &Option<PathBuf>) -> Result<PathBuf> {
if let Some(rel_path) = artifact_path {
let full = dir.join(rel_path);
if full.is_file() {
return Ok(full);
}
bail!(
"cannot select .wasm for '{}': artifact.path '{}' not found in '{}'",
brick_id,
rel_path.display(),
dir.display()
);
}
let mut wasm_files: Vec<PathBuf> = std::fs::read_dir(dir)
.with_context(|| format!("reading directory '{}'", dir.display()))?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "wasm"))
.collect();
wasm_files.sort();
if wasm_files.len() == 1 {
return Ok(wasm_files.into_iter().next().unwrap());
}
let short_name = brick_id.rsplit('.').next().unwrap_or(brick_id);
let named = dir.join(format!("{short_name}.wasm"));
if named.is_file() {
return Ok(named);
}
let candidates: Vec<String> = wasm_files
.iter()
.map(|p| {
p.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
})
.collect();
if candidates.is_empty() {
bail!(
"cannot select .wasm for '{}': no .wasm files in '{}'",
brick_id,
dir.display()
);
}
bail!(
"cannot select .wasm for '{}': multiple .wasm files in '{}': [{}]. \
Use artifact.path in manifest or ensure only one .wasm exists.",
brick_id,
dir.display(),
candidates.join(", ")
);
}
fn verify_artifact(brick_id: &str, manifest: &BrickManifest, wasm_bytes: &[u8]) -> Result<()> {
let actual_size = wasm_bytes.len() as u64;
if actual_size != manifest.artifact.size_bytes {
bail!(
"size mismatch for '{}': manifest declares {} bytes, actual {} bytes",
brick_id,
manifest.artifact.size_bytes,
actual_size
);
}
let expected_digest = &manifest.artifact.digest;
let expected_hex = expected_digest.strip_prefix("sha256:").with_context(|| {
format!(
"unsupported digest format for '{}': expected 'sha256:<hex>', got '{}'",
brick_id, expected_digest
)
})?;
let expected_hex = expected_hex.to_ascii_lowercase();
if expected_hex.len() != 64 || !expected_hex.chars().all(|c| c.is_ascii_hexdigit()) {
bail!(
"invalid digest for '{}': expected 64 hex chars after 'sha256:', got '{}'",
brick_id,
expected_hex
);
}
let mut hasher = Sha256::new();
hasher.update(wasm_bytes);
let actual_hex = hex::encode(hasher.finalize());
if actual_hex != expected_hex {
bail!(
"digest mismatch for '{}': expected sha256:{}, got sha256:{}",
brick_id,
expected_hex,
actual_hex
);
}
Ok(())
}
pub fn check_version(brick_id: &str, manifest_version: &str, version_or_range: &str) -> Result<()> {
let version = semver::Version::parse(manifest_version).with_context(|| {
format!(
"invalid semver in manifest for '{}': '{}'",
brick_id, manifest_version
)
})?;
let range_str = version_or_range.trim();
let req = semver::VersionReq::parse(range_str).with_context(|| {
format!(
"invalid version range for '{}': '{}' (manifest version: {})",
brick_id, range_str, manifest_version
)
})?;
if !req.matches(&version) {
bail!(
"version mismatch for '{}': manifest version {} does not satisfy {}",
brick_id,
manifest_version,
range_str
);
}
Ok(())
}
fn validate_manifest(brick_id: &str, manifest: &BrickManifest, manifest_path: &Path) -> Result<()> {
if manifest.artifact.format != "wasm" {
bail!(
"invalid manifest '{}': artifact.format must be 'wasm', got '{}'",
manifest_path.display(),
manifest.artifact.format
);
}
if manifest.artifact.entrypoint != "invoke" {
bail!(
"invalid manifest '{}': artifact.entrypoint must be 'invoke', got '{}'",
manifest_path.display(),
manifest.artifact.entrypoint
);
}
if !manifest.capabilities.is_empty() {
bail!(
"invalid manifest '{}': capabilities must be empty in v0.2, got {:?}",
manifest_path.display(),
manifest.capabilities
);
}
if !manifest.required_runtime_features.is_empty() {
bail!(
"unsupported feature: brick '{}' declares required_runtime_features {:?} \
(Phase 2 runtime does not support runtime intrinsics)",
brick_id,
manifest.required_runtime_features
);
}
if manifest.limits.max_ms == 0 {
bail!(
"invalid manifest '{}': limits.max_ms must be > 0",
manifest_path.display()
);
}
if manifest.limits.max_mem_mb == 0 {
bail!(
"invalid manifest '{}': limits.max_mem_mb must be > 0",
manifest_path.display()
);
}
if manifest.limits.max_output_bytes == 0 {
bail!(
"invalid manifest '{}': limits.max_output_bytes must be > 0",
manifest_path.display()
);
}
if manifest.limits.max_input_bytes.is_none() {
bail!(
"invalid manifest '{}': limits.max_input_bytes is required \
(Phase 2 runtime enforces input size guards)",
manifest_path.display()
);
}
if manifest.carry_state_class != "none" {
bail!(
"unsupported feature: brick '{}' declares carry_state_class '{}' \
(Phase 2 runtime only supports 'none')",
brick_id,
manifest.carry_state_class
);
}
if !manifest.graph_ref_slots.is_empty() {
bail!(
"unsupported feature: brick '{}' declares {} graph_ref_slots \
(Phase 2 runtime does not support graph refs)",
brick_id,
manifest.graph_ref_slots.len()
);
}
Ok(())
}
pub fn resolve_brick(
brick_id: &str,
version_or_range: &str,
brick_dir: &Path,
brick_map: &Option<BrickMap>,
) -> Result<ResolvedBrick> {
let dir = resolve_brick_dir(brick_id, brick_dir, brick_map)?;
let manifest_path = dir.join("manifest.yaml");
let manifest = manifest::load_brick(&manifest_path)?;
validate_manifest(brick_id, &manifest, &manifest_path)?;
check_version(brick_id, &manifest.version, version_or_range)?;
let wasm_path = select_wasm(brick_id, &dir, &manifest.artifact.path)?;
let wasm_bytes = std::fs::read(&wasm_path)
.with_context(|| format!("reading WASM file '{}'", wasm_path.display()))?;
verify_artifact(brick_id, &manifest, &wasm_bytes)?;
Ok(ResolvedBrick {
manifest,
wasm_bytes,
manifest_path,
wasm_path,
})
}