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