Skip to main content

greentic_component/cmd/info/
reader.rs

1use std::path::Path;
2
3use anyhow::{Context, Result, anyhow};
4
5use super::report::{Capabilities, InfoReport, ManifestSource};
6use crate::capabilities::Capabilities as ManifestCapabilities;
7
8/// Read a compiled component `.wasm` and project it into an [`InfoReport`].
9///
10/// Returns an error only if the file can't be read or is not a valid
11/// WebAssembly Component Model binary. A missing manifest is not an
12/// error — the report's `manifest_source` is simply `None`.
13pub fn read(path: &Path) -> Result<InfoReport> {
14    let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
15    let size_bytes = bytes.len() as u64;
16
17    let engine = wasmtime::Engine::default();
18    // wasmtime's error type is its own re-export of anyhow::Error and does not
19    // implement std::error::Error, so use `map_err` instead of `.with_context`.
20    let component =
21        wasmtime::component::Component::from_binary(&engine, &bytes).map_err(|err| {
22            anyhow!(
23                "{}: not a valid wasm32-wasip2 component: {err}",
24                path.display()
25            )
26        })?;
27    let ct = component.component_type();
28    let exports: Vec<String> = ct.exports(&engine).map(|(n, _)| n.to_string()).collect();
29    let imports: Vec<String> = ct.imports(&engine).map(|(n, _)| n.to_string()).collect();
30
31    let (manifest_source, id, name, version, description, capabilities, world_from_manifest) =
32        load_manifest(path, &bytes);
33
34    // Prefer the manifest-declared world identifier; fall back to a heuristic
35    // over the binary-derived exports when no manifest was found.
36    let wit_world = world_from_manifest.or_else(|| pick_wit_world(&exports));
37
38    Ok(InfoReport {
39        info_schema_version: 1,
40        component_id: id,
41        name,
42        version,
43        description,
44        artifact_type: "component/wasm".into(),
45        size_bytes,
46        wit_world,
47        exports,
48        imports,
49        capabilities,
50        manifest_source,
51    })
52}
53
54type ManifestFields = (
55    ManifestSource,
56    Option<String>, // id
57    Option<String>, // name
58    Option<String>, // version
59    Option<String>, // description
60    Option<Capabilities>,
61    Option<String>, // wit_world
62);
63
64fn load_manifest(path: &Path, bytes: &[u8]) -> ManifestFields {
65    // 1. Try the embedded custom section first.
66    if let Ok(Some(verified)) =
67        crate::embedded_descriptor::read_and_verify_embedded_component_manifest_section_v1(bytes)
68    {
69        let m = &verified.manifest;
70        return (
71            ManifestSource::Embedded,
72            Some(m.id.clone()),
73            Some(m.name.clone()),
74            Some(m.version.clone()),
75            // EmbeddedComponentManifestV1 has no description field.
76            None,
77            Some(extract_capabilities(&m.capabilities)),
78            Some(m.world.clone()),
79        );
80    }
81
82    // 2. Fall back to a sibling manifest JSON.
83    //    Try `<stem>.manifest.json` first, then the generic `component.manifest.json`.
84    let stem = path
85        .file_stem()
86        .and_then(|s| s.to_str())
87        .unwrap_or("component");
88    let candidates = [
89        path.with_file_name(format!("{stem}.manifest.json")),
90        path.with_file_name("component.manifest.json"),
91    ];
92
93    for candidate in &candidates {
94        if !candidate.exists() {
95            continue;
96        }
97        let Ok(raw) = std::fs::read_to_string(candidate) else {
98            continue;
99        };
100        let Ok(m) = crate::manifest::parse_manifest(&raw) else {
101            continue;
102        };
103        return (
104            ManifestSource::Sibling,
105            Some(m.id.as_str().to_string()),
106            Some(m.name.clone()),
107            Some(m.version.to_string()),
108            // ComponentManifest has no description field today.
109            None,
110            Some(extract_capabilities(&m.capabilities)),
111            Some(m.world.as_str().to_string()),
112        );
113    }
114
115    (ManifestSource::None, None, None, None, None, None, None)
116}
117
118/// Project the manifest's structured capability declaration into a flat list
119/// of human-readable identifiers (e.g. `messaging.inbound`, `state.read`,
120/// `wasi.clocks`). Only surfaces that are actually enabled are included.
121fn extract_capabilities(caps: &ManifestCapabilities) -> Capabilities {
122    let mut host: Vec<String> = Vec::new();
123    let mut wasi: Vec<String> = Vec::new();
124
125    // WASI surfaces.
126    if let Some(fs) = &caps.wasi.filesystem {
127        use crate::capabilities::FilesystemMode::{None as FsNone, ReadOnly, Sandbox};
128        match fs.mode {
129            FsNone => {}
130            ReadOnly => wasi.push("filesystem.read_only".into()),
131            Sandbox => wasi.push("filesystem.sandbox".into()),
132        }
133    }
134    if let Some(env) = &caps.wasi.env
135        && !env.allow.is_empty()
136    {
137        wasi.push("env".into());
138    }
139    if caps.wasi.random {
140        wasi.push("random".into());
141    }
142    if caps.wasi.clocks {
143        wasi.push("clocks".into());
144    }
145
146    // Host surfaces.
147    if let Some(messaging) = &caps.host.messaging {
148        if messaging.inbound {
149            host.push("messaging.inbound".into());
150        }
151        if messaging.outbound {
152            host.push("messaging.outbound".into());
153        }
154    }
155    if let Some(events) = &caps.host.events {
156        if events.inbound {
157            host.push("events.inbound".into());
158        }
159        if events.outbound {
160            host.push("events.outbound".into());
161        }
162    }
163    if let Some(http) = &caps.host.http {
164        if http.client {
165            host.push("http.client".into());
166        }
167        if http.server {
168            host.push("http.server".into());
169        }
170    }
171    if let Some(state) = &caps.host.state {
172        if state.read {
173            host.push("state.read".into());
174        }
175        if state.write {
176            host.push("state.write".into());
177        }
178    }
179    if let Some(secrets) = &caps.host.secrets
180        && !secrets.required.is_empty()
181    {
182        host.push(format!("secrets.required[{}]", secrets.required.len()));
183    }
184    if let Some(telemetry) = &caps.host.telemetry {
185        use crate::capabilities::TelemetryScope::*;
186        let scope = match telemetry.scope {
187            Tenant => "tenant",
188            Pack => "pack",
189            Node => "node",
190        };
191        host.push(format!("telemetry.{scope}"));
192    }
193    if let Some(iac) = &caps.host.iac {
194        if iac.write_templates {
195            host.push("iac.write_templates".into());
196        }
197        if iac.execute_plans {
198            host.push("iac.execute_plans".into());
199        }
200    }
201
202    Capabilities { host, wasi }
203}
204
205/// Heuristic pick for the WIT world name when no manifest is available.
206///
207/// Look for an export that looks like a WIT world or descriptor identifier:
208/// typical greentic components export `greentic:component/descriptor@X.Y.Z`
209/// or similar `.../world@<version>` forms.
210fn pick_wit_world(exports: &[String]) -> Option<String> {
211    exports
212        .iter()
213        .find(|e| e.contains("/world@") || e.contains("/descriptor@"))
214        .cloned()
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use std::path::PathBuf;
221
222    fn fixture_wasm() -> PathBuf {
223        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
224            .join("tests/contract/fixtures/component_v0_6_0/component.wasm")
225    }
226
227    #[test]
228    fn reads_size_and_exports_from_fixture() {
229        let path = fixture_wasm();
230        if !path.exists() {
231            eprintln!("skipping: no fixture at {}", path.display());
232            return;
233        }
234        let r = read(&path).expect("read info");
235        assert!(r.size_bytes > 0);
236        assert!(!r.exports.is_empty());
237        assert_eq!(r.info_schema_version, 1);
238        assert_eq!(r.artifact_type, "component/wasm");
239        // Sibling `component.manifest.json` exists in this fixture dir, so
240        // manifest_source should be Embedded or Sibling (or None if neither
241        // survives parsing — we tolerate all three).
242        assert!(matches!(
243            r.manifest_source,
244            ManifestSource::Embedded | ManifestSource::Sibling | ManifestSource::None
245        ));
246    }
247
248    #[test]
249    fn pick_wit_world_prefers_world_segments() {
250        let exports = vec![
251            "wasi:io/streams@0.2.0".to_string(),
252            "greentic:component/descriptor@0.6.0".to_string(),
253        ];
254        let picked = pick_wit_world(&exports);
255        assert_eq!(
256            picked.as_deref(),
257            Some("greentic:component/descriptor@0.6.0")
258        );
259    }
260
261    #[test]
262    fn extract_capabilities_empty_when_default() {
263        let caps = ManifestCapabilities::default();
264        let out = extract_capabilities(&caps);
265        assert!(out.host.is_empty());
266        assert!(out.wasi.is_empty());
267    }
268
269    #[test]
270    fn extract_capabilities_flags_enabled_surfaces() {
271        use crate::capabilities::{HostCapabilities, MessagingCapabilities, StateCapabilities};
272        let caps = ManifestCapabilities {
273            wasi: crate::capabilities::WasiCapabilities {
274                filesystem: None,
275                env: None,
276                random: false,
277                clocks: true,
278            },
279            host: HostCapabilities {
280                messaging: Some(MessagingCapabilities {
281                    inbound: true,
282                    outbound: false,
283                }),
284                events: None,
285                http: None,
286                secrets: None,
287                state: Some(StateCapabilities {
288                    read: true,
289                    write: false,
290                }),
291                telemetry: None,
292                iac: None,
293            },
294        };
295        let out = extract_capabilities(&caps);
296        assert_eq!(out.wasi, vec!["clocks".to_string()]);
297        assert_eq!(
298            out.host,
299            vec!["messaging.inbound".to_string(), "state.read".to_string()]
300        );
301    }
302}