Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed

- **Stale `reloc.CODE` offsets no longer silently miscompile shared-memory
fusion (#351, soundness backstop).** A producer that relaxes address
immediates from 5-byte-padded to minimal LEB128 without rewriting `reloc.*`
offsets in lockstep (clang 22.1.4 + wit-component 0.245.1; upstream
pulseengine/wasm-tools#3) leaves each reloc site drifted by +2 bytes per
preceding memory-address reloc. Under `--memory shared --address-rebase` a
drifted site could land past its operator and be silently skipped — e.g. a
`ptr = &data[0]` `i32.const` left un-rebased, aliasing another component's
memory (grounded runtime bug on v0.41.1: `ptr-b` returned `65536` instead of
the rebased `196608`). meld now verifies every `R_WASM_MEMORY_ADDR_*` site
lands on a rebasable immediate and hard-fails with `MisalignedReloc` rather
than emit a plausible-but-wrong module. **Falsification:**
`test_351_stale_reloc_offsets_hard_error` fuses the exact reproducing
components and asserts the hard error at code offset 42; the working-path
oracle (consistent relocs) still rebases correctly. Drift-tolerant *correct*
rebasing (so such inputs fuse rather than fail) is tracked as a follow-up.

## [0.41.1] - 2026-07-15

Reproducibility patch: `--reproducible` output no longer depends on the input
Expand Down
25 changes: 25 additions & 0 deletions meld-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,31 @@ pub enum Error {
module: String,
},

/// A `reloc.CODE` memory-address relocation site does not land on a
/// rebasable immediate in the emitted code (issue #351). This is the
/// signature of stale relocation offsets — e.g. a producer that relaxed
/// address immediates from 5-byte-padded to minimal LEB128 without
/// rewriting `reloc.*` offsets in lockstep (pulseengine/wasm-tools#3),
/// leaving each site drifted by the accumulated width reduction. meld
/// cannot safely rebase from misaligned relocs: applying them at the wrong
/// site (or silently skipping a drifted one) corrupts the shared address
/// space. Hard-fail rather than emit a plausible-but-wrong module.
#[error(
"component '{component}' module {module}: relocation site at code offset {offset} \
(reloc.CODE) does not land on a rebasable address immediate — the relocation \
metadata is stale relative to the emitted code (a producer relaxed LEB immediates \
without updating reloc offsets; see pulseengine/wasm-tools#3 and meld#351). \
Cannot rebase safely under `--memory shared --address-rebase`"
)]
MisalignedReloc {
/// Display name of the component that owns the offending module.
component: String,
/// The offending module's index within its component.
module: String,
/// The stale reloc.CODE code-content offset that failed to align.
offset: u32,
},

/// Adapter generation error
#[error("adapter generation failed: {0}")]
AdapterGeneration(String),
Expand Down
21 changes: 20 additions & 1 deletion meld-core/src/merger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1952,7 +1952,26 @@ impl Merger {
)));
}
data_addr_relocs = info.data_memory_addr_entries();
Some(info.code_memory_addr_offsets())
let code_offsets = info.code_memory_addr_offsets();
// #351 backstop: verify every reloc.CODE memory-address site
// still lands on a rebasable immediate. A producer that relaxed
// LEB immediates without rewriting reloc offsets
// (pulseengine/wasm-tools#3) leaves them drifted; a drifted site
// is applied to the wrong operator or silently skipped, so
// hard-fail rather than corrupt the shared address space.
if let Some((start, end)) = module.code_section_range {
let code_content = &module.bytes[start..end];
if let Some(offset) =
crate::reloc::first_misaligned_code_reloc(code_content, &code_offsets)?
{
return Err(Error::MisalignedReloc {
component: component_display_name(components, comp_idx),
module: mod_idx.to_string(),
offset,
});
}
}
Some(code_offsets)
} else {
None
}
Expand Down
179 changes: 179 additions & 0 deletions meld-core/src/reloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,9 @@ pub enum RelocError {
/// The version byte that was found.
found: u32,
},
/// Failed to parse the code section while validating reloc alignment (#351).
#[error("failed to parse code section during reloc-alignment check: {0}")]
CodeParse(String),
}

impl From<RelocError> for Error {
Expand Down Expand Up @@ -528,6 +531,124 @@ fn parse_reloc_section(suffix: &str, body: &[u8]) -> Result<RelocSection> {
})
}

