Skip to main content

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, found) = decode_world(&bytes)?;
37    if let WorldSource::Metadata = decoded.source {
38        let normalized_expected = normalize_world_ref(expected)?;
39        if !worlds_match(&found, &normalized_expected) {
40            return Err(AbiError::WorldMismatch {
41                expected: normalized_expected,
42                found,
43            });
44        }
45    }
46
47    ensure_required_exports(&decoded.resolve, decoded.world, &found)?;
48    Ok(())
49}
50
51pub fn check_world_base(wasm_path: &Path, expected: &str) -> Result<String, AbiError> {
52    let bytes = fs::read(wasm_path)?;
53    ensure_wasi_target(&bytes)?;
54
55    let (decoded, found) = decode_world(&bytes)?;
56    let normalized_expected = normalize_world_ref(expected)?;
57    if !worlds_match(&found, &normalized_expected) {
58        return Err(AbiError::WorldMismatch {
59            expected: normalized_expected,
60            found,
61        });
62    }
63    ensure_required_exports(&decoded.resolve, decoded.world, &found)?;
64    Ok(found)
65}
66
67pub fn has_lifecycle(wasm_path: &Path) -> Result<Lifecycle, AbiError> {
68    let bytes = fs::read(wasm_path)?;
69    let names = extract_export_names(&bytes).unwrap_or_default();
70    Ok(Lifecycle {
71        init: names.iter().any(|name| name.eq_ignore_ascii_case("init")),
72        health: names.iter().any(|name| name.eq_ignore_ascii_case("health")),
73        shutdown: names
74            .iter()
75            .any(|name| name.eq_ignore_ascii_case("shutdown")),
76    })
77}
78
79fn ensure_wasi_target(bytes: &[u8]) -> Result<(), AbiError> {
80    if bytes
81        .windows(WASI_TARGET_MARKER.len())
82        .any(|window| window == WASI_TARGET_MARKER.as_bytes())
83    {
84        Ok(())
85    } else {
86        Err(AbiError::MissingWasiTarget)
87    }
88}
89
90fn decode_world(bytes: &[u8]) -> Result<(wasm::DecodedWorld, String), AbiError> {
91    let decoded = wasm::decode_world(bytes).map_err(AbiError::Metadata)?;
92    let found = format_world(&decoded.resolve, decoded.world);
93    Ok((decoded, found))
94}
95
96fn normalize_world_ref(input: &str) -> Result<String, AbiError> {
97    let raw = input.trim();
98    if !raw.contains('/') {
99        return Ok(raw.to_string());
100    }
101    let (pkg_part, version) = match raw.split_once('@') {
102        Some((pkg, ver)) if !pkg.is_empty() && !ver.is_empty() => (pkg, Some(ver)),
103        _ => (raw, None),
104    };
105
106    let (pkg, world) =
107        pkg_part
108            .rsplit_once('/')
109            .ok_or_else(|| AbiError::InvalidWorldReference {
110                raw: input.to_string(),
111            })?;
112    let (namespace, name) =
113        pkg.rsplit_once(':')
114            .ok_or_else(|| AbiError::InvalidWorldReference {
115                raw: input.to_string(),
116            })?;
117
118    let mut id = format!("{namespace}:{name}/{world}");
119    if let Some(ver) = version {
120        id.push('@');
121        id.push_str(ver);
122    }
123    Ok(id)
124}
125
126fn format_world(resolve: &Resolve, world_id: WorldId) -> String {
127    let world = &resolve.worlds[world_id];
128    if let Some(pkg_id) = world.package {
129        let pkg = &resolve.packages[pkg_id];
130        if let Some(version) = &pkg.name.version {
131            format!(
132                "{}:{}/{}@{}",
133                pkg.name.namespace, pkg.name.name, world.name, version
134            )
135        } else {
136            format!("{}:{}/{}", pkg.name.namespace, pkg.name.name, world.name)
137        }
138    } else {
139        world.name.clone()
140    }
141}
142
143fn worlds_match(found: &str, expected: &str) -> bool {
144    if found == expected {
145        return true;
146    }
147    let found_base = found.split('@').next().unwrap_or(found);
148    let expected_base = expected.split('@').next().unwrap_or(expected);
149    if found_base == expected_base {
150        return true;
151    }
152    if !expected_base.contains('/') {
153        if let Some((_, world)) = found_base.rsplit_once('/') {
154            return world == expected_base;
155        }
156        return found_base == expected_base;
157    }
158    false
159}
160
161fn ensure_required_exports(
162    resolve: &Resolve,
163    world_id: WorldId,
164    display: &str,
165) -> Result<(), AbiError> {
166    let world = &resolve.worlds[world_id];
167    let has_exports = world.exports.iter().any(|(_, item)| match item {
168        WorldItem::Function(_) => true,
169        WorldItem::Interface { id, .. } => !resolve.interfaces[*id].functions.is_empty(),
170        WorldItem::Type(_) => false,
171    });
172
173    if !has_exports {
174        return Err(AbiError::MissingExports {
175            world: display.to_string(),
176        });
177    }
178
179    // Soft check for commonly required ops. If the world exports any of
180    // these symbols (directly or via interfaces) then we're satisfied.
181    let mut satisfied = DEFAULT_REQUIRED_EXPORTS
182        .iter()
183        .map(|name| (*name, false))
184        .collect::<Vec<_>>();
185
186    for (_key, item) in &world.exports {
187        match item {
188            WorldItem::Function(func) => mark_export(func.name.as_str(), &mut satisfied),
189            WorldItem::Interface { id, .. } => {
190                for (func, _) in resolve.interfaces[*id].functions.iter() {
191                    mark_export(func, &mut satisfied);
192                }
193            }
194            WorldItem::Type(_) => {}
195        }
196
197        if satisfied.iter().all(|(_, hit)| *hit) {
198            break;
199        }
200    }
201
202    Ok(())
203}
204
205fn mark_export(name: &str, satisfied: &mut [(&str, bool)]) {
206    for (needle, flag) in satisfied.iter_mut() {
207        if name.eq_ignore_ascii_case(needle) {
208            *flag = true;
209        }
210    }
211}
212
213fn extract_export_names(bytes: &[u8]) -> Result<Vec<String>, AbiError> {
214    use wasmparser::{ComponentExternalKind, ExternalKind, Parser, Payload};
215
216    let mut names = Vec::new();
217    for payload in Parser::new(0).parse_all(bytes) {
218        let payload = payload.map_err(|err| AbiError::Metadata(err.into()))?;
219        match payload {
220            Payload::ComponentExportSection(section) => {
221                for export in section {
222                    let export = export.map_err(|err| AbiError::Metadata(err.into()))?;
223                    if let ComponentExternalKind::Func = export.kind {
224                        names.push(export.name.0.to_string());
225                    }
226                }
227            }
228            Payload::ExportSection(section) => {
229                for export in section {
230                    let export = export.map_err(|err| AbiError::Metadata(err.into()))?;
231                    if let ExternalKind::Func = export.kind {
232                        names.push(export.name.to_string());
233                    }
234                }
235            }
236            _ => {}
237        }
238    }
239    Ok(names)
240}