Skip to main content

hara_native/wasm_binding/
direct.rs

1use 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}