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