Skip to main content

hara_native/wasm_binding/
memory.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use sha2::{Digest, Sha256};
4
5use crate::extension::ExtensionExport;
6use crate::kernel::Form;
7
8use super::{
9    BindingParameter, BindingResult, DirectWasmInspection, HaraValueType, Lifting, Lowering,
10    MemoryContract, Ownership, WasmInterface, WasmValueType,
11};
12
13pub const MEMORY_BINDING_SCHEMA: &str = "hara.wasm-memory-binding/0-alpha";
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct MemoryBindingPlan {
17    pub schema: String,
18    pub namespace: String,
19    pub module: String,
20    pub memory: MemoryContract,
21    pub functions: Vec<MemoryFunctionPlan>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct MemoryFunctionPlan {
26    pub name: String,
27    pub wasm_export: String,
28    pub arguments: Vec<MemoryArgumentPlan>,
29    pub returns: MemoryResultPlan,
30    pub raw_arguments: Vec<WasmValueType>,
31    pub raw_returns: WasmValueType,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct MemoryArgumentPlan {
36    pub name: String,
37    pub hara_type: HaraValueType,
38    pub lowering: Option<Lowering>,
39    pub ownership: Option<Ownership>,
40    pub raw_types: Vec<WasmValueType>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct MemoryResultPlan {
45    pub hara_type: HaraValueType,
46    pub lifting: Option<Lifting>,
47    pub ownership: Option<Ownership>,
48    pub raw_type: WasmValueType,
49}
50
51impl WasmInterface {
52    pub fn memory_plan(&self) -> Result<MemoryBindingPlan, String> {
53        MemoryBindingPlan::compile(self)
54    }
55}
56
57impl MemoryBindingPlan {
58    pub fn compile(interface: &WasmInterface) -> Result<Self, String> {
59        if interface.capabilities.is_empty()
60            && interface
61                .exports
62                .iter()
63                .all(|export| export.capabilities.is_empty())
64        {
65            // Memory bindings are pure module calls in this tranche.
66        } else {
67            return Err(
68                "wasm-binding/capability-denied: memory.v1 cannot require host capabilities".into(),
69            );
70        }
71        if interface
72            .exports
73            .iter()
74            .any(|export| export.asynchronous || export.errors.is_some())
75        {
76            return Err(
77                "wasm-binding/feature-unsupported: async and error mappings require HTA".into(),
78            );
79        }
80
81        let memory = interface.memory.clone().ok_or_else(|| {
82            "wasm-binding/memory-missing: non-scalar bindings require :memory".to_owned()
83        })?;
84        if memory.reallocate.is_some() {
85            return Err(
86                "wasm-binding/feature-unsupported: :reallocate is reserved for a later memory.v1 revision"
87                    .into(),
88            );
89        }
90        let mut raw_names = BTreeSet::new();
91        let mut requires_allocate = false;
92        let mut requires_release = false;
93        let mut uses_memory = false;
94        let mut functions = Vec::with_capacity(interface.exports.len());
95
96        for export in &interface.exports {
97            if !raw_names.insert(export.wasm_export.as_str()) {
98                return Err(format!(
99                    "wasm-binding/export-ambiguous: multiple Hara exports map to {}",
100                    export.wasm_export
101                ));
102            }
103            let mut raw_arguments = Vec::new();
104            let mut arguments = Vec::with_capacity(export.arguments.len());
105            for argument in &export.arguments {
106                let compiled = compile_argument(argument, &export.name)?;
107                uses_memory |= compiled.lowering.is_some();
108                if compiled.lowering == Some(Lowering::PointerLength) {
109                    requires_allocate = true;
110                }
111                raw_arguments.extend(compiled.raw_types.iter().copied());
112                arguments.push(compiled);
113            }
114            let returns = compile_result(&export.returns, &export.name)?;
115            uses_memory |= returns.lifting.is_some();
116            if returns.ownership == Some(Ownership::Caller) {
117                requires_release = true;
118            }
119            functions.push(MemoryFunctionPlan {
120                name: export.name.clone(),
121                wasm_export: export.wasm_export.clone(),
122                arguments,
123                returns: returns.clone(),
124                raw_arguments,
125                raw_returns: returns.raw_type,
126            });
127        }
128
129        if !uses_memory {
130            return Err(
131                "wasm-binding/memory-unused: memory.v1 requires at least one lowered or lifted value"
132                    .into(),
133            );
134        }
135        if requires_allocate && memory.allocate.is_none() {
136            return Err(
137                "wasm-binding/allocator-missing: pointer/length inputs require :memory :allocate"
138                    .into(),
139            );
140        }
141        if requires_release && memory.release.is_none() {
142            return Err(
143                "wasm-binding/release-missing: caller-owned results require :memory :release"
144                    .into(),
145            );
146        }
147
148        Ok(Self {
149            schema: MEMORY_BINDING_SCHEMA.into(),
150            namespace: interface.namespace.clone(),
151            module: interface.module.clone(),
152            memory,
153            functions,
154        })
155    }
156
157    pub fn canonical_source(&self) -> String {
158        plan_form(self).to_string()
159    }
160
161    pub fn digest(&self) -> String {
162        let digest = Sha256::digest(self.canonical_source().as_bytes());
163        format!("sha256:{digest:x}")
164    }
165
166    pub fn verify(&self, inspection: &DirectWasmInspection) -> Result<(), String> {
167        if inspection.start.is_some() {
168            return Err(
169                "wasm-binding/start-denied: memory.v1 modules must not declare a start function"
170                    .into(),
171            );
172        }
173        let discovered = inspection
174            .direct_exports()
175            .map_err(|error| format!("wasm-binding/module-incompatible: {error}"))?
176            .into_iter()
177            .collect::<BTreeMap<_, _>>();
178
179        let memory_exported = inspection.memories.iter().any(|memory| {
180            memory
181                .export_names
182                .iter()
183                .any(|name| name == &self.memory.export)
184        });
185        if !memory_exported {
186            return Err(format!(
187                "wasm-binding/memory-missing: module does not export {}",
188                self.memory.export
189            ));
190        }
191
192        for function in &self.functions {
193            verify_signature(
194                &discovered,
195                &function.wasm_export,
196                &ExtensionExport {
197                    arguments: function
198                        .raw_arguments
199                        .iter()
200                        .map(|value| value.as_keyword().to_owned())
201                        .collect(),
202                    returns: function.raw_returns.as_keyword().to_owned(),
203                    asynchronous: false,
204                    raw_export: None,
205                },
206                &format!("{} -> {}", function.name, function.wasm_export),
207            )?;
208        }
209        if let Some(name) = self.memory.allocate.as_deref() {
210            verify_signature(
211                &discovered,
212                name,
213                &signature(&[WasmValueType::I32], WasmValueType::I32),
214                "memory allocator",
215            )?;
216        }
217        if let Some(name) = self.memory.reallocate.as_deref() {
218            verify_signature(
219                &discovered,
220                name,
221                &signature(
222                    &[WasmValueType::I32, WasmValueType::I32],
223                    WasmValueType::I32,
224                ),
225                "memory reallocator",
226            )?;
227        }
228        if let Some(name) = self.memory.release.as_deref() {
229            verify_signature(
230                &discovered,
231                name,
232                &signature(&[WasmValueType::I32], WasmValueType::Void),
233                "memory release",
234            )?;
235        }
236        Ok(())
237    }
238}
239
240fn compile_argument(
241    argument: &BindingParameter,
242    export: &str,
243) -> Result<MemoryArgumentPlan, String> {
244    if let Some(expected) = argument.hara_type.direct_wasm_type() {
245        if expected != argument.wasm_type
246            || argument.lowering.is_some()
247            || argument.ownership.is_some()
248        {
249            return Err(format!(
250                "wasm-binding/signature-mismatch: scalar argument {} in {export} must map directly to :{}",
251                argument.name,
252                expected.as_keyword()
253            ));
254        }
255        return Ok(MemoryArgumentPlan {
256            name: argument.name.clone(),
257            hara_type: argument.hara_type.clone(),
258            lowering: None,
259            ownership: None,
260            raw_types: vec![expected],
261        });
262    }
263
264    let memory_value = matches!(
265        argument.hara_type,
266        HaraValueType::String | HaraValueType::Bytes
267    );
268    if !memory_value
269        || argument.lowering != Some(Lowering::PointerLength)
270        || argument.wasm_type != WasmValueType::I32
271    {
272        return Err(format!(
273            "wasm-binding/feature-unsupported: argument {} in {export} must be :string or :bytes lowered as [:pointer :length] from :i32",
274            argument.name
275        ));
276    }
277    match argument.ownership {
278        Some(Ownership::Borrowed | Ownership::Transferred) => {}
279        _ => {
280            return Err(format!(
281                "wasm-binding/ownership-invalid: argument {} in {export} must be :borrowed or :transferred",
282                argument.name
283            ))
284        }
285    }
286    Ok(MemoryArgumentPlan {
287        name: argument.name.clone(),
288        hara_type: argument.hara_type.clone(),
289        lowering: argument.lowering,
290        ownership: argument.ownership,
291        raw_types: vec![WasmValueType::I32, WasmValueType::I32],
292    })
293}
294
295fn compile_result(result: &BindingResult, export: &str) -> Result<MemoryResultPlan, String> {
296    if let Some(expected) = result.hara_type.direct_wasm_type() {
297        if expected != result.wasm_type || result.lifting.is_some() || result.ownership.is_some() {
298            return Err(format!(
299                "wasm-binding/signature-mismatch: scalar result in {export} must map directly to :{}",
300                expected.as_keyword()
301            ));
302        }
303        return Ok(MemoryResultPlan {
304            hara_type: result.hara_type.clone(),
305            lifting: None,
306            ownership: None,
307            raw_type: expected,
308        });
309    }
310
311    let memory_value = matches!(
312        result.hara_type,
313        HaraValueType::String | HaraValueType::Bytes
314    );
315    if !memory_value
316        || result.lifting != Some(Lifting::PackedI64)
317        || result.wasm_type != WasmValueType::I64
318    {
319        return Err(format!(
320            "wasm-binding/feature-unsupported: result in {export} must be :string or :bytes lifted from :packed-i64"
321        ));
322    }
323    match result.ownership {
324        Some(Ownership::Caller | Ownership::Callee) => {}
325        _ => {
326            return Err(format!(
327                "wasm-binding/ownership-invalid: result in {export} must be :caller or :callee"
328            ))
329        }
330    }
331    Ok(MemoryResultPlan {
332        hara_type: result.hara_type.clone(),
333        lifting: result.lifting,
334        ownership: result.ownership,
335        raw_type: WasmValueType::I64,
336    })
337}
338
339fn verify_signature(
340    discovered: &BTreeMap<String, ExtensionExport>,
341    raw_name: &str,
342    expected: &ExtensionExport,
343    label: &str,
344) -> Result<(), String> {
345    let found = discovered
346        .get(raw_name)
347        .ok_or_else(|| format!("wasm-binding/export-missing: {label} requires {raw_name}"))?;
348    if found != expected {
349        return Err(format!(
350            "wasm-binding/signature-mismatch: {label} expected {expected:?}, found {found:?}"
351        ));
352    }
353    Ok(())
354}
355
356fn signature(arguments: &[WasmValueType], returns: WasmValueType) -> ExtensionExport {
357    ExtensionExport {
358        arguments: arguments
359            .iter()
360            .map(|argument| argument.as_keyword().to_owned())
361            .collect(),
362        returns: returns.as_keyword().to_owned(),
363        asynchronous: false,
364        raw_export: None,
365    }
366}
367
368fn plan_form(plan: &MemoryBindingPlan) -> Form {
369    Form::Map(vec![
370        (keyword("schema"), string(&plan.schema)),
371        (keyword("namespace"), symbol(&plan.namespace)),
372        (keyword("module"), string(&plan.module)),
373        (keyword("target"), keyword("memory.v1")),
374        (keyword("memory"), memory_form(&plan.memory)),
375        (
376            keyword("functions"),
377            Form::Vector(plan.functions.iter().map(function_form).collect()),
378        ),
379    ])
380}
381
382fn memory_form(memory: &MemoryContract) -> Form {
383    let mut fields = vec![(keyword("export"), string(&memory.export))];
384    push_string(&mut fields, "allocate", memory.allocate.as_deref());
385    push_string(&mut fields, "reallocate", memory.reallocate.as_deref());
386    push_string(&mut fields, "release", memory.release.as_deref());
387    Form::Map(fields)
388}
389
390fn function_form(function: &MemoryFunctionPlan) -> Form {
391    Form::Map(vec![
392        (keyword("hara/name"), symbol(&function.name)),
393        (keyword("wasm/export"), string(&function.wasm_export)),
394        (
395            keyword("arguments"),
396            Form::Vector(function.arguments.iter().map(argument_form).collect()),
397        ),
398        (keyword("returns"), result_form(&function.returns)),
399        (
400            keyword("wasm/arguments"),
401            Form::Vector(
402                function
403                    .raw_arguments
404                    .iter()
405                    .map(|value| keyword(value.as_keyword()))
406                    .collect(),
407            ),
408        ),
409        (
410            keyword("wasm/returns"),
411            keyword(function.raw_returns.as_keyword()),
412        ),
413    ])
414}
415
416fn argument_form(argument: &MemoryArgumentPlan) -> Form {
417    let mut fields = vec![
418        (keyword("name"), symbol(&argument.name)),
419        (keyword("hara/type"), hara_type_form(&argument.hara_type)),
420        (
421            keyword("wasm/types"),
422            Form::Vector(
423                argument
424                    .raw_types
425                    .iter()
426                    .map(|value| keyword(value.as_keyword()))
427                    .collect(),
428            ),
429        ),
430    ];
431    if let Some(lowering) = argument.lowering {
432        fields.push((keyword("lower"), lowering_form(lowering)));
433    }
434    if let Some(ownership) = argument.ownership {
435        fields.push((keyword("ownership"), keyword(ownership_name(ownership))));
436    }
437    Form::Map(fields)
438}
439
440fn result_form(result: &MemoryResultPlan) -> Form {
441    let mut fields = vec![
442        (keyword("hara/type"), hara_type_form(&result.hara_type)),
443        (keyword("wasm/type"), keyword(result.raw_type.as_keyword())),
444    ];
445    if let Some(lifting) = result.lifting {
446        fields.push((keyword("lift"), lifting_form(lifting)));
447    }
448    if let Some(ownership) = result.ownership {
449        fields.push((keyword("ownership"), keyword(ownership_name(ownership))));
450    }
451    Form::Map(fields)
452}
453
454fn hara_type_form(value: &HaraValueType) -> Form {
455    match value {
456        HaraValueType::I32 => keyword("i32"),
457        HaraValueType::I64 => keyword("i64"),
458        HaraValueType::F32 => keyword("f32"),
459        HaraValueType::F64 => keyword("f64"),
460        HaraValueType::Boolean => keyword("boolean"),
461        HaraValueType::String => keyword("string"),
462        HaraValueType::Bytes => keyword("bytes"),
463        HaraValueType::Record(name) => named_type("record", name),
464        HaraValueType::Variant(name) => named_type("variant", name),
465        HaraValueType::Handle(name) => named_type("handle", name),
466        HaraValueType::Callback(name) => named_type("callback", name),
467        HaraValueType::Void => keyword("void"),
468    }
469}
470
471fn lowering_form(value: Lowering) -> Form {
472    match value {
473        Lowering::Direct => keyword("direct"),
474        Lowering::PointerLength => Form::Vector(vec![keyword("pointer"), keyword("length")]),
475    }
476}
477
478fn lifting_form(value: Lifting) -> Form {
479    match value {
480        Lifting::Direct => keyword("direct"),
481        Lifting::PointerLength => Form::Vector(vec![keyword("pointer"), keyword("length")]),
482        Lifting::PackedI64 => keyword("packed-i64"),
483    }
484}
485
486fn ownership_name(value: Ownership) -> &'static str {
487    match value {
488        Ownership::Borrowed => "borrowed",
489        Ownership::Caller => "caller",
490        Ownership::Callee => "callee",
491        Ownership::Transferred => "transferred",
492    }
493}
494
495fn named_type(kind: &str, name: &str) -> Form {
496    Form::Vector(vec![keyword(kind), symbol(name)])
497}
498
499fn push_string(fields: &mut Vec<(Form, Form)>, name: &str, value: Option<&str>) {
500    if let Some(value) = value {
501        fields.push((keyword(name), string(value)));
502    }
503}
504
505fn keyword(value: &str) -> Form {
506    Form::Keyword(value.into())
507}
508
509fn symbol(value: &str) -> Form {
510    Form::Symbol(value.into())
511}
512
513fn string(value: &str) -> Form {
514    Form::String(value.into())
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::direct_wasm::{DirectWasmFunctionExport, DirectWasmMemory};
521
522    const INTERFACE: &str = r#"
523      (wasm/interface
524       {:schema "hara.wasm-interface/0-alpha"
525        :namespace codec.echo
526        :module "echo.wasm"
527        :memory {:export "memory" :allocate "alloc" :release "free"}
528        :exports
529        {echo {:wasm/export "echo_bytes"
530               :arguments [{:name input
531                            :hara/type :bytes
532                            :wasm/type :i32
533                            :lower [:pointer :length]
534                            :ownership :borrowed}]
535               :returns {:hara/type :bytes
536                         :wasm/type :i64
537                         :lift :packed-i64
538                         :ownership :caller}}}})"#;
539
540    #[test]
541    fn compiles_a_closed_memory_plan() {
542        let interface = WasmInterface::parse(INTERFACE, "fixture").unwrap();
543        let plan = interface.memory_plan().unwrap();
544        assert_eq!(
545            plan.functions[0].raw_arguments,
546            [WasmValueType::I32, WasmValueType::I32]
547        );
548        assert_eq!(plan.functions[0].raw_returns, WasmValueType::I64);
549        assert!(plan.canonical_source().contains(":target :memory.v1"));
550        assert!(plan.canonical_source().contains(":ownership :caller"));
551        assert!(plan.digest().starts_with("sha256:"));
552        plan.verify(&inspection()).unwrap();
553    }
554
555    #[test]
556    fn rejects_signature_drift_and_missing_lifecycle_helpers() {
557        let interface = WasmInterface::parse(INTERFACE, "fixture").unwrap();
558        let plan = interface.memory_plan().unwrap();
559        let mut drifted = inspection();
560        drifted.exports[0].signature.returns = "i32".into();
561        assert!(plan
562            .verify(&drifted)
563            .unwrap_err()
564            .starts_with("wasm-binding/signature-mismatch"));
565
566        let missing_release = INTERFACE.replace(" :release \"free\"", "");
567        assert!(WasmInterface::parse(&missing_release, "fixture")
568            .unwrap()
569            .memory_plan()
570            .unwrap_err()
571            .starts_with("wasm-binding/release-missing"));
572
573        let mut started = inspection();
574        started.start = Some(0);
575        assert!(plan
576            .verify(&started)
577            .unwrap_err()
578            .starts_with("wasm-binding/start-denied"));
579    }
580
581    #[test]
582    fn rejects_directionally_invalid_ownership() {
583        let invalid_input = INTERFACE.replace(":ownership :borrowed", ":ownership :caller");
584        assert!(WasmInterface::parse(&invalid_input, "fixture")
585            .unwrap()
586            .memory_plan()
587            .unwrap_err()
588            .starts_with("wasm-binding/ownership-invalid"));
589
590        let invalid_result = INTERFACE.replace(":ownership :caller", ":ownership :borrowed");
591        assert!(WasmInterface::parse(&invalid_result, "fixture")
592            .unwrap()
593            .memory_plan()
594            .unwrap_err()
595            .starts_with("wasm-binding/ownership-invalid"));
596    }
597
598    #[test]
599    fn rejects_reallocate_until_a_revision_defines_its_lifecycle() {
600        let with_reallocate = INTERFACE.replace(
601            ":allocate \"alloc\"",
602            ":allocate \"alloc\" :reallocate \"realloc\"",
603        );
604        assert!(WasmInterface::parse(&with_reallocate, "fixture")
605            .unwrap()
606            .memory_plan()
607            .unwrap_err()
608            .starts_with("wasm-binding/feature-unsupported"));
609    }
610
611    fn inspection() -> DirectWasmInspection {
612        DirectWasmInspection {
613            imports: Vec::new(),
614            memories: vec![DirectWasmMemory {
615                imported: false,
616                minimum_pages: 1,
617                maximum_pages: Some(16),
618                shared: false,
619                export_names: vec!["memory".into()],
620            }],
621            exports: vec![
622                function("echo_bytes", &["i32", "i32"], "i64"),
623                function("alloc", &["i32"], "i32"),
624                function("free", &["i32"], "void"),
625            ],
626            start: None,
627        }
628    }
629
630    fn function(name: &str, arguments: &[&str], returns: &str) -> DirectWasmFunctionExport {
631        DirectWasmFunctionExport {
632            name: name.into(),
633            imported: false,
634            signature: ExtensionExport {
635                arguments: arguments.iter().map(|value| (*value).into()).collect(),
636                returns: returns.into(),
637                asynchronous: false,
638                raw_export: None,
639            },
640        }
641    }
642}