Skip to main content

greentic_component/
loader.rs

1use std::borrow::Cow;
2use std::ffi::OsStr;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use directories::BaseDirs;
7use thiserror::Error;
8
9use crate::manifest::{ComponentManifest, parse_manifest};
10use crate::signing::{SigningError, verify_manifest_hash};
11
12const MANIFEST_NAME: &str = "component.manifest.json";
13
14#[derive(Debug, Clone)]
15pub struct ComponentHandle {
16    pub manifest: ComponentManifest,
17    pub wasm_path: PathBuf,
18    pub root: PathBuf,
19    pub manifest_path: PathBuf,
20}
21
22#[derive(Debug, Error)]
23pub enum LoadError {
24    #[error(
25        "component not found for `{0}`; if pointing at a wasm file, pass --manifest <path/to/component.manifest.json>"
26    )]
27    NotFound(String),
28    #[error("failed to read {path}: {source}")]
29    Io {
30        path: PathBuf,
31        #[source]
32        source: std::io::Error,
33    },
34    #[error("manifest parse failed at {path}: {source}")]
35    Manifest {
36        path: PathBuf,
37        #[source]
38        source: crate::manifest::ManifestError,
39    },
40    #[error("missing artifact `{path}` declared in manifest")]
41    MissingArtifact { path: PathBuf },
42    #[error("hash verification failed: {0}")]
43    Signing(#[from] SigningError),
44}
45
46pub fn discover(path_or_id: &str) -> Result<ComponentHandle, LoadError> {
47    discover_with_manifest(path_or_id, None)
48}
49
50pub fn discover_with_manifest(
51    path_or_id: &str,
52    manifest_override: Option<&Path>,
53) -> Result<ComponentHandle, LoadError> {
54    if let Some(manifest_path) = manifest_override {
55        return load_from_manifest(manifest_path);
56    }
57    let normalized = normalize_path_or_id(path_or_id);
58    let normalized_str = normalized.as_ref();
59    if let Some(handle) = try_explicit(normalized_str)? {
60        return Ok(handle);
61    }
62    if let Some(handle) = try_workspace(normalized_str)? {
63        return Ok(handle);
64    }
65    if let Some(handle) = try_registry(path_or_id)? {
66        return Ok(handle);
67    }
68    Err(LoadError::NotFound(path_or_id.to_string()))
69}
70
71fn try_explicit(arg: &str) -> Result<Option<ComponentHandle>, LoadError> {
72    let path = Path::new(arg);
73    if !path.exists() {
74        return Ok(None);
75    }
76
77    let target = if path.is_dir() {
78        path.join(MANIFEST_NAME)
79    } else if path.extension().and_then(OsStr::to_str) == Some("json") {
80        path.to_path_buf()
81    } else if path.extension().and_then(OsStr::to_str) == Some("wasm") {
82        path.parent()
83            .map(|dir| dir.join(MANIFEST_NAME))
84            .unwrap_or_else(|| path.to_path_buf())
85    } else {
86        path.join(MANIFEST_NAME)
87    };
88
89    if target.exists() {
90        return load_from_manifest(&target).map(Some);
91    }
92
93    Ok(None)
94}
95
96fn try_workspace(id: &str) -> Result<Option<ComponentHandle>, LoadError> {
97    let cwd = std::env::current_dir().map_err(|e| LoadError::Io {
98        path: PathBuf::from("."),
99        source: e,
100    })?;
101    let target = cwd.join("target").join("wasm32-wasip2");
102    let file_name = format!("{id}.wasm");
103
104    for profile in ["release", "debug"] {
105        let candidate = target.join(profile).join(&file_name);
106        if candidate.exists() {
107            let manifest_path = candidate
108                .parent()
109                .map(|dir| dir.join(MANIFEST_NAME))
110                .unwrap_or_else(|| candidate.with_extension("manifest.json"));
111            if manifest_path.exists() {
112                return load_from_manifest(&manifest_path).map(Some);
113            }
114        }
115    }
116
117    Ok(None)
118}
119
120fn try_registry(id: &str) -> Result<Option<ComponentHandle>, LoadError> {
121    let Some(base) = BaseDirs::new() else {
122        return Ok(None);
123    };
124    let registry_root = base.home_dir().join(".greentic").join("components");
125    if !registry_root.exists() {
126        return Ok(None);
127    }
128
129    let mut candidates = Vec::new();
130    for entry in fs::read_dir(&registry_root).map_err(|err| LoadError::Io {
131        path: registry_root.clone(),
132        source: err,
133    })? {
134        let entry = entry.map_err(|err| LoadError::Io {
135            path: registry_root.clone(),
136            source: err,
137        })?;
138        let name = entry.file_name();
139        let name = name.to_string_lossy();
140        if name == id || (!id.contains('@') && name.starts_with(id)) {
141            candidates.push(entry.path());
142        }
143    }
144
145    candidates.sort();
146    candidates.reverse();
147
148    for dir in candidates {
149        let manifest_path = dir.join(MANIFEST_NAME);
150        if manifest_path.exists() {
151            return load_from_manifest(&manifest_path).map(Some);
152        }
153    }
154
155    Ok(None)
156}
157
158fn load_from_manifest(path: &Path) -> Result<ComponentHandle, LoadError> {
159    let contents = fs::read_to_string(path).map_err(|source| LoadError::Io {
160        path: path.to_path_buf(),
161        source,
162    })?;
163    let manifest = parse_manifest(&contents).map_err(|source| LoadError::Manifest {
164        path: path.to_path_buf(),
165        source,
166    })?;
167    let root = path
168        .parent()
169        .map(|p| p.to_path_buf())
170        .unwrap_or_else(|| PathBuf::from("."));
171    let wasm_path = root.join(manifest.artifacts.component_wasm());
172    if !wasm_path.exists() {
173        return Err(LoadError::MissingArtifact { path: wasm_path });
174    }
175    verify_manifest_hash(&manifest, &root)?;
176    Ok(ComponentHandle {
177        manifest,
178        wasm_path,
179        root,
180        manifest_path: path.to_path_buf(),
181    })
182}
183
184fn normalize_path_or_id(input: &str) -> Cow<'_, str> {
185    if let Some(rest) = input.strip_prefix("file://") {
186        Cow::Owned(rest.to_string())
187    } else {
188        Cow::Borrowed(input)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use std::fs;
196    use std::sync::{Mutex, OnceLock};
197
198    fn cwd_lock() -> &'static Mutex<()> {
199        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
200        LOCK.get_or_init(|| Mutex::new(()))
201    }
202
203    fn manifest_json(artifact: &str, hash: &str) -> String {
204        format!(
205            r#"{{
206  "id": "com.greentic.test.component",
207  "name": "Test Component",
208  "version": "0.1.0",
209  "world": "greentic:component/component@0.6.0",
210  "describe_export": "describe",
211  "operations": [{{
212    "name": "run",
213    "input_schema": {{"type":"object","properties":{{}},"required":[],"additionalProperties":false}},
214    "output_schema": {{"type":"object","properties":{{}},"required":[],"additionalProperties":false}}
215  }}],
216  "default_operation": "run",
217  "supports": ["messaging"],
218  "profiles": {{"default": "stateless", "supported": ["stateless"]}},
219  "secret_requirements": [],
220  "capabilities": {{
221    "wasi": {{
222      "filesystem": {{"mode":"none","mounts":[]}},
223      "random": true,
224      "clocks": true
225    }},
226    "host": {{
227      "messaging": {{"inbound": true, "outbound": true}},
228      "telemetry": {{"scope": "tenant"}}
229    }}
230  }},
231  "config_schema": {{"type":"object","properties":{{}},"required":[],"additionalProperties":false}},
232  "limits": {{"memory_mb": 64, "wall_time_ms": 1000}},
233  "artifacts": {{"component_wasm": "{artifact}"}},
234  "hashes": {{"component_wasm": "{hash}"}},
235  "dev_flows": {{
236    "default": {{
237      "format": "flow-ir-json",
238      "graph": {{
239        "nodes": [{{"id":"start","type":"start"}}, {{"id":"end","type":"end"}}],
240        "edges": [{{"from":"start","to":"end"}}]
241      }}
242    }}
243  }}
244}}"#
245        )
246    }
247
248    fn write_component_fixture() -> (tempfile::TempDir, PathBuf, PathBuf) {
249        let dir = tempfile::tempdir().expect("fixture dir");
250        let wasm_path = dir.path().join("component.wasm");
251        fs::write(&wasm_path, b"fixture-wasm").expect("write wasm");
252        let hash = format!("blake3:{}", blake3::hash(b"fixture-wasm").to_hex());
253        let manifest_path = dir.path().join(MANIFEST_NAME);
254        fs::write(&manifest_path, manifest_json("component.wasm", &hash)).expect("write manifest");
255        (dir, manifest_path, wasm_path)
256    }
257
258    #[test]
259    fn normalize_path_or_id_strips_file_scheme_only() {
260        assert_eq!(
261            normalize_path_or_id("file:///tmp/component"),
262            "/tmp/component"
263        );
264        assert_eq!(normalize_path_or_id("component-id"), "component-id");
265    }
266
267    #[test]
268    fn discover_with_manifest_uses_override_before_searching_by_id() {
269        let (_dir, manifest_path, wasm_path) = write_component_fixture();
270
271        let handle =
272            discover_with_manifest("not-a-real-component", Some(&manifest_path)).expect("load");
273
274        assert_eq!(handle.manifest_path, manifest_path);
275        assert_eq!(handle.wasm_path, wasm_path);
276    }
277
278    #[test]
279    fn load_from_manifest_reports_missing_artifact() {
280        let dir = tempfile::tempdir().expect("fixture dir");
281        let manifest_path = dir.path().join(MANIFEST_NAME);
282        fs::write(
283            &manifest_path,
284            manifest_json(
285                "missing/component.wasm",
286                "blake3:0000000000000000000000000000000000000000000000000000000000000000",
287            ),
288        )
289        .expect("write manifest");
290
291        let err = load_from_manifest(&manifest_path).expect_err("artifact should be missing");
292        assert!(
293            matches!(err, LoadError::MissingArtifact { path } if path.ends_with("missing/component.wasm"))
294        );
295    }
296
297    #[test]
298    fn try_explicit_discovers_manifest_next_to_wasm() {
299        let (_dir, manifest_path, wasm_path) = write_component_fixture();
300
301        let handle = try_explicit(wasm_path.to_str().expect("utf-8 path"))
302            .expect("try_explicit succeeds")
303            .expect("fixture should resolve");
304
305        assert_eq!(handle.manifest_path, manifest_path);
306        assert_eq!(handle.wasm_path, wasm_path);
307    }
308
309    #[test]
310    fn try_explicit_returns_none_for_missing_paths() {
311        let missing = tempfile::tempdir()
312            .expect("tempdir")
313            .path()
314            .join("missing-component");
315
316        let resolved = try_explicit(missing.to_str().expect("utf-8")).expect("lookup succeeds");
317
318        assert!(resolved.is_none());
319    }
320
321    #[test]
322    fn try_explicit_discovers_manifest_inside_directory() {
323        let (_dir, manifest_path, wasm_path) = write_component_fixture();
324        let component_dir = manifest_path.parent().expect("manifest parent");
325
326        let handle = try_explicit(component_dir.to_str().expect("utf-8"))
327            .expect("try_explicit succeeds")
328            .expect("fixture should resolve");
329
330        assert_eq!(handle.manifest_path, manifest_path);
331        assert_eq!(handle.wasm_path, wasm_path);
332    }
333
334    #[test]
335    fn discover_reports_not_found_when_no_locations_match() {
336        let err = discover("com.greentic.missing.component").expect_err("missing component");
337
338        assert!(matches!(err, LoadError::NotFound(id) if id == "com.greentic.missing.component"));
339    }
340
341    #[test]
342    fn load_from_manifest_reports_parse_errors() {
343        let dir = tempfile::tempdir().expect("fixture dir");
344        let manifest_path = dir.path().join(MANIFEST_NAME);
345        fs::write(&manifest_path, "{not valid json").expect("write invalid manifest");
346
347        let err = load_from_manifest(&manifest_path).expect_err("invalid manifest should fail");
348
349        assert!(matches!(err, LoadError::Manifest { path, .. } if path == manifest_path));
350    }
351
352    #[test]
353    fn load_from_manifest_returns_handle_for_valid_fixture() {
354        let (_dir, manifest_path, wasm_path) = write_component_fixture();
355
356        let handle = load_from_manifest(&manifest_path).expect("valid manifest should load");
357
358        assert_eq!(
359            handle.root,
360            manifest_path.parent().expect("manifest parent")
361        );
362        assert_eq!(handle.manifest_path, manifest_path);
363        assert_eq!(handle.wasm_path, wasm_path);
364    }
365
366    #[test]
367    fn try_explicit_accepts_manifest_json_path_directly() {
368        let (_dir, manifest_path, wasm_path) = write_component_fixture();
369
370        let handle = try_explicit(manifest_path.to_str().expect("utf-8 path"))
371            .expect("lookup succeeds")
372            .expect("fixture should resolve");
373
374        assert_eq!(handle.manifest_path, manifest_path);
375        assert_eq!(handle.wasm_path, wasm_path);
376    }
377
378    #[test]
379    fn try_explicit_returns_none_for_existing_non_manifest_path() {
380        let dir = tempfile::tempdir().expect("tempdir");
381        let existing = dir.path().join("notes.txt");
382        fs::write(&existing, b"notes").expect("write note");
383
384        let handle = try_explicit(existing.to_str().expect("utf-8")).expect("lookup succeeds");
385
386        assert!(handle.is_none());
387    }
388
389    #[test]
390    fn try_workspace_discovers_component_from_target_directory() {
391        let _guard = cwd_lock().lock().expect("cwd lock");
392        let original_cwd = std::env::current_dir().expect("cwd");
393        let dir = tempfile::tempdir().expect("tempdir");
394        std::env::set_current_dir(dir.path()).expect("set cwd");
395
396        let profile_dir = dir.path().join("target/wasm32-wasip2/release");
397        fs::create_dir_all(&profile_dir).expect("create target dir");
398        let wasm_path = profile_dir.join("com.greentic.test.component.wasm");
399        fs::write(&wasm_path, b"fixture-wasm").expect("write wasm");
400        let hash = format!("blake3:{}", blake3::hash(b"fixture-wasm").to_hex());
401        let manifest_path = profile_dir.join(MANIFEST_NAME);
402        fs::write(
403            &manifest_path,
404            manifest_json("com.greentic.test.component.wasm", &hash),
405        )
406        .expect("write manifest");
407
408        let handle = try_workspace("com.greentic.test.component")
409            .expect("workspace lookup")
410            .expect("fixture should resolve");
411
412        assert_eq!(handle.manifest_path, manifest_path);
413        assert_eq!(handle.wasm_path, wasm_path);
414
415        std::env::set_current_dir(original_cwd).expect("restore cwd");
416    }
417
418    #[test]
419    fn try_workspace_ignores_wasm_without_adjacent_manifest() {
420        let _guard = cwd_lock().lock().expect("cwd lock");
421        let original_cwd = std::env::current_dir().expect("cwd");
422        let dir = tempfile::tempdir().expect("tempdir");
423        std::env::set_current_dir(dir.path()).expect("set cwd");
424
425        let profile_dir = dir.path().join("target/wasm32-wasip2/release");
426        fs::create_dir_all(&profile_dir).expect("create target dir");
427        fs::write(
428            profile_dir.join("com.greentic.test.component.wasm"),
429            b"fixture-wasm",
430        )
431        .expect("write wasm");
432
433        let handle = try_workspace("com.greentic.test.component").expect("workspace lookup");
434
435        assert!(handle.is_none());
436
437        std::env::set_current_dir(original_cwd).expect("restore cwd");
438    }
439}