Skip to main content

hara_native/wasm_binding/
wit.rs

1//! A bounded, data-only bridge for the WebAssembly Interface Types format.
2//!
3//! WIT is used to seed a canonical Hara interface and to project the exact
4//! subset already represented by that interface. It never selects a fallback
5//! loader route or invents a second runtime type system.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::fmt;
9
10use sha2::{Digest, Sha256};
11
12use crate::kernel::Form;
13
14use super::wit_format;
15use super::wit_parser::{self, Document, Interface, Type, TypeDecl};
16use super::{HaraValueType, Lifting, Lowering, WasmInterface};
17
18pub const WIT_IR_SCHEMA: &str = "hara.wasm-wit/0-alpha";
19pub const WIT_MANIFEST_SCHEMA: &str = "hara.wasm-wit-manifest/0-alpha";
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
22pub enum WitDiagnosticSeverity {
23    Lossy,
24    Unsupported,
25}
26
27impl WitDiagnosticSeverity {
28    pub(super) fn as_keyword(self) -> &'static str {
29        match self {
30            Self::Lossy => "lossy",
31            Self::Unsupported => "unsupported",
32        }
33    }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
37pub struct WitDiagnostic {
38    pub severity: WitDiagnosticSeverity,
39    pub code: String,
40    pub path: String,
41    pub message: String,
42}
43
44impl fmt::Display for WitDiagnostic {
45    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46        write!(
47            formatter,
48            "{} {} at {}: {}",
49            self.severity.as_keyword(),
50            self.code,
51            self.path,
52            self.message
53        )
54    }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
58pub enum WitRoute {
59    DirectImport,
60    HtaRequire,
61}
62
63impl WitRoute {
64    pub fn as_keyword(self) -> &'static str {
65        match self {
66            Self::DirectImport => "import",
67            Self::HtaRequire => "require",
68        }
69    }
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Eq)]
73pub struct WitImportOptions {
74    pub namespace: Option<String>,
75    pub module: Option<String>,
76    pub world: Option<String>,
77    pub interface: Option<String>,
78    pub strict: bool,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct WitImportArtifact {
83    pub namespace: String,
84    pub world: Option<String>,
85    pub interface: String,
86    pub route: WitRoute,
87    pub interface_source: String,
88    pub normalized_ir: String,
89    pub diagnostics: Vec<WitDiagnostic>,
90    pub source_digest: String,
91    pub interface_digest: String,
92}
93
94impl WitImportArtifact {
95    pub fn manifest_source(&self, origin: &str) -> String {
96        Form::Map(vec![
97            (keyword("schema"), string(WIT_MANIFEST_SCHEMA)),
98            (keyword("origin"), string(origin)),
99            (keyword("source-digest"), string(&self.source_digest)),
100            (keyword("interface-digest"), string(&self.interface_digest)),
101            (keyword("route"), keyword(self.route.as_keyword())),
102            (
103                keyword("diagnostics"),
104                Form::Vector(
105                    self.diagnostics
106                        .iter()
107                        .map(wit_format::diagnostic_form)
108                        .collect(),
109                ),
110            ),
111        ])
112        .to_string()
113    }
114}
115
116#[derive(Debug, Clone, Default, PartialEq, Eq)]
117pub struct WitProjectionOptions {
118    pub package: Option<String>,
119    pub interface: Option<String>,
120    pub world: Option<String>,
121    pub strict: bool,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct WitProjectionArtifact {
126    pub source: String,
127    pub diagnostics: Vec<WitDiagnostic>,
128}
129
130pub fn import_wit(
131    source: &str,
132    origin: &str,
133    options: &WitImportOptions,
134) -> Result<WitImportArtifact, String> {
135    let document = wit_parser::parse(source)
136        .map_err(|error| format!("wasm-wit/malformed {origin}: {error}"))?;
137    let (interface_name, world_name, interface, mut diagnostics) =
138        select_interface(&document, options, origin)?;
139    let namespace = options
140        .namespace
141        .clone()
142        .unwrap_or_else(|| default_namespace(document.package.as_deref(), &interface_name));
143    if !valid_namespace(&namespace) {
144        return Err(format!(
145            "wasm-wit/malformed {origin}: namespace must be a qualified lower-case name"
146        ));
147    }
148    let module = options
149        .module
150        .clone()
151        .unwrap_or_else(|| "module.wasm".to_owned());
152    if !valid_module(&module) {
153        return Err(format!(
154            "wasm-wit/malformed {origin}: module must be a safe relative .wasm path"
155        ));
156    }
157
158    let mut context = MappingContext {
159        types: &interface.types,
160        diagnostics: &mut diagnostics,
161        memory: false,
162        hta: !interface.resources.is_empty(),
163        reported: BTreeSet::new(),
164    };
165    for resource in &interface.resources {
166        context.diagnostic(
167            WitDiagnosticSeverity::Lossy,
168            "resource",
169            resource,
170            "resources require explicit HTA handle ownership and release semantics",
171        );
172    }
173    let mut exports = Vec::new();
174    for function in &interface.functions {
175        if function.async_ {
176            context.diagnostic(
177                WitDiagnosticSeverity::Unsupported,
178                "async",
179                &function.name,
180                "async WIT functions require an HTA provider",
181            );
182            context.hta = true;
183        }
184        let arguments = function
185            .arguments
186            .iter()
187            .enumerate()
188            .map(|(index, (name, ty))| {
189                let path = format!("{}.argument.{index}", function.name);
190                let mapped = context.map_type(ty, &path, &function.name);
191                (name.clone(), mapped)
192            })
193            .collect::<Vec<_>>();
194        let result = function
195            .result
196            .as_ref()
197            .map(|ty| context.map_type(ty, &format!("{}.result", function.name), &function.name));
198        exports.push((function, arguments, result));
199    }
200    for (name, declaration) in &interface.types {
201        if let TypeDecl::Unsupported(feature) = declaration {
202            context.diagnostic(
203                WitDiagnosticSeverity::Unsupported,
204                feature,
205                &format!("type.{name}"),
206                "the declaration is not represented by the Hara binding IR",
207            );
208        }
209    }
210    if !document
211        .worlds
212        .get(world_name.as_deref().unwrap_or(""))
213        .map_or(true, |world| world.imports.is_empty())
214    {
215        context.diagnostic(
216            WitDiagnosticSeverity::Unsupported,
217            "world-import",
218            "world",
219            "imported host interfaces need an explicit HTA capability contract",
220        );
221        context.hta = true;
222    }
223    if exports.is_empty() {
224        context.diagnostic(
225            WitDiagnosticSeverity::Unsupported,
226            "empty-interface",
227            "interface",
228            "an empty interface cannot seed a bindable Hara export map",
229        );
230    }
231    let memory = context.memory;
232    let hta = context.hta;
233    drop(context);
234    diagnostics.sort();
235    if options.strict && !diagnostics.is_empty() {
236        return Err(strict_error(origin, &diagnostics));
237    }
238
239    let route = if hta {
240        WitRoute::HtaRequire
241    } else {
242        WitRoute::DirectImport
243    };
244    let interface_source =
245        wit_format::skeleton_source(&namespace, &module, &exports, memory, route);
246    let normalized_ir = wit_format::normalized_source(
247        &namespace,
248        document.package.as_deref(),
249        world_name.as_deref(),
250        &interface_name,
251        route,
252        &interface,
253        &exports,
254        &diagnostics,
255        source,
256        origin,
257    );
258    Ok(WitImportArtifact {
259        namespace,
260        world: world_name,
261        interface: interface_name,
262        route,
263        source_digest: digest(source.as_bytes()),
264        interface_digest: digest(interface_source.as_bytes()),
265        interface_source,
266        normalized_ir,
267        diagnostics,
268    })
269}
270
271pub fn project_wit(
272    interface: &WasmInterface,
273    options: &WitProjectionOptions,
274) -> Result<WitProjectionArtifact, String> {
275    let mut diagnostics = Vec::new();
276    let mut functions = Vec::new();
277    for export in &interface.exports {
278        let args = export
279            .arguments
280            .iter()
281            .enumerate()
282            .map(|(index, argument)| {
283                wit_format::wit_projection_type(
284                    &argument.hara_type,
285                    argument.wasm_type,
286                    argument.lowering,
287                    argument.ownership,
288                    &mut diagnostics,
289                    &format!("{}.argument.{index}", export.name),
290                )
291                .map(|ty| (argument.name.clone(), ty))
292            })
293            .collect::<Option<Vec<_>>>();
294        let result = wit_format::wit_projection_type(
295            &export.returns.hara_type,
296            export.returns.wasm_type,
297            export.returns.lifting.map(|value| match value {
298                Lifting::Direct => Lowering::Direct,
299                Lifting::PointerLength => Lowering::PointerLength,
300                Lifting::PackedI64 => Lowering::PointerLength,
301            }),
302            export.returns.ownership,
303            &mut diagnostics,
304            &format!("{}.result", export.name),
305        );
306        if let (Some(args), Some(result)) = (args, result) {
307            functions.push((export.name.clone(), args, result));
308        }
309        if export.asynchronous {
310            wit_format::diagnostic(
311                &mut diagnostics,
312                WitDiagnosticSeverity::Unsupported,
313                "async",
314                &export.name,
315                "HTA asynchronous exports are not part of an exact WIT projection",
316            );
317        }
318        if export.errors.is_some() {
319            wit_format::diagnostic(
320                &mut diagnostics,
321                WitDiagnosticSeverity::Unsupported,
322                "error-mapping",
323                &export.name,
324                "Hara error conventions need an explicit WIT result definition",
325            );
326        }
327        if !export.capabilities.is_empty() || !interface.capabilities.is_empty() {
328            wit_format::diagnostic(
329                &mut diagnostics,
330                WitDiagnosticSeverity::Unsupported,
331                "capability",
332                &export.name,
333                "Hara capabilities are not representable in a WIT interface",
334            );
335        }
336    }
337    diagnostics.sort();
338    if options.strict && !diagnostics.is_empty() {
339        return Err(strict_error(&interface.namespace, &diagnostics));
340    }
341    let interface_name = options.interface.clone().unwrap_or_else(|| {
342        interface
343            .namespace
344            .rsplit('.')
345            .next()
346            .unwrap_or("interface")
347            .into()
348    });
349    let package = options
350        .package
351        .clone()
352        .unwrap_or_else(|| default_package(&interface.namespace));
353    let world = options
354        .world
355        .clone()
356        .unwrap_or_else(|| format!("{interface_name}-world"));
357    if !valid_wit_name(&interface_name) || !valid_wit_name(&world) {
358        return Err("wasm-wit/malformed: projection names must be valid WIT identifiers".into());
359    }
360    let mut source = format!("package {package};\n\ninterface {interface_name} {{\n");
361    for (name, args, result) in functions {
362        source.push_str("  ");
363        source.push_str(&name);
364        source.push_str(": func(");
365        source.push_str(
366            &args
367                .into_iter()
368                .map(|(name, ty)| format!("{name}: {ty}"))
369                .collect::<Vec<_>>()
370                .join(", "),
371        );
372        source.push(')');
373        if result != "unit" {
374            source.push_str(" -> ");
375            source.push_str(&result);
376        }
377        source.push_str(";\n");
378    }
379    source.push_str("}\n\nworld ");
380    source.push_str(&world);
381    source.push_str(" {\n  export ");
382    source.push_str(&interface_name);
383    source.push_str(";\n}\n");
384    Ok(WitProjectionArtifact {
385        source,
386        diagnostics,
387    })
388}
389
390fn select_interface(
391    document: &Document,
392    options: &WitImportOptions,
393    origin: &str,
394) -> Result<(String, Option<String>, Interface, Vec<WitDiagnostic>), String> {
395    let mut diagnostics = Vec::new();
396    let world_name = options.world.clone().or_else(|| {
397        (document.worlds.len() == 1)
398            .then(|| document.worlds.keys().next().cloned())
399            .flatten()
400    });
401    if options.world.is_some()
402        && !document
403            .worlds
404            .contains_key(options.world.as_ref().unwrap())
405    {
406        return Err(format!(
407            "wasm-wit/malformed {origin}: requested world is not declared"
408        ));
409    }
410    let interface_name = if let Some(name) = options.interface.clone() {
411        name
412    } else if let Some(world) = world_name
413        .as_ref()
414        .and_then(|name| document.worlds.get(name))
415    {
416        let matches = world
417            .exports
418            .iter()
419            .filter(|name| document.interfaces.contains_key(*name))
420            .cloned()
421            .collect::<Vec<_>>();
422        if matches.len() != 1 {
423            diagnostics.push(WitDiagnostic {
424                severity: WitDiagnosticSeverity::Unsupported,
425                code: "world-export-selection".into(),
426                path: "world".into(),
427                message: "a world must select exactly one declared interface".into(),
428            });
429        }
430        matches
431            .first()
432            .cloned()
433            .or_else(|| document.interfaces.keys().next().cloned())
434            .ok_or_else(|| format!("wasm-wit/malformed {origin}: world has no interface export"))?
435    } else if document.interfaces.len() == 1 {
436        document.interfaces.keys().next().cloned().unwrap()
437    } else {
438        diagnostics.push(WitDiagnostic {
439            severity: WitDiagnosticSeverity::Unsupported,
440            code: "interface-selection".into(),
441            path: "document".into(),
442            message: "multiple interfaces require --interface or a world export".into(),
443        });
444        document
445            .interfaces
446            .keys()
447            .next()
448            .cloned()
449            .ok_or_else(|| format!("wasm-wit/malformed {origin}: no interface was declared"))?
450    };
451    let interface = document
452        .interfaces
453        .get(&interface_name)
454        .cloned()
455        .ok_or_else(|| {
456            format!("wasm-wit/malformed {origin}: interface {interface_name} is not declared")
457        })?;
458    Ok((interface_name, world_name, interface, diagnostics))
459}
460
461struct MappingContext<'a> {
462    types: &'a BTreeMap<String, TypeDecl>,
463    diagnostics: &'a mut Vec<WitDiagnostic>,
464    memory: bool,
465    hta: bool,
466    reported: BTreeSet<String>,
467}
468
469impl MappingContext<'_> {
470    fn map_type(&mut self, ty: &Type, path: &str, context: &str) -> HaraValueType {
471        match ty {
472            Type::Atom(name) => match name.as_str() {
473                "bool" => HaraValueType::Boolean,
474                "u8" | "u16" | "u32" | "s8" | "s16" => {
475                    self.diagnostic(
476                        WitDiagnosticSeverity::Lossy,
477                        "integer-width",
478                        path,
479                        &format!("WIT {name} is lowered to the existing Hara i32 scalar"),
480                    );
481                    HaraValueType::I32
482                }
483                "s32" => HaraValueType::I32,
484                "u64" => {
485                    self.diagnostic(
486                        WitDiagnosticSeverity::Lossy,
487                        "integer-width",
488                        path,
489                        "WIT u64 is lowered to the existing Hara i64 scalar",
490                    );
491                    HaraValueType::I64
492                }
493                "s64" => HaraValueType::I64,
494                "f32" => HaraValueType::F32,
495                "f64" => HaraValueType::F64,
496                "char" => {
497                    self.diagnostic(
498                        WitDiagnosticSeverity::Lossy,
499                        "char",
500                        path,
501                        "WIT char is lowered to the existing Hara i32 scalar",
502                    );
503                    HaraValueType::I32
504                }
505                "string" => {
506                    self.memory = true;
507                    HaraValueType::String
508                }
509                "unit" => HaraValueType::Void,
510                name => match self.types.get(name) {
511                    Some(TypeDecl::Alias(value)) => self.map_type(value, path, context),
512                    Some(TypeDecl::Record(_)) => {
513                        self.lossy_named("record", name, path);
514                        HaraValueType::Record(name.into())
515                    }
516                    Some(TypeDecl::Variant(_)) => {
517                        self.lossy_named("variant", name, path);
518                        HaraValueType::Variant(name.into())
519                    }
520                    Some(TypeDecl::Resource) => {
521                        self.hta = true;
522                        self.diagnostic(
523                            WitDiagnosticSeverity::Lossy,
524                            "resource",
525                            path,
526                            "resource is represented as an HTA-owned Hara handle",
527                        );
528                        HaraValueType::Handle(name.into())
529                    }
530                    Some(TypeDecl::Unsupported(_)) | None => {
531                        self.diagnostic(
532                            WitDiagnosticSeverity::Unsupported,
533                            "type",
534                            path,
535                            &format!("named type {name} is not represented"),
536                        );
537                        HaraValueType::Variant(name.into())
538                    }
539                },
540            },
541            Type::List(value) => {
542                if matches!(value.as_ref(), Type::Atom(name) if name == "u8") {
543                    self.memory = true;
544                    HaraValueType::Bytes
545                } else {
546                    self.hta = true;
547                    self.diagnostic(
548                        WitDiagnosticSeverity::Lossy,
549                        "list",
550                        path,
551                        "only list<u8> has an exact existing Hara bytes representation",
552                    );
553                    HaraValueType::Record(format!("{}-list", context))
554                }
555            }
556            Type::Option(value) => {
557                self.hta = true;
558                self.diagnostic(
559                    WitDiagnosticSeverity::Lossy,
560                    "option",
561                    path,
562                    &format!(
563                        "option<{}> is represented as an HTA variant until a canonical Hara option owner is authored",
564                        wit_parser::type_label(value)
565                    ),
566                );
567                HaraValueType::Variant(format!("{}-option", context))
568            }
569            Type::Result(ok, error) => {
570                self.hta = true;
571                self.diagnostic(
572                    WitDiagnosticSeverity::Lossy,
573                    "result",
574                    path,
575                    &format!(
576                        "result<{}, {}> is represented as an HTA variant until Hara error ownership is authored",
577                        ok.as_deref()
578                            .map(wit_parser::type_label)
579                            .unwrap_or_else(|| "unit".into()),
580                        error
581                            .as_deref()
582                            .map(wit_parser::type_label)
583                            .unwrap_or_else(|| "unit".into())
584                    ),
585                );
586                HaraValueType::Variant(format!("{}-result", context))
587            }
588            Type::Tuple(values) => {
589                self.hta = true;
590                self.diagnostic(
591                    WitDiagnosticSeverity::Lossy,
592                    "tuple",
593                    path,
594                    &format!(
595                        "tuple of {} values needs an authored Hara record",
596                        values.len()
597                    ),
598                );
599                HaraValueType::Record(format!("{}-tuple", context))
600            }
601        }
602    }
603
604    fn lossy_named(&mut self, kind: &str, name: &str, path: &str) {
605        self.hta = true;
606        self.diagnostic(
607            WitDiagnosticSeverity::Lossy,
608            kind,
609            path,
610            &format!("{kind} {name} is seeded by name; fields and ownership remain canonical .hal"),
611        );
612    }
613
614    fn diagnostic(
615        &mut self,
616        severity: WitDiagnosticSeverity,
617        code: &str,
618        path: &str,
619        message: &str,
620    ) {
621        let key = format!("{}:{code}:{path}:{message}", severity.as_keyword());
622        if self.reported.insert(key) {
623            self.diagnostics.push(WitDiagnostic {
624                severity,
625                code: code.into(),
626                path: path.into(),
627                message: message.into(),
628            });
629        }
630    }
631}
632
633fn strict_error(origin: &str, diagnostics: &[WitDiagnostic]) -> String {
634    let details = diagnostics
635        .iter()
636        .map(ToString::to_string)
637        .collect::<Vec<_>>()
638        .join("\n");
639    format!("wasm-wit/strict {origin}: unsupported or lossy mappings:\n{details}")
640}
641
642fn default_namespace(package: Option<&str>, interface: &str) -> String {
643    let package = package.unwrap_or("hara:wit");
644    let package = package.split('@').next().unwrap_or(package);
645    let package = package.replace(':', ".");
646    let namespace = if package.rsplit('.').next() == Some(interface) {
647        package
648    } else {
649        format!("{package}.{interface}")
650    };
651    if valid_namespace(&namespace) {
652        namespace
653    } else {
654        format!("hara.wit.{interface}")
655    }
656}
657
658fn default_package(namespace: &str) -> String {
659    let mut parts = namespace.split('.');
660    let namespace = parts.next().unwrap_or("hara");
661    let name = parts.last().unwrap_or("interface");
662    format!("{namespace}:{name}")
663}
664
665fn valid_namespace(value: &str) -> bool {
666    let parts = value.split('.').collect::<Vec<_>>();
667    parts.len() > 1 && parts.iter().all(|part| valid_wit_name(part))
668}
669
670fn valid_wit_name(value: &str) -> bool {
671    !value.is_empty()
672        && value.chars().all(|character| {
673            character.is_ascii_alphanumeric() || character == '_' || character == '-'
674        })
675        && value
676            .chars()
677            .next()
678            .is_some_and(|character| character.is_ascii_lowercase() || character == '_')
679}
680
681fn valid_module(value: &str) -> bool {
682    !value.is_empty()
683        && value.ends_with(".wasm")
684        && !value.starts_with('/')
685        && !value.contains('\\')
686        && !value.contains(':')
687        && !value.bytes().any(|byte| byte == 0)
688        && value
689            .split('/')
690            .all(|part| !part.is_empty() && part != "." && part != "..")
691}
692
693pub(super) fn digest(bytes: &[u8]) -> String {
694    format!("sha256:{:x}", Sha256::digest(bytes))
695}
696
697pub(super) fn keyword(value: &str) -> Form {
698    Form::Keyword(value.into())
699}
700
701pub(super) fn symbol(value: &str) -> Form {
702    Form::Symbol(value.into())
703}
704
705pub(super) fn string(value: &str) -> Form {
706    Form::String(value.into())
707}
708
709pub(super) fn named_type(kind: &str, name: &str) -> Form {
710    Form::Vector(vec![keyword(kind), symbol(name)])
711}