/// Verify that every `reloc.CODE` memory-address site in `code_addr_offsets`
/// lands on a *rebasable* operator's immediate in the emitted code section
/// `code_content` (the bytes a `CodeSectionReader` consumes — the same
/// coordinate space [`RelocInfo::code_memory_addr_offsets`] reports).
///
/// Returns `Some(offset)` for the first site that does NOT fall inside a
/// rebasable operator (an `i32.const`/`i64.const` address literal or a
/// load/store `memarg`), i.e. the first *misaligned* reloc. `None` means every
/// site aligns.
///
/// **Why this matters (#351).** The reloc consumer rebases the operator a site
/// lands on. If a producer relaxes address immediates (5-byte-padded → minimal
/// LEB128) without rewriting `reloc.*` offsets in lockstep
/// (pulseengine/wasm-tools#3), every site drifts by the accumulated width
/// reduction. When a site drifts *past* its operator entirely (as with the
/// trailing `i32.const` in meld#351) the rebase is silently skipped, corrupting
/// the shared address space. Detecting a site that no longer lands on a
/// rebasable immediate lets the caller hard-fail instead of emitting a wrong
/// module.
///
/// A site is "rebasable-aligned" when it falls within the byte range
/// `[op_start, op_end)` of a rebasable operator. This is exactly the window the
/// rewriter's `old_pos <= r < op_end` test uses to set `addr_reloc`, so a site
/// this function accepts is one the rewriter will actually rebase.
///
/// **Scope / known limitation (backstop, not a full guarantee).** This catches
/// the *drift-past-operator* case — a site outside every rebasable range. It
/// does NOT catch *drift-into-an-adjacent-rebasable-operator*: if drift shifts a
/// site off its intended immediate but into a neighbouring `i32.const`/`memarg`
/// range, this returns `None` and the rewriter rebases the wrong (but still
/// rebasable) immediate. Distinguishing that from a legitimately-placed site is
/// undecidable from `(code_content, code_addr_offsets)` alone — it needs the
/// reloc's *intended* target (symbol-resolved address / value confirmation),
/// which the drift-tolerant tier-2 fix adds. So this function is a soundness
/// *backstop* that converts the observed #351 silent corruption into a loud
/// error, not a complete alignment verifier.
pub fn first_misaligned_code_reloc(
code_content: &[u8],
code_addr_offsets: &std::collections::HashSet<u32>,
) -> Result<Option<u32>> {
if code_addr_offsets.is_empty() {
return Ok(None);
}
// Collect the byte ranges of every rebasable operator, in one pass.
let mut rebasable_ranges: Vec<(usize, usize)> = Vec::new();
let reader = wasmparser::CodeSectionReader::new(wasmparser::BinaryReader::new(code_content, 0))
.map_err(|e| RelocError::CodeParse(e.to_string()))?;
for body in reader {
let body = body.map_err(|e| RelocError::CodeParse(e.to_string()))?;
let ops = body
.get_operators_reader()
.map_err(|e| RelocError::CodeParse(e.to_string()))?;
let items: Vec<(wasmparser::Operator<'_>, usize)> = ops
.into_iter_with_offsets()
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| RelocError::CodeParse(e.to_string()))?;
let ends = items
.iter()
.skip(1)
.map(|(_, pos)| *pos)
.chain(std::iter::once(code_content.len()));
for ((op, start), end) in items.iter().zip(ends) {
if operator_is_rebasable(op) {
rebasable_ranges.push((*start, end));
}
}
}
// A site aligns iff it falls inside some rebasable operator's range.
let mut misaligned: Vec<u32> = code_addr_offsets
.iter()
.copied()
.filter(|&r| {
let r = r as usize;
!rebasable_ranges
.iter()
.any(|&(start, end)| r >= start && r < end)
})
.collect();
misaligned.sort_unstable();
Ok(misaligned.first().copied())
}

