greentic_component/
abi.rs1use 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 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}