diff --git a/CHANGELOG.md b/CHANGELOG.md index b5203c1..8566444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.21.0] - 2026-05-30 + +### Added + +- **Static resource-lifetime validation for async streams** (#142 (iv), + SR-34). `meld fuse` now rejects a fusion where a component declares a + `stream` / `future` whose element type is a resource handle. + `borrow` is a definite violation — the borrow cannot outlive its + lending call across the async boundary (use-after-scope); `own` is + flagged as the drop-while-referenced hazard #142 names. Surfaced as + `StreamValidationIssue::ResourceLifetime` → + `Error::StreamValidation`. Detection reuses + `ParsedComponent::resolve_to_resource` (the same `Type(idx)` → handle + resolution meld applies to function params), with the wasmparser + `ComponentValType::Type(N)` descriptor form pinned by a regression + test. New loss scenario **LS-R-14** (approved). Limitation: a handle + nested inside a composite element (`stream>>`) is not + flagged — the same boundary as `stream_elements_in_valtype`. + +### Notes + +- **#142 (ii) bounded-channel capacity is not applicable.** The + Component-Model canonical ABI has no bounded-channel / capacity + concept — `stream.new` takes no capacity and streams are unbounded by + construction — so there is nothing in the component binary to + validate. Documented in `p3_stream.rs` and LS-R-14; this closes the + #142 (i)–(iv) checklist (i/iii shipped in v0.13.0/v0.15.0, iv here, + ii not-applicable). + ## [0.20.0] - 2026-05-29 ### Changed diff --git a/Cargo.lock b/Cargo.lock index c5a0d3f..fc91262 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1381,7 +1381,7 @@ checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" [[package]] name = "meld-cli" -version = "0.20.0" +version = "0.21.0" dependencies = [ "anyhow", "clap", @@ -1396,7 +1396,7 @@ dependencies = [ [[package]] name = "meld-core" -version = "0.20.0" +version = "0.21.0" dependencies = [ "anyhow", "bitflags", diff --git a/Cargo.toml b/Cargo.toml index 956b8bd..3c2be9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ exclude = [ ] [workspace.package] -version = "0.20.0" +version = "0.21.0" authors = ["PulseEngine "] edition = "2024" license = "Apache-2.0" diff --git a/meld-core/src/p3_stream.rs b/meld-core/src/p3_stream.rs index 141d674..c5a6bfe 100644 --- a/meld-core/src/p3_stream.rs +++ b/meld-core/src/p3_stream.rs @@ -303,10 +303,22 @@ pub fn build_stream_pair_graph( // legal bidirectional-pipe pattern (two independent streams // in opposite directions, each individually acyclic). // -// Checks **(ii) bounded-channel capacity** and **(iv) resource -// lifetime across async boundaries** remain TODO — they need -// information beyond `CanonicalEntry` / `ParsedComponent` (capacity -// flags + per-handle lifetime tracking). +// (iv) Resource lifetime across async boundaries — a resource +// handle (`own` / `borrow`) carried as a `stream` / +// `future` element type. A `borrow` is only valid within +// its lending call, but a stream/future is read *after* that +// call returns across the async boundary → use-after-scope; an +// `own` transferred into a stream and then dropped by the +// producer while the consumer still holds it is the +// drop-while-referenced hazard #142 names. See +// [`resource_lifetime_issues`]. +// +// Check **(ii) bounded-channel capacity** is **not applicable**: the +// Component-Model canonical ABI has no bounded-channel / capacity +// concept — `stream.new` (`CanonicalEntry::StreamNew { ty }`) takes no +// capacity, and streams are unbounded by construction. There is nothing +// in the component binary to validate; "declare a capacity" presupposes +// an annotation mechanism the ABI does not provide (#142). /// A validation issue raised by [`validate_stream_pair_graph`]. #[derive(Debug, Clone, PartialEq, Eq)] @@ -329,6 +341,20 @@ pub enum StreamValidationIssue { /// ≥ 3. Could deadlock at runtime if every component is waiting on /// inbound stream data before producing outbound data. Cycle { component_cycle: Vec }, + /// Check (iv): a `stream` / `future` carries a resource handle + /// as its element type. `owned == false` is a `borrow` — + /// definitely invalid, the borrow cannot outlive its lending call + /// across the async boundary. `owned == true` is an `own` — + /// permitted only if the producer never drops the handle while the + /// consumer still references it (the drop-while-referenced hazard + /// meld cannot rule out statically). `descriptor` is the offending + /// `stream<…>` / `future<…>` type descriptor. + ResourceLifetime { + component: usize, + descriptor: String, + resource_type_id: u32, + owned: bool, + }, } /// Return every `stream` element type reachable from the given @@ -685,6 +711,64 @@ pub fn validate_stream_pair_graph( issues } +/// Check (iv): flag any `stream` / `future` whose element type is +/// a resource handle (`own` / `borrow`). +/// +/// The P3Async descriptor records the element type as the Debug form of +/// the wasmparser `ComponentValType`, so a handle element appears as +/// `stream` where component type index `N` resolves to a +/// `Defined(Own(R))` / `Defined(Borrow(R))`. We parse the `Type(N)` +/// index out and reuse [`ParsedComponent::resolve_to_resource`] — the +/// same `Type(idx)` → handle chase meld already applies to function +/// params — rather than trusting the Debug text for the handle kind. +/// +/// Limitations (acknowledged, not targets): only a *direct* handle +/// element is detected. A handle nested inside a composite element +/// (`stream>>` → `stream`) is not flagged, the +/// same boundary as [`stream_elements_in_valtype`]. +pub fn resource_lifetime_issues(components: &[ParsedComponent]) -> Vec { + let mut issues = Vec::new(); + for (ci, comp) in components.iter().enumerate() { + for ty in &comp.types { + let ComponentTypeKind::P3Async(desc) = &ty.kind else { + continue; + }; + let Some(inner) = desc + .strip_prefix("stream<") + .or_else(|| desc.strip_prefix("future<")) + .and_then(|s| s.strip_suffix('>')) + else { + continue; + }; + let Some(idx) = parse_type_index(inner.trim()) else { + continue; + }; + if let Some((resource_type_id, owned)) = + comp.resolve_to_resource(&ComponentValType::Type(idx)) + { + issues.push(StreamValidationIssue::ResourceLifetime { + component: ci, + descriptor: desc.clone(), + resource_type_id, + owned, + }); + } + } + } + issues +} + +/// Parse the wasmparser `ComponentValType::Type(N)` Debug form +/// (`"Type(N)"`) back to its index. Returns `None` for any other shape +/// (e.g. `"Primitive(U8)"`, `"List(..)"`). +fn parse_type_index(s: &str) -> Option { + s.strip_prefix("Type(")? + .strip_suffix(')')? + .trim() + .parse() + .ok() +} + /// Cycle-only sub-pass — exposed so tests that only care about (iii) /// don't have to construct `ParsedComponent` fixtures. pub fn cycle_issues_from_pairs(graph: &StreamPairGraph) -> Vec { @@ -1544,4 +1628,98 @@ mod tests { "matching stream types on resolved edge must not raise; got {issues:?}" ); } + + // ─── (iv) resource lifetime across async boundaries ────────────── + + /// Pin the wasmparser `ComponentValType::Type(N)` Debug form that + /// `parse_type_index` depends on. If a wasmparser upgrade changes + /// this, the resource-lifetime check would silently stop detecting + /// handle elements — this test fails first. + #[test] + fn wasmparser_type_debug_form_is_stable() { + assert_eq!( + format!("{:?}", wasmparser::ComponentValType::Type(7)), + "Type(7)" + ); + assert_eq!(parse_type_index("Type(7)"), Some(7)); + assert_eq!(parse_type_index("Primitive(U8)"), None); + assert_eq!(parse_type_index("List(..)"), None); + } + + /// Build a component whose type table is all defined types, with a + /// matching all-`Defined` `component_type_defs` so + /// `get_type_definition(idx)` (and thus `resolve_to_resource`) + /// resolves `Type(idx)` to `types[idx]`. + fn comp_with_defined_types(types: Vec) -> ParsedComponent { + let defs = vec![crate::parser::ComponentTypeDef::Defined; types.len()]; + let mut c = make_component(vec![], vec![], types, vec![]); + c.component_type_defs = defs; + c + } + + fn defined(vt: ComponentValType) -> ComponentType { + ComponentType { + kind: ComponentTypeKind::Defined(vt), + } + } + + #[test] + fn ls_r_14_borrow_handle_in_stream_flagged() { + // types[0] = stream; types[1] = borrow. + let comp = comp_with_defined_types(vec![ + stream_type("Type(1)"), + defined(ComponentValType::Borrow(42)), + ]); + let issues = resource_lifetime_issues(&[comp]); + assert_eq!(issues.len(), 1, "borrow-in-stream must flag: {issues:?}"); + match &issues[0] { + StreamValidationIssue::ResourceLifetime { + component, + resource_type_id, + owned, + .. + } => { + assert_eq!(*component, 0); + assert_eq!(*resource_type_id, 42); + assert!(!owned, "borrow ⇒ owned = false"); + } + other => panic!("expected ResourceLifetime, got {other:?}"), + } + } + + #[test] + fn ls_r_14_own_handle_in_future_flagged_as_owned() { + // types[0] = future; types[1] = own. + let future_ty = ComponentType { + kind: ComponentTypeKind::P3Async("future".to_string()), + }; + let comp = comp_with_defined_types(vec![future_ty, defined(ComponentValType::Own(5))]); + let issues = resource_lifetime_issues(&[comp]); + assert_eq!(issues.len(), 1, "own-in-future must flag: {issues:?}"); + assert!( + matches!( + &issues[0], + StreamValidationIssue::ResourceLifetime { + owned: true, + resource_type_id: 5, + .. + } + ), + "own ⇒ owned = true; got {:?}", + issues[0] + ); + } + + #[test] + fn ls_r_14_primitive_element_stream_not_flagged() { + // A plain data stream carries no handle — must not flag. + let comp = comp_with_defined_types(vec![ + stream_type("Primitive(U8)"), + defined(ComponentValType::Primitive(PrimitiveValType::U8)), + ]); + assert!( + resource_lifetime_issues(&[comp]).is_empty(), + "primitive-element stream must not flag (iv)" + ); + } } diff --git a/meld-core/src/parser.rs b/meld-core/src/parser.rs index 9aae1fd..3972d06 100644 --- a/meld-core/src/parser.rs +++ b/meld-core/src/parser.rs @@ -1948,7 +1948,7 @@ impl ParsedComponent { /// /// Returns `Some((resource_type_id, is_owned))` for `Own(T)`, `Borrow(T)`, /// and `Type(idx)` that resolves to a `Defined(Own(T))` or `Defined(Borrow(T))`. - fn resolve_to_resource(&self, ty: &ComponentValType) -> Option<(u32, bool)> { + pub(crate) fn resolve_to_resource(&self, ty: &ComponentValType) -> Option<(u32, bool)> { match ty { ComponentValType::Own(id) => Some((*id, true)), ComponentValType::Borrow(id) => Some((*id, false)), diff --git a/meld-core/src/resolver.rs b/meld-core/src/resolver.rs index c57b360..9c1c7e2 100644 --- a/meld-core/src/resolver.rs +++ b/meld-core/src/resolver.rs @@ -1563,15 +1563,26 @@ impl Resolver { )); // Issue #142: static stream validation. Catches dataflow cycles - // (SCC ≥ 3 in the producer→consumer graph) and type-mismatches - // on stream-typed import edges. See the module comment in - // p3_stream.rs for the precision boundary on (i). - if let Some(spg) = graph.stream_pair_graph.as_ref() { - let issues = crate::p3_stream::validate_stream_pair_graph( - components, - &graph.resolved_imports, - spg, - ); + // (SCC ≥ 3 in the producer→consumer graph), type-mismatches on + // stream-typed import edges (i/iii), and resource handles + // carried as stream/future element types (iv). See the module + // comment in p3_stream.rs for the precision boundary on (i) and + // why (ii) bounded-channel capacity is not applicable. + { + let mut issues = graph + .stream_pair_graph + .as_ref() + .map(|spg| { + crate::p3_stream::validate_stream_pair_graph( + components, + &graph.resolved_imports, + spg, + ) + }) + .unwrap_or_default(); + // (iv) resource lifetime — scans component types directly, so + // it runs even when no cross-component stream pairs were found. + issues.extend(crate::p3_stream::resource_lifetime_issues(components)); if !issues.is_empty() { let mut lines = Vec::with_capacity(issues.len()); for issue in &issues { @@ -1591,6 +1602,17 @@ impl Resolver { " · cycle: components {component_cycle:?} form a closed stream-pair loop (SCC size ≥ 3)" )); } + crate::p3_stream::StreamValidationIssue::ResourceLifetime { + component, + descriptor, + resource_type_id, + owned, + } => { + let kind = if *owned { "own" } else { "borrow" }; + lines.push(format!( + " · resource lifetime: component {component} carries a {kind} handle as a `{descriptor}` element — a handle's lifetime cannot be guaranteed across the async stream/future boundary (#142 iv)" + )); + } } } return Err(crate::error::Error::StreamValidation(lines.join("\n"))); diff --git a/safety/stpa/loss-scenarios.yaml b/safety/stpa/loss-scenarios.yaml index 662279b..27d4286 100644 --- a/safety/stpa/loss-scenarios.yaml +++ b/safety/stpa/loss-scenarios.yaml @@ -1486,6 +1486,78 @@ loss-scenarios: plus negative test `bidirectional_pipe_is_not_flagged_as_cycle`. + - id: LS-R-14 + title: Resource handle carried across an async stream/future boundary + uca: UCA-R-5 + hazards: [H-1, H-3] + type: inadequate-control-algorithm + scenario: > + A component declares a `stream` or `future` whose element + type `T` is a resource handle — `borrow` or `own`. A + `borrow` handle is valid only for the duration of the call that + received it, but a stream/future is read by the consumer *after* + the producing call has returned, across the async boundary: the + borrow is dereferenced after its lending scope ended — a + use-after-scope on the resource's representation [H-3], and any + witness/sigil claim about that handle's validity is de-grounded + [H-1]. An `own` transferred into a stream is the + drop-while-referenced hazard #142 (iv) names: if the producer + drops the handle while the consumer still holds it via the stream, + the representation is freed under the consumer. Meld cannot rule + out the drop discipline statically, so it surfaces the structural + precondition (a handle as a stream/future element) and flags + `borrow` as a definite violation, `own` as one to verify. + causal-factors: + - >- + The component-model type system permits a `stream` / + `future` element to be any value type, including a resource + handle, with no validator rejecting borrow-in-stream + - >- + Resource handles surface in the parsed stream descriptor only + indirectly, as `stream` where component type index N + resolves to a `Defined(Own/Borrow)` — so a naive descriptor + scan that looks only for primitive element names misses them + - >- + The hazard is invisible at fusion time and at producer runtime; + it manifests only when the consumer dereferences the handle + after the producer's scope/ownership ended + process-model-flaw: > + The model treated a stream's element type as plain data that is + copied through the ring buffer. Resource handles are not plain + data — they are scoped (`borrow`) or singly-owned (`own`) + capabilities whose validity is tied to a call or an ownership + chain that the async stream boundary breaks. + status: approved + priority: medium + fix: > + `p3_stream::resource_lifetime_issues` (#142 (iv)). Scans every + component's `stream` / `future` type, parses the + `Type(N)` element index out of the descriptor (the wasmparser + `ComponentValType::Type(N)` Debug form, pinned by + `wasmparser_type_debug_form_is_stable`), and reuses + `ParsedComponent::resolve_to_resource` — the same `Type(idx)` → + handle chase meld applies to function params — to classify the + element as `borrow` or `own`. Emits + `StreamValidationIssue::ResourceLifetime`, surfaced as an + `Error::StreamValidation`. Confirmed by + `ls_r_14_borrow_handle_in_stream_flagged`, + `ls_r_14_own_handle_in_future_flagged_as_owned`, and negative + test `ls_r_14_primitive_element_stream_not_flagged`. Limitation + (acknowledged): a handle nested inside a composite element + (`stream>>`) is not flagged — same boundary as + `stream_elements_in_valtype`. + ### + ### #142 (ii) bounded-channel capacity — NOT APPLICABLE + ### + The Component-Model canonical ABI has no bounded-channel / + capacity concept: `stream.new` (`CanonicalEntry::StreamNew { ty }`) + takes no capacity argument and streams are unbounded by + construction. There is nothing in the component binary to + validate — "components that opt into bounded channels but don't + declare capacity" presupposes a capacity-annotation mechanism the + ABI does not provide. (ii) is therefore closed as not-applicable + rather than implemented; documented in `p3_stream.rs`. + # ========================================================================== # Merger scenarios # ==========================================================================