1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::UNIX_EPOCH;
4
5use dashmap::DashMap;
6use once_cell::sync::Lazy;
7
8#[cfg(test)]
9use std::sync::atomic::{AtomicUsize, Ordering};
10
11use crate::abi;
12use crate::capabilities::Capabilities;
13use crate::describe::{self, DescribePayload};
14use crate::error::ComponentError;
15use crate::lifecycle::Lifecycle;
16use crate::limits::Limits;
17use crate::loader;
18use crate::manifest::ComponentManifest;
19use crate::schema::{self, JsonPath};
20use crate::signing::{SigningError, compute_wasm_hash};
21use crate::telemetry::TelemetrySpec;
22
23#[derive(Debug, Clone)]
24pub struct PreparedComponent {
25 pub manifest: ComponentManifest,
26 pub manifest_path: PathBuf,
27 pub wasm_path: PathBuf,
28 pub root: PathBuf,
29 pub wasm_hash: String,
30 pub describe: DescribePayload,
31 pub lifecycle: Lifecycle,
32 pub redactions: Vec<JsonPath>,
33 pub defaults: Vec<String>,
34 pub hash_verified: bool,
35 pub world_ok: bool,
36}
37
38static ABI_CACHE: Lazy<DashMap<(PathBuf, String), FileStamp>> = Lazy::new(DashMap::new);
39static DESCRIBE_CACHE: Lazy<DashMap<PathBuf, DescribeCacheEntry>> = Lazy::new(DashMap::new);
40
41#[cfg(test)]
42static ABI_MISSES: Lazy<AtomicUsize> = Lazy::new(|| AtomicUsize::new(0));
43#[cfg(test)]
44static DESCRIBE_MISSES: Lazy<AtomicUsize> = Lazy::new(|| AtomicUsize::new(0));
45
46pub fn prepare_component(path_or_id: &str) -> Result<PreparedComponent, ComponentError> {
47 prepare_component_with_manifest(path_or_id, None)
48}
49
50pub fn prepare_component_with_manifest(
51 path_or_id: &str,
52 manifest_override: Option<&Path>,
53) -> Result<PreparedComponent, ComponentError> {
54 let handle = loader::discover_with_manifest(path_or_id, manifest_override)?;
55 let manifest = handle.manifest.clone();
56 let manifest_path = handle.manifest_path.clone();
57 let root = handle.root.clone();
58 let wasm_path = handle.wasm_path.clone();
59
60 let computed_hash = compute_wasm_hash(&wasm_path)?;
61 if computed_hash != manifest.hashes.component_wasm.as_str() {
62 return Err(SigningError::HashMismatch {
63 expected: manifest.hashes.component_wasm.as_str().to_string(),
64 found: computed_hash,
65 }
66 .into());
67 }
68
69 cached_world_check(&wasm_path, manifest.world.as_str())?;
70 let lifecycle = abi::has_lifecycle(&wasm_path)?;
71 let describe_payload = cached_describe(&wasm_path, &manifest)?;
72 let mut redactions = Vec::new();
73 let mut defaults = Vec::new();
74 for version in &describe_payload.versions {
75 let (mut hits, defaults_hits) =
76 schema::collect_redactions_and_defaults_from_value(&version.schema);
77 redactions.append(&mut hits);
78 defaults.extend(
79 defaults_hits
80 .into_iter()
81 .map(|(path, applied)| format!("{}={}", path.as_str(), applied)),
82 );
83 }
84
85 Ok(PreparedComponent {
86 manifest,
87 manifest_path,
88 wasm_path,
89 root,
90 wasm_hash: computed_hash,
91 describe: describe_payload,
92 lifecycle,
93 redactions,
94 defaults,
95 hash_verified: true,
96 world_ok: true,
97 })
98}
99
100fn cached_world_check(path: &Path, expected: &str) -> Result<(), ComponentError> {
101 let stamp = file_stamp(path)?;
102 let key = (path.to_path_buf(), expected.to_string());
103 if let Some(entry) = ABI_CACHE.get(&key)
104 && *entry == stamp
105 {
106 return Ok(());
107 }
108
109 abi::check_world(path, expected)?;
110 #[cfg(test)]
111 {
112 ABI_MISSES.fetch_add(1, Ordering::SeqCst);
113 }
114 ABI_CACHE.insert(key, stamp);
115 Ok(())
116}
117
118fn cached_describe(
119 path: &Path,
120 manifest: &ComponentManifest,
121) -> Result<DescribePayload, ComponentError> {
122 let stamp = file_stamp(path)?;
123 if let Some(entry) = DESCRIBE_CACHE.get(path)
124 && entry.stamp == stamp
125 && entry.export == manifest.describe_export.as_str()
126 {
127 return Ok(entry.payload.clone());
128 }
129
130 let payload = describe::load(path, manifest)?;
131 #[cfg(test)]
132 {
133 DESCRIBE_MISSES.fetch_add(1, Ordering::SeqCst);
134 }
135 DESCRIBE_CACHE.insert(
136 path.to_path_buf(),
137 DescribeCacheEntry {
138 stamp,
139 export: manifest.describe_export.as_str().to_string(),
140 payload: payload.clone(),
141 },
142 );
143 Ok(payload)
144}
145
146fn file_stamp(path: &Path) -> Result<FileStamp, ComponentError> {
147 let meta = fs::metadata(path)?;
148 let len = meta.len();
149 let modified = meta
150 .modified()
151 .ok()
152 .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
153 .map(|dur| dur.as_nanos())
154 .unwrap_or(0);
155 Ok(FileStamp { len, modified })
156}
157
158#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
159struct FileStamp {
160 len: u64,
161 modified: u128,
162}
163
164#[derive(Clone)]
165struct DescribeCacheEntry {
166 stamp: FileStamp,
167 export: String,
168 payload: DescribePayload,
169}
170
171pub fn clear_cache_for(path: &Path) {
172 let path_buf = path.to_path_buf();
173 ABI_CACHE.retain(|(p, _), _| p != &path_buf);
174 DESCRIBE_CACHE.remove(path);
175}
176
177#[derive(Debug, Clone)]
178pub struct RunnerConfig {
179 pub wasm_path: PathBuf,
180 pub world: String,
181 pub capabilities: Capabilities,
182 pub limits: Option<Limits>,
183 pub telemetry: Option<TelemetrySpec>,
184 pub redactions: Vec<JsonPath>,
185 pub defaults: Vec<String>,
186 pub describe: DescribePayload,
187}
188
189#[derive(Debug, Clone)]
190pub struct PackEntry {
191 pub manifest_json: String,
192 pub describe_schema: Option<String>,
193 pub wasm_hash: String,
194 pub world: String,
195}
196
197impl PreparedComponent {
198 pub fn redaction_paths(&self) -> &[JsonPath] {
199 &self.redactions
200 }
201
202 pub fn defaults_applied(&self) -> &[String] {
203 &self.defaults
204 }
205
206 pub fn to_runner_config(&self) -> RunnerConfig {
207 RunnerConfig {
208 wasm_path: self.wasm_path.clone(),
209 world: self.manifest.world.as_str().to_string(),
210 capabilities: self.manifest.capabilities.clone(),
211 limits: self.manifest.limits.clone(),
212 telemetry: self.manifest.telemetry.clone(),
213 redactions: self.redactions.clone(),
214 defaults: self.defaults.clone(),
215 describe: self.describe.clone(),
216 }
217 }
218
219 pub fn to_pack_entry(&self) -> Result<PackEntry, ComponentError> {
220 let manifest_json = fs::read_to_string(&self.manifest_path)?;
221 let describe_schema = self.describe.versions.first().map(|version| {
222 serde_json::to_string(&version.schema).expect("describe schema serialization")
223 });
224 Ok(PackEntry {
225 manifest_json,
226 describe_schema,
227 wasm_hash: self.wasm_hash.clone(),
228 world: self.manifest.world.as_str().to_string(),
229 })
230 }
231}
232
233#[cfg(test)]
234pub(crate) fn cache_stats() -> (usize, usize) {
235 (
236 ABI_MISSES.load(Ordering::SeqCst),
237 DESCRIBE_MISSES.load(Ordering::SeqCst),
238 )
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use blake3::Hasher;
245 use tempfile::TempDir;
246 use wasm_encoder::{
247 CodeSection, CustomSection, ExportKind, ExportSection, Function, FunctionSection,
248 Instruction, Module, TypeSection,
249 };
250 use wit_component::{StringEncoding, metadata};
251 use wit_parser::{Resolve, WorldId};
252
253 const TEST_WIT: &str = r#"
254package greentic:component@0.1.0;
255world node {
256 export describe: func();
257}
258"#;
259
260 #[test]
261 fn caches_results() {
262 ABI_MISSES.store(0, Ordering::SeqCst);
263 DESCRIBE_MISSES.store(0, Ordering::SeqCst);
264 let fixture = TestFixture::new(TEST_WIT, &["describe"]);
265 prepare_component(fixture.manifest_path.to_str().unwrap()).unwrap();
266 let first = cache_stats();
267 prepare_component(fixture.manifest_path.to_str().unwrap()).unwrap();
268 assert_eq!(first, cache_stats());
269 }
270
271 struct TestFixture {
272 _temp: TempDir,
273 manifest_path: PathBuf,
274 }
275
276 impl TestFixture {
277 fn new(world_src: &str, funcs: &[&str]) -> Self {
278 let temp = TempDir::new().expect("tempdir");
279 let (wasm, manifest) = build_component(world_src, funcs);
280 fs::write(temp.path().join("component.wasm"), &wasm).unwrap();
281 let manifest_path = temp.path().join("component.manifest.json");
282 fs::write(&manifest_path, manifest).unwrap();
283 Self {
284 _temp: temp,
285 manifest_path,
286 }
287 }
288 }
289
290 fn build_component(world_src: &str, funcs: &[&str]) -> (Vec<u8>, String) {
291 let mut resolve = Resolve::default();
292 let pkg = resolve.push_str("test.wit", world_src).unwrap();
293 let world = resolve.select_world(&[pkg], Some("node")).unwrap();
294 let metadata = metadata::encode(&resolve, world, StringEncoding::UTF8, None).unwrap();
295
296 let mut module = Module::new();
297 let mut types = TypeSection::new();
298 types.ty().function([], []);
299 module.section(&types);
300
301 let mut funcs_section = FunctionSection::new();
302 for _ in funcs {
303 funcs_section.function(0);
304 }
305 module.section(&funcs_section);
306
307 let mut exports = ExportSection::new();
308 for (idx, name) in funcs.iter().enumerate() {
309 exports.export(name, ExportKind::Func, idx as u32);
310 }
311 module.section(&exports);
312
313 let mut code = CodeSection::new();
314 for _ in funcs {
315 let mut body = Function::new([]);
316 body.instruction(&Instruction::End);
317 code.function(&body);
318 }
319 module.section(&code);
320
321 module.section(&CustomSection {
322 name: "component-type".into(),
323 data: std::borrow::Cow::Borrowed(&metadata),
324 });
325 let wasm_bytes = module.finish();
326 let observed_world = detect_world(&wasm_bytes).unwrap_or_else(|| "root:root/root".into());
327 let mut hasher = Hasher::new();
328 hasher.update(&wasm_bytes);
329 let digest = hasher.finalize();
330 let hash = format!("blake3:{}", hex::encode(digest.as_bytes()));
331
332 let manifest = serde_json::json!({
333 "id": "com.greentic.test.component",
334 "name": "Test",
335 "version": "0.1.0",
336 "world": observed_world,
337 "describe_export": "describe",
338 "operations": [
339 {
340 "name": "describe",
341 "input_schema": {},
342 "output_schema": {}
343 }
344 ],
345 "default_operation": "describe",
346 "supports": ["messaging"],
347 "profiles": {
348 "default": "stateless",
349 "supported": ["stateless"]
350 },
351 "config_schema": {
352 "type": "object",
353 "properties": {},
354 "required": [],
355 "additionalProperties": false
356 },
357 "dev_flows": {
358 "default": {
359 "format": "flow-ir-json",
360 "graph": {
361 "nodes": [
362 { "id": "start", "type": "start" },
363 { "id": "end", "type": "end" }
364 ],
365 "edges": [
366 { "from": "start", "to": "end" }
367 ]
368 }
369 }
370 },
371 "capabilities": {
372 "wasi": {
373 "filesystem": {
374 "mode": "none",
375 "mounts": []
376 },
377 "random": true,
378 "clocks": true
379 },
380 "host": {
381 "messaging": {
382 "inbound": true,
383 "outbound": true
384 }
385 }
386 },
387 "limits": {"memory_mb": 64, "wall_time_ms": 1000},
388 "telemetry": {"span_prefix": "test.component"},
389 "artifacts": {"component_wasm": "component.wasm"},
390 "hashes": {"component_wasm": hash},
391 });
392
393 (wasm_bytes, serde_json::to_string_pretty(&manifest).unwrap())
394 }
395
396 fn detect_world(bytes: &[u8]) -> Option<String> {
397 let decoded = crate::wasm::decode_world(bytes).ok()?;
398 Some(world_label(&decoded.resolve, decoded.world))
399 }
400
401 fn world_label(resolve: &Resolve, world_id: WorldId) -> String {
402 let world = &resolve.worlds[world_id];
403 if let Some(pkg_id) = world.package {
404 let pkg = &resolve.packages[pkg_id];
405 if let Some(version) = &pkg.name.version {
406 format!(
407 "{}:{}/{}@{}",
408 pkg.name.namespace, pkg.name.name, world.name, version
409 )
410 } else {
411 format!("{}:{}/{}", pkg.name.namespace, pkg.name.name, world.name)
412 }
413 } else {
414 world.name.clone()
415 }
416 }
417}