/// Whether a memory-address relocation can be applied to this operator — an
/// `i32`/`i64` constant (an absolute address literal) or a memory load/store
/// (whose `memarg` offset can encode an absolute address). These are exactly
/// the arms the rewriter rebases when `addr_reloc` is set.
fn operator_is_rebasable(op: &wasmparser::Operator<'_>) -> bool {
use wasmparser::Operator::*;
matches!(
op,
I32Const { .. }
| I64Const { .. }
| I32Load { .. }
| I64Load { .. }
| F32Load { .. }
| F64Load { .. }
| I32Load8S { .. }
| I32Load8U { .. }
| I32Load16S { .. }
| I32Load16U { .. }
| I64Load8S { .. }
| I64Load8U { .. }
| I64Load16S { .. }
| I64Load16U { .. }
| I64Load32S { .. }
| I64Load32U { .. }
| I32Store { .. }
| I64Store { .. }
| F32Store { .. }
| F64Store { .. }
| I32Store8 { .. }
| I32Store16 { .. }
| I64Store8 { .. }
| I64Store16 { .. }
| I64Store32 { .. }
)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -898,4 +1019,62 @@ mod tests {
rebase_data_segment_pointers(&mut payload, 0, &entries, 0x1_0000);
assert_eq!(payload, vec![1u8, 0, 0, 0], "out-of-range site untouched");
}

/// #351: a reloc site landing on a rebasable operator's immediate aligns;
/// one drifted onto a non-rebasable operator (or past the code) is caught.
#[test]
fn first_misaligned_code_reloc_detects_drift() {
use std::collections::HashSet;
// Code-section content: one function `(i32.const 65536) end`.
// @0 count=1 @1 body_size=6 @2 locals=0
// @3 i32.const opcode @4..6 operand (0x80 0x80 0x04) @7 end
let code: &[u8] = &[0x01, 0x06, 0x00, 0x41, 0x80, 0x80, 0x04, 0x0b];

// A site on the i32.const immediate (or its opcode range) aligns.
let aligned: HashSet<u32> = [4u32].into_iter().collect();
assert_eq!(first_misaligned_code_reloc(code, &aligned).unwrap(), None);

// Drifted onto the (non-rebasable) `end` operator → caught.
let onto_end: HashSet<u32> = [7u32].into_iter().collect();
assert_eq!(
first_misaligned_code_reloc(code, &onto_end).unwrap(),
Some(7)
);

// Drifted past the end of the code section entirely → caught (this is
// exactly the meld#351 shape: SLEB site one byte past its operator).
let past: HashSet<u32> = [8u32].into_iter().collect();
assert_eq!(first_misaligned_code_reloc(code, &past).unwrap(), Some(8));

// No sites → trivially aligned.
assert_eq!(
first_misaligned_code_reloc(code, &HashSet::new()).unwrap(),
None
);
}

/// Documents the KNOWN LIMITATION of this backstop (see the fn docs): it
/// catches drift *past* an operator, but NOT drift *into an adjacent
/// rebasable operator*. Three consecutive `i32.const`; a site intended for
/// the middle const's immediate (@6) that drifts +2 to @8 lands inside the
/// THIRD const's range `[7,9)`, so the backstop returns `None` (accepts) even
/// though the rewriter would then rebase the wrong const. This is undecidable
/// from `(code, offsets)` alone and is closed by the drift-tolerant tier-2
/// fix; the test pins the boundary so the backstop is not mistaken for a
/// complete alignment verifier.
#[test]
fn first_misaligned_code_reloc_adjacent_drift_is_a_known_gap() {
use std::collections::HashSet;
// @0 count=1 @1 body_size=8 @2 locals=0
// @3 i32.const opcode @4 imm @5 opcode @6 imm @7 opcode @8 imm @9 end
let code: &[u8] = &[0x01, 0x08, 0x00, 0x41, 0x01, 0x41, 0x01, 0x41, 0x01, 0x0b];
// A site drifted from the middle const (@6) into the third const (@8)
// is NOT caught — the documented gap.
let adjacent_drift: HashSet<u32> = [8u32].into_iter().collect();
assert_eq!(
first_misaligned_code_reloc(code, &adjacent_drift).unwrap(),
None,
"adjacent-operator drift is a known backstop gap (tier-2 closes it)"
);
}
}
46 changes: 46 additions & 0 deletions meld-core/tests/rebasing_end_to_end.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,3 +564,49 @@ fn test_326_shared_rebase_without_relocs_hard_errors() {
"expected MissingRelocMetadata, got: {err:?}"
);
}

/// #351 backstop: real-world components whose `reloc.CODE` offsets are STALE
/// relative to the emitted (minimal-LEB) code — produced by clang 22.1.4 +
/// wit-component 0.245.1, which relaxed the address immediates 5-byte→3-byte
/// without rewriting the reloc offsets (drift +2 per preceding memory-address
/// reloc; upstream pulseengine/wasm-tools#3).
///
/// The trailing `ptr-b` `i32.const` site (`SLEB@42`) drifts PAST its operator's
/// byte range, so pre-backstop meld silently left it un-rebased and `ptr-b`
/// returned `0x10000` (aliasing component-a's window) — grounded runtime bug on
/// v0.41.1. meld must now hard-fail with `MisalignedReloc` rather than emit a
/// plausible-but-wrong module. Fixtures are the exact bytes from the issue
/// (`b.wasm` sha256 `481c36…d3a3`).
#[test]
fn test_351_stale_reloc_offsets_hard_error() {
let component_a =
std::fs::read("../tests/reloc351/a.wasm").expect("meld#351 fixture a.wasm missing");
let component_b =
std::fs::read("../tests/reloc351/b.wasm").expect("meld#351 fixture b.wasm missing");

let config = FuserConfig {
memory_strategy: MemoryStrategy::SharedMemory,
address_rebasing: true,
..Default::default()
};
let mut fuser = Fuser::new(config);
fuser
.add_component_named(&component_a, Some("a.wasm"))
.unwrap();
fuser
.add_component_named(&component_b, Some("b.wasm"))
.unwrap();

let err = fuser
.fuse()
.expect_err("stale-reloc module must hard-fail, not silently miscompile");
match err {
meld_core::Error::MisalignedReloc { offset, .. } => {
assert_eq!(
offset, 42,
"the drifted ptr-b SLEB site is at code offset 42"
);
}
other => panic!("expected MisalignedReloc at offset 42, got: {other:?}"),
}
}
56 changes: 56 additions & 0 deletions safety/requirements/safety-requirements.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1917,3 +1917,59 @@ artifacts:
(default attestation descriptor, wsc `InputArtifact`, provenance
`component_id`).

- id: SR-53
type: sw-req
title: Stale reloc.CODE offsets hard-fail rather than silently miscompile
description: >
Under `--memory shared --address-rebase`, when consuming an input's
`reloc.CODE` to rebase memory-address sites, meld shall verify that every
`R_WASM_MEMORY_ADDR_*` site lands on a rebasable operator immediate
(an `i32`/`i64` const or a load/store `memarg`) in the emitted code. If any
site does not, the relocation metadata is stale relative to the code — the
signature of a producer that relaxed address immediates from 5-byte-padded
to minimal LEB128 without rewriting the reloc offsets in lockstep (drift
accumulates +2 bytes per preceding memory-address reloc;
pulseengine/wasm-tools#3). When a site drifts PAST its operator entirely it
is silently skipped, corrupting the shared address space (the observed
meld#351 shape). meld shall hard-fail with `MisalignedReloc` rather than
emit a plausible-but-wrong module (#351). SCOPE: this backstop catches the
drift-past-operator case (site outside every rebasable immediate); it does
NOT catch drift INTO an adjacent rebasable operator (undecidable from the
code + reloc offsets alone — needs symbol-resolved value confirmation).
Drift-tolerant CORRECT rebasing that closes the adjacent case and lets such
inputs fuse is the tracked tier-2 follow-up.
status: verified
tags: [reloc, rebasing, shared-memory, miscompile, correctness, v0.41.2]
links:
- type: derives-from
target: SYS-1
- type: mitigates
target: LS-D-1
cited-source:
- uri: "https://github.com/pulseengine/meld/issues/351"
kind: github
last-checked: 2026-07-16
- uri: "https://github.com/pulseengine/wasm-tools/issues/3"
kind: github
last-checked: 2026-07-16
release: v0.41.2
fields:
implementation:
- meld-core/src/reloc.rs
- meld-core/src/merger.rs
- meld-core/src/error.rs
verification-method: test
verification-description: >
VERIFIED. meld-core/tests/rebasing_end_to_end.rs
`test_351_stale_reloc_offsets_hard_error`: the exact reproducing
components from the issue (clang 22.1.4 + wit-component 0.245.1; b.wasm
sha256 481c36…d3a3), whose trailing `ptr-b` SLEB site drifts +4 to code
offset 42 (past its `i32.const` operator range [37,41)), now fuse to a
hard `MisalignedReloc { offset: 42 }` instead of the pre-fix silent
miscompile (`ptr-b` returned 65536, aliasing component-a, verified vs
wasmtime on v0.41.1). Unit oracle `reloc::first_misaligned_code_reloc_detects_drift`:
a site on a rebasable immediate aligns; one drifted onto the `end`
operator or past the code section is caught. Negative: the working-path
oracle `test_326_reloc_const_rebasing_end_to_end` (consistent relocs) and
a real clang-22.1.8 consistent-reloc fixture still rebase correctly
(ptr_b = 196608), so the backstop does not over-reject valid modules.
Loading
Loading