Skip to main content

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 (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}