greentic_component/
loader.rs1use 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(®istry_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}