diff --git a/meld-core/src/merger.rs b/meld-core/src/merger.rs index f24f3c8..f4bed31 100644 --- a/meld-core/src/merger.rs +++ b/meld-core/src/merger.rs @@ -5557,6 +5557,26 @@ mod tests { .map(|i| format!("{}/{}", i.module, i.name)) .collect::>() ); + + // #301: the retained import's module is in the recognized + // `pulseengine:embedder` namespace, so meld preserves it as INTENTIONAL + // embedder passthrough — not merely incidental survival of an unresolved + // import. This ties the real fork fixture's module name to the explicit + // recognition predicate the strict-resolution carve-out relies on. + assert!( + merged + .imports + .iter() + .any(|i| i.name == "__cabi_arena_realloc" + && crate::resolver::is_embedder_passthrough(&i.module)), + "retained arena import must be recognized as embedder passthrough by namespace, \ + got imports: {:?}", + merged + .imports + .iter() + .map(|i| format!("{}/{}", i.module, i.name)) + .collect::>() + ); } // -- SR-31: Multiply-instantiated module detection ------------------------- diff --git a/meld-core/src/resolver.rs b/meld-core/src/resolver.rs index ad22563..f40e77f 100644 --- a/meld-core/src/resolver.rs +++ b/meld-core/src/resolver.rs @@ -1587,6 +1587,40 @@ fn resolve_resource_positions( resolved } +/// The well-known WIT package namespace for embedder-provided seams. +/// +/// Imports in this namespace (e.g. `pulseengine:embedder/arena`'s +/// `__cabi_arena_realloc`) are satisfied by the *embedder* downstream — at +/// native link / synth dissolve — NOT by another component in the fusion set. +/// meld must recognize them as intentional passthrough: never bind them to a +/// provider, never treat them as a resolution failure, and preserve them into +/// the fused artifact (meld#301, gale#89). +/// +/// Recognition is by **namespace**, not by the bare field name +/// `cabi-arena-realloc`: a magic-name match would be collision-prone and +/// invisible to review, whereas a namespaced interface is an explicit, +/// greppable contract (the "explicit, not auto" stance that settled #300). +pub const EMBEDDER_PASSTHROUGH_NAMESPACE: &str = "pulseengine:embedder"; + +/// Returns `true` if `import_name` names an import in the +/// [`EMBEDDER_PASSTHROUGH_NAMESPACE`] — an embedder-provided seam that is +/// intentionally left unresolved through fusion (see the constant's docs). +/// +/// Matches the *package* portion of a WIT interface/instance import name, +/// tolerating a version suffix on either the package or the interface: +/// `pulseengine:embedder/arena`, `pulseengine:embedder/arena@0.1.0`, and +/// `pulseengine:embedder@0.1.0/arena` all match; the prefix look-alikes +/// `pulseengine:embedderx/foo` and `pulseengine:embed/foo`, and unrelated +/// namespaces like `wasi:io/streams`, do not. +pub fn is_embedder_passthrough(import_name: &str) -> bool { + // Take the package portion (before the first '/'), then strip any + // package-level version suffix (`@x.y.z`). Exact-match the namespace so a + // longer prefix (`...embedderx`) can never false-positive. + let package = import_name.split('/').next().unwrap_or(import_name); + let package = package.split('@').next().unwrap_or(package); + package == EMBEDDER_PASSTHROUGH_NAMESPACE +} + /// Dependency resolver pub struct Resolver { /// Whether to allow unresolved imports @@ -2104,29 +2138,31 @@ impl Resolver { ) -> Result<()> { for (comp_idx, component) in components.iter().enumerate() { for import in &component.imports { - // Look for a matching export - if let Some(exports) = export_index.get(&import.name) { - // Check directed wiring hints first (from composition DAG) - let hinted = wiring_hints.get(&(comp_idx, import.name.clone())); - let resolved = if let Some(&target) = hinted { - exports.iter().find(|(idx, _)| *idx == target) - } else { - None - }; - // Fallback: first export from a different component - let resolved = - resolved.or_else(|| exports.iter().find(|(idx, _)| *idx != comp_idx)); - if let Some((export_comp_idx, _export)) = resolved { - graph.resolved_imports.insert( - (comp_idx, import.name.clone()), - (*export_comp_idx, import.name.clone()), - ); - } else if !self.allow_unresolved { - return Err(Error::UnresolvedImport { - module: "component".to_string(), - name: import.name.clone(), - }); - } + // Look for a matching export: directed wiring hint first (from + // the composition DAG), then the first export from a *different* + // component. + let resolved = export_index.get(&import.name).and_then(|exports| { + let hinted = wiring_hints + .get(&(comp_idx, import.name.clone())) + .and_then(|&target| exports.iter().find(|(idx, _)| *idx == target)); + hinted.or_else(|| exports.iter().find(|(idx, _)| *idx != comp_idx)) + }); + + if let Some((export_comp_idx, _export)) = resolved { + graph.resolved_imports.insert( + (comp_idx, import.name.clone()), + (*export_comp_idx, import.name.clone()), + ); + } else if is_embedder_passthrough(&import.name) { + // Embedder-provided seam (e.g. `pulseengine:embedder/arena`): + // satisfied downstream at native link / synth dissolve, never + // by a component in the fusion set. Recognize it explicitly so + // it is preserved as intentional passthrough and is NOT a + // resolution failure — even under strict resolution (#301). + log::debug!( + "embedder passthrough import preserved (not bound to a provider): {}", + import.name + ); } else if !self.allow_unresolved { return Err(Error::UnresolvedImport { module: "component".to_string(), @@ -4272,6 +4308,70 @@ mod tests { } } + /// #301: `is_embedder_passthrough` recognizes the embedder namespace by + /// *package*, version-tolerantly, with no prefix false-positives. + #[test] + fn test_is_embedder_passthrough_recognizes_namespace_only() { + // Recognized: the embedder namespace, with/without version suffixes on + // either the package or the interface. + assert!(is_embedder_passthrough("pulseengine:embedder/arena")); + assert!(is_embedder_passthrough("pulseengine:embedder/arena@0.1.0")); + assert!(is_embedder_passthrough("pulseengine:embedder@0.1.0/arena")); + assert!(is_embedder_passthrough("pulseengine:embedder/alloc")); + + // Not recognized: unrelated namespaces, prefix look-alikes (the exact + // namespace match must not be fooled by a longer/shorter package), and + // bare names. + assert!(!is_embedder_passthrough("wasi:io/streams@0.2.6")); + assert!(!is_embedder_passthrough("pulseengine:embedderx/foo")); + assert!(!is_embedder_passthrough("pulseengine:embed/foo")); + assert!(!is_embedder_passthrough("nonexistent-iface")); + assert!(!is_embedder_passthrough("env")); + } + + /// LS-R-17 / #301 / SC-9: an embedder-provided import (the + /// `pulseengine:embedder` namespace) is INTENTIONAL passthrough — + /// satisfied downstream at native link / synth dissolve — so it must + /// survive resolution as a recognized seam, NOT be bound to a coincidental + /// provider nor reported as an unresolved-import failure, even under strict + /// mode. Contrast `test_resolver_unresolved_import_error`: an ordinary + /// unexported import DOES still error under strict mode, so the recognition + /// is scoped to the embedder namespace and is not over-broad. + #[test] + fn ls_r_17_embedder_passthrough_survives_strict_resolution() { + // An embedder-namespace import with no provider must NOT error, even + // though strict mode rejects ordinary unresolved imports. + let components = vec![ + make_component(&["pulseengine:embedder/arena"], &[]), + make_component(&[], &["some-other-iface"]), + ]; + let result = Resolver::strict().resolve(&components); + assert!( + result.is_ok(), + "strict resolver must NOT reject an embedder-namespace passthrough import, got: {:?}", + result.err() + ); + + // Guard: a NON-embedder unexported import alongside the embedder one + // still errors under strict mode — the exemption is namespace-scoped. + let components = vec![ + make_component(&["pulseengine:embedder/arena", "nonexistent-iface"], &[]), + make_component(&[], &["some-other-iface"]), + ]; + let err = Resolver::strict() + .resolve(&components) + .expect_err("a non-embedder unexported import must still error under strict mode"); + match err { + Error::UnresolvedImport { name, .. } => assert_eq!( + name, "nonexistent-iface", + "the non-embedder import must be the one reported" + ), + other => { + panic!("expected UnresolvedImport for the non-embedder import, got: {other:?}") + } + } + } + /// SR-19 / LS-CP-2: Resolver order stability (determinism). /// /// Running the same resolution five times must produce an identical diff --git a/safety/stpa/loss-scenarios.yaml b/safety/stpa/loss-scenarios.yaml index fdfcfae..35b9121 100644 --- a/safety/stpa/loss-scenarios.yaml +++ b/safety/stpa/loss-scenarios.yaml @@ -4036,3 +4036,66 @@ loss-scenarios: open+empty; EOF 0 only after drop_writable AND drain; sticky). slot_exhaustion_falls_back_to_host_stream pins the untagged fallback direction (host ops are reached exactly once each). + + - id: LS-R-17 + title: Embedder-provided import mis-bound or rejected during fusion + uca: UCA-R-3 + hazards: [H-1, H-3.1] + type: inadequate-control-algorithm + scenario: > + The BYO-OS lean-MCU dissolve pipeline (gale#89) routes `cabi_realloc` + to an embedder-provided seam in the well-known `pulseengine:embedder` + WIT package namespace (e.g. `pulseengine:embedder/arena`'s + `__cabi_arena_realloc`), satisfied downstream at native link / synth + dissolve — NOT by any component in the fusion set. If the resolver + treats this seam as an ordinary import it can go wrong two ways. (a) + Mis-bind: a component that coincidentally exports a name colliding with + the seam is matched as its provider [UCA-R-3], wiring the fused core to + the wrong allocator — a trap on signature mismatch [H-1] or, if the + signatures align, a silently wrong allocation path [H-3.1]. (b) False + rejection / drop: a stricter resolver mode rejects the seam as an + unresolved import (or a future "aggressive resolve" pass drops it), + so the fused core can no longer allocate. Both stem from the seam + surviving only INCIDENTALLY (`allow_unresolved: true`) rather than + being recognized as intentional embedder passthrough. + causal-factors: + - >- + Embedder seams were preserved only incidentally (the fusion + resolver's `allow_unresolved: true`), with no positive recognition, + so a future strict/aggressive resolver could silently drop or reject + them + - >- + A bare magic-name contract (`cabi-arena-realloc`) would be + collision-prone (any component exporting that name captures the + seam) and invisible to review + process-model-flaw: > + The resolver's model was "every import is either bound to a provider in + the fusion set or is a (tolerated) generic unresolved import". Embedder + seams are a third, distinct category — provider-is-the-embedder, + resolved post-fuse — that must be recognized explicitly so it is never + bound to a fusion-set provider and never counts as a resolution failure. + status: approved + priority: medium + fix: > + resolver.rs adds the well-known `EMBEDDER_PASSTHROUGH_NAMESPACE` + (`pulseengine:embedder`) and `is_embedder_passthrough(import_name)`, + which matches by *package namespace* (version-tolerant, no prefix + false-positive) rather than the bare field name. The component-level + resolution path (`resolve_component_imports_with_hints`) recognizes + such imports as intentional passthrough: they are never bound to a + coincidental provider and are exempt from the strict-mode + `UnresolvedImport` error. Pinned by + `ls_r_17_embedder_passthrough_survives_strict_resolution` (strict + resolver does NOT reject a `pulseengine:embedder/arena` import, but a + non-embedder unexported import alongside it STILL errors — the + exemption is namespace-scoped, not over-broad), + `test_is_embedder_passthrough_recognizes_namespace_only` (namespace + match incl. version suffixes; rejects `pulseengine:embedderx/foo`, + `pulseengine:embed/foo`, `wasi:io/streams`), and the merger fixture + `test_298_fork_arena_realloc_fuses_under_shared_rebase_today`, which + now also asserts the retained `__cabi_arena_realloc` import's module is + recognized by `is_embedder_passthrough`. Core-module passthrough + already held (`allow_unresolved: true`); this makes it explicit and + robust to a future strict resolver. The `--output component` (P2 wrap) + exposure of embedder imports remains a tracked follow-up (needs a + `wasm-tools component new --import-passthrough` fixture).