greentic_component/
prepare.rs

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}