1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::{bail, Context, Result};
5use sha2::{Digest, Sha256};
6
7use crate::manifest::{self, BrickManifest};
8
9pub struct ResolvedBrick {
11 pub manifest: BrickManifest,
12 pub wasm_bytes: Vec<u8>,
13 pub manifest_path: PathBuf,
14 pub wasm_path: PathBuf,
15}
16
17pub struct BrickMap {
19 pub base_dir: PathBuf,
20 pub map: HashMap<String, PathBuf>,
21}
22
23impl BrickMap {
24 pub fn resolve(&self, brick_id: &str) -> Option<PathBuf> {
26 self.map.get(brick_id).map(|p| {
27 if p.is_relative() {
28 self.base_dir.join(p)
29 } else {
30 p.clone()
31 }
32 })
33 }
34}
35
36pub fn load_brick_map(path: &Path) -> Result<BrickMap> {
38 let contents = std::fs::read_to_string(path)
39 .with_context(|| format!("reading brick-map '{}'", path.display()))?;
40
41 let map: HashMap<String, PathBuf> = match path.extension().and_then(|e| e.to_str()) {
42 Some("json") => serde_json::from_str(&contents)
43 .with_context(|| format!("parsing brick-map as JSON '{}'", path.display()))?,
44 _ => serde_yaml::from_str(&contents)
45 .with_context(|| format!("parsing brick-map as YAML '{}'", path.display()))?,
46 };
47
48 let base_dir = path
49 .parent()
50 .unwrap_or_else(|| Path::new("."))
51 .to_path_buf();
52
53 Ok(BrickMap { base_dir, map })
54}
55
56fn resolve_brick_dir(
62 brick_id: &str,
63 brick_dir: &Path,
64 brick_map: &Option<BrickMap>,
65) -> Result<PathBuf> {
66 if let Some(map) = brick_map {
67 if let Some(dir) = map.resolve(brick_id) {
68 return Ok(dir);
69 }
70 }
71
72 let short_name = brick_id.rsplit('.').next().unwrap_or(brick_id);
73 let dir = brick_dir.join(short_name);
74
75 if !dir.is_dir() {
76 bail!(
77 "cannot resolve brick '{}': directory '{}' does not exist",
78 brick_id,
79 dir.display()
80 );
81 }
82
83 Ok(dir)
84}
85
86fn select_wasm(brick_id: &str, dir: &Path, artifact_path: &Option<PathBuf>) -> Result<PathBuf> {
94 if let Some(rel_path) = artifact_path {
96 let full = dir.join(rel_path);
97 if full.is_file() {
98 return Ok(full);
99 }
100 bail!(
101 "cannot select .wasm for '{}': artifact.path '{}' not found in '{}'",
102 brick_id,
103 rel_path.display(),
104 dir.display()
105 );
106 }
107
108 let mut wasm_files: Vec<PathBuf> = std::fs::read_dir(dir)
110 .with_context(|| format!("reading directory '{}'", dir.display()))?
111 .filter_map(|entry| entry.ok())
112 .map(|entry| entry.path())
113 .filter(|p| p.extension().is_some_and(|ext| ext == "wasm"))
114 .collect();
115 wasm_files.sort();
116
117 if wasm_files.len() == 1 {
119 return Ok(wasm_files.into_iter().next().unwrap());
120 }
121
122 let short_name = brick_id.rsplit('.').next().unwrap_or(brick_id);
124 let named = dir.join(format!("{short_name}.wasm"));
125 if named.is_file() {
126 return Ok(named);
127 }
128
129 let candidates: Vec<String> = wasm_files
131 .iter()
132 .map(|p| {
133 p.file_name()
134 .unwrap_or_default()
135 .to_string_lossy()
136 .into_owned()
137 })
138 .collect();
139
140 if candidates.is_empty() {
141 bail!(
142 "cannot select .wasm for '{}': no .wasm files in '{}'",
143 brick_id,
144 dir.display()
145 );
146 }
147
148 bail!(
149 "cannot select .wasm for '{}': multiple .wasm files in '{}': [{}]. \
150 Use artifact.path in manifest or ensure only one .wasm exists.",
151 brick_id,
152 dir.display(),
153 candidates.join(", ")
154 );
155}
156
157fn verify_artifact(brick_id: &str, manifest: &BrickManifest, wasm_bytes: &[u8]) -> Result<()> {
159 let actual_size = wasm_bytes.len() as u64;
161 if actual_size != manifest.artifact.size_bytes {
162 bail!(
163 "size mismatch for '{}': manifest declares {} bytes, actual {} bytes",
164 brick_id,
165 manifest.artifact.size_bytes,
166 actual_size
167 );
168 }
169
170 let expected_digest = &manifest.artifact.digest;
172 let expected_hex = expected_digest.strip_prefix("sha256:").with_context(|| {
173 format!(
174 "unsupported digest format for '{}': expected 'sha256:<hex>', got '{}'",
175 brick_id, expected_digest
176 )
177 })?;
178
179 let expected_hex = expected_hex.to_ascii_lowercase();
181 if expected_hex.len() != 64 || !expected_hex.chars().all(|c| c.is_ascii_hexdigit()) {
182 bail!(
183 "invalid digest for '{}': expected 64 hex chars after 'sha256:', got '{}'",
184 brick_id,
185 expected_hex
186 );
187 }
188
189 let mut hasher = Sha256::new();
190 hasher.update(wasm_bytes);
191 let actual_hex = hex::encode(hasher.finalize());
192
193 if actual_hex != expected_hex {
194 bail!(
195 "digest mismatch for '{}': expected sha256:{}, got sha256:{}",
196 brick_id,
197 expected_hex,
198 actual_hex
199 );
200 }
201
202 Ok(())
203}
204
205pub fn check_version(brick_id: &str, manifest_version: &str, version_or_range: &str) -> Result<()> {
207 let version = semver::Version::parse(manifest_version).with_context(|| {
208 format!(
209 "invalid semver in manifest for '{}': '{}'",
210 brick_id, manifest_version
211 )
212 })?;
213
214 let range_str = version_or_range.trim();
215 let req = semver::VersionReq::parse(range_str).with_context(|| {
216 format!(
217 "invalid version range for '{}': '{}' (manifest version: {})",
218 brick_id, range_str, manifest_version
219 )
220 })?;
221
222 if !req.matches(&version) {
223 bail!(
224 "version mismatch for '{}': manifest version {} does not satisfy {}",
225 brick_id,
226 manifest_version,
227 range_str
228 );
229 }
230
231 Ok(())
232}
233
234fn validate_manifest(brick_id: &str, manifest: &BrickManifest, manifest_path: &Path) -> Result<()> {
236 if manifest.artifact.format != "wasm" {
237 bail!(
238 "invalid manifest '{}': artifact.format must be 'wasm', got '{}'",
239 manifest_path.display(),
240 manifest.artifact.format
241 );
242 }
243
244 if manifest.artifact.entrypoint != "invoke" {
245 bail!(
246 "invalid manifest '{}': artifact.entrypoint must be 'invoke', got '{}'",
247 manifest_path.display(),
248 manifest.artifact.entrypoint
249 );
250 }
251
252 if !manifest.capabilities.is_empty() {
253 bail!(
254 "invalid manifest '{}': capabilities must be empty in v0.2, got {:?}",
255 manifest_path.display(),
256 manifest.capabilities
257 );
258 }
259
260 if !manifest.required_runtime_features.is_empty() {
261 bail!(
262 "unsupported feature: brick '{}' declares required_runtime_features {:?} \
263 (Phase 2 runtime does not support runtime intrinsics)",
264 brick_id,
265 manifest.required_runtime_features
266 );
267 }
268
269 if manifest.limits.max_ms == 0 {
271 bail!(
272 "invalid manifest '{}': limits.max_ms must be > 0",
273 manifest_path.display()
274 );
275 }
276 if manifest.limits.max_mem_mb == 0 {
277 bail!(
278 "invalid manifest '{}': limits.max_mem_mb must be > 0",
279 manifest_path.display()
280 );
281 }
282 if manifest.limits.max_output_bytes == 0 {
283 bail!(
284 "invalid manifest '{}': limits.max_output_bytes must be > 0",
285 manifest_path.display()
286 );
287 }
288 if manifest.limits.max_input_bytes.is_none() {
289 bail!(
290 "invalid manifest '{}': limits.max_input_bytes is required \
291 (Phase 2 runtime enforces input size guards)",
292 manifest_path.display()
293 );
294 }
295
296 if manifest.carry_state_class != "none" {
297 bail!(
298 "unsupported feature: brick '{}' declares carry_state_class '{}' \
299 (Phase 2 runtime only supports 'none')",
300 brick_id,
301 manifest.carry_state_class
302 );
303 }
304
305 if !manifest.graph_ref_slots.is_empty() {
306 bail!(
307 "unsupported feature: brick '{}' declares {} graph_ref_slots \
308 (Phase 2 runtime does not support graph refs)",
309 brick_id,
310 manifest.graph_ref_slots.len()
311 );
312 }
313
314 Ok(())
315}
316
317pub fn resolve_brick(
319 brick_id: &str,
320 version_or_range: &str,
321 brick_dir: &Path,
322 brick_map: &Option<BrickMap>,
323) -> Result<ResolvedBrick> {
324 let dir = resolve_brick_dir(brick_id, brick_dir, brick_map)?;
326
327 let manifest_path = dir.join("manifest.yaml");
329 let manifest = manifest::load_brick(&manifest_path)?;
330
331 validate_manifest(brick_id, &manifest, &manifest_path)?;
333
334 check_version(brick_id, &manifest.version, version_or_range)?;
336
337 let wasm_path = select_wasm(brick_id, &dir, &manifest.artifact.path)?;
339
340 let wasm_bytes = std::fs::read(&wasm_path)
342 .with_context(|| format!("reading WASM file '{}'", wasm_path.display()))?;
343 verify_artifact(brick_id, &manifest, &wasm_bytes)?;
344
345 Ok(ResolvedBrick {
346 manifest,
347 wasm_bytes,
348 manifest_path,
349 wasm_path,
350 })
351}