Skip to content

Commit ae4984e

Browse files
authored
Backport #6820 to testnet_conway: decode all zstd frames when decompressing bytecodes on wasm32 (#6821)
Backport of #6820. ## Motivation The web wallet decompresses bytecodes with `ruzstd`, whose `StreamingDecoder` decodes a single zstd frame and then keeps returning `Ok(0)`. Our `wasm32` code did not account for that, as noted in #2710: * `decompress` looped `while !decoder.get_ref().is_empty()`, so a bytecode made of more than one frame made it **loop forever**, freezing the tab, instead of merely decoding the first frame. * `decompressed_size_at_most` measured the first frame only, so it underestimated the size of a multi-frame bytecode. Validators use the `zstd` crate, which accepts several concatenated frames, both for the size check in `linera-execution/src/policy.rs` and for decompression in `linera-storage/src/lib.rs`. Nothing that we compress produces multiple frames, but nothing rejects them either: a module published as two frames is valid to a validator and hangs every web client that loads it. ## Proposal Decode frame by frame on `wasm32`, creating a `StreamingDecoder` per frame and stepping over skippable frames using the length in their header, which is what `zstd` does. The two targets now accept the same bytecodes, so this is not a change in validation. The frame loop is shared by `decompress` and `decompressed_size_at_most`, and replaces the `TODO(#2710)` comments in both. Also drops the two `#[cfg(with_metrics)]` blocks from the `wasm32` impl: `with_metrics` is defined as `all(not(target_arch = "wasm32"), feature = "metrics")`, so they were dead code, and the one in `decompress` referred to an unqualified `BYTECODE_DECOMPRESSION_LATENCY` that would not have compiled had it ever been enabled. ## Test Plan Cherry-picked from #6820 with no conflicts, then rechecked against this branch's Rust 1.86 toolchain: `cargo test -p linera-base`, `cargo clippy -p linera-base --all-targets --all-features -- -D warnings`, `cargo clippy -p linera-base --target wasm32-unknown-unknown -- -D warnings`, and `cargo fmt --check` on `nightly-2025-04-03`. The three unit tests that come with the fix cover a bytecode of two frames followed by a skippable frame: all frames are decompressed, all frames count towards the size limit, and trailing garbage is an error rather than a hang. ## Release Plan - These changes should be backported to the latest `testnet` branch, then - be released in a new SDK. ## Links - #6820 - #2710 - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent c0db6d1 commit ae4984e

2 files changed

Lines changed: 132 additions & 24 deletions

File tree

linera-base/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ linera-witty = { workspace = true, features = ["test"] }
114114
tempfile.workspace = true
115115
test-case.workspace = true
116116

117+
# `ruzstd` is the decompressor used on `wasm32`; it is a dev-dependency here so that its
118+
# frame handling is also covered by the tests running on other targets.
119+
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
120+
ruzstd.workspace = true
121+
117122
[build-dependencies]
118123
cfg_aliases.workspace = true
119124

linera-base/src/data_types.rs

Lines changed: 127 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1542,21 +1542,52 @@ impl CompressedBytecode {
15421542
}
15431543
}
15441544

