Skip to main content

ncp_runtime/
resolver.rs

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
9/// A resolved brick: parsed manifest + verified WASM bytes.
10pub struct ResolvedBrick {
11    pub manifest: BrickManifest,
12    pub wasm_bytes: Vec<u8>,
13    pub manifest_path: PathBuf,
14    pub wasm_path: PathBuf,
15}
16
17/// Brick-map: maps brick_id → directory path, resolved relative to the map file's parent.
18pub struct BrickMap {
19    pub base_dir: PathBuf,
20    pub map: HashMap<String, PathBuf>,
21}
22
23impl BrickMap {
24    /// Resolve a brick_id to its directory, joining relative paths against base_dir.
25    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
36/// Load a brick-map file (YAML or JSON based on extension).
37pub 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
56/// Resolve a brick_id to its directory.
57///
58/// Precedence:
59/// 1. brick-map entry (exact brick_id match, resolved relative to map file)
60/// 2. `<brick_dir>/<short_name>/` where short_name = last dot-segment of brick_id
61fn 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
86/// Select the .wasm file from a brick directory (deterministic rule).
87///
88/// Precedence:
89/// 1. manifest.artifact.path if present
90/// 2. Exactly one .wasm file in directory
91/// 3. <short_name>.wasm
92/// 4. Error listing candidates (sorted for determinism)
93fn select_wasm(brick_id: &str, dir: &Path, artifact_path: &Option<PathBuf>) -> Result<PathBuf> {
94    // 1. artifact.path
95    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    // Scan directory for .wasm files
109    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    // 2. Exactly one .wasm
118    if wasm_files.len() == 1 {
119        return Ok(wasm_files.into_iter().next().unwrap());
120    }
121
122    // 3. <short_name>.wasm
123    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    // 4. Error (sorted candidates for determinism)
130    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
157/// Verify digest and size of WASM bytes against manifest.
158fn verify_artifact(brick_id: &str, manifest: &BrickManifest, wasm_bytes: &[u8]) -> Result<()> {
159    // Size check
160    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    // Digest check — expect "sha256:<hex>"
171    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    // Strict validation: must be exactly 64 lowercase hex chars
180    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
205/// Check brick manifest version against a node's version_or_range requirement.
206pub 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
234/// Validate manifest invariants required by the runtime (Phase 2).
235fn 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    // Limits sanity
270    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
317/// Resolve, verify, and validate a single brick.
318pub 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    // 1. Find brick directory
325    let dir = resolve_brick_dir(brick_id, brick_dir, brick_map)?;
326
327    // 2. Load manifest
328    let manifest_path = dir.join("manifest.yaml");
329    let manifest = manifest::load_brick(&manifest_path)?;
330
331    // 3. Validate manifest invariants
332    validate_manifest(brick_id, &manifest, &manifest_path)?;
333
334    // 4. Check version
335    check_version(brick_id, &manifest.version, version_or_range)?;
336
337    // 5. Select .wasm file
338    let wasm_path = select_wasm(brick_id, &dir, &manifest.artifact.path)?;
339
340    // 6. Read and verify WASM bytes
341    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}