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