greentic_component/
abi.rs

1use std::fs;
2use std::path::Path;
3
4use thiserror::Error;
5use wit_parser::{Resolve, WorldId, WorldItem};
6
7use crate::lifecycle::Lifecycle;
8use crate::wasm::{self, WorldSource};
9
10const WASI_TARGET_MARKER: &str = "wasm32-wasip2";
11const DEFAULT_REQUIRED_EXPORTS: [&str; 1] = ["describe"];
12
13#[derive(Debug, Error)]
14pub enum AbiError {
15    #[error("failed to read component: {source}")]
16    Io {
17        #[from]
18        source: std::io::Error,
19    },
20    #[error("failed to decode embedded component metadata: {0}")]
21    Metadata(anyhow::Error),
22    #[error("component world mismatch (expected `{expected}`, found `{found}`)")]
23    WorldMismatch { expected: String, found: String },
24    #[error("invalid world reference `{raw}`; expected namespace:package/world[@version]")]
25    InvalidWorldReference { raw: String },
26    #[error("component does not export any callable interfaces in `{world}`")]
27    MissingExports { world: String },
28    #[error("component must target wasm32-wasip2")]
29    MissingWasiTarget,
30}
31
32pub fn check_world(wasm_path: &Path, expected: &str) -> Result<(), AbiError> {
33    let bytes = fs::read(wasm_path)?;
34    ensure_wasi_target(&bytes)?;
35
36    let decoded = wasm::decode_world(&bytes).map_err(AbiError::Metadata)?;
37    let found = format_world(&decoded.resolve, decoded.world);
38    if let WorldSource::Metadata = decoded.source {
39        let normalized_expected = normalize_world_ref(expected)?;
40        if !worlds_match(&found, &normalized_expected) {
41            return Err(AbiError::WorldMismatch {
42                expected: normalized_expected,
43                found,
44            });
45        }
46    }
47
48    ensure_required_exports(&decoded.resolve, decoded.world, &found)?;
49    Ok(())
50}
51
52pub fn has_lifecycle(wasm_path: &Path) -> Result<Lifecycle, AbiError> {
53    let bytes = fs::read(wasm_path)?;
54    let names = extract_export_names(&bytes).unwrap_or_default();
55    Ok(Lifecycle {
56        init: names.iter().any(|name| name.eq_ignore_ascii_case("init")),
57        health: names.iter().any(|name| name.eq_ignore_ascii_case("health")),
58        shutdown: names
59            .iter()
60            .any(|name| name.eq_ignore_ascii_case("shutdown")),
61    })
62}
63
64fn ensure_wasi_target(bytes: &[u8]) -> Result<(), AbiError> {
65    if bytes
66        .windows(WASI_TARGET_MARKER.len())
67        .any(|window| window == WASI_TARGET_MARKER.as_bytes())
68    {
69        Ok(())
70    } else {
71        Err(AbiError::MissingWasiTarget)
72    }
73}
74
75fn normalize_world_ref(input: &str) -> Result<String, AbiError> {
76    let raw = input.trim();
77    if !raw.contains('/') {
78        return Ok(raw.to_string());
79    }
80    let (pkg_part, version) = match raw.split_once('@') {
81        Some((pkg, ver)) if !pkg.is_empty() && !ver.is_empty() => (pkg, Some(ver)),
82        _ => (raw, None),
83    };
84
85    let (pkg, world) =
86        pkg_part
87            .rsplit_once('/')
88            .ok_or_else(|| AbiError::InvalidWorldReference {
89                raw: input.to_string(),
90            })?;
91    let (namespace, name) =
92        pkg.rsplit_once(':')
93            .ok_or_else(|| AbiError::InvalidWorldReference {
94                raw: input.to_string(),
95            })?;
96
97    let mut id = format!("{namespace}:{name}/{world}");
98    if let Some(ver) = version {
99        id.push('@');
100        id.push_str(ver);
101    }
102    Ok(id)
103}
104
105fn format_world(resolve: &Resolve, world_id: WorldId) -> String {
106    let world = &resolve.worlds[world_id];
107    if let Some(pkg_id) = world.package {
108        let pkg = &resolve.packages[pkg_id];
109        if let Some(version) = &pkg.name.version {
110            format!(
111                "{}:{}/{}@{}",
112                pkg.name.namespace, pkg.name.name, world.name, version
113            )
114        } else {
115            format!("{}:{}/{}", pkg.name.namespace, pkg.name.name, world.name)
116        }
117    } else {
118        world.name.clone()
119    }
120}
121
122fn worlds_match(found: &str, expected: &str) -> bool {
123    if found == expected {
124        return true;
125    }
126    let found_base = found.split('@').next().unwrap_or(found);
127    let expected_base = expected.split('@').next().unwrap_or(expected);
128    if found_base == expected_base {
129        return true;
130    }
131    if !expected_base.contains('/') {
132        if let Some((_, world)) = found_base.rsplit_once('/') {
133            return world == expected_base;
134        }
135        return found_base == expected_base;
136    }
137    false
138}
139
140fn ensure_required_exports(
141    resolve: &Resolve,
142    world_id: WorldId,
143    display: &str,
144) -> Result<(), AbiError> {
145    let world = &resolve.worlds[world_id];
146    let has_exports = world.exports.iter().any(|(_, item)| match item {
147        WorldItem::Function(_) => true,
148        WorldItem::Interface { id, .. } => !resolve.interfaces[*id].functions.is_empty(),
149        WorldItem::Type(_) => false,
150    });
151
152    if !has_exports {
153        return Err(AbiError::MissingExports {
154            world: display.to_string(),
155        });
156    }
157
158    // Soft check for commonly required ops. If the world exports any of
159    // these symbols (directly or via interfaces) then we're satisfied.
160    let mut satisfied = DEFAULT_REQUIRED_EXPORTS
161        .iter()
162        .map(|name| (*name, false))
163        .collect::<Vec<_>>();
164
165    for (_key, item) in &world.exports {
166        match item {
167            WorldItem::Function(func) => mark_export(func.name.as_str(), &mut satisfied),
168            WorldItem::Interface { id, .. } => {
169                for (func, _) in resolve.interfaces[*id].functions.iter() {
170                    mark_export(func, &mut satisfied);
171                }
172            }
173            WorldItem::Type(_) => {}
174        }
175
176        if satisfied.iter().all(|(_, hit)| *hit) {
177            break;
178        }
179    }
180
181    Ok(())
182}
183
184fn mark_export(name: &str, satisfied: &mut [(&str, bool)]) {
185    for (needle, flag) in satisfied.iter_mut() {
186        if name.eq_ignore_ascii_case(needle) {
187            *flag = true;
188        }
189    }
190}
191
192fn extract_export_names(bytes: &[u8]) -> Result<Vec<String>, AbiError> {
193    use wasmparser::{ComponentExternalKind, ExternalKind, Parser, Payload};
194
195    let mut names = Vec::new();
196    for payload in Parser::new(0).parse_all(bytes) {
197        let payload = payload.map_err(|err| AbiError::Metadata(err.into()))?;
198        match payload {
199            Payload::ComponentExportSection(section) => {
200                for export in section {
201                    let export = export.map_err(|err| AbiError::Metadata(err.into()))?;
202                    if let ComponentExternalKind::Func = export.kind {
203                        names.push(export.name.0.to_string());
204                    }
205                }
206            }
207            Payload::ExportSection(section) => {
208                for export in section {
209                    let export = export.map_err(|err| AbiError::Metadata(err.into()))?;
210                    if let ExternalKind::Func = export.kind {
211                        names.push(export.name.to_string());
212                    }
213                }
214            }
215            _ => {}
216        }
217    }
218    Ok(names)
219}