Skip to main content

obeli_sk_utils/
wasm_tools.rs

1use crate::wit::from_wit_package_name_to_pkg_fqn;
2use anyhow::Context;
3use concepts::{
4    ComponentType, ContentDigest, FnName, FunctionExtension, FunctionFqn, FunctionMetadata,
5    IfcFqnName, PackageIfcFns, ParameterType, ParameterTypes, PkgFqn, ReturnType,
6    ReturnTypeExtendable, ReturnTypeNonExtendable, SUFFIX_FN_AWAIT_NEXT, SUFFIX_FN_CANCELLABLE,
7    SUFFIX_FN_GET, SUFFIX_FN_SCHEDULE, SUFFIX_FN_STUB, SUFFIX_FN_SUBMIT, SUFFIX_PKG_EXT,
8    SUFFIX_PKG_SCHEDULE, SUFFIX_PKG_STUB, StrVariant,
9};
10use indexmap::{IndexMap, indexmap};
11use std::{
12    fmt::Display,
13    path::{Path, PathBuf},
14    sync::Arc,
15};
16use strum::IntoEnumIterator as _;
17use tracing::{debug, error, info, instrument, trace, warn};
18use tracing_error::SpanTrace;
19use val_json::type_wrapper::{TypeKey, TypeWrapper};
20use wit_component::{ComponentEncoder, WitPrinter};
21use wit_parser::{InterfaceId, PackageId, Param, Resolve, World, WorldKey, decoding::DecodedWasm};
22
23#[derive(derive_more::Debug, Clone)]
24pub struct WasmComponent {
25    pub exim: ExIm,
26    resolve: Resolve, // always post `rebuild_resolve`, so safe for wit printing.
27    main_pkg_id: PackageId,
28}
29
30impl WasmComponent {
31    /// Convert input WASM file to a Component.
32    /// Return `Ok(Some(output_path))` if conversion was successful. Output path is constructed as
33    /// `{output_parent}/sha256_{input_digest}_transformed.wasm` .
34    /// If the output path already exists, the conversion is skipped as it is assumed that Obelisk has made the transformation.
35    /// Return Ok(None) if the input file is a Component.
36    pub async fn convert_core_module_to_component(
37        wasm_path: &Path,
38        input_content_digest: &ContentDigest,
39        output_parent: &Path,
40    ) -> Result<Option<PathBuf>, anyhow::Error> {
41        use tokio::io::AsyncReadExt;
42
43        let stopwatch = std::time::Instant::now();
44        let mut wasm_file = tokio::fs::File::open(wasm_path)
45            .await
46            .with_context(|| format!("cannot open {wasm_path:?}"))?;
47
48        let mut header_vec = Vec::new();
49        loop {
50            let mut header = [0_u8; 8];
51            let n = wasm_file.read(&mut header).await?;
52            if n == 0 {
53                break;
54            }
55            header_vec.extend(header[..n].iter());
56            if header_vec.len() >= 8 {
57                // `is_component` and `is_core_wasm` need only first 8 bytes.
58                break;
59            }
60        }
61        if wasmparser::Parser::is_component(&header_vec) {
62            return Ok(None);
63        }
64        if !wasmparser::Parser::is_core_wasm(&header_vec) {
65            anyhow::bail!("not a WASM Component or a Core WASM Module: {wasm_file:?}");
66        }
67
68        let output_file = output_parent.join(format!(
69            "{}_transformed.wasm",
70            input_content_digest.with_infix("_"),
71        ));
72        // already transformed?
73        if output_file.exists() {
74            debug!("Found the transformed WASM Component {output_file:?}");
75            return Ok(Some(output_file));
76        }
77
78        let wasm = tokio::fs::read(wasm_path).await?;
79        let mut encoder = ComponentEncoder::default().validate(true);
80        encoder = encoder.module(&wasm)?;
81        let component_contents = encoder
82            .encode()
83            .with_context(|| {
84                format!(
85                    "failed to transform a WASM Component from the Core WASM Module {wasm_path:?}"
86                )
87            })
88            .inspect_err(|err| error!("{err:?}"))?;
89
90        tokio::fs::write(&output_file, component_contents)
91            .await
92            .with_context(|| {
93                format!("cannot write the transformed WASM Component to {output_file:?}")
94            })
95            .inspect_err(|err| error!("{err:?}"))?;
96        info!(
97            "Transformed Core WASM Module {wasm_path:?} to WASM Component {output_file:?} in {:?}",
98            stopwatch.elapsed()
99        );
100        Ok(Some(output_file))
101    }
102
103    /// Attempt to open the WASM file, parse it using `wit_parser`.
104    /// Check that the file is a WASM Component.
105    pub fn verify_wasm(wasm_path: impl AsRef<Path>) -> Result<(), DecodeError> {
106        let wasm_path = wasm_path.as_ref();
107        Self::decode_using_wit_parser_inner(
108            wasm_path, false, // `submittable_exports` parameter does not matter
109        )?;
110        Ok(())
111    }
112
113    pub fn new(
114        wasm_path: impl AsRef<Path>,
115        component_type: ComponentType,
116    ) -> Result<Self, DecodeError> {
117        let wasm_path = wasm_path.as_ref();
118        let (exim_lite, resolve, main_pkg_id) =
119            Self::decode_using_wit_parser(wasm_path, component_type)?;
120
121        let exim = ExIm::decode(exim_lite, component_type)?;
122        let (resolve, main_pkg_id) = crate::wit::rebuild_resolve(&exim, resolve, main_pkg_id)
123            .map_err(|err| {
124                DecodeError::new_with_source(
125                    format!("cannot rebuild resolve from {wasm_path:?}"),
126                    err,
127                )
128            })?;
129        Ok(Self {
130            exim,
131            resolve,
132            main_pkg_id,
133        })
134    }
135
136    /// Create a `WasmComponent` from a WIT string (no WASM binary).
137    /// Used for JS workflows/activities where the user interface is synthesized.
138    pub fn new_from_wit_string(
139        wit: &str,
140        component_type: ComponentType,
141    ) -> Result<Self, DecodeError> {
142        let mut resolve = Resolve::default();
143        let group =
144            wit_parser::UnresolvedPackageGroup::parse(PathBuf::new(), wit).map_err(|source| {
145                DecodeError::new_with_source(format!("cannot parse synthesized WIT: {wit}"), source)
146            })?;
147        let main_pkg_id = resolve.push_group(group).map_err(|source| {
148            DecodeError::new_with_source(
149                format!("cannot push synthesized WIT group: {wit}"),
150                source,
151            )
152        })?;
153        let world_id = resolve
154            .select_world(&[main_pkg_id], None)
155            .map_err(|source| {
156                DecodeError::new_with_source(
157                    "cannot select default world from synthesized WIT".to_string(),
158                    source,
159                )
160            })?;
161        let world = resolve
162            .worlds
163            .get(world_id)
164            .expect("world was found by wit-parser");
165        let exim_lite =
166            Self::create_exim_lite(&resolve, world, has_submittable_exports(component_type))?;
167
168        let exim = ExIm::decode(exim_lite, component_type)?;
169        let (resolve, main_pkg_id) = crate::wit::rebuild_resolve(&exim, resolve, main_pkg_id)
170            .map_err(|err| {
171                DecodeError::new_with_source(
172                    "cannot rebuild resolve from synthesized WIT".to_string(),
173                    err,
174                )
175            })?;
176        Ok(Self {
177            exim,
178            resolve,
179            main_pkg_id,
180        })
181    }
182
183    /// Create a `WasmComponent` directly from a function signature, bypassing WIT text synthesis
184    /// and re-parsing. Used for JS activities/workflows/stubs where we already have all the type
185    /// information and don't need the WIT text roundtrip that `new_from_wit_string` performs.
186    pub fn new_from_fn_signature(
187        ffqn: &FunctionFqn,
188        params: &[ParameterType],
189        return_type: &ReturnTypeExtendable,
190        component_type: ComponentType,
191        world_name: &str,
192    ) -> Result<Self, DecodeError> {
193        let fn_metadata = FunctionMetadata {
194            ffqn: ffqn.clone(),
195            parameter_types: ParameterTypes(params.to_vec()),
196            return_type: ReturnType::Extendable(return_type.clone()),
197            extension: None,
198            submittable: has_submittable_exports(component_type),
199        };
200        let pkg_ifc_fns = PackageIfcFns {
201            ifc_fqn: ffqn.ifc_fqn.clone(),
202            extension: false,
203            fns: IndexMap::from_iter([(ffqn.function_name.clone(), fn_metadata)]),
204        };
205        let exim_lite = ExImLite {
206            exports: vec![pkg_ifc_fns.clone()],
207            imports: vec![],
208        };
209        let exim = ExIm::decode(exim_lite, component_type)?;
210        let pkg_fqn = ffqn.ifc_fqn.pkg_fqn_name();
211        let (resolve, main_pkg_id) =
212            crate::wit::build_primary_resolve(&pkg_fqn, &[&pkg_ifc_fns], Some(world_name))
213                .map_err(|e| {
214                    DecodeError::new_with_source("cannot build resolve for JS function", e)
215                })?;
216        let (resolve, main_pkg_id) = crate::wit::rebuild_resolve(&exim, resolve, main_pkg_id)
217            .map_err(|e| {
218                DecodeError::new_with_source("cannot rebuild resolve for JS function", e)
219            })?;
220        Ok(Self {
221            exim,
222            resolve,
223            main_pkg_id,
224        })
225    }
226
227    pub fn new_from_wit_folder(
228        path: impl AsRef<Path>,
229        component_type: ComponentType,
230    ) -> Result<Self, DecodeError> {
231        let path = path.as_ref();
232        let mut resolve = Resolve::default();
233        let (main_pkg_id, _) = resolve.push_dir(path).map_err(|source| {
234            DecodeError::new_with_source(
235                format!("cannot parse the WIT directory of {path:?}"),
236                source,
237            )
238        })?;
239        let world_id = resolve
240            .select_world(&[main_pkg_id], None)
241            .map_err(|source| {
242                DecodeError::new_with_source(
243                    format!("cannot select the default world of {path:?}"),
244                    source,
245                )
246            })?;
247        let world = resolve
248            .worlds
249            .get(world_id)
250            .expect("world was found by wit-parser");
251        let exim_lite =
252            Self::create_exim_lite(&resolve, world, has_submittable_exports(component_type))?;
253
254        let exim = ExIm::decode(exim_lite, component_type)?;
255        let (resolve, main_pkg_id) = crate::wit::rebuild_resolve(&exim, resolve, main_pkg_id)
256            .map_err(|err| {
257                DecodeError::new_with_source(format!("cannot rebuild resolve from {path:?}"), err)
258            })?;
259        Ok(Self {
260            exim,
261            resolve,
262            main_pkg_id,
263        })
264    }
265
266    #[must_use]
267    pub fn exported_functions(&self, extensions: bool) -> &[FunctionMetadata] {
268        if extensions {
269            &self.exim.exports_flat_ext
270        } else {
271            &self.exim.exports_flat_noext
272        }
273    }
274
275    #[must_use]
276    pub fn imported_functions(&self) -> &[FunctionMetadata] {
277        &self.exim.imports_flat
278    }
279
280    /// Return the WIT text for this component, including obelisk extension packages.
281    ///
282    /// Cannot fail: every constructor calls `rebuild_resolve` which already proves the stored
283    /// resolve is printable, so `WitPrinter::print` on it a second time is infallible.
284    #[must_use]
285    pub fn wit(&self) -> String {
286        crate::wit::wit(&self.resolve, self.main_pkg_id)
287            .expect("WitPrinter on a post-rebuild_resolve Resolve cannot fail")
288    }
289
290    #[must_use]
291    pub fn exports_hierarchy_ext(&self) -> &[PackageIfcFns] {
292        self.exim.get_exports_hierarchy_ext()
293    }
294
295    pub fn exported_extension_wits(
296        &self,
297    ) -> Result<hashbrown::HashMap<PkgFqn, String>, anyhow::Error> {
298        let mut pkgs_to_wits = hashbrown::HashMap::new();
299
300        let exported_ext_packages: hashbrown::HashSet<_> = self
301            .exim
302            .exports_hierarchy_ext
303            .iter()
304            .filter_map(|pkg_ifc_fns| {
305                if pkg_ifc_fns.extension {
306                    Some(pkg_ifc_fns.ifc_fqn.pkg_fqn_name().to_string())
307                } else {
308                    None
309                }
310            })
311            .collect();
312
313        for (package_id, package) in self
314            .resolve
315            .packages
316            .iter()
317            .filter(|(_, package)| exported_ext_packages.contains(&package.name.to_string()))
318        {
319            let pkg_fqn = from_wit_package_name_to_pkg_fqn(&package.name);
320
321            let mut printer = wit_component::WitPrinter::default();
322            printer.print(&self.resolve, package_id, &[]).unwrap();
323            let wit = printer.output.to_string();
324
325            pkgs_to_wits.insert(pkg_fqn, wit);
326        }
327        Ok(pkgs_to_wits)
328    }
329
330    fn decode_using_wit_parser(
331        wasm_path: &Path,
332        component_type: ComponentType,
333    ) -> Result<(ExImLite, Resolve, PackageId), DecodeError> {
334        Self::decode_using_wit_parser_inner(wasm_path, has_submittable_exports(component_type))
335    }
336
337    #[instrument]
338    fn decode_using_wit_parser_inner(
339        wasm_path: &Path,
340        submittable_exports: bool, // whether exported functions + `-schedule` extensions should be submittable
341    ) -> Result<(ExImLite, Resolve, PackageId), DecodeError> {
342        trace!("Decoding using wit_parser");
343
344        let wasm_file = std::fs::File::open(wasm_path)
345            .with_context(|| format!("cannot open {wasm_path:?}"))
346            .map_err(|err| {
347                DecodeError::new_with_source(format!("cannot read {wasm_path:?}"), err)
348            })?;
349        let stopwatch = std::time::Instant::now();
350        let decoded = wit_parser::decoding::decode_reader(wasm_file).map_err(|err| {
351            DecodeError::new_with_source(format!("cannot read {wasm_path:?} using wit_parser"), err)
352        })?;
353        let main_package = decoded.package();
354        let DecodedWasm::Component(resolve, world_id) = decoded else {
355            return Err(DecodeError::new_without_source(format!(
356                "{wasm_path:?} must be a WASM Component"
357            )));
358        };
359        let world = resolve
360            .worlds
361            .get(world_id)
362            .expect("world was found by wit-parser");
363        let exim_lite = Self::create_exim_lite(&resolve, world, submittable_exports)?;
364        debug!("Parsed with wit_parser in {:?}", stopwatch.elapsed());
365        trace!("{exim_lite:?}");
366        Ok((exim_lite, resolve, main_package))
367    }
368
369    fn create_exim_lite(
370        resolve: &Resolve,
371        world: &World,
372        submittable_exports: bool,
373    ) -> Result<ExImLite, DecodeError> {
374        let exports = populate_ifcs_with_compatible_fns(
375            resolve,
376            world_interfaces(world, ExOrIm::Exports),
377            if submittable_exports {
378                ProcessingKind::ExportsSubmittable
379            } else {
380                ProcessingKind::ExportsOfActivityStub
381            },
382        )?;
383        let imports = populate_ifcs_with_compatible_fns(
384            resolve,
385            world_interfaces(world, ExOrIm::Imports),
386            ProcessingKind::Imports,
387        )?;
388        Ok(ExImLite { imports, exports })
389    }
390}
391
392#[derive(Clone, Copy, PartialEq)]
393pub(crate) enum ExOrIm {
394    Exports,
395    Imports,
396}
397
398fn world_interfaces(world: &World, exorim: ExOrIm) -> impl Iterator<Item = InterfaceId> {
399    match exorim {
400        ExOrIm::Exports => &world.exports,
401        ExOrIm::Imports => &world.imports,
402    }
403    .keys()
404    .filter_map(|world_key| {
405        if let WorldKey::Interface(ifc_id) = world_key {
406            Some(*ifc_id)
407        } else {
408            None
409        }
410    })
411}
412
413fn has_submittable_exports(component_type: ComponentType) -> bool {
414    // Activity stubs don't have submittable exports
415    !matches!(component_type, ComponentType::ActivityStub)
416}
417
418#[derive(Debug, thiserror::Error)]
419pub struct DecodeError {
420    reason: StrVariant,
421    #[source]
422    source: Option<Box<dyn std::error::Error + Send + Sync>>,
423    #[allow(dead_code)] // context is needed for debug output
424    context: SpanTrace,
425}
426impl DecodeError {
427    pub fn new_with_source(
428        reason: impl Into<StrVariant>,
429        source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
430    ) -> Self {
431        DecodeError {
432            reason: reason.into(),
433            source: Some(source.into()),
434            context: SpanTrace::capture(),
435        }
436    }
437
438    pub fn new_without_source(reason: impl Into<StrVariant>) -> Self {
439        DecodeError {
440            reason: reason.into(),
441            source: None,
442            context: SpanTrace::capture(),
443        }
444    }
445}
446impl Display for DecodeError {
447    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448        write!(f, "{}", self.reason)?;
449        if let Some(source) = &self.source {
450            write!(f, " - {source}")?;
451        }
452        Ok(())
453    }
454}
455
456#[derive(Debug, Clone)]
457pub struct ExIm {
458    inner: Arc<ExImInner>,
459}
460
461impl std::ops::Deref for ExIm {
462    type Target = ExImInner;
463
464    fn deref(&self) -> &Self::Target {
465        &self.inner
466    }
467}
468
469#[derive(Debug)]
470pub struct ExImInner {
471    pub(crate) exports_hierarchy_ext: Vec<PackageIfcFns>,
472    exports_flat_noext: Vec<FunctionMetadata>,
473    exports_flat_ext: Vec<FunctionMetadata>,
474    pub imports_flat: Vec<FunctionMetadata>,
475}
476
477impl ExIm {
478    #[must_use]
479    pub fn get_exports(&self, extensions: bool) -> &[FunctionMetadata] {
480        if extensions {
481            &self.exports_flat_ext
482        } else {
483            &self.exports_flat_noext
484        }
485    }
486
487    #[must_use]
488    pub fn get_exports_hierarchy_ext(&self) -> &[PackageIfcFns] {
489        &self.exports_hierarchy_ext
490    }
491
492    fn decode(exim_lite: ExImLite, component_type: ComponentType) -> Result<ExIm, DecodeError> {
493        let mut exports_hierarchy_ext = exim_lite.exports;
494        // Verify that there is no -obelisk-ext export and that the `-cancellable`
495        // suffix is only used on workflow exports.
496        for PackageIfcFns {
497            ifc_fqn,
498            fns,
499            extension,
500        } in &exports_hierarchy_ext
501        {
502            assert!(
503                !extension,
504                "the list must not be enriched with extensions yet"
505            );
506            if ifc_fqn.is_extension() {
507                return Err(DecodeError::new_without_source(format!(
508                    "invalid package `{}`, {SUFFIX_PKG_EXT} is reserved",
509                    ifc_fqn.package_name()
510                )));
511            }
512            if component_type != ComponentType::Workflow {
513                for fn_name in fns.keys() {
514                    if fn_name.ends_with(SUFFIX_FN_CANCELLABLE) {
515                        return Err(DecodeError::new_without_source(format!(
516                            "invalid function `{ifc_fqn}.{fn_name}`, {SUFFIX_FN_CANCELLABLE} is reserved for workflow exports"
517                        )));
518                    }
519                }
520            }
521        }
522
523        let exports_flat_noext = Self::flatten(&exports_hierarchy_ext);
524        Self::enrich_exports_with_extensions(&mut exports_hierarchy_ext, component_type);
525        let exports_flat_ext = Self::flatten(&exports_hierarchy_ext);
526        let imports_hierarchy = exim_lite.imports;
527        let imports_flat = Self::flatten(&imports_hierarchy);
528        Ok(Self {
529            inner: Arc::new(ExImInner {
530                exports_hierarchy_ext,
531                exports_flat_noext,
532                exports_flat_ext,
533                imports_flat,
534            }),
535        })
536    }
537
538    fn enrich_exports_with_extensions(
539        exports_hierarchy: &mut Vec<PackageIfcFns>,
540        component_type: ComponentType,
541    ) {
542        if component_type == ComponentType::WebhookEndpoint {
543            return;
544        }
545        // initialize values for reuse
546        let execution_id_type_wrapper =
547            TypeWrapper::Record(indexmap! {TypeKey::new_kebab("id") => TypeWrapper::String});
548        let delay_id_type_wrapper =
549            TypeWrapper::Record(indexmap! {TypeKey::new_kebab("id") => TypeWrapper::String});
550        let join_set_id_type_wrapper = TypeWrapper::Borrow;
551
552        let return_type_execution_id = ReturnType::NonExtendable(ReturnTypeNonExtendable {
553            type_wrapper: execution_id_type_wrapper.clone(),
554            wit_type: concepts::StrVariant::Static("execution-id"),
555        });
556        let param_type_execution_id = ParameterType {
557            type_wrapper: execution_id_type_wrapper.clone(),
558            name: StrVariant::Static("execution-id"),
559            wit_type: StrVariant::Static("execution-id"),
560        };
561        let param_type_join_set = ParameterType {
562            type_wrapper: join_set_id_type_wrapper.clone(),
563            name: StrVariant::Static("join-set"),
564            wit_type: StrVariant::Static("borrow<join-set>"),
565        };
566        let duration_type_wrapper = TypeWrapper::Variant(indexmap! {
567            TypeKey::new_kebab("milliseconds") => Some(TypeWrapper::U64),
568            TypeKey::new_kebab("seconds") => Some(TypeWrapper::U64),
569            TypeKey::new_kebab("minutes") => Some(TypeWrapper::U32),
570            TypeKey::new_kebab("hours") => Some(TypeWrapper::U32),
571            TypeKey::new_kebab("days") => Some(TypeWrapper::U32),
572        });
573        let param_type_scheduled_at = ParameterType {
574            type_wrapper: TypeWrapper::Variant(indexmap! {
575                TypeKey::new_kebab("now") => None,
576                TypeKey::new_kebab("at") => Some(TypeWrapper::Record(indexmap! {
577                    TypeKey::new_kebab("seconds") => TypeWrapper::U64,
578                    TypeKey::new_kebab("nanoseconds") => TypeWrapper::U32,
579                })),
580                TypeKey::new_kebab("in") => Some(duration_type_wrapper),
581            }),
582            name: StrVariant::Static("scheduled-at"),
583            wit_type: StrVariant::Static("schedule-at"),
584        };
585
586        let function_type_wrapper = TypeWrapper::Record(indexmap! {
587            TypeKey::new_kebab("interface-name") => TypeWrapper::String,
588            TypeKey::new_kebab("function-name") => TypeWrapper::String,
589        });
590
591        let response_id = TypeWrapper::Variant(indexmap! {
592            TypeKey::new_kebab("execution-id") => Some(execution_id_type_wrapper.clone()),
593            TypeKey::new_kebab("delay-id") => Some(delay_id_type_wrapper.clone()),
594        });
595
596        // record function-mismatch
597        let function_mismatch_type_wrapper = TypeWrapper::Record(indexmap! {
598            TypeKey::new_kebab("specified-function") => function_type_wrapper.clone(),
599            TypeKey::new_kebab("actual-function") => TypeWrapper::Option(Box::from(function_type_wrapper)),
600            TypeKey::new_kebab("actual-id") => response_id,
601        });
602
603        // await-next-extension-error
604        let await_next_extension_error_type_wrapper = TypeWrapper::Variant(indexmap! {
605            TypeKey::new_kebab("all-processed") => None,
606            TypeKey::new_kebab("function-mismatch") => Some(function_mismatch_type_wrapper.clone()),
607        });
608
609        // get-extension-error
610        let get_extension_error_type_wrapper = TypeWrapper::Variant(indexmap! {
611            TypeKey::new_kebab("function-mismatch") => Some(function_mismatch_type_wrapper.clone()),
612            TypeKey::new_kebab("not-found-in-processed-responses") => None,
613        });
614
615        // stub-error
616        let stub_error_type_wrapper = TypeWrapper::Variant(indexmap! {
617            TypeKey::new_kebab("execution-not-found") => None,
618            TypeKey::new_kebab("type-check-error") => Some(TypeWrapper::String),
619            TypeKey::new_kebab("conflict") => None,
620        });
621
622        let mut extensions = Vec::new();
623        let mut schedules = Vec::new();
624        let mut stubs = Vec::new();
625        for PackageIfcFns {
626            ifc_fqn,
627            fns,
628            extension,
629        } in exports_hierarchy.iter()
630        {
631            assert!(!extension);
632            let obelisk_ext_ifc = IfcFqnName::from_parts(
633                ifc_fqn.namespace(),
634                &format!("{}{SUFFIX_PKG_EXT}", ifc_fqn.package_name()),
635                ifc_fqn.ifc_name(),
636                ifc_fqn.version(),
637            );
638            let obelisk_schedule_ifc = IfcFqnName::from_parts(
639                ifc_fqn.namespace(),
640                &format!("{}{SUFFIX_PKG_SCHEDULE}", ifc_fqn.package_name()),
641                ifc_fqn.ifc_name(),
642                ifc_fqn.version(),
643            );
644            let obelisk_stub_ifc = IfcFqnName::from_parts(
645                ifc_fqn.namespace(),
646                &format!("{}{SUFFIX_PKG_STUB}", ifc_fqn.package_name()),
647                ifc_fqn.ifc_name(),
648                ifc_fqn.version(),
649            );
650
651            let mut extension_fns = IndexMap::new();
652            let mut schedule_fns = IndexMap::new();
653            let mut insert_ext = |fn_metadata: FunctionMetadata| {
654                extension_fns.insert(fn_metadata.ffqn.function_name.clone(), fn_metadata);
655            };
656            let mut insert_schedule = |fn_metadata: FunctionMetadata| {
657                schedule_fns.insert(fn_metadata.ffqn.function_name.clone(), fn_metadata);
658            };
659
660            let mut stub_fns = IndexMap::new();
661            for (
662                fun,
663                FunctionMetadata {
664                    ffqn: _,
665                    parameter_types,
666                    return_type,
667                    extension,
668                    submittable: original_submittable,
669                },
670            ) in fns
671            {
672                assert!(
673                    extension.is_none(),
674                    "`exports_hierarchy` must not contain extensions"
675                );
676                let exported_fn_metadata = FunctionMetadata {
677                    ffqn: FunctionFqn {
678                        ifc_fqn: ifc_fqn.clone(),
679                        function_name: fun.clone(),
680                    },
681                    parameter_types: parameter_types.clone(),
682                    return_type: return_type.clone(),
683                    extension: None,
684                    submittable: false,
685                };
686                let ReturnType::Extendable(return_type) = return_type else {
687                    unreachable!(
688                        "all ExImLite exported functions must have their return type validated"
689                    )
690                };
691
692                // -submit(join-set: join-set, original params) -> execution id
693                let fn_submit = FunctionMetadata {
694                    ffqn: FunctionFqn {
695                        ifc_fqn: obelisk_ext_ifc.clone(),
696                        function_name: FnName::from(format!(
697                            "{}{SUFFIX_FN_SUBMIT}",
698                            exported_fn_metadata.ffqn.function_name
699                        )),
700                    },
701                    parameter_types: {
702                        let mut params =
703                            Vec::with_capacity(exported_fn_metadata.parameter_types.len() + 1);
704                        params.push(param_type_join_set.clone());
705                        params.extend_from_slice(&exported_fn_metadata.parameter_types.0);
706                        ParameterTypes(params)
707                    },
708                    return_type: return_type_execution_id.clone(),
709                    extension: Some(FunctionExtension::Submit),
710                    submittable: false,
711                };
712                insert_ext(fn_submit);
713
714                // -await-next(join-set: join-set) -> result<original_return_type, await-next-extension-error>
715                // The response id is dropped from the tuple; read it via join-set.last-id.
716                // Structurally identical to -get.
717                let fn_await_next = FunctionMetadata {
718                    ffqn: FunctionFqn {
719                        ifc_fqn: obelisk_ext_ifc.clone(),
720                        function_name: FnName::from(format!(
721                            "{}{SUFFIX_FN_AWAIT_NEXT}",
722                            exported_fn_metadata.ffqn.function_name
723                        )),
724                    },
725                    parameter_types: ParameterTypes(vec![param_type_join_set.clone()]),
726                    return_type: {
727                        ReturnType::detect(
728                            TypeWrapper::Result {
729                                ok: Some(Box::new(TypeWrapper::from(
730                                    return_type.type_wrapper_tl.clone(),
731                                ))),
732                                err: Some(Box::new(
733                                    await_next_extension_error_type_wrapper.clone(),
734                                )),
735                            },
736                            StrVariant::from(format!(
737                                "result<{original_ret}, await-next-extension-error>",
738                                original_ret = exported_fn_metadata.return_type
739                            )),
740                        )
741                    },
742                    extension: Some(FunctionExtension::AwaitNext),
743                    submittable: false,
744                };
745                insert_ext(fn_await_next);
746
747                // -get(execution-id) -> result<original_return_type, get-extension-error>
748                let fn_get = FunctionMetadata {
749                    ffqn: FunctionFqn {
750                        ifc_fqn: obelisk_ext_ifc.clone(),
751                        function_name: FnName::from(format!(
752                            "{}{SUFFIX_FN_GET}",
753                            exported_fn_metadata.ffqn.function_name
754                        )),
755                    },
756                    parameter_types: ParameterTypes(vec![param_type_execution_id.clone()]),
757                    return_type: {
758                        ReturnType::detect(
759                            TypeWrapper::Result {
760                                ok: Some(Box::new(TypeWrapper::from(
761                                    return_type.type_wrapper_tl.clone(),
762                                ))),
763                                err: Some(Box::new(get_extension_error_type_wrapper.clone())),
764                            },
765                            StrVariant::from(format!(
766                                "result<{}, get-extension-error>",
767                                return_type.wit_type
768                            )),
769                        )
770                    },
771                    extension: Some(FunctionExtension::Get),
772                    submittable: false,
773                };
774                insert_ext(fn_get);
775
776                if component_type != ComponentType::ActivityStub {
777                    assert!(
778                        original_submittable,
779                        "original exported function must be submittable for components different from activity stubs"
780                    );
781                    // -schedule(schedule: schedule-at, original params) -> string (execution id)
782                    let fn_schedule = FunctionMetadata {
783                        ffqn: FunctionFqn {
784                            ifc_fqn: obelisk_schedule_ifc.clone(),
785                            function_name: FnName::from(format!(
786                                "{}{SUFFIX_FN_SCHEDULE}",
787                                exported_fn_metadata.ffqn.function_name
788                            )),
789                        },
790                        parameter_types: {
791                            let mut params =
792                                Vec::with_capacity(exported_fn_metadata.parameter_types.len() + 1);
793                            params.push(param_type_scheduled_at.clone());
794                            params.extend_from_slice(&exported_fn_metadata.parameter_types.0);
795                            ParameterTypes(params)
796                        },
797                        return_type: return_type_execution_id.clone(),
798                        extension: Some(FunctionExtension::Schedule),
799                        submittable: true,
800                    };
801                    insert_schedule(fn_schedule);
802                }
803
804                if component_type == ComponentType::ActivityStub {
805                    // -stub(execution-id: execution-id, original retval) -> result<_, stub-error>
806                    let fn_stub = FunctionMetadata {
807                        ffqn: FunctionFqn {
808                            ifc_fqn: obelisk_stub_ifc.clone(),
809                            function_name: FnName::from(format!(
810                                "{}{SUFFIX_FN_STUB}",
811                                exported_fn_metadata.ffqn.function_name
812                            )),
813                        },
814                        parameter_types: {
815                            let mut params = vec![param_type_execution_id.clone()];
816
817                            params.push(ParameterType {
818                                type_wrapper: TypeWrapper::from(
819                                    return_type.type_wrapper_tl.clone(),
820                                ),
821                                name: StrVariant::Static("execution-result"),
822                                wit_type: return_type.wit_type.clone(),
823                            });
824
825                            ParameterTypes(params)
826                        },
827                        return_type: ReturnType::detect(
828                            TypeWrapper::Result {
829                                ok: None,
830                                err: Some(Box::new(stub_error_type_wrapper.clone())),
831                            },
832                            StrVariant::Static("result<_, stub-error>"),
833                        ),
834                        extension: Some(FunctionExtension::Stub),
835                        submittable: false,
836                    };
837                    stub_fns.insert(fn_stub.ffqn.function_name.clone(), fn_stub);
838                }
839            }
840            extensions.push((obelisk_ext_ifc, extension_fns));
841            schedules.push((obelisk_schedule_ifc, schedule_fns));
842            stubs.push((obelisk_stub_ifc, stub_fns));
843        }
844        for (ifc_fqn, fns) in extensions {
845            if !fns.is_empty() {
846                exports_hierarchy.push(PackageIfcFns {
847                    ifc_fqn,
848                    fns,
849                    extension: true,
850                });
851            }
852        }
853        for (ifc_fqn, fns) in schedules {
854            if !fns.is_empty() {
855                exports_hierarchy.push(PackageIfcFns {
856                    ifc_fqn,
857                    fns,
858                    extension: true,
859                });
860            }
861        }
862        for (ifc_fqn, fns) in stubs {
863            if !fns.is_empty() {
864                exports_hierarchy.push(PackageIfcFns {
865                    ifc_fqn,
866                    fns,
867                    extension: true,
868                });
869            }
870        }
871    }
872
873    fn flatten(input: &[PackageIfcFns]) -> Vec<FunctionMetadata> {
874        input
875            .iter()
876            .flat_map(|pif| pif.fns.values().cloned())
877            .collect()
878    }
879}
880
881// Only contains functions with result types of ResultType::Compatible
882#[derive(Debug)]
883struct ExImLite {
884    imports: Vec<PackageIfcFns>,
885    // Only no-ext functions in exports, guarded by DecodeError::ExportingExt
886    exports: Vec<PackageIfcFns>,
887}
888
889#[derive(Clone, Copy, PartialEq)]
890enum ProcessingKind {
891    Imports,
892    ExportsOfActivityStub,
893    ExportsSubmittable,
894}
895impl ProcessingKind {
896    fn is_export(&self) -> bool {
897        matches!(
898            self,
899            ProcessingKind::ExportsSubmittable | ProcessingKind::ExportsOfActivityStub
900        )
901    }
902}
903
904fn populate_ifcs_with_compatible_fns(
905    resolve: &Resolve,
906    ifc_ids: impl Iterator<Item = InterfaceId>,
907    processing_kind: ProcessingKind,
908) -> Result<Vec<PackageIfcFns>, DecodeError> {
909    let mut vec = Vec::new();
910    let submittable = processing_kind == ProcessingKind::ExportsSubmittable;
911    for ifc in ifc_ids
912        .map(|ifc_id| {
913            resolve
914                .interfaces
915                .get(ifc_id)
916                .expect("`iter` must be derived from `resolve`")
917        })
918        // skip inline interfaces
919        .filter(|ifc| ifc.name.is_some())
920    {
921        let Some(package) = ifc
922            .package
923            .and_then(|pkg| resolve.packages.get(pkg))
924            .map(|p| &p.name)
925        else {
926            unreachable!("interface's package cannot be empty");
927        };
928        let Some(ifc_name) = ifc.name.as_deref() else {
929            unreachable!("inline interfaces already filterd out");
930        };
931        let pkg_fqn = PkgFqn {
932            namespace: package.namespace.clone(),
933            package_name: package.name.clone(),
934            version: package
935                .version
936                .as_ref()
937                .map(std::string::ToString::to_string),
938        };
939        let package_ext = pkg_fqn.split_ext().map(|(_, pkg_ext)| pkg_ext);
940        if processing_kind.is_export() && package_ext.is_some() {
941            return Err(DecodeError::new_without_source(format!(
942                "component is exporting obelisk extended package {pkg_fqn}"
943            )));
944        }
945        let ifc_fqn = if let Some(version) = &package.version {
946            format!(
947                "{namespace}:{name}/{ifc_name}@{version}",
948                namespace = package.namespace,
949                name = package.name
950            )
951        } else {
952            format!("{package}/{ifc_name}")
953        };
954        let ifc_fqn: Arc<str> = Arc::from(ifc_fqn);
955        let mut fns = IndexMap::new();
956        for (function_name, function) in &ifc.functions {
957            let ffqn = FunctionFqn::new_arc(ifc_fqn.clone(), Arc::from(function_name.clone()));
958            let return_type = if let Some(return_type) = function.result {
959                let mut printer = WitPrinter::default();
960                let wit_type = printer
961                    .print_type_name(resolve, &return_type)
962                    .ok()
963                    .map_or(StrVariant::Static("unknown"), |()| {
964                        StrVariant::from(printer.output.to_string())
965                    });
966                Some(ReturnType::detect(
967                    match TypeWrapper::from_wit_parser_type(resolve, &return_type) {
968                        Ok(ok) => ok,
969                        Err(err) => {
970                            return Err(DecodeError::new_with_source(
971                                format!("unsupported type in {ffqn}"),
972                                err,
973                            ));
974                        }
975                    },
976                    wit_type,
977                ))
978            } else {
979                None
980            };
981
982            let return_type_valid = matches!(return_type, Some(ReturnType::Extendable(_)));
983
984            match (return_type, processing_kind.is_export()) {
985                (Some(return_type @ ReturnType::Extendable(_)), true)
986                | (Some(return_type), false) => {
987                    let ffqn =
988                        FunctionFqn::new_arc(ifc_fqn.clone(), Arc::from(function_name.clone()));
989                    let parameter_types = ParameterTypes({
990                        let mut params = Vec::new();
991                        for Param {
992                            name: param_name,
993                            ty: param_ty,
994                            span: _,
995                        } in &function.params
996                        {
997                            let mut printer = WitPrinter::default();
998                            let item = ParameterType {
999                                type_wrapper: match TypeWrapper::from_wit_parser_type(
1000                                    resolve, param_ty,
1001                                ) {
1002                                    Ok(ok) => ok,
1003                                    Err(err) => {
1004                                        return Err(DecodeError::new_with_source(
1005                                            format!("unsupported type in {ffqn}"),
1006                                            err,
1007                                        ));
1008                                    }
1009                                },
1010                                name: StrVariant::from(param_name.clone()),
1011                                wit_type: printer
1012                                    .print_type_name(resolve, param_ty)
1013                                    .ok()
1014                                    .map_or(StrVariant::Static("unknown"), |()| {
1015                                        StrVariant::from(printer.output.to_string())
1016                                    }),
1017                            };
1018                            params.push(item);
1019                        }
1020                        params
1021                    });
1022
1023                    fns.insert(
1024                        ffqn.function_name.clone(),
1025                        FunctionMetadata {
1026                            parameter_types,
1027                            return_type,
1028                            extension: {
1029                                let guessed_fn_extension = FunctionExtension::iter()
1030                                    .find(|fn_ext| function_name.ends_with(fn_ext.suffix()));
1031                                match (package_ext, guessed_fn_extension) {
1032                                    (None, _) => None,
1033                                    (Some(pkg_ext), Some(fn_ext)) if fn_ext.belongs_to(pkg_ext) => {
1034                                        Some(fn_ext)
1035                                    }
1036                                    _ => {
1037                                        return Err(DecodeError::new_without_source(format!(
1038                                            "wrong extension import {ffqn}"
1039                                        )));
1040                                    }
1041                                }
1042                            },
1043                            ffqn,
1044                            submittable,
1045                        },
1046                    );
1047                }
1048                (Some(return_type), true)
1049                    if processing_kind.is_export()
1050                        && !return_type_valid
1051                        && !ifc_fqn.starts_with("wasi:http/incoming-handler@") =>
1052                {
1053                    // Warn if this is export and a function return type is not compatible.
1054                    // Mute warnings for `incoming-handlers`, exported by webhooks.
1055                    warn!("Ignoring export {ffqn} with unsupported return type {return_type:?}");
1056                }
1057                _ => {}
1058            }
1059        }
1060        if !fns.is_empty() {
1061            vec.push(PackageIfcFns {
1062                ifc_fqn: IfcFqnName::new_arc(ifc_fqn),
1063                fns,
1064                extension: package_ext.is_some(),
1065            });
1066        }
1067    }
1068    Ok(vec)
1069}
1070
1071/// Strip the content hash from a WASM filename for use in snapshot names.
1072/// e.g. `test_programs_fibo_workflow_53ce9157236ba9d9_component.wasm` →
1073///      `test_programs_fibo_workflow_component.wasm`
1074#[cfg(test)]
1075pub(crate) fn strip_wasm_hash(filename: &str) -> String {
1076    let mut result = filename.to_string();
1077    if let Some(pos) = result.find('_').and_then(|_| {
1078        result
1079            .as_bytes()
1080            .windows(17)
1081            .position(|w| w[0] == b'_' && w[1..].iter().all(u8::is_ascii_hexdigit))
1082    }) {
1083        result.replace_range(pos..pos + 17, "");
1084    }
1085    result
1086}
1087
1088#[cfg(test)]
1089pub(crate) mod tests {
1090    use super::{populate_ifcs_with_compatible_fns, strip_wasm_hash};
1091    use crate::wasm_tools::{ExOrIm, ProcessingKind, WasmComponent, world_interfaces};
1092    use concepts::ComponentType;
1093    use rstest::rstest;
1094    use std::path::PathBuf;
1095    use wit_parser::decoding::DecodedWasm;
1096
1097    #[derive(Debug, Clone, Copy)]
1098    enum ExIm {
1099        Exports,
1100        ExportsExtended,
1101        Imports,
1102    }
1103
1104    #[rstest]
1105    #[case(test_programs_fibo_workflow_builder::TEST_PROGRAMS_FIBO_WORKFLOW)]
1106    #[case(test_programs_http_get_workflow_builder::TEST_PROGRAMS_HTTP_GET_WORKFLOW)]
1107    fn exports_imports(
1108        #[case] wasm_path: &str,
1109        #[values(ExIm::Exports, ExIm::ExportsExtended, ExIm::Imports)] exim: ExIm,
1110    ) {
1111        test_utils::set_up();
1112
1113        let wasm_path = PathBuf::from(wasm_path);
1114        let wasm_file = strip_wasm_hash(&wasm_path.file_name().unwrap().to_string_lossy());
1115        let component = WasmComponent::new(&wasm_path, ComponentType::Workflow).unwrap();
1116        match exim {
1117            ExIm::Exports => {
1118                let exports = component
1119                    .exported_functions(false)
1120                    .iter()
1121                    .map(|fn_metadata| (fn_metadata.ffqn.to_string(), fn_metadata))
1122                    .collect::<hashbrown::HashMap<_, _>>();
1123                insta::with_settings!({sort_maps => true, snapshot_suffix => format!("{wasm_file}_exports_noext")}, {insta::assert_json_snapshot!(exports)});
1124            }
1125            ExIm::ExportsExtended => {
1126                let exports = component
1127                    .exported_functions(true)
1128                    .iter()
1129                    .map(|fn_metadata| (fn_metadata.ffqn.to_string(), fn_metadata))
1130                    .collect::<hashbrown::HashMap<_, _>>();
1131                insta::with_settings!({sort_maps => true, snapshot_suffix => format!("{wasm_file}_exports_ext")}, {insta::assert_json_snapshot!(exports)});
1132            }
1133            ExIm::Imports => {
1134                let imports = component
1135                    .imported_functions()
1136                    .iter()
1137                    .map(|fn_metadata| (fn_metadata.ffqn.to_string(), fn_metadata))
1138                    .collect::<hashbrown::HashMap<_, _>>();
1139                insta::with_settings!({sort_maps => true, snapshot_suffix => format!("{wasm_file}_imports")}, {insta::assert_json_snapshot!(imports)});
1140            }
1141        }
1142    }
1143
1144    #[rstest]
1145    fn test_params(#[values(true, false)] exports: bool) {
1146        let wasm_path =
1147            PathBuf::from(test_programs_fibo_workflow_builder::TEST_PROGRAMS_FIBO_WORKFLOW);
1148        let wasm_file = strip_wasm_hash(&wasm_path.file_name().unwrap().to_string_lossy());
1149        let file = std::fs::File::open(&wasm_path).unwrap();
1150        let decoded = wit_parser::decoding::decode_reader(file).unwrap();
1151        let DecodedWasm::Component(resolve, world_id) = decoded else {
1152            panic!();
1153        };
1154        let world = resolve.worlds.get(world_id).expect("world must exist");
1155
1156        if exports {
1157            let exports = populate_ifcs_with_compatible_fns(
1158                &resolve,
1159                world_interfaces(world, ExOrIm::Exports),
1160                ProcessingKind::ExportsSubmittable,
1161            )
1162            .unwrap()
1163            .into_iter()
1164            .flat_map(|ifc| ifc.fns)
1165            .map(|(_fn_name, fn_metadata)| (fn_metadata.ffqn.to_string(), fn_metadata))
1166            .collect::<hashbrown::HashMap<_, _>>();
1167            insta::with_settings!({sort_maps => true,  snapshot_suffix => format!("{wasm_file}_exports")}, {insta::assert_json_snapshot!(exports)});
1168        } else {
1169            let imports = populate_ifcs_with_compatible_fns(
1170                &resolve,
1171                world_interfaces(world, ExOrIm::Imports),
1172                ProcessingKind::Imports,
1173            )
1174            .unwrap()
1175            .into_iter()
1176            .flat_map(|ifc| ifc.fns)
1177            .map(|(_fn_name, fn_metadata)| (fn_metadata.ffqn.to_string(), fn_metadata))
1178            .collect::<hashbrown::HashMap<_, _>>();
1179            insta::with_settings!({ sort_maps => true,  snapshot_suffix => format!("{wasm_file}_imports")}, {insta::assert_json_snapshot!(imports)});
1180        }
1181    }
1182
1183    #[rstest]
1184    #[case("fibo/activity", ComponentType::Activity)]
1185    #[case("fibo/workflow", ComponentType::Workflow)]
1186    #[case("fibo/webhook", ComponentType::WebhookEndpoint)]
1187    #[case("stub/activity", ComponentType::ActivityStub)]
1188    fn test_wit_folder_parsing(#[case] path: &'static str, #[case] component_type: ComponentType) {
1189        use std::fmt::Write;
1190
1191        let workspace_dir = PathBuf::from(
1192            std::env::var("CARGO_WORKSPACE_DIR")
1193                .as_deref()
1194                .unwrap_or("."),
1195        )
1196        .canonicalize()
1197        .unwrap();
1198        let wasm_component = WasmComponent::new_from_wit_folder(
1199            workspace_dir
1200                .join("crates/testing/test-programs")
1201                .join(path)
1202                .join("wit"),
1203            component_type,
1204        )
1205        .unwrap();
1206
1207        let mut pkgs_to_wits: Vec<_> = wasm_component
1208            .exported_extension_wits()
1209            .unwrap()
1210            .into_iter()
1211            .map(|(pkg_fqn, wit)| (pkg_fqn.to_string(), wit))
1212            .collect();
1213        pkgs_to_wits.sort_by(|(pkg_fqn, _), (pkg_fqn2, _)| pkg_fqn.cmp(pkg_fqn2));
1214
1215        let mut snapshot = String::new();
1216        for (pkg_fqn, wit) in pkgs_to_wits {
1217            write!(&mut snapshot, "{pkg_fqn}\n{wit}\n\n").unwrap();
1218        }
1219
1220        insta::with_settings!({  snapshot_suffix => format!("{path}")}, {insta::assert_snapshot!(snapshot)});
1221    }
1222
1223    #[test]
1224    fn test_new_from_wit_string() {
1225        let wit = "
1226            package a:b;
1227            interface c {
1228                fn: func(p1: string) -> result;
1229            }
1230            world z {
1231                export c;
1232            }
1233        ";
1234        let user_wasm_component =
1235            WasmComponent::new_from_wit_string(wit, ComponentType::Activity).unwrap();
1236        let exports = user_wasm_component.exported_functions(false).to_vec();
1237        insta::assert_debug_snapshot!(exports);
1238    }
1239
1240    const CANCELLABLE_WIT: &str = "
1241            package a:b;
1242            interface c {
1243                run-cancellable: func(p1: string) -> result;
1244            }
1245            world z {
1246                export c;
1247            }
1248        ";
1249
1250    #[test]
1251    fn cancellable_workflow_export_is_accepted() {
1252        WasmComponent::new_from_wit_string(CANCELLABLE_WIT, ComponentType::Workflow).unwrap();
1253    }
1254
1255    #[rstest]
1256    #[case(ComponentType::Activity)]
1257    #[case(ComponentType::WebhookEndpoint)]
1258    fn cancellable_suffix_rejected_on_non_workflow(#[case] wrong_type: ComponentType) {
1259        let err = WasmComponent::new_from_wit_string(CANCELLABLE_WIT, wrong_type).unwrap_err();
1260        assert!(
1261            err.to_string()
1262                .contains("-cancellable is reserved for workflow exports"),
1263            "unexpected error: {err}"
1264        );
1265    }
1266}