Skip to main content

hara_native/
extension.rs

1//! Strict metadata and host boundary for runtime-generated WASM namespaces.
2
3use std::collections::{HashMap, HashSet};
4use std::rc::Rc;
5
6pub use crate::core::{Promise, PromiseState, Value};
7use crate::kernel::{parse, Form};
8
9const MANIFEST_FIELDS: &[&str] = &[
10    "root",
11    "namespace",
12    "identity",
13    "version",
14    "provider",
15    "module",
16    "abi",
17    "exports",
18    "capabilities",
19    "host-calls",
20    "callbacks",
21    "handles",
22    "targets",
23    "assets",
24];
25const EXPORT_FIELDS: &[&str] = &["args", "returns", "async", "wasm/export", "operation"];
26const HANDLE_FIELDS: &[&str] = &["tag", "release"];
27const TARGET_FIELDS: &[&str] = &["provider", "runtime"];
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum WasmAbi {
31    CoreV1,
32    HtaV1,
33    MemoryV1,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ExtensionExport {
38    pub arguments: Vec<String>,
39    pub returns: String,
40    pub asynchronous: bool,
41    pub raw_export: Option<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct ExtensionCallback {
46    pub arguments: Vec<String>,
47    pub returns: String,
48}
49
50impl ExtensionExport {
51    pub fn raw_name<'a>(&'a self, public_name: &'a str) -> &'a str {
52        self.raw_export.as_deref().unwrap_or(public_name)
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ExtensionTarget {
58    pub provider: String,
59    pub runtime: String,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ExtensionManifest {
64    pub namespace: String,
65    pub root: Option<String>,
66    pub identity: Option<String>,
67    pub version: String,
68    pub provider: String,
69    pub module: Option<String>,
70    pub abi: WasmAbi,
71    pub targets: HashMap<String, ExtensionTarget>,
72    pub assets: Vec<String>,
73    pub exports: Vec<(String, ExtensionExport)>,
74    pub operations: HashMap<String, String>,
75    pub capabilities: Vec<String>,
76    pub host_calls: HashMap<String, Vec<String>>,
77    pub host_call_capabilities: HashMap<String, Vec<String>>,
78    pub callbacks: HashMap<String, ExtensionCallback>,
79    pub handle_tags: HashMap<String, String>,
80    pub handle_releases: HashMap<String, String>,
81}
82
83impl ExtensionManifest {
84    pub fn parse(source: &str, origin: &str) -> Result<Self, String> {
85        let form = parse(source)
86            .map_err(|error| malformed(origin, format!("cannot parse manifest: {error}")))?;
87        let entries = map(&form, origin, "manifest")?;
88        reject_unknown(entries, MANIFEST_FIELDS, origin, "manifest")?;
89        let namespace = named_string(entries, "namespace", origin)?;
90        if !valid_namespace(&namespace) {
91            return Err(malformed(
92                origin,
93                "namespace must be a qualified lower-case symbol",
94            ));
95        }
96        let root = optional(entries, "root")
97            .map(|form| string(form, origin, "root").map(str::to_owned))
98            .transpose()?;
99        if let Some(root) = root.as_deref() {
100            safe_relative(root, None, origin, "root")?;
101        }
102        let identity = optional(entries, "identity")
103            .map(|form| string(form, origin, "identity").map(str::to_owned))
104            .transpose()?;
105        if identity
106            .as_deref()
107            .is_some_and(|value| !valid_identity(value))
108        {
109            return Err(malformed(
110                origin,
111                "identity must be an owner/name coordinate",
112            ));
113        }
114        let version = named_string(entries, "version", origin)?;
115        let provider = named_keyword(entries, "provider", origin)?;
116        let module = optional(entries, "module")
117            .map(|form| string(form, origin, "module").map(str::to_owned))
118            .transpose()?;
119        let targets = optional(entries, "targets")
120            .map_or_else(|| Ok(HashMap::new()), |form| parse_targets(form, origin))?;
121        let assets = optional(entries, "assets")
122            .map_or_else(|| Ok(Vec::new()), |form| parse_assets(form, origin))?;
123        match provider.as_str() {
124            "wasm" if module.is_some() && targets.is_empty() => {
125                safe_relative(module.as_deref().unwrap(), Some(".wasm"), origin, "module")?;
126            }
127            "hta" if module.is_none() && !targets.is_empty() => {}
128            "wasm" => {
129                return Err(malformed(
130                    origin,
131                    "WASM providers require :module and cannot declare :targets",
132                ))
133            }
134            "hta" => {
135                return Err(malformed(
136                    origin,
137                    "HTA providers require :targets and cannot declare :module",
138                ))
139            }
140            _ => {
141                return Err(malformed(
142                    origin,
143                    format!("unsupported provider :{provider}"),
144                ))
145            }
146        }
147        let abi = match named_keyword(entries, "abi", origin)?.as_str() {
148            "core.v1" => WasmAbi::CoreV1,
149            "hta.v1" => WasmAbi::HtaV1,
150            "memory.v1" => WasmAbi::MemoryV1,
151            value => return Err(malformed(origin, format!("unsupported WASM ABI :{value}"))),
152        };
153        if provider == "hta" && abi != WasmAbi::HtaV1 {
154            return Err(malformed(origin, "HTA providers require :abi :hta.v1"));
155        }
156        let (exports, operations) = parse_exports(required(entries, "exports", origin)?, origin)?;
157        let capabilities = keyword_vector(
158            required(entries, "capabilities", origin)?,
159            origin,
160            "capabilities",
161        )?;
162        let (host_calls, host_call_capabilities) = optional(entries, "host-calls").map_or_else(
163            || Ok((HashMap::new(), HashMap::new())),
164            |form| parse_host_calls(form, origin),
165        )?;
166        let callbacks = optional(entries, "callbacks")
167            .map_or_else(|| Ok(HashMap::new()), |form| parse_callbacks(form, origin))?;
168        let (handle_tags, handle_releases) = optional(entries, "handles").map_or_else(
169            || Ok((HashMap::new(), HashMap::new())),
170            |form| parse_handles(form, origin),
171        )?;
172        Ok(Self {
173            namespace,
174            root,
175            identity,
176            version,
177            provider,
178            module,
179            targets,
180            assets,
181            abi,
182            exports,
183            operations,
184            capabilities,
185            host_calls,
186            host_call_capabilities,
187            callbacks,
188            handle_tags,
189            handle_releases,
190        })
191    }
192
193    pub fn permits_host_call(&self, service: &str, method: &str) -> bool {
194        self.host_calls.get(service).map_or(false, |methods| {
195            methods.iter().any(|candidate| candidate == method)
196        })
197    }
198
199    pub fn host_call_capabilities(&self, service: &str, method: &str) -> &[String] {
200        self.host_call_capabilities
201            .get(&format!("{service}/{method}"))
202            .map(Vec::as_slice)
203            .unwrap_or(&[])
204    }
205}
206
207pub trait WasmExtensionProvider {
208    fn supports(&self, abi: WasmAbi) -> bool;
209
210    fn capabilities(&self) -> Vec<String> {
211        Vec::new()
212    }
213
214    fn start(&self, manifest: &ExtensionManifest) -> Result<(), String>;
215
216    fn invoke(
217        &self,
218        manifest: &ExtensionManifest,
219        export: &str,
220        arguments: &[Value],
221    ) -> Result<Value, String>;
222
223    fn cancel(&self, manifest: &ExtensionManifest, request: u64) -> Result<(), String>;
224
225    fn release(&self, _manifest: &ExtensionManifest, _handle: &Value) -> Result<(), String> {
226        Err("extension/release-unsupported: provider has no HTA handle boundary".into())
227    }
228
229    fn shutdown(&self, manifest: &ExtensionManifest);
230}
231
232struct ExtensionSession {
233    manifest: ExtensionManifest,
234    provider: Rc<dyn WasmExtensionProvider>,
235}
236
237impl Drop for ExtensionSession {
238    fn drop(&mut self) {
239        self.provider.shutdown(&self.manifest);
240    }
241}
242
243#[derive(Clone)]
244pub struct ExtensionBinding {
245    pub namespace: String,
246    pub name: String,
247    pub specification: ExtensionExport,
248    session: Rc<ExtensionSession>,
249}
250
251impl ExtensionBinding {
252    pub fn invoke(&self, arguments: &[Value]) -> Result<Value, String> {
253        if arguments.len() != self.specification.arguments.len() {
254            return Err(format!(
255                "extension/arity: {}/{} expects {} arguments, got {}",
256                self.namespace,
257                self.name,
258                self.specification.arguments.len(),
259                arguments.len()
260            ));
261        }
262        let result = self
263            .session
264            .provider
265            .invoke(&self.session.manifest, &self.name, arguments)?;
266        if self.specification.asynchronous && !matches!(result, Value::Promise(_)) {
267            return Err(format!(
268                "extension/protocol: asynchronous export {}/{} must return a promise",
269                self.namespace, self.name
270            ));
271        }
272        Ok(result)
273    }
274
275    pub fn release(&self, handle: &Value) -> Result<(), String> {
276        self.session
277            .provider
278            .release(&self.session.manifest, handle)
279            .map_err(|error| format!("extension/release: {error}"))
280    }
281}
282
283pub struct WasmExtension {
284    manifest: ExtensionManifest,
285    provider: Rc<dyn WasmExtensionProvider>,
286    session: Option<Rc<ExtensionSession>>,
287}
288
289impl WasmExtension {
290    pub fn new<P: WasmExtensionProvider + 'static>(
291        manifest: ExtensionManifest,
292        provider: P,
293    ) -> Result<Self, String> {
294        if !provider.supports(manifest.abi) {
295            return Err(format!(
296                "extension/unsupported: provider does not support {:?}",
297                manifest.abi
298            ));
299        }
300        Ok(Self {
301            manifest,
302            provider: Rc::new(provider),
303            session: None,
304        })
305    }
306
307    pub fn namespace(&self) -> &str {
308        &self.manifest.namespace
309    }
310
311    pub fn require(&mut self) -> Result<Vec<ExtensionBinding>, String> {
312        if self.session.is_none() {
313            let available = self
314                .provider
315                .capabilities()
316                .into_iter()
317                .collect::<HashSet<_>>();
318            let missing = self
319                .manifest
320                .capabilities
321                .iter()
322                .chain(self.manifest.host_call_capabilities.values().flatten())
323                .find(|capability| !available.contains(*capability));
324            if let Some(capability) = missing {
325                return Err(format!(
326                    "extension/denied: {} requires capability :{}",
327                    self.manifest.namespace, capability
328                ));
329            }
330            self.provider
331                .start(&self.manifest)
332                .map_err(|error| format!("extension/start: {error}"))?;
333            self.session = Some(Rc::new(ExtensionSession {
334                manifest: self.manifest.clone(),
335                provider: self.provider.clone(),
336            }));
337        }
338        let session = self.session.as_ref().expect("session started").clone();
339        Ok(self
340            .manifest
341            .exports
342            .iter()
343            .map(|(name, specification)| ExtensionBinding {
344                namespace: self.manifest.namespace.clone(),
345                name: name.clone(),
346                specification: specification.clone(),
347                session: session.clone(),
348            })
349            .collect())
350    }
351
352    pub fn cancel(&self, request: u64) -> Result<(), String> {
353        let session = self.session.as_ref().ok_or_else(|| {
354            format!(
355                "extension/not-started: namespace has not been required: {}",
356                self.manifest.namespace
357            )
358        })?;
359        session
360            .provider
361            .cancel(&session.manifest, request)
362            .map_err(|error| format!("extension/cancel: {error}"))
363    }
364}
365
366fn parse_targets(form: &Form, origin: &str) -> Result<HashMap<String, ExtensionTarget>, String> {
367    map(form, origin, "targets")?
368        .iter()
369        .map(|(host, specification)| {
370            let host = keyword(host, origin, "target host")?.to_owned();
371            if host != "node" && host != "browser" {
372                return Err(malformed(origin, format!("unsupported target :{host}")));
373            }
374            let entries = map(specification, origin, "target")?;
375            reject_unknown(entries, TARGET_FIELDS, origin, &format!("target {host}"))?;
376            let provider = named_string(entries, "provider", origin)?;
377            safe_relative(&provider, Some(".mjs"), origin, "target provider")?;
378            let runtime = named_keyword(entries, "runtime", origin)?;
379            let compatible = (host == "node" && runtime == "process")
380                || (host == "browser" && runtime == "web-worker");
381            if !compatible {
382                return Err(malformed(
383                    origin,
384                    format!("target {host} has incompatible runtime :{runtime}"),
385                ));
386            }
387            Ok((host, ExtensionTarget { provider, runtime }))
388        })
389        .collect()
390}
391
392fn parse_assets(form: &Form, origin: &str) -> Result<Vec<String>, String> {
393    let mut seen = HashSet::new();
394    vector(form, origin, "assets")?
395        .iter()
396        .map(|value| {
397            let value = string(value, origin, "asset")?.to_owned();
398            safe_relative(&value, None, origin, "asset")?;
399            if !seen.insert(value.clone()) {
400                return Err(malformed(origin, format!("duplicate asset {value}")));
401            }
402            Ok(value)
403        })
404        .collect()
405}
406
407fn safe_relative(
408    value: &str,
409    suffix: Option<&str>,
410    origin: &str,
411    field: &str,
412) -> Result<(), String> {
413    let unsafe_path = value.is_empty()
414        || value.starts_with('/')
415        || value.contains('\\')
416        || value.bytes().any(|byte| byte == 0)
417        || value.contains(':')
418        || value
419            .split('/')
420            .any(|part| part.is_empty() || part == "." || part == "..");
421    if unsafe_path || suffix.is_some_and(|suffix| !value.ends_with(suffix)) {
422        return Err(malformed(
423            origin,
424            format!("{field} must be a safe relative package file"),
425        ));
426    }
427    Ok(())
428}
429
430fn parse_exports(
431    form: &Form,
432    origin: &str,
433) -> Result<(Vec<(String, ExtensionExport)>, HashMap<String, String>), String> {
434    let entries = map(form, origin, "exports")?;
435    if entries.is_empty() {
436        return Err(malformed(origin, "exports cannot be empty"));
437    }
438    let mut operations = HashMap::new();
439    let exports = entries
440        .iter()
441        .map(|(name, specification)| {
442            let name = string(name, origin, "export name")?.to_owned();
443            let specification = map(specification, origin, "export specification")?;
444            reject_unknown(
445                specification,
446                EXPORT_FIELDS,
447                origin,
448                &format!("export {name}"),
449            )?;
450            let arguments = wire_vector(
451                required(specification, "args", origin)?,
452                origin,
453                "export args",
454            )?;
455            let returns = wire_type(
456                required(specification, "returns", origin)?,
457                origin,
458                "export returns",
459            )?;
460            let asynchronous = match optional(specification, "async") {
461                None => false,
462                Some(Form::Bool(value)) => *value,
463                Some(_) => return Err(malformed(origin, "export async must be boolean")),
464            };
465            let raw_export = optional(specification, "wasm/export")
466                .map(|form| string(form, origin, "export wasm/export").map(str::to_owned))
467                .transpose()?;
468            if let Some(operation) = optional(specification, "operation") {
469                let operation = string(operation, origin, "export operation")?.to_owned();
470                if operations.insert(name.clone(), operation).is_some() {
471                    return Err(malformed(origin, format!("duplicate export {name}")));
472                }
473            }
474            Ok((
475                name,
476                ExtensionExport {
477                    arguments,
478                    returns,
479                    asynchronous,
480                    raw_export,
481                },
482            ))
483        })
484        .collect::<Result<Vec<_>, _>>()?;
485    Ok((exports, operations))
486}
487
488fn parse_host_calls(
489    form: &Form,
490    origin: &str,
491) -> Result<(HashMap<String, Vec<String>>, HashMap<String, Vec<String>>), String> {
492    let mut host_calls = HashMap::new();
493    let mut host_call_capabilities = HashMap::new();
494    for (service, specification) in map(form, origin, "host-calls")? {
495        let service = string(service, origin, "host-call service")?.to_owned();
496        let (methods, capabilities) = match specification {
497            Form::Vector(_) => (
498                vector(specification, origin, "host-call methods")?
499                    .iter()
500                    .map(|method| string(method, origin, "host-call method").map(str::to_owned))
501                    .collect::<Result<Vec<_>, _>>()?,
502                Vec::new(),
503            ),
504            Form::Map(entries) => {
505                reject_unknown(entries, &["methods", "capabilities"], origin, "host-call")?;
506                let methods = vector(
507                    required(entries, "methods", origin)?,
508                    origin,
509                    "host-call methods",
510                )?
511                .iter()
512                .map(|method| string(method, origin, "host-call method").map(str::to_owned))
513                .collect::<Result<Vec<_>, _>>()?;
514                let capabilities = optional(entries, "capabilities")
515                    .map(|form| keyword_vector(form, origin, "host-call capabilities"))
516                    .transpose()?
517                    .unwrap_or_default();
518                (methods, capabilities)
519            }
520            _ => {
521                return Err(malformed(
522                    origin,
523                    "host-call specification must be a vector or map",
524                ))
525            }
526        };
527        if host_calls
528            .insert(service.clone(), methods.clone())
529            .is_some()
530        {
531            return Err(malformed(
532                origin,
533                format!("duplicate host-call service {service}"),
534            ));
535        }
536        for method in methods {
537            host_call_capabilities.insert(format!("{service}/{method}"), capabilities.clone());
538        }
539    }
540    Ok((host_calls, host_call_capabilities))
541}
542
543fn parse_handles(
544    form: &Form,
545    origin: &str,
546) -> Result<(HashMap<String, String>, HashMap<String, String>), String> {
547    let mut tags = HashMap::new();
548    let mut releases = HashMap::new();
549    for (type_name, specification) in map(form, origin, "handles")? {
550        let type_name = string(type_name, origin, "handle type")?.to_owned();
551        let specification = map(specification, origin, "handle specification")?;
552        reject_unknown(
553            specification,
554            HANDLE_FIELDS,
555            origin,
556            &format!("handle {type_name}"),
557        )?;
558        let tag = match required(specification, "tag", origin)? {
559            Form::Symbol(tag) if valid_tag(tag) => tag.clone(),
560            _ => return Err(malformed(origin, "handle tag must be a lower-case symbol")),
561        };
562        tags.insert(type_name.clone(), tag);
563        if let Some(release) = optional(specification, "release") {
564            releases.insert(
565                type_name,
566                string(release, origin, "handle release")?.to_owned(),
567            );
568        }
569    }
570    Ok((tags, releases))
571}
572
573fn parse_callbacks(
574    form: &Form,
575    origin: &str,
576) -> Result<HashMap<String, ExtensionCallback>, String> {
577    let mut callbacks = HashMap::new();
578    for (name, specification) in map(form, origin, "callbacks")? {
579        let name = string(name, origin, "callback name")?.to_owned();
580        let entries = map(specification, origin, "callback specification")?;
581        reject_unknown(entries, &["args", "returns", "reentrant"], origin, "callback")?;
582        let arguments = wire_vector(required(entries, "args", origin)?, origin, "callback args")?;
583        let returns = wire_type(
584            required(entries, "returns", origin)?,
585            origin,
586            "callback returns",
587        )?;
588        if matches!(optional(entries, "reentrant"), Some(Form::Bool(true))) {
589            return Err(malformed(
590                origin,
591                format!("callback {name} cannot be reentrant"),
592            ));
593        }
594        if optional(entries, "reentrant").is_some_and(|form| !matches!(form, Form::Bool(_))) {
595            return Err(malformed(origin, "callback reentrant must be boolean"));
596        }
597        if callbacks
598            .insert(name.clone(), ExtensionCallback { arguments, returns })
599            .is_some()
600        {
601            return Err(malformed(origin, format!("duplicate callback {name}")));
602        }
603    }
604    Ok(callbacks)
605}
606
607fn valid_identity(value: &str) -> bool {
608    value
609        .split_once("/")
610        .is_some_and(|(owner, name)| valid_component(owner) && valid_tag(name))
611}
612
613fn valid_namespace(value: &str) -> bool {
614    value.contains('.') && value.split('.').all(valid_component)
615}
616
617fn valid_tag(value: &str) -> bool {
618    value.split('.').all(valid_component)
619}
620
621fn valid_component(value: &str) -> bool {
622    !value.is_empty()
623        && value
624            .chars()
625            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
626}
627
628fn map<'a>(form: &'a Form, origin: &str, field: &str) -> Result<&'a [(Form, Form)], String> {
629    match form {
630        Form::Map(entries) => Ok(entries),
631        _ => Err(malformed(origin, format!("{field} must be a map"))),
632    }
633}
634
635fn vector<'a>(form: &'a Form, origin: &str, field: &str) -> Result<&'a [Form], String> {
636    match form {
637        Form::Vector(values) => Ok(values),
638        _ => Err(malformed(origin, format!("{field} must be a vector"))),
639    }
640}
641
642fn string<'a>(form: &'a Form, origin: &str, field: &str) -> Result<&'a str, String> {
643    match form {
644        Form::String(value) if !value.is_empty() => Ok(value),
645        _ => Err(malformed(
646            origin,
647            format!("{field} must be a non-empty string"),
648        )),
649    }
650}
651
652fn keyword<'a>(form: &'a Form, origin: &str, field: &str) -> Result<&'a str, String> {
653    match form {
654        Form::Keyword(value) => Ok(value),
655        _ => Err(malformed(origin, format!("{field} must be a keyword"))),
656    }
657}
658
659fn keyword_vector(form: &Form, origin: &str, field: &str) -> Result<Vec<String>, String> {
660    vector(form, origin, field)?
661        .iter()
662        .map(|form| keyword(form, origin, field).map(str::to_owned))
663        .collect()
664}
665
666fn wire_vector(form: &Form, origin: &str, field: &str) -> Result<Vec<String>, String> {
667    vector(form, origin, field)?
668        .iter()
669        .map(|form| wire_type(form, origin, field))
670        .collect()
671}
672
673fn wire_type(form: &Form, origin: &str, field: &str) -> Result<String, String> {
674    match form {
675        Form::Keyword(value) => Ok(value.clone()),
676        Form::Vector(_) => Ok(form.to_string()),
677        _ => Err(malformed(origin, format!("{field} must be a type form"))),
678    }
679}
680
681fn key(form: &Form) -> Option<&str> {
682    match form {
683        Form::Keyword(value) | Form::Symbol(value) | Form::String(value) => Some(value),
684        _ => None,
685    }
686}
687
688fn required<'a>(entries: &'a [(Form, Form)], name: &str, origin: &str) -> Result<&'a Form, String> {
689    optional(entries, name)
690        .ok_or_else(|| malformed(origin, format!("missing required field {name}")))
691}
692
693fn optional<'a>(entries: &'a [(Form, Form)], name: &str) -> Option<&'a Form> {
694    entries
695        .iter()
696        .find(|(candidate, _)| key(candidate) == Some(name))
697        .map(|(_, value)| value)
698}
699
700fn named_string(entries: &[(Form, Form)], name: &str, origin: &str) -> Result<String, String> {
701    string(required(entries, name, origin)?, origin, name).map(str::to_owned)
702}
703
704fn named_keyword(entries: &[(Form, Form)], name: &str, origin: &str) -> Result<String, String> {
705    keyword(required(entries, name, origin)?, origin, name).map(str::to_owned)
706}
707
708fn reject_unknown(
709    entries: &[(Form, Form)],
710    allowed: &[&str],
711    origin: &str,
712    scope: &str,
713) -> Result<(), String> {
714    let mut seen = HashSet::new();
715    for (candidate, _) in entries {
716        let Some(name) = key(candidate) else {
717            return Err(malformed(origin, format!("{scope} keys must be named")));
718        };
719        if !allowed.contains(&name) {
720            return Err(malformed(origin, format!("unknown {scope} field: {name}")));
721        }
722        if !seen.insert(name) {
723            return Err(malformed(
724                origin,
725                format!("duplicate {scope} field: {name}"),
726            ));
727        }
728    }
729    Ok(())
730}
731
732fn malformed(origin: &str, message: impl AsRef<str>) -> String {
733    format!("extension/malformed {origin}: {}", message.as_ref())
734}
735
736#[cfg(test)]
737mod tests {
738    use super::{ExtensionManifest, WasmAbi};
739
740    const MANIFEST: &str = r#"
741      {:namespace "crypto.hash"
742       :identity "hara/crypto.hash"
743       :version "0.1.0"
744       :provider :wasm
745       :module "hash.wasm"
746       :abi :hta.v1
747       :exports {"digest" {:args [:bytes] :returns :bytes :async true}}
748       :host-calls {"crypto.random" ["fill"]}
749       :handles {"digest" {:tag crypto}}
750       :capabilities [:random]}"#;
751
752    const ALIASED_MANIFEST: &str = r#"
753      {:namespace "math.scalar"
754       :version "0.1.0"
755       :provider :wasm
756       :module "math.wasm"
757       :abi :core.v1
758       :exports {"sum" {:wasm/export "add_i64"
759                         :args [:i64 :i64]
760                         :returns :i64}}
761       :capabilities []}"#;
762
763    const MEMORY_MANIFEST: &str = r#"
764      {:namespace "codec.echo"
765       :version "0.1.0"
766       :provider :wasm
767       :module "echo.wasm"
768       :abi :memory.v1
769       :exports {"echo" {:wasm/export "echo_bytes"
770                          :args [:bytes]
771                          :returns :bytes}}
772       :capabilities []}"#;
773
774    #[test]
775    fn parses_the_wasm_manifest_contract() {
776        let manifest = ExtensionManifest::parse(MANIFEST, "fixture").unwrap();
777        assert_eq!(manifest.namespace, "crypto.hash");
778        assert_eq!(manifest.identity.as_deref(), Some("hara/crypto.hash"));
779        assert_eq!(manifest.abi, WasmAbi::HtaV1);
780        assert!(manifest.exports[0].1.asynchronous);
781        assert_eq!(manifest.exports[0].1.raw_name("digest"), "digest");
782        assert_eq!(manifest.handle_tags["digest"], "crypto");
783        assert!(manifest.permits_host_call("crypto.random", "fill"));
784    }
785
786    #[test]
787    fn parses_memory_v1_manifests_without_downgrading_the_abi() {
788        let manifest = ExtensionManifest::parse(MEMORY_MANIFEST, "fixture").unwrap();
789        assert_eq!(manifest.abi, WasmAbi::MemoryV1);
790        assert_eq!(manifest.exports[0].1.raw_name("echo"), "echo_bytes");
791    }
792
793    #[test]
794    fn preserves_public_and_raw_export_names() {
795        let manifest = ExtensionManifest::parse(ALIASED_MANIFEST, "fixture").unwrap();
796        assert_eq!(manifest.exports[0].0, "sum");
797        assert_eq!(manifest.exports[0].1.raw_name("sum"), "add_i64");
798    }
799
800    #[test]
801    fn rejects_non_wasm_unknown_duplicate_unsafe_and_unsupported_manifests() {
802        for source in [
803            MANIFEST.replace(":wasm", ":pod"),
804            MANIFEST.replace(":capabilities [:random]", ":capabilities [] :extra true"),
805            MANIFEST.replace(":version \"0.1.0\"", ":version \"0.1.0\" :version \"2\""),
806            MANIFEST.replace("hash.wasm", "../hash.wasm"),
807            MANIFEST.replace(":hta.v1", ":unknown-v1"),
808        ] {
809            assert!(ExtensionManifest::parse(&source, "fixture")
810                .unwrap_err()
811                .starts_with("extension/malformed fixture:"));
812        }
813    }
814
815    #[test]
816    fn hta_targets_name_provider_implementations() {
817        let source = r#"
818{:namespace "demo.hta"
819 :version "1"
820 :provider :hta
821 :abi :hta.v1
822 :targets {:node {:provider "node/provider.mjs" :runtime :process}
823           :browser {:provider "browser/provider.mjs" :runtime :web-worker}}
824 :exports {"open" {:args [] :returns :value}}
825 :capabilities []}"#;
826        let manifest = ExtensionManifest::parse(source, "fixture").unwrap();
827        assert_eq!(manifest.targets["browser"].provider, "browser/provider.mjs");
828        assert!(ExtensionManifest::parse(
829            &source.replace(":provider \"browser/provider.mjs\"", ":module \"browser/worker.mjs\""),
830            "fixture"
831        )
832        .is_err());
833    }
834}