hara_native/wasm_binding/
direct.rs1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::direct_wasm;
4use crate::extension::ExtensionExport;
5use crate::kernel::Form;
6
7use super::{DirectWasmInspection, WasmInterface, WASM_INTERFACE_SCHEMA};
8
9pub const DIRECT_WASM_INSPECTION_SCHEMA: &str = "hara.wasm-inspection/0-alpha";
10
11pub fn inspect_direct(bytes: &[u8]) -> Result<DirectWasmInspection, String> {
12 direct_wasm::inspect(bytes)
13}
14
15pub fn direct_inspection_source(inspection: &DirectWasmInspection) -> String {
16 Form::Map(vec![
17 (
18 keyword_form("schema"),
19 string_form(DIRECT_WASM_INSPECTION_SCHEMA),
20 ),
21 (
22 keyword_form("imports"),
23 Form::Vector(
24 inspection
25 .imports
26 .iter()
27 .map(|import| {
28 let mut fields = vec![
29 (keyword_form("module"), string_form(&import.module)),
30 (keyword_form("name"), string_form(&import.name)),
31 (keyword_form("kind"), keyword_form(import.kind.as_keyword())),
32 ];
33 if let Some(signature) = import.signature.as_ref() {
34 fields.extend(signature_forms(signature));
35 }
36 Form::Map(fields)
37 })
38 .collect(),
39 ),
40 ),
41 (
42 keyword_form("memories"),
43 Form::Vector(
44 inspection
45 .memories
46 .iter()
47 .map(|memory| {
48 Form::Map(vec![
49 (keyword_form("imported"), Form::Bool(memory.imported)),
50 (
51 keyword_form("minimum-pages"),
52 Form::Number(i64::from(memory.minimum_pages)),
53 ),
54 (
55 keyword_form("maximum-pages"),
56 memory
57 .maximum_pages
58 .map(|value| Form::Number(i64::from(value)))
59 .unwrap_or(Form::Nil),
60 ),
61 (keyword_form("shared"), Form::Bool(memory.shared)),
62 (
63 keyword_form("exports"),
64 Form::Vector(
65 memory
66 .export_names
67 .iter()
68 .map(|name| string_form(name))
69 .collect(),
70 ),
71 ),
72 ])
73 })
74 .collect(),
75 ),
76 ),
77 (
78 keyword_form("start"),
79 inspection
80 .start
81 .map(|value| Form::Number(i64::from(value)))
82 .unwrap_or(Form::Nil),
83 ),
84 (
85 keyword_form("exports"),
86 Form::Vector(
87 inspection
88 .exports
89 .iter()
90 .map(|export| {
91 let mut fields = vec![(keyword_form("name"), string_form(&export.name))];
92 fields.extend(signature_forms(&export.signature));
93 fields.push((keyword_form("imported"), Form::Bool(export.imported)));
94 Form::Map(fields)
95 })
96 .collect(),
97 ),
98 ),
99 ])
100 .to_string()
101}
102
103pub fn direct_interface_skeleton(
104 namespace: &str,
105 module: &str,
106 inspection: &DirectWasmInspection,
107) -> Result<String, String> {
108 if !valid_namespace(namespace) {
109 return Err("wasm-binding/malformed: namespace must be a qualified lower-case name".into());
110 }
111 if !valid_module_path(module) {
112 return Err(
113 "wasm-binding/malformed: module must be a safe relative .wasm package path".into(),
114 );
115 }
116 if inspection.exports.is_empty() {
117 return Err("wasm-binding/export-missing: module has no function exports".into());
118 }
119
120 let mut names = BTreeSet::new();
121 let exports = inspection
122 .exports
123 .iter()
124 .enumerate()
125 .map(|(index, export)| {
126 let public_name = unique_binding_name(&export.name, index, &mut names);
127 let arguments = export
128 .signature
129 .arguments
130 .iter()
131 .enumerate()
132 .map(|(argument, wasm_type)| {
133 Form::Map(vec![
134 (
135 keyword_form("name"),
136 symbol_form(&format!("arg-{argument}")),
137 ),
138 (keyword_form("hara/type"), keyword_form("unresolved")),
139 (keyword_form("wasm/type"), keyword_form(wasm_type)),
140 ])
141 })
142 .collect();
143 let hara_result = if export.signature.returns == "void" {
144 "void"
145 } else {
146 "unresolved"
147 };
148 (
149 symbol_form(&public_name),
150 Form::Map(vec![
151 (keyword_form("wasm/export"), string_form(&export.name)),
152 (keyword_form("arguments"), Form::Vector(arguments)),
153 (
154 keyword_form("returns"),
155 Form::Map(vec![
156 (keyword_form("hara/type"), keyword_form(hara_result)),
157 (
158 keyword_form("wasm/type"),
159 keyword_form(&export.signature.returns),
160 ),
161 ]),
162 ),
163 ]),
164 )
165 })
166 .collect();
167
168 Ok(Form::List(vec![
169 symbol_form("wasm/interface"),
170 Form::Map(vec![
171 (keyword_form("schema"), string_form(WASM_INTERFACE_SCHEMA)),
172 (keyword_form("namespace"), symbol_form(namespace)),
173 (keyword_form("module"), string_form(module)),
174 (keyword_form("exports"), Form::Map(exports)),
175 ]),
176 ])
177 .to_string())
178}
179
180impl WasmInterface {
181 pub fn verify_direct(&self, inspection: &DirectWasmInspection) -> Result<(), String> {
182 if self.memory.is_some() {
183 return Err(
184 "wasm-binding/feature-unsupported: :memory requires the memory binding tranche"
185 .into(),
186 );
187 }
188 if !self.capabilities.is_empty()
189 || self
190 .exports
191 .iter()
192 .any(|export| !export.capabilities.is_empty())
193 {
194 return Err(
195 "wasm-binding/capability-denied: direct core.v1 bindings cannot require capabilities"
196 .into(),
197 );
198 }
199 if self.exports.iter().any(|export| export.errors.is_some()) {
200 return Err(
201 "wasm-binding/feature-unsupported: error mappings require a richer binding target"
202 .into(),
203 );
204 }
205
206 let discovered = inspection
207 .direct_exports()
208 .map_err(|error| format!("wasm-binding/module-incompatible: {error}"))?
209 .into_iter()
210 .collect::<BTreeMap<_, _>>();
211 let mut raw_names = BTreeSet::new();
212
213 for export in &self.exports {
214 if !raw_names.insert(export.wasm_export.as_str()) {
215 return Err(format!(
216 "wasm-binding/export-ambiguous: multiple Hara exports map to {}",
217 export.wasm_export
218 ));
219 }
220 let expected = ExtensionExport {
221 arguments: export
222 .arguments
223 .iter()
224 .map(|argument| argument.wasm_type.as_keyword().to_owned())
225 .collect(),
226 returns: export.returns.wasm_type.as_keyword().to_owned(),
227 asynchronous: false,
228 raw_export: None,
229 };
230 let found = discovered.get(&export.wasm_export).ok_or_else(|| {
231 format!(
232 "wasm-binding/export-missing: {} maps to absent Wasm export {}",
233 export.name, export.wasm_export
234 )
235 })?;
236 if found != &expected {
237 return Err(format!(
238 "wasm-binding/signature-mismatch: {} -> {} expected {:?}, found {:?}",
239 export.name, export.wasm_export, expected, found
240 ));
241 }
242 }
243 Ok(())
244 }
245}
246
247fn signature_forms(signature: &ExtensionExport) -> Vec<(Form, Form)> {
248 vec![
249 (
250 keyword_form("arguments"),
251 Form::Vector(
252 signature
253 .arguments
254 .iter()
255 .map(|argument| keyword_form(argument))
256 .collect(),
257 ),
258 ),
259 (keyword_form("returns"), keyword_form(&signature.returns)),
260 ]
261}
262
263fn unique_binding_name(raw: &str, index: usize, used: &mut BTreeSet<String>) -> String {
264 let base = sanitize_binding_name(raw, index);
265 if used.insert(base.clone()) {
266 return base;
267 }
268 for suffix in 2.. {
269 let candidate = format!("{base}-{suffix}");
270 if used.insert(candidate.clone()) {
271 return candidate;
272 }
273 }
274 unreachable!("unbounded suffix search must find a unique binding name")
275}
276
277fn sanitize_binding_name(raw: &str, index: usize) -> String {
278 let mut output = String::new();
279 let mut separated = false;
280 for character in raw.chars() {
281 if character.is_ascii_alphanumeric() {
282 if separated && !output.is_empty() {
283 output.push('-');
284 }
285 output.push(character.to_ascii_lowercase());
286 separated = false;
287 } else {
288 separated = true;
289 }
290 }
291 if output.is_empty() {
292 format!("function-{index}")
293 } else {
294 output
295 }
296}
297
298fn valid_namespace(value: &str) -> bool {
299 value.contains('.') && value.split('.').all(valid_component)
300}
301
302fn valid_component(value: &str) -> bool {
303 !value.is_empty()
304 && value.chars().all(|character| {
305 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
306 })
307}
308
309fn valid_module_path(value: &str) -> bool {
310 value.ends_with(".wasm")
311 && !value.starts_with('/')
312 && !value.contains('\\')
313 && !value.contains(':')
314 && !value.bytes().any(|byte| byte == 0)
315 && value
316 .split('/')
317 .all(|part| !part.is_empty() && part != "." && part != "..")
318}
319
320fn keyword_form(value: &str) -> Form {
321 Form::Keyword(value.to_owned())
322}
323
324fn symbol_form(value: &str) -> Form {
325 Form::Symbol(value.to_owned())
326}
327
328fn string_form(value: &str) -> Form {
329 Form::String(value.to_owned())
330}
331
332#[cfg(test)]
333mod tests {
334 use super::{
335 direct_inspection_source, direct_interface_skeleton, inspect_direct, WasmInterface,
336 };
337
338 const ADD: &[u8] = b"\0asm\x01\0\0\0\x01\x07\x01\x60\x02\x7e\x7e\x01\x7e\x03\x02\x01\0\x07\x07\x01\x03add\0\0\x0a\x09\x01\x07\0\x20\0\x20\x01\x7c\x0b";
339 const IMPORT: &[u8] =
340 b"\0asm\x01\0\0\0\x01\x05\x01\x60\x01\x7f\0\x02\x0b\x01\x03env\x03log\0\0";
341
342 const INTERFACE: &str = r#"
343 (wasm/interface
344 {:schema "hara.wasm-interface/0-alpha"
345 :namespace math.scalar
346 :module "math.wasm"
347 :exports
348 {sum {:wasm/export "add"
349 :arguments [{:name left :hara/type :i64 :wasm/type :i64}
350 {:name right :hara/type :i64 :wasm/type :i64}]
351 :returns {:hara/type :i64 :wasm/type :i64}}}})"#;
352
353 #[test]
354 fn emits_an_explicitly_unresolved_interface_skeleton() {
355 let inspection = inspect_direct(ADD).unwrap();
356 let source = direct_interface_skeleton("generated.math", "math.wasm", &inspection).unwrap();
357 assert!(source.contains(":hara/type :unresolved"));
358 assert!(source.contains(":wasm/export \"add\""));
359 assert!(source.contains(":wasm/type :i64"));
360 let error = WasmInterface::parse(&source, "skeleton").unwrap_err();
361 assert!(error.contains("unsupported Hara type :unresolved"));
362 }
363
364 #[test]
365 fn verifies_hara_names_against_exact_raw_exports() {
366 let interface = WasmInterface::parse(INTERFACE, "fixture").unwrap();
367 let inspection = inspect_direct(ADD).unwrap();
368 interface.verify_direct(&inspection).unwrap();
369
370 let mut drifted = inspection.clone();
371 drifted.exports[0].signature.returns = "i32".into();
372 assert!(interface
373 .verify_direct(&drifted)
374 .unwrap_err()
375 .starts_with("wasm-binding/signature-mismatch"));
376 }
377
378 #[test]
379 fn renders_imports_without_claiming_they_are_bindable() {
380 let inspection = inspect_direct(IMPORT).unwrap();
381 let report = direct_inspection_source(&inspection);
382 assert!(report.contains(":kind :function"));
383 assert!(report.contains(":module \"env\""));
384 assert!(report.contains(":arguments [:i32]"));
385 let interface = WasmInterface::parse(INTERFACE, "fixture").unwrap();
386 assert!(interface
387 .verify_direct(&inspection)
388 .unwrap_err()
389 .contains("import-free"));
390 }
391}