1545+
/// Decompresses all the zstd frames in `compressed_bytes`, writing the result to `writer`.
1546+
///
1547+
/// A [`StreamingDecoder`](ruzstd::decoding::StreamingDecoder) decodes a single frame, so one is
1548+
/// created per frame, and skippable frames are stepped over using the length in their header.
1549+
#[cfg(any(target_arch = "wasm32", test))]
1550+
fn decompress_frames(
1551+
mut compressed_bytes: &[u8],
1552+
writer: &mut impl io::Write,
1553+
) -> Result<(), io::Error> {
1554+
use ruzstd::decoding::{
1555+
errors::{FrameDecoderError, ReadFrameHeaderError},
1556+
StreamingDecoder,
1557+
};
1558+
1559+
while !compressed_bytes.is_empty() {
1560+
match StreamingDecoder::new(&mut compressed_bytes) {
1561+
Ok(mut decoder) => {
1562+
io::copy(&mut decoder, writer)?;
1563+
}
1564+
Err(FrameDecoderError::ReadFrameHeaderError(ReadFrameHeaderError::SkipFrame {
1565+
length,
1566+
..
1567+
})) => {
1568+
compressed_bytes = compressed_bytes
1569+
.get(length as usize..)
1570+
.ok_or_else(|| io::Error::other("Truncated skippable frame"))?;
1571+
}
1572+
Err(error) => return Err(io::Error::other(error)),
1573+
}
1574+
}
1575+
1576+
Ok(())
1577+
}
1578+
15451579
#[cfg(target_arch = "wasm32")]
15461580
impl CompressedBytecode {
15471581
/// Returns `true` if the decompressed size does not exceed the limit.
15481582
pub fn decompressed_size_at_most(
15491583
compressed_bytes: &[u8],
15501584
limit: u64,
15511585
) -> Result<bool, DecompressionError> {
1552-
use ruzstd::decoding::StreamingDecoder;
15531586
let limit = usize::try_from(limit).unwrap_or(usize::MAX);
15541587
let mut writer = LimitedWriter::new(io::sink(), limit);
1555-
let mut decoder = StreamingDecoder::new(compressed_bytes).map_err(io::Error::other)?;
15561588

1557-
// TODO(#2710): Decode multiple frames, if present
1558-
match io::copy(&mut decoder, &mut writer) {
1559-
Ok(_) => Ok(true),
1589+
match decompress_frames(compressed_bytes, &mut writer) {
1590+
Ok(()) => Ok(true),
15601591
Err(error) => {
15611592
error.downcast::<LimitedWriterError>()?;
15621593
Ok(false)
@@ -1566,26 +1597,8 @@ impl CompressedBytecode {
15661597

15671598
/// Decompresses a [`CompressedBytecode`] into a [`Bytecode`].
15681599
pub fn decompress(&self) -> Result<Bytecode, DecompressionError> {
1569-
use ruzstd::{decoding::StreamingDecoder, io::Read};
1570-
1571-
#[cfg(with_metrics)]
1572-
let _decompression_latency = BYTECODE_DECOMPRESSION_LATENCY.measure_latency();
1573-
1574-
let compressed_bytes = &*self.compressed_bytes;
15751600
let mut bytes = Vec::new();
1576-
let mut decoder = StreamingDecoder::new(&**compressed_bytes).map_err(io::Error::other)?;
1577-
1578-
// TODO(#2710): Decode multiple frames, if present
1579-
while !decoder.get_ref().is_empty() {
1580-
decoder
1581-
.read_to_end(&mut bytes)
1582-
.expect("Reading from a slice in memory should not result in I/O errors");
1583-
}
1584-
1585-
#[cfg(with_metrics)]
1586-
BYTECODE_DECOMPRESSED_SIZE_BYTES
1587-
.with_label_values(&[])
1588-
.observe(bytes.len() as f64);
1601+
decompress_frames(&self.compressed_bytes, &mut bytes)?;
15891602

15901603
Ok(Bytecode { bytes })
15911604
}
@@ -2220,4 +2233,94 @@ mod tests {
22202233
serde_json::from_value(serde_json::Value::String(hex.to_owned())).unwrap();
22212234
assert_eq!(roundtrip, module_id);
22222235
}
2236+
2237+
/// Tests for [`decompress_frames`], which is what `wasm32` targets decompress bytecodes
2238+
/// with. They run everywhere else, where `zstd` is available to compress the inputs and to
2239+
/// compare against.
2240+
#[cfg(not(target_arch = "wasm32"))]
2241+
mod compression {
2242+
use std::{io, sync::Arc};
2243+
2244+
use super::super::{decompress_frames, Bytecode, CompressedBytecode};
2245+
use crate::limited_writer::{LimitedWriter, LimitedWriterError};
2246+
2247+
/// Builds a bytecode made of two zstd frames followed by a skippable frame, together
2248+
/// with the bytes it decompresses to.
2249+
///
2250+
/// `zstd` accepts such a concatenation, so the decompressor used on `wasm32` has to
2251+
/// accept it too, or the two disagree about which bytecodes are valid.
2252+
fn multi_frame_bytecode() -> (CompressedBytecode, Vec<u8>) {
2253+
let first = vec![b'a'; 100_000];
2254+
let second = vec![b'b'; 50_000];
2255+
2256+
let mut compressed_bytes = Bytecode::new(first.clone())
2257+
.compress()
2258+
.compressed_bytes
2259+
.to_vec();
2260+
compressed_bytes
2261+
.extend_from_slice(&Bytecode::new(second.clone()).compress().compressed_bytes);
2262+
// Magic number 0x184d2a50 and a four-byte little-endian payload length.
2263+
compressed_bytes.extend_from_slice(&[0x50, 0x2a, 0x4d, 0x18, 4, 0, 0, 0, 1, 2, 3, 4]);
2264+
2265+
let compressed_bytecode = CompressedBytecode {
2266+
compressed_bytes: Arc::new(compressed_bytes.into_boxed_slice()),
2267+
};
2268+
2269+
(compressed_bytecode, [first, second].concat())
2270+
}
2271+
2272+
#[test]
2273+
fn all_frames_are_decompressed() {
2274+
let (compressed_bytecode, expected) = multi_frame_bytecode();
2275+
2276+
assert_eq!(compressed_bytecode.decompress().unwrap().bytes, expected);
2277+
2278+
let mut bytes = Vec::new();
2279+
decompress_frames(&compressed_bytecode.compressed_bytes, &mut bytes).unwrap();
2280+
assert_eq!(bytes, expected);
2281+
}
2282+
2283+
#[test]
2284+
fn all_frames_count_towards_the_size_limit() {
2285+
let (compressed_bytecode, expected) = multi_frame_bytecode();
2286+
let compressed_bytes = &**compressed_bytecode.compressed_bytes;
2287+
let size = expected.len();
2288+
2289+
for limit in [size / 2, size - 1, size, size + 1] {
2290+
let mut writer = LimitedWriter::new(io::sink(), limit);
2291+
let within_limit = match decompress_frames(compressed_bytes, &mut writer) {
2292+
Ok(()) => true,
2293+
Err(error) => {
2294+
error.downcast::<LimitedWriterError>().unwrap();
2295+
false
2296+
}
2297+
};
2298+
2299+
assert_eq!(within_limit, limit >= size);
2300+
assert_eq!(
2301+
CompressedBytecode::decompressed_size_at_most(
2302+
compressed_bytes,
2303+
u64::try_from(limit).unwrap()
2304+
)
2305+
.unwrap(),
2306+
within_limit
2307+
);
2308+
}
2309+
}
2310+
2311+
#[test]
2312+
fn trailing_garbage_is_rejected() {
2313+
let (compressed_bytecode, _) = multi_frame_bytecode();
2314+
let mut compressed_bytes = compressed_bytecode.compressed_bytes.to_vec();
2315+
compressed_bytes.extend_from_slice(b"not a zstd frame");
2316+
2317+
let mut bytes = Vec::new();
2318+
assert!(decompress_frames(&compressed_bytes, &mut bytes).is_err());
2319+
2320+
let compressed_bytecode = CompressedBytecode {
2321+
compressed_bytes: Arc::new(compressed_bytes.into_boxed_slice()),
2322+
};
2323+
assert!(compressed_bytecode.decompress().is_err());
2324+
}
2325+
}
22232326
}

0 commit comments

Comments
 (0)