diff --git a/Cargo.lock b/Cargo.lock index 339c176..7fbabd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,7 +82,9 @@ version = "0.1.0" dependencies = [ "we-css", "we-dom", + "we-encoding", "we-html", + "we-image", "we-net", "we-style", "we-text", diff --git a/crates/encoding/src/lib.rs b/crates/encoding/src/lib.rs index eac02d3..7030a26 100644 --- a/crates/encoding/src/lib.rs +++ b/crates/encoding/src/lib.rs @@ -3,6 +3,7 @@ pub mod error; mod single_byte; pub mod sniff; +pub mod streaming; mod utf16; mod utf8; diff --git a/crates/encoding/src/streaming.rs b/crates/encoding/src/streaming.rs new file mode 100644 index 0000000..8dbbb73 --- /dev/null +++ b/crates/encoding/src/streaming.rs @@ -0,0 +1,339 @@ +//! Streaming UTF-8 decoder for use by `TextDecoderStream` and similar APIs. +//! +//! Wraps the WHATWG-compliant byte-by-byte UTF-8 state machine so callers can +//! feed bytes incrementally. A partial multi-byte sequence at the end of one +//! chunk is held in internal state and combined with the next chunk's leading +//! bytes — no replacement character is emitted at chunk boundaries (only at +//! end-of-stream when fatal mode is off). + +use crate::error::EncodingError; + +/// Streaming UTF-8 decoder. +/// +/// `fatal` controls whether malformed sequences raise an error (`true`) or +/// are replaced with U+FFFD (`false`). +/// +/// `ignore_bom` (when `false`) causes a leading UTF-8 BOM (EF BB BF) seen at +/// the very start of the stream to be skipped. When `true`, the BOM is +/// preserved as the code point U+FEFF. +pub struct StreamingUtf8Decoder { + code_point: u32, + bytes_seen: u8, + bytes_needed: u8, + lower_boundary: u8, + upper_boundary: u8, + /// `true` once at least one byte has been observed. + saw_input: bool, + /// Possible pending BOM bytes at the start of the stream. + bom_buf: Vec, + bom_done: bool, + fatal: bool, + ignore_bom: bool, +} + +impl StreamingUtf8Decoder { + pub fn new(fatal: bool, ignore_bom: bool) -> Self { + Self { + code_point: 0, + bytes_seen: 0, + bytes_needed: 0, + lower_boundary: 0x80, + upper_boundary: 0xBF, + saw_input: false, + bom_buf: Vec::with_capacity(3), + bom_done: false, + fatal, + ignore_bom, + } + } + + /// Decode bytes into a string. + /// + /// In fatal mode, returns an error on the first invalid sequence. + pub fn decode(&mut self, bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Ok(String::new()); + } + self.saw_input = true; + let mut out = String::with_capacity(bytes.len()); + let mut idx = 0; + // Handle the leading BOM. + if !self.bom_done { + while idx < bytes.len() && self.bom_buf.len() < 3 { + self.bom_buf.push(bytes[idx]); + idx += 1; + if self.bom_check_failed() { + // No BOM — flush bom_buf back through the decoder. + let buf = std::mem::take(&mut self.bom_buf); + self.bom_done = true; + self.decode_inner(&buf, &mut out)?; + break; + } + if self.bom_buf.len() == 3 { + // Full BOM observed. + if !self.ignore_bom { + // Per WHATWG, when ignore_bom is false, the BOM is + // *consumed* (not emitted); when true, the BOM bytes + // are decoded as the U+FEFF code point. + // Wait — the spec is the opposite: by default the BOM + // is consumed; if `ignoreBOM` is true the bytes are + // surfaced as U+FEFF. + // We follow the spec: ignore_bom=false → strip; + // ignore_bom=true → emit U+FEFF. + } + if self.ignore_bom { + out.push('\u{FEFF}'); + } + self.bom_done = true; + self.bom_buf.clear(); + break; + } + } + if idx >= bytes.len() && !self.bom_done { + return Ok(out); + } + } + if idx < bytes.len() { + self.decode_inner(&bytes[idx..], &mut out)?; + } + Ok(out) + } + + /// Returns true once we've seen the first three bytes and they aren't BOM. + fn bom_check_failed(&self) -> bool { + !matches!( + self.bom_buf.as_slice(), + [0xEF] | [0xEF, 0xBB] | [0xEF, 0xBB, 0xBF] + ) + } + + fn decode_inner(&mut self, bytes: &[u8], out: &mut String) -> Result<(), EncodingError> { + let mut i = 0; + while i < bytes.len() { + match self.process_byte(bytes[i]) { + ProcessResult::CodePoint(ch) => { + out.push(ch); + i += 1; + } + ProcessResult::Error => { + if self.fatal { + return Err(EncodingError::InvalidSequence { + encoding: "UTF-8", + position: i, + }); + } + out.push('\u{FFFD}'); + i += 1; + } + ProcessResult::ErrorPrepend => { + if self.fatal { + return Err(EncodingError::InvalidSequence { + encoding: "UTF-8", + position: i, + }); + } + out.push('\u{FFFD}'); + // do not advance i; re-process this byte + } + ProcessResult::Continue => { + i += 1; + } + } + } + Ok(()) + } + + /// Flush any state at end-of-stream. + /// + /// Returns `\u{FFFD}` if a partial multi-byte sequence is pending and + /// `fatal` is false; returns an error if `fatal` is true; otherwise + /// returns an empty string. + pub fn flush(&mut self) -> Result { + if !self.bom_done && !self.bom_buf.is_empty() { + // Bytes pending in BOM detector that aren't BOM bytes. + let buf = std::mem::take(&mut self.bom_buf); + self.bom_done = true; + let mut out = String::new(); + self.decode_inner(&buf, &mut out)?; + if self.bytes_needed > 0 { + if self.fatal { + return Err(EncodingError::InvalidSequence { + encoding: "UTF-8", + position: 0, + }); + } + out.push('\u{FFFD}'); + self.reset(); + } + return Ok(out); + } + if self.bytes_needed > 0 { + if self.fatal { + return Err(EncodingError::InvalidSequence { + encoding: "UTF-8", + position: 0, + }); + } + self.reset(); + return Ok(String::from('\u{FFFD}')); + } + Ok(String::new()) + } + + fn process_byte(&mut self, byte: u8) -> ProcessResult { + if self.bytes_needed == 0 { + match byte { + 0x00..=0x7F => ProcessResult::CodePoint(byte as char), + 0xC2..=0xDF => { + self.bytes_needed = 1; + self.code_point = (byte & 0x1F) as u32; + ProcessResult::Continue + } + 0xE0 => { + self.bytes_needed = 2; + self.lower_boundary = 0xA0; + self.code_point = (byte & 0x0F) as u32; + ProcessResult::Continue + } + 0xE1..=0xEC | 0xEE..=0xEF => { + self.bytes_needed = 2; + self.code_point = (byte & 0x0F) as u32; + ProcessResult::Continue + } + 0xED => { + self.bytes_needed = 2; + self.upper_boundary = 0x9F; + self.code_point = (byte & 0x0F) as u32; + ProcessResult::Continue + } + 0xF0 => { + self.bytes_needed = 3; + self.lower_boundary = 0x90; + self.code_point = (byte & 0x07) as u32; + ProcessResult::Continue + } + 0xF1..=0xF3 => { + self.bytes_needed = 3; + self.code_point = (byte & 0x07) as u32; + ProcessResult::Continue + } + 0xF4 => { + self.bytes_needed = 3; + self.upper_boundary = 0x8F; + self.code_point = (byte & 0x07) as u32; + ProcessResult::Continue + } + _ => ProcessResult::Error, + } + } else if byte < self.lower_boundary || byte > self.upper_boundary { + self.reset(); + ProcessResult::ErrorPrepend + } else { + self.lower_boundary = 0x80; + self.upper_boundary = 0xBF; + self.code_point = (self.code_point << 6) | (byte & 0x3F) as u32; + self.bytes_seen += 1; + if self.bytes_seen == self.bytes_needed { + let cp = self.code_point; + self.reset(); + let ch = char::from_u32(cp).unwrap_or('\u{FFFD}'); + ProcessResult::CodePoint(ch) + } else { + ProcessResult::Continue + } + } + } + + fn reset(&mut self) { + self.code_point = 0; + self.bytes_seen = 0; + self.bytes_needed = 0; + self.lower_boundary = 0x80; + self.upper_boundary = 0xBF; + } +} + +enum ProcessResult { + CodePoint(char), + Error, + ErrorPrepend, + Continue, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_whole_string() { + let mut d = StreamingUtf8Decoder::new(false, false); + let out = d.decode("hello".as_bytes()).unwrap(); + assert_eq!(out, "hello"); + assert!(d.flush().unwrap().is_empty()); + } + + #[test] + fn decode_split_two_byte() { + // U+00E9 = 0xC3 0xA9 + let mut d = StreamingUtf8Decoder::new(false, false); + let a = d.decode(&[0xC3]).unwrap(); + assert_eq!(a, ""); + let b = d.decode(&[0xA9]).unwrap(); + assert_eq!(b, "\u{00E9}"); + assert!(d.flush().unwrap().is_empty()); + } + + #[test] + fn decode_split_four_byte() { + // U+1F600 = 0xF0 0x9F 0x98 0x80 + let mut d = StreamingUtf8Decoder::new(false, false); + assert_eq!(d.decode(&[0xF0]).unwrap(), ""); + assert_eq!(d.decode(&[0x9F, 0x98]).unwrap(), ""); + assert_eq!(d.decode(&[0x80]).unwrap(), "\u{1F600}"); + } + + #[test] + fn decode_split_after_bom() { + let mut d = StreamingUtf8Decoder::new(false, false); + // BOM split across two chunks. + assert_eq!(d.decode(&[0xEF, 0xBB]).unwrap(), ""); + assert_eq!(d.decode(&[0xBF, b'A']).unwrap(), "A"); + } + + #[test] + fn decode_ignore_bom_emits_feff() { + let mut d = StreamingUtf8Decoder::new(false, true); + assert_eq!(d.decode(&[0xEF, 0xBB, 0xBF, b'A']).unwrap(), "\u{FEFF}A"); + } + + #[test] + fn flush_partial_replaces_with_fffd() { + let mut d = StreamingUtf8Decoder::new(false, false); + assert_eq!(d.decode(&[0xC3]).unwrap(), ""); + assert_eq!(d.flush().unwrap(), "\u{FFFD}"); + } + + #[test] + fn fatal_mode_errors_on_truncated() { + let mut d = StreamingUtf8Decoder::new(true, false); + d.decode(&[0xC3]).unwrap(); + assert!(d.flush().is_err()); + } + + #[test] + fn fatal_mode_errors_on_invalid() { + let mut d = StreamingUtf8Decoder::new(true, false); + assert!(d.decode(&[0xFF]).is_err()); + } + + #[test] + fn flush_with_pending_non_bom_bytes() { + let mut d = StreamingUtf8Decoder::new(false, false); + // Bytes that look like the start of a BOM but aren't. + assert_eq!(d.decode(&[0xEF, 0xBB]).unwrap(), ""); + let tail = d.flush().unwrap(); + // 0xEF 0xBB looks like the lead+continuation of a 3-byte sequence + // expecting a third byte; flush emits a single replacement char. + assert_eq!(tail, "\u{FFFD}"); + } +} diff --git a/crates/image/src/deflate.rs b/crates/image/src/deflate.rs index c7f65a4..6177120 100644 --- a/crates/image/src/deflate.rs +++ b/crates/image/src/deflate.rs @@ -898,6 +898,889 @@ pub fn deflate_fixed(data: &[u8]) -> Vec { writer.finish() } +// --------------------------------------------------------------------------- +// Streaming inflate (state machine) +// --------------------------------------------------------------------------- + +/// Sub-state within a compressed block. +enum CompressedSubState { + /// Ready to decode the next literal/length symbol. + AtSymbol, + /// Copying a back-reference of `remaining` bytes at the given distance. + Backref { remaining: u16, distance: u16 }, +} + +/// Streaming-inflate state. +enum InflateStreamState { + /// At the start of a block; need to read BFINAL + BTYPE. + BlockHeader, + /// Inside a non-compressed block; need to read LEN/NLEN then copy data. + UncompressedHeader { + bfinal: bool, + }, + UncompressedData { + remaining: u32, + bfinal: bool, + }, + /// Inside a compressed block (fixed or dynamic) with built tables. + Compressed { + lit_table: HuffmanTable, + dist_table: HuffmanTable, + sub: CompressedSubState, + bfinal: bool, + }, + /// All input consumed past the final block. + Done, +} + +/// Incremental DEFLATE decompressor that accepts input bytes piecewise. +/// +/// Internally retains: +/// - A buffer of compressed input from which decoding resumes. +/// - The bit-reader cursor (byte offset + bit accumulator). +/// - The decompressed output (which doubles as the LZ77 history window). +/// - A state-machine position that survives across `push` calls. +/// +/// Decoding makes progress whenever new input is appended. Operations that +/// cannot complete because of insufficient input restore their state, so +/// subsequent `push` calls re-attempt from the same point. +pub struct StreamingInflater { + input: Vec, + pos: usize, + bit_buf: u32, + bits_in_buf: u8, + out: Vec, + out_drained: usize, + state: InflateStreamState, +} + +impl Default for StreamingInflater { + fn default() -> Self { + Self::new() + } +} + +impl StreamingInflater { + pub fn new() -> Self { + Self { + input: Vec::new(), + pos: 0, + bit_buf: 0, + bits_in_buf: 0, + out: Vec::new(), + out_drained: 0, + state: InflateStreamState::BlockHeader, + } + } + + /// Append compressed input bytes and decompress as much as possible. + /// Returns any newly-decoded output bytes. + pub fn push(&mut self, data: &[u8]) -> Result> { + self.input.extend_from_slice(data); + self.run_until_blocked()?; + let out = self.take_output(); + self.compact_input(); + Ok(out) + } + + /// Mark the end of input. Returns the final tail of decompressed bytes. + /// + /// Errors if the stream ended before the final block was fully consumed. + pub fn finish(&mut self) -> Result> { + self.run_until_blocked()?; + match self.state { + InflateStreamState::Done => {} + _ => return Err(DeflateError::UnexpectedEof), + } + Ok(self.take_output()) + } + + /// Whether the stream has consumed its final (BFINAL=1) block. + pub fn is_done(&self) -> bool { + matches!(self.state, InflateStreamState::Done) + } + + /// Pop accumulated output bytes since the last drain. + fn take_output(&mut self) -> Vec { + let out = self.out[self.out_drained..].to_vec(); + self.out_drained = self.out.len(); + // Keep at most the last 32 KiB for back-reference resolution. + if self.out_drained > MAX_WINDOW { + let excess = self.out_drained - MAX_WINDOW; + self.out.drain(0..excess); + self.out_drained -= excess; + } + out + } + + /// Drop input bytes that the reader has already consumed. + fn compact_input(&mut self) { + if self.pos > 0 && self.pos <= self.input.len() { + self.input.drain(0..self.pos); + self.pos = 0; + } + } + + fn run_until_blocked(&mut self) -> Result<()> { + loop { + match self.step()? { + StepResult::Progress => continue, + StepResult::Blocked => return Ok(()), + StepResult::Done => return Ok(()), + } + } + } + + fn step(&mut self) -> Result { + if matches!(self.state, InflateStreamState::Done) { + return Ok(StepResult::Done); + } + // Take state out so the BitReader borrow of self.input doesn't conflict. + let state = std::mem::replace(&mut self.state, InflateStreamState::Done); + let outcome = step_state( + state, + &self.input, + &mut self.pos, + &mut self.bit_buf, + &mut self.bits_in_buf, + &mut self.out, + ); + match outcome { + Ok((new_state, res)) => { + self.state = new_state; + Ok(res) + } + Err((restore, err)) => { + self.state = restore; + Err(err) + } + } + } +} + +fn step_state( + state: InflateStreamState, + input: &[u8], + pos: &mut usize, + bit_buf: &mut u32, + bits_in_buf: &mut u8, + out: &mut Vec, +) -> std::result::Result<(InflateStreamState, StepResult), (InflateStreamState, DeflateError)> { + let mut reader = BitReader { + data: input, + pos: *pos, + bit_buf: *bit_buf, + bits_in_buf: *bits_in_buf, + }; + + macro_rules! commit { + ($r:expr) => {{ + *pos = $r.pos; + *bit_buf = $r.bit_buf; + *bits_in_buf = $r.bits_in_buf; + }}; + } + + match state { + InflateStreamState::Done => Ok((InflateStreamState::Done, StepResult::Done)), + + InflateStreamState::BlockHeader => { + let bfinal = match reader.read_bits(1) { + Ok(v) => v == 1, + Err(DeflateError::UnexpectedEof) => { + return Ok((InflateStreamState::BlockHeader, StepResult::Blocked)); + } + Err(e) => return Err((InflateStreamState::BlockHeader, e)), + }; + let btype = match reader.read_bits(2) { + Ok(v) => v as u8, + Err(DeflateError::UnexpectedEof) => { + return Ok((InflateStreamState::BlockHeader, StepResult::Blocked)); + } + Err(e) => return Err((InflateStreamState::BlockHeader, e)), + }; + match btype { + 0 => { + commit!(reader); + Ok(( + InflateStreamState::UncompressedHeader { bfinal }, + StepResult::Progress, + )) + } + 1 => { + let lit_table = match HuffmanTable::from_code_lengths(&FIXED_LIT_LENGTHS, 9) { + Ok(t) => t, + Err(e) => return Err((InflateStreamState::BlockHeader, e)), + }; + let dist_table = match HuffmanTable::from_code_lengths(&FIXED_DIST_LENGTHS, 5) { + Ok(t) => t, + Err(e) => return Err((InflateStreamState::BlockHeader, e)), + }; + commit!(reader); + Ok(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::AtSymbol, + bfinal, + }, + StepResult::Progress, + )) + } + 2 => match parse_dynamic_header(&mut reader) { + Ok((lit_table, dist_table)) => { + commit!(reader); + Ok(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::AtSymbol, + bfinal, + }, + StepResult::Progress, + )) + } + Err(DeflateError::UnexpectedEof) => { + Ok((InflateStreamState::BlockHeader, StepResult::Blocked)) + } + Err(e) => Err((InflateStreamState::BlockHeader, e)), + }, + _ => Err(( + InflateStreamState::BlockHeader, + DeflateError::InvalidBlockType(btype), + )), + } + } + + InflateStreamState::UncompressedHeader { bfinal } => { + reader.align_to_byte(); + let restore = InflateStreamState::UncompressedHeader { bfinal }; + let lo = match reader.read_byte() { + Ok(v) => v, + Err(DeflateError::UnexpectedEof) => return Ok((restore, StepResult::Blocked)), + Err(e) => return Err((restore, e)), + }; + let hi = match reader.read_byte() { + Ok(v) => v, + Err(DeflateError::UnexpectedEof) => return Ok((restore, StepResult::Blocked)), + Err(e) => return Err((restore, e)), + }; + let nlo = match reader.read_byte() { + Ok(v) => v, + Err(DeflateError::UnexpectedEof) => return Ok((restore, StepResult::Blocked)), + Err(e) => return Err((restore, e)), + }; + let nhi = match reader.read_byte() { + Ok(v) => v, + Err(DeflateError::UnexpectedEof) => return Ok((restore, StepResult::Blocked)), + Err(e) => return Err((restore, e)), + }; + let len = (lo as u16) | ((hi as u16) << 8); + let nlen = (nlo as u16) | ((nhi as u16) << 8); + if len != !nlen { + return Err((restore, DeflateError::LenMismatch { len, nlen })); + } + commit!(reader); + Ok(( + InflateStreamState::UncompressedData { + remaining: len as u32, + bfinal, + }, + StepResult::Progress, + )) + } + + InflateStreamState::UncompressedData { + mut remaining, + bfinal, + } => { + if remaining == 0 { + if bfinal { + return Ok((InflateStreamState::Done, StepResult::Done)); + } + return Ok((InflateStreamState::BlockHeader, StepResult::Progress)); + } + let avail = input.len().saturating_sub(*pos); + if avail == 0 { + return Ok(( + InflateStreamState::UncompressedData { remaining, bfinal }, + StepResult::Blocked, + )); + } + let to_copy = avail.min(remaining as usize); + let start = *pos; + out.extend_from_slice(&input[start..start + to_copy]); + *pos = start + to_copy; + *bit_buf = 0; + *bits_in_buf = 0; + remaining -= to_copy as u32; + Ok(( + InflateStreamState::UncompressedData { remaining, bfinal }, + StepResult::Progress, + )) + } + + InflateStreamState::Compressed { + lit_table, + dist_table, + sub, + bfinal, + } => match sub { + CompressedSubState::AtSymbol => { + let sym = match lit_table.decode(&mut reader) { + Ok(s) => s, + Err(DeflateError::UnexpectedEof) => { + return Ok(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::AtSymbol, + bfinal, + }, + StepResult::Blocked, + )); + } + Err(e) => { + return Err(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::AtSymbol, + bfinal, + }, + e, + )); + } + }; + match sym { + 0..=255 => { + commit!(reader); + out.push(sym as u8); + Ok(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::AtSymbol, + bfinal, + }, + StepResult::Progress, + )) + } + 256 => { + commit!(reader); + if bfinal { + Ok((InflateStreamState::Done, StepResult::Done)) + } else { + Ok((InflateStreamState::BlockHeader, StepResult::Progress)) + } + } + 257..=285 => { + let length_index = (sym - 257) as usize; + let (base_len, extra_bits) = LENGTH_TABLE[length_index]; + let length = base_len as u32 + + if extra_bits > 0 { + match reader.read_bits(extra_bits) { + Ok(v) => v, + Err(DeflateError::UnexpectedEof) => { + return Ok(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::AtSymbol, + bfinal, + }, + StepResult::Blocked, + )); + } + Err(e) => { + return Err(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::AtSymbol, + bfinal, + }, + e, + )); + } + } + } else { + 0 + }; + let dist_sym = match dist_table.decode(&mut reader) { + Ok(s) => s, + Err(DeflateError::UnexpectedEof) => { + return Ok(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::AtSymbol, + bfinal, + }, + StepResult::Blocked, + )); + } + Err(e) => { + return Err(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::AtSymbol, + bfinal, + }, + e, + )); + } + }; + if dist_sym as usize >= DISTANCE_TABLE.len() { + return Err(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::AtSymbol, + bfinal, + }, + DeflateError::InvalidCode, + )); + } + let (base_dist, dist_extra) = DISTANCE_TABLE[dist_sym as usize]; + let distance = base_dist as u32 + + if dist_extra > 0 { + match reader.read_bits(dist_extra) { + Ok(v) => v, + Err(DeflateError::UnexpectedEof) => { + return Ok(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::AtSymbol, + bfinal, + }, + StepResult::Blocked, + )); + } + Err(e) => { + return Err(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::AtSymbol, + bfinal, + }, + e, + )); + } + } + } else { + 0 + }; + if distance as usize > out.len() { + return Err(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::AtSymbol, + bfinal, + }, + DeflateError::InvalidDistance { + distance: distance as usize, + available: out.len(), + }, + )); + } + commit!(reader); + Ok(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::Backref { + remaining: length as u16, + distance: distance as u16, + }, + bfinal, + }, + StepResult::Progress, + )) + } + _ => Err(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: CompressedSubState::AtSymbol, + bfinal, + }, + DeflateError::InvalidCode, + )), + } + } + CompressedSubState::Backref { + remaining, + distance, + } => { + let dist = distance as usize; + let cap = (remaining as usize).min(4096); + for _ in 0..cap { + let b = out[out.len() - dist]; + out.push(b); + } + let left = remaining as usize - cap; + let new_sub = if left == 0 { + CompressedSubState::AtSymbol + } else { + CompressedSubState::Backref { + remaining: left as u16, + distance, + } + }; + Ok(( + InflateStreamState::Compressed { + lit_table, + dist_table, + sub: new_sub, + bfinal, + }, + StepResult::Progress, + )) + } + }, + } +} + +enum StepResult { + Progress, + Blocked, + Done, +} + +/// Maximum LZ77 window size (32 KiB) — RFC 1951 §3.2.5. +const MAX_WINDOW: usize = 32_768; + +/// Cached fixed literal/length code lengths (RFC 1951 §3.2.6). +const FIXED_LIT_LENGTHS: [u8; 288] = { + let mut lengths = [0u8; 288]; + let mut i = 0; + while i <= 143 { + lengths[i] = 8; + i += 1; + } + while i <= 255 { + lengths[i] = 9; + i += 1; + } + while i <= 279 { + lengths[i] = 7; + i += 1; + } + while i <= 287 { + lengths[i] = 8; + i += 1; + } + lengths +}; + +const FIXED_DIST_LENGTHS: [u8; 32] = [5u8; 32]; + +/// Parse a dynamic-Huffman block header and build its two tables. +fn parse_dynamic_header(reader: &mut BitReader<'_>) -> Result<(HuffmanTable, HuffmanTable)> { + let hlit = reader.read_bits(5)? as usize + 257; + let hdist = reader.read_bits(5)? as usize + 1; + let hclen = reader.read_bits(4)? as usize + 4; + + let mut cl_lengths = [0u8; 19]; + for i in 0..hclen { + cl_lengths[CODE_LENGTH_ORDER[i]] = reader.read_bits(3)? as u8; + } + + let cl_table = HuffmanTable::from_code_lengths(&cl_lengths, 7)?; + + let total = hlit + hdist; + let mut combined_lengths = vec![0u8; total]; + let mut i = 0; + + while i < total { + let sym = cl_table.decode(reader)?; + match sym { + 0..=15 => { + combined_lengths[i] = sym as u8; + i += 1; + } + 16 => { + if i == 0 { + return Err(DeflateError::InvalidCodeLengths); + } + let repeat = reader.read_bits(2)? as usize + 3; + let prev = combined_lengths[i - 1]; + for _ in 0..repeat { + if i >= total { + return Err(DeflateError::InvalidCodeLengths); + } + combined_lengths[i] = prev; + i += 1; + } + } + 17 => { + let repeat = reader.read_bits(3)? as usize + 3; + for _ in 0..repeat { + if i >= total { + return Err(DeflateError::InvalidCodeLengths); + } + combined_lengths[i] = 0; + i += 1; + } + } + 18 => { + let repeat = reader.read_bits(7)? as usize + 11; + for _ in 0..repeat { + if i >= total { + return Err(DeflateError::InvalidCodeLengths); + } + combined_lengths[i] = 0; + i += 1; + } + } + _ => return Err(DeflateError::InvalidCodeLengths), + } + } + + let lit_lengths = &combined_lengths[..hlit]; + let dist_lengths = &combined_lengths[hlit..]; + + let lit_table = HuffmanTable::from_code_lengths(lit_lengths, 9)?; + let dist_table = HuffmanTable::from_code_lengths(dist_lengths, 6)?; + Ok((lit_table, dist_table)) +} + +// --------------------------------------------------------------------------- +// Streaming deflate (fixed-Huffman, single open block) +// --------------------------------------------------------------------------- + +/// Incremental fixed-Huffman DEFLATE compressor. +/// +/// Emits one fixed-Huffman block (BFINAL=0, BTYPE=01) that stays open across +/// `push` calls so LZ77 back-references can span chunk boundaries. `finish` +/// closes the block with an EOB symbol and writes a final BFINAL=1 stored +/// block of zero length, byte-aligned and padded. +pub struct StreamingDeflater { + writer: BitWriter, + /// Sliding window of input bytes seen so far (most recent 32 KiB + new chunk). + window: Vec, + /// Byte position within `window` reached so far. New chunks are appended + /// and then encoded from this position. + encoded_pos: usize, + /// Whether the open block's header has been emitted. + block_opened: bool, + /// Hash-chain head table for LZ77 match-finding. + head: Vec, + /// Hash-chain prev table for LZ77 match-finding. + prev: Vec, + /// True after `finish()` is called. + finished: bool, +} + +impl Default for StreamingDeflater { + fn default() -> Self { + Self::new() + } +} + +impl StreamingDeflater { + pub fn new() -> Self { + Self { + writer: BitWriter::with_capacity(0), + window: Vec::with_capacity(MAX_WINDOW), + encoded_pos: 0, + block_opened: false, + head: vec![ENC_NIL; ENC_HASH_SIZE], + prev: Vec::new(), + finished: false, + } + } + + /// Append uncompressed input bytes and emit any compressed output that + /// can be produced without waiting for more. + /// + /// Holds back the trailing `ENC_MAX_MATCH - 1` bytes of input so future + /// matches can extend through them; emits all earlier bytes as + /// literals/back-references. + pub fn push(&mut self, data: &[u8]) -> Vec { + if self.finished { + return Vec::new(); + } + self.window.extend_from_slice(data); + self.prev.resize(self.window.len(), ENC_NIL); + + if !self.block_opened { + // BFINAL=0, BTYPE=01 (fixed Huffman): three bits emitted LSB-first + // as 010. + self.writer.write_bits(0b010, 3); + self.block_opened = true; + } + + // Encode bytes from `encoded_pos` up to `safe_end` (leaving a tail of + // ENC_MAX_MATCH - 1 bytes for potential future matches). + let n = self.window.len(); + let safe_end = n.saturating_sub(ENC_MAX_MATCH - 1); + self.encode_range(safe_end); + + self.take_output() + } + + /// Close the open block, emit a final empty block, byte-align, and return + /// any remaining output bytes. After `finish`, further `push` calls are + /// no-ops. + pub fn finish(&mut self) -> Vec { + if self.finished { + return Vec::new(); + } + if !self.block_opened { + // Empty stream — emit a single final empty fixed block. + self.writer.write_bits(0b011, 3); + emit_lit_len(&mut self.writer, 256); + self.finished = true; + return self.take_output_final(); + } + // Encode any remaining bytes (the held-back tail). + let n = self.window.len(); + self.encode_range(n); + // End-of-block for the open (non-final) block. + emit_lit_len(&mut self.writer, 256); + // Final BFINAL=1, BTYPE=01 fixed block consisting of just EOB. + self.writer.write_bits(0b011, 3); + emit_lit_len(&mut self.writer, 256); + self.finished = true; + self.take_output_final() + } + + fn encode_range(&mut self, end: usize) { + // Local copies to keep the borrow checker happy. + let writer = &mut self.writer; + let head = &mut self.head; + let prev = &mut self.prev; + let window = &self.window; + let mut pos = self.encoded_pos; + + while pos < end { + let n = window.len(); + let mut best_len = 0usize; + let mut best_dist = 0usize; + + if pos + ENC_MIN_MATCH <= n { + let h = enc_hash3(window, pos); + let max_match = (n - pos).min(ENC_MAX_MATCH); + + let mut chain_pos = head[h]; + let mut chain_count = 0usize; + while chain_pos != ENC_NIL && chain_count < ENC_MAX_CHAIN { + let cp = chain_pos as usize; + if cp >= pos { + break; + } + let dist = pos - cp; + if dist > ENC_MAX_DIST { + break; + } + + if best_len >= ENC_MIN_MATCH + && best_len < max_match + && window[cp + best_len] != window[pos + best_len] + { + chain_pos = prev[cp]; + chain_count += 1; + continue; + } + + let mut l = 0usize; + while l < max_match && window[cp + l] == window[pos + l] { + l += 1; + } + if l > best_len && l >= ENC_MIN_MATCH { + best_len = l; + best_dist = dist; + if l >= ENC_MAX_MATCH { + break; + } + } + + chain_pos = prev[cp]; + chain_count += 1; + } + } + + if best_len >= ENC_MIN_MATCH { + emit_length_distance(writer, best_len as u16, best_dist as u16); + let stop = pos + best_len; + #[allow(clippy::needless_range_loop)] + for p in pos..stop { + if p + ENC_MIN_MATCH <= n { + let h = enc_hash3(window, p); + prev[p] = head[h]; + head[h] = p as u32; + } + } + pos = stop; + } else { + emit_lit_len(writer, window[pos] as u16); + if pos + ENC_MIN_MATCH <= n { + let h = enc_hash3(window, pos); + prev[pos] = head[h]; + head[h] = pos as u32; + } + pos += 1; + } + } + + self.encoded_pos = pos; + self.compact_window(); + } + + /// Drop window bytes older than `ENC_MAX_DIST` so the window can't grow + /// unboundedly. Adjusts the hash/prev tables to match. + fn compact_window(&mut self) { + if self.window.len() <= MAX_WINDOW * 2 { + return; + } + // Keep the most recent `MAX_WINDOW` bytes. Shift everything by `shift` + // and reset the LZ77 indices so back-references can't reach the + // dropped portion. + let shift = self.window.len() - MAX_WINDOW; + self.window.drain(0..shift); + self.encoded_pos = self.encoded_pos.saturating_sub(shift); + // Adjust prev[] entries that are still valid; invalidate the rest. + let mut new_prev = vec![ENC_NIL; self.window.len()]; + for (i, &p) in self.prev[shift..].iter().enumerate() { + new_prev[i] = if p == ENC_NIL || (p as usize) < shift { + ENC_NIL + } else { + p - shift as u32 + }; + } + self.prev = new_prev; + for h in self.head.iter_mut() { + if *h == ENC_NIL || (*h as usize) < shift { + *h = ENC_NIL; + } else { + *h -= shift as u32; + } + } + } + + /// Take output bytes accumulated in the writer so far. Leaves the bit + /// accumulator in place for future writes — only full bytes are taken. + fn take_output(&mut self) -> Vec { + std::mem::take(&mut self.writer.out) + } + + /// Take output and flush the bit buffer to a byte boundary. + fn take_output_final(&mut self) -> Vec { + if self.writer.bits_in_buf > 0 { + self.writer.out.push(self.writer.bit_buf as u8); + self.writer.bit_buf = 0; + self.writer.bits_in_buf = 0; + } + std::mem::take(&mut self.writer.out) + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1549,6 +2432,151 @@ mod tests { assert!(decompressed.iter().all(|&b| b == 0xFF)); } + // -- Streaming inflate/deflate tests -- + + fn streaming_roundtrip(data: &[u8], chunk_sizes: &[usize]) { + let compressed = deflate_fixed(data); + + let mut inflater = StreamingInflater::new(); + let mut out = Vec::new(); + let mut offset = 0; + for &cs in chunk_sizes { + let end = (offset + cs).min(compressed.len()); + out.extend(inflater.push(&compressed[offset..end]).unwrap()); + offset = end; + if offset >= compressed.len() { + break; + } + } + if offset < compressed.len() { + out.extend(inflater.push(&compressed[offset..]).unwrap()); + } + out.extend(inflater.finish().unwrap()); + assert_eq!(out, data, "streaming inflate mismatch len={}", data.len()); + } + + #[test] + fn streaming_inflate_full_at_once() { + streaming_roundtrip(b"Hello, world!", &[1024]); + } + + #[test] + fn streaming_inflate_byte_at_a_time() { + let data = b"The quick brown fox jumps over the lazy dog"; + let compressed = deflate_fixed(data); + let mut inflater = StreamingInflater::new(); + let mut out = Vec::new(); + for &b in &compressed { + out.extend(inflater.push(&[b]).unwrap()); + } + out.extend(inflater.finish().unwrap()); + assert_eq!(out, data); + } + + #[test] + fn streaming_inflate_split_chunks() { + let data: Vec = b"abcdefghijklmnopqrstuvwxyz".repeat(50); + streaming_roundtrip(&data, &[1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144]); + } + + #[test] + fn streaming_inflate_uniform() { + let data = vec![0xABu8; 50_000]; + streaming_roundtrip(&data, &[10, 20, 30, 40, 50]); + } + + #[test] + fn streaming_inflate_finish_premature_errors() { + let data = b"hello"; + let compressed = deflate_fixed(data); + let mut inflater = StreamingInflater::new(); + // Only push half the bytes. + let half = compressed.len() / 2; + let _ = inflater.push(&compressed[..half]).unwrap(); + assert!(matches!( + inflater.finish(), + Err(DeflateError::UnexpectedEof) + )); + } + + fn streaming_deflate_roundtrip(data: &[u8], chunk_size: usize) { + let mut def = StreamingDeflater::new(); + let mut compressed = Vec::new(); + for chunk in data.chunks(chunk_size.max(1)) { + compressed.extend(def.push(chunk)); + } + compressed.extend(def.finish()); + let decompressed = inflate(&compressed).expect("inflate streaming-deflated bytes"); + assert_eq!(decompressed, data, "deflate streaming roundtrip mismatch"); + } + + #[test] + fn streaming_deflate_empty() { + streaming_deflate_roundtrip(&[], 16); + } + + #[test] + fn streaming_deflate_small() { + streaming_deflate_roundtrip(b"hello world", 5); + } + + #[test] + fn streaming_deflate_many_chunks() { + let data: Vec = b"abcabcabcdefghijklmn".repeat(200); + streaming_deflate_roundtrip(&data, 7); + } + + #[test] + fn streaming_deflate_uniform_compresses() { + let data = vec![0x55u8; 100_000]; + let mut def = StreamingDeflater::new(); + let mut compressed = Vec::new(); + for chunk in data.chunks(4096) { + compressed.extend(def.push(chunk)); + } + compressed.extend(def.finish()); + assert!( + compressed.len() < data.len() / 10, + "uniform stream compressed to {} bytes from {}", + compressed.len(), + data.len() + ); + let decompressed = inflate(&compressed).unwrap(); + assert_eq!(decompressed, data); + } + + #[test] + fn streaming_deflate_then_streaming_inflate() { + let data: Vec = b"The quick brown fox jumps over the lazy dog. ".repeat(50); + let mut def = StreamingDeflater::new(); + let mut compressed = Vec::new(); + for chunk in data.chunks(32) { + compressed.extend(def.push(chunk)); + } + compressed.extend(def.finish()); + + let mut inf = StreamingInflater::new(); + let mut decompressed = Vec::new(); + for chunk in compressed.chunks(11) { + decompressed.extend(inf.push(chunk).unwrap()); + } + decompressed.extend(inf.finish().unwrap()); + assert_eq!(decompressed, data); + } + + #[test] + fn streaming_inflate_dynamic_block() { + // "AAAAAAAAAA" using dynamic Huffman codes + let input = [0x73, 0x74, 0x84, 0x01, 0x00]; + let mut inf = StreamingInflater::new(); + let mut out = Vec::new(); + for &b in &input { + out.extend(inf.push(&[b]).unwrap()); + } + out.extend(inf.finish().unwrap()); + assert_eq!(out, b"AAAAAAAAAA"); + } + #[test] fn deflate_roundtrip_screenshot_like() { // Simulate scanline-filtered RGBA data: one zero filter byte per diff --git a/crates/image/src/gzip.rs b/crates/image/src/gzip.rs new file mode 100644 index 0000000..a654dc1 --- /dev/null +++ b/crates/image/src/gzip.rs @@ -0,0 +1,580 @@ +//! gzip (RFC 1952) compression and decompression — pure Rust. +//! +//! Implements the gzip framing on top of [`crate::deflate`]: a fixed 10-byte +//! header (skipping the optional extra/name/comment fields on decode), the +//! DEFLATE stream, and an 8-byte trailer carrying the CRC-32 of the +//! uncompressed data and the (modulo 2^32) uncompressed length. +//! +//! Used by `CompressionStream('gzip')` / `DecompressionStream('gzip')` in the +//! Streams API. Both one-shot and incremental variants are provided; the +//! incremental forms hold no input beyond the in-flight DEFLATE state. + +use crate::deflate::{self, StreamingDeflater, StreamingInflater}; +use std::fmt; + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GzipError { + /// Input did not begin with the gzip magic bytes (0x1F, 0x8B). + BadMagic, + /// Compression method other than 8 (DEFLATE) was requested. + UnsupportedMethod(u8), + /// Reserved flag bits in FLG byte were set. + ReservedFlags(u8), + /// CRC-32 of the decompressed data did not match the trailer. + CrcMismatch { expected: u32, actual: u32 }, + /// Uncompressed length in the trailer did not match the actual length + /// (mod 2^32). + LengthMismatch { expected: u32, actual: u32 }, + /// Input ended before the gzip frame completed. + UnexpectedEof, + /// Underlying DEFLATE failed. + Deflate(deflate::DeflateError), +} + +impl fmt::Display for GzipError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BadMagic => write!(f, "invalid gzip magic"), + Self::UnsupportedMethod(m) => write!(f, "unsupported compression method: {m}"), + Self::ReservedFlags(b) => write!(f, "reserved flag bits set: {b:#04x}"), + Self::CrcMismatch { expected, actual } => write!( + f, + "CRC-32 mismatch: expected {expected:#010x}, got {actual:#010x}" + ), + Self::LengthMismatch { expected, actual } => { + write!(f, "ISIZE mismatch: expected {expected}, got {actual}") + } + Self::UnexpectedEof => write!(f, "unexpected end of gzip stream"), + Self::Deflate(e) => write!(f, "deflate error: {e}"), + } + } +} + +impl From for GzipError { + fn from(e: deflate::DeflateError) -> Self { + Self::Deflate(e) + } +} + +pub type Result = std::result::Result; + +// --------------------------------------------------------------------------- +// CRC-32 (IEEE 802.3 polynomial 0xEDB88320) +// --------------------------------------------------------------------------- + +/// Pre-computed table for byte-wise CRC-32 (polynomial 0xEDB88320 reflected). +const CRC32_TABLE: [u32; 256] = { + let mut table = [0u32; 256]; + let mut i = 0u32; + while i < 256 { + let mut c = i; + let mut k = 0; + while k < 8 { + c = if c & 1 != 0 { + 0xEDB88320 ^ (c >> 1) + } else { + c >> 1 + }; + k += 1; + } + table[i as usize] = c; + i += 1; + } + table +}; + +/// Compute CRC-32 (IEEE) of `data`. +pub fn crc32(data: &[u8]) -> u32 { + let mut crc = Crc32::new(); + crc.update(data); + crc.finalize() +} + +/// Incremental CRC-32 accumulator. +#[derive(Debug, Clone, Copy)] +pub struct Crc32 { + state: u32, +} + +impl Default for Crc32 { + fn default() -> Self { + Self::new() + } +} + +impl Crc32 { + pub fn new() -> Self { + Self { state: 0xFFFF_FFFF } + } + + pub fn update(&mut self, data: &[u8]) { + let mut s = self.state; + for &b in data { + s = CRC32_TABLE[((s ^ b as u32) & 0xFF) as usize] ^ (s >> 8); + } + self.state = s; + } + + pub fn finalize(self) -> u32 { + self.state ^ 0xFFFF_FFFF + } +} + +// --------------------------------------------------------------------------- +// Streaming gzip inflater +// --------------------------------------------------------------------------- + +enum GzInfStage { + /// Reading the 10-byte fixed header. + FixedHeader, + /// Skipping the FEXTRA block (XLEN + data). + Extra { + remaining: u16, + len_bytes: u8, + }, + /// Skipping FNAME (zero-terminated). + Name, + /// Skipping FCOMMENT (zero-terminated). + Comment, + /// Skipping FHCRC (2 bytes). + HeaderCrc { + remaining: u8, + }, + /// Inflating the DEFLATE payload. + Body, + /// Reading the 8-byte trailer. + Trailer, + Done, +} + +/// Incremental gzip decompressor. +pub struct StreamingGzipInflater { + stage: GzInfStage, + fixed: Vec, + /// Flags byte from header, after we read it. + flags: u8, + inflater: StreamingInflater, + crc: Crc32, + isize_bytes: u32, + trailer: Vec, + saved_input: Vec, +} + +impl Default for StreamingGzipInflater { + fn default() -> Self { + Self::new() + } +} + +impl StreamingGzipInflater { + pub fn new() -> Self { + Self { + stage: GzInfStage::FixedHeader, + fixed: Vec::with_capacity(10), + flags: 0, + inflater: StreamingInflater::new(), + crc: Crc32::new(), + isize_bytes: 0, + trailer: Vec::with_capacity(8), + saved_input: Vec::new(), + } + } + + pub fn push(&mut self, data: &[u8]) -> Result> { + let mut buf: Vec = std::mem::take(&mut self.saved_input); + buf.extend_from_slice(data); + let mut out = Vec::new(); + let mut cursor: usize = 0; + loop { + match &mut self.stage { + GzInfStage::FixedHeader => { + while self.fixed.len() < 10 && cursor < buf.len() { + self.fixed.push(buf[cursor]); + cursor += 1; + } + if self.fixed.len() < 10 { + // Save remaining input for next push. + break; + } + if self.fixed[0] != 0x1F || self.fixed[1] != 0x8B { + return Err(GzipError::BadMagic); + } + let cm = self.fixed[2]; + if cm != 8 { + return Err(GzipError::UnsupportedMethod(cm)); + } + let flg = self.fixed[3]; + if flg & 0xE0 != 0 { + return Err(GzipError::ReservedFlags(flg)); + } + self.flags = flg; + // Skip MTIME/XFL/OS (already consumed). + self.stage = if flg & 0x04 != 0 { + GzInfStage::Extra { + remaining: 0, + len_bytes: 0, + } + } else if flg & 0x08 != 0 { + GzInfStage::Name + } else if flg & 0x10 != 0 { + GzInfStage::Comment + } else if flg & 0x02 != 0 { + GzInfStage::HeaderCrc { remaining: 2 } + } else { + GzInfStage::Body + }; + } + + GzInfStage::Extra { + remaining, + len_bytes, + } => { + while *len_bytes < 2 && cursor < buf.len() { + let b = buf[cursor]; + cursor += 1; + if *len_bytes == 0 { + *remaining = b as u16; + } else { + *remaining |= (b as u16) << 8; + } + *len_bytes += 1; + } + if *len_bytes < 2 { + break; + } + let take = (*remaining as usize).min(buf.len() - cursor); + cursor += take; + *remaining -= take as u16; + if *remaining == 0 { + self.stage = next_optional_stage(self.flags & !0x04); + } else { + break; + } + } + + GzInfStage::Name => { + while cursor < buf.len() { + let b = buf[cursor]; + cursor += 1; + if b == 0 { + self.stage = next_optional_stage(self.flags & !0x08); + break; + } + } + if !matches!(self.stage, GzInfStage::Name) { + continue; + } + break; + } + + GzInfStage::Comment => { + while cursor < buf.len() { + let b = buf[cursor]; + cursor += 1; + if b == 0 { + self.stage = next_optional_stage(self.flags & !0x10); + break; + } + } + if !matches!(self.stage, GzInfStage::Comment) { + continue; + } + break; + } + + GzInfStage::HeaderCrc { remaining } => { + while *remaining > 0 && cursor < buf.len() { + cursor += 1; + *remaining -= 1; + } + if *remaining == 0 { + self.stage = GzInfStage::Body; + } else { + break; + } + } + + GzInfStage::Body => { + // Feed bytes one at a time so we can detect end-of-stream + // and capture any trailing trailer bytes. + let mut idx = cursor; + while idx < buf.len() { + let chunk = self.inflater.push(&[buf[idx]])?; + if !chunk.is_empty() { + self.crc.update(&chunk); + self.isize_bytes = self.isize_bytes.wrapping_add(chunk.len() as u32); + out.extend(chunk); + } + idx += 1; + if self.inflater.is_done() { + cursor = idx; + self.stage = GzInfStage::Trailer; + break; + } + } + if matches!(self.stage, GzInfStage::Body) { + cursor = idx; + break; + } + } + + GzInfStage::Trailer => { + while self.trailer.len() < 8 && cursor < buf.len() { + self.trailer.push(buf[cursor]); + cursor += 1; + } + if self.trailer.len() == 8 { + let expected_crc = (self.trailer[0] as u32) + | ((self.trailer[1] as u32) << 8) + | ((self.trailer[2] as u32) << 16) + | ((self.trailer[3] as u32) << 24); + let actual_crc = self.crc.finalize(); + if actual_crc != expected_crc { + return Err(GzipError::CrcMismatch { + expected: expected_crc, + actual: actual_crc, + }); + } + let expected_len = (self.trailer[4] as u32) + | ((self.trailer[5] as u32) << 8) + | ((self.trailer[6] as u32) << 16) + | ((self.trailer[7] as u32) << 24); + if expected_len != self.isize_bytes { + return Err(GzipError::LengthMismatch { + expected: expected_len, + actual: self.isize_bytes, + }); + } + self.stage = GzInfStage::Done; + } else { + break; + } + } + + GzInfStage::Done => break, + } + } + if cursor < buf.len() { + // Preserve unconsumed input for next push. + self.saved_input = buf[cursor..].to_vec(); + } + Ok(out) + } + + pub fn finish(&mut self) -> Result> { + if !matches!(self.stage, GzInfStage::Done) { + return Err(GzipError::UnexpectedEof); + } + Ok(Vec::new()) + } +} + +fn next_optional_stage(remaining_flags: u8) -> GzInfStage { + if remaining_flags & 0x08 != 0 { + GzInfStage::Name + } else if remaining_flags & 0x10 != 0 { + GzInfStage::Comment + } else if remaining_flags & 0x02 != 0 { + GzInfStage::HeaderCrc { remaining: 2 } + } else { + GzInfStage::Body + } +} + +// --------------------------------------------------------------------------- +// Streaming gzip deflater +// --------------------------------------------------------------------------- + +/// Incremental gzip compressor. +pub struct StreamingGzipDeflater { + deflater: StreamingDeflater, + crc: Crc32, + isize_bytes: u32, + header_emitted: bool, + finished: bool, +} + +impl Default for StreamingGzipDeflater { + fn default() -> Self { + Self::new() + } +} + +impl StreamingGzipDeflater { + pub fn new() -> Self { + Self { + deflater: StreamingDeflater::new(), + crc: Crc32::new(), + isize_bytes: 0, + header_emitted: false, + finished: false, + } + } + + pub fn push(&mut self, data: &[u8]) -> Vec { + if self.finished { + return Vec::new(); + } + self.crc.update(data); + self.isize_bytes = self.isize_bytes.wrapping_add(data.len() as u32); + let mut out = Vec::new(); + if !self.header_emitted { + out.extend(gzip_header()); + self.header_emitted = true; + } + out.extend(self.deflater.push(data)); + out + } + + pub fn finish(&mut self) -> Vec { + if self.finished { + return Vec::new(); + } + self.finished = true; + let mut out = Vec::new(); + if !self.header_emitted { + out.extend(gzip_header()); + self.header_emitted = true; + } + out.extend(self.deflater.finish()); + let crc = self.crc.finalize(); + out.push(crc as u8); + out.push((crc >> 8) as u8); + out.push((crc >> 16) as u8); + out.push((crc >> 24) as u8); + let isz = self.isize_bytes; + out.push(isz as u8); + out.push((isz >> 8) as u8); + out.push((isz >> 16) as u8); + out.push((isz >> 24) as u8); + out + } +} + +/// Minimal gzip header: magic, deflate method, no flags, zero MTIME, no extra +/// flags, OS = 0xFF (unknown). +fn gzip_header() -> [u8; 10] { + [ + 0x1F, 0x8B, // magic + 0x08, // CM = DEFLATE + 0x00, // FLG = none + 0x00, 0x00, 0x00, 0x00, // MTIME = 0 + 0x00, // XFL + 0xFF, // OS = unknown + ] +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crc32_empty() { + assert_eq!(crc32(b""), 0); + } + + #[test] + fn crc32_known_values() { + // Well-known vectors. + assert_eq!(crc32(b"a"), 0xE8B7BE43); + assert_eq!(crc32(b"abc"), 0x352441C2); + assert_eq!( + crc32(b"The quick brown fox jumps over the lazy dog"), + 0x414FA339 + ); + } + + #[test] + fn crc32_incremental_matches_oneshot() { + let data = b"hello world, gzip!"; + let mut c = Crc32::new(); + c.update(&data[..5]); + c.update(&data[5..]); + assert_eq!(c.finalize(), crc32(data)); + } + + #[test] + fn gzip_roundtrip_empty() { + let mut d = StreamingGzipDeflater::new(); + let mut comp = d.push(b""); + comp.extend(d.finish()); + let mut i = StreamingGzipInflater::new(); + let out = i.push(&comp).unwrap(); + i.finish().unwrap(); + assert!(out.is_empty()); + } + + #[test] + fn gzip_roundtrip_basic() { + let payload = b"Hello, gzip streaming world!"; + let mut d = StreamingGzipDeflater::new(); + let mut comp = d.push(payload); + comp.extend(d.finish()); + let mut i = StreamingGzipInflater::new(); + let mut out = Vec::new(); + for &b in &comp { + out.extend(i.push(&[b]).unwrap()); + } + i.finish().unwrap(); + assert_eq!(out, payload); + } + + #[test] + fn gzip_roundtrip_chunked() { + let payload: Vec = (0..3000u32) + .map(|i| (i.wrapping_mul(2654435761) & 0xff) as u8) + .collect(); + let mut d = StreamingGzipDeflater::new(); + let mut comp = Vec::new(); + for chunk in payload.chunks(19) { + comp.extend(d.push(chunk)); + } + comp.extend(d.finish()); + + let mut i = StreamingGzipInflater::new(); + let mut out = Vec::new(); + for chunk in comp.chunks(5) { + out.extend(i.push(chunk).unwrap()); + } + i.finish().unwrap(); + assert_eq!(out, payload); + } + + #[test] + fn gzip_inflate_rejects_bad_magic() { + let mut i = StreamingGzipInflater::new(); + let bytes = [0x00u8; 10]; + assert!(matches!(i.push(&bytes), Err(GzipError::BadMagic))); + } + + #[test] + fn gzip_inflate_rejects_bad_crc() { + let payload = b"hello"; + let mut d = StreamingGzipDeflater::new(); + let mut comp = d.push(payload); + comp.extend(d.finish()); + // Corrupt one byte of CRC (positions n-8..n-4). + let n = comp.len(); + comp[n - 8] ^= 0xAA; + let mut i = StreamingGzipInflater::new(); + let mut err = None; + for &b in &comp { + match i.push(&[b]) { + Ok(_) => {} + Err(e) => { + err = Some(e); + break; + } + } + } + assert!(matches!(err, Some(GzipError::CrcMismatch { .. }))); + } +} diff --git a/crates/image/src/lib.rs b/crates/image/src/lib.rs index fbbde9c..5fd2fe3 100644 --- a/crates/image/src/lib.rs +++ b/crates/image/src/lib.rs @@ -2,6 +2,7 @@ pub mod deflate; pub mod gif; +pub mod gzip; pub mod jpeg; pub mod pixel; pub mod png; diff --git a/crates/image/src/zlib.rs b/crates/image/src/zlib.rs index 37d627f..02efb32 100644 --- a/crates/image/src/zlib.rs +++ b/crates/image/src/zlib.rs @@ -1,9 +1,12 @@ -//! zlib decompression (RFC 1950). +//! zlib compression and decompression (RFC 1950). //! //! Parses the zlib header and trailer, delegates to DEFLATE for the actual -//! compressed data, and verifies the Adler-32 checksum. +//! compressed data, and verifies the Adler-32 checksum. Also exposes +//! incremental [`StreamingZlibInflater`] and [`StreamingZlibDeflater`] used +//! by the Streams API `CompressionStream('deflate')` and +//! `DecompressionStream('deflate')` transforms. -use crate::deflate; +use crate::deflate::{self, StreamingDeflater, StreamingInflater}; use std::fmt; // --------------------------------------------------------------------------- @@ -70,7 +73,7 @@ pub type Result = std::result::Result; // --------------------------------------------------------------------------- /// Compute the Adler-32 checksum of `data`. -fn adler32(data: &[u8]) -> u32 { +pub fn adler32(data: &[u8]) -> u32 { const MOD_ADLER: u32 = 65521; let mut a: u32 = 1; @@ -168,6 +171,241 @@ pub fn zlib_decompress(input: &[u8]) -> Result> { Ok(decompressed) } +// --------------------------------------------------------------------------- +// Incremental Adler-32 +// --------------------------------------------------------------------------- + +const ADLER_MOD: u32 = 65521; +const ADLER_BLOCK: usize = 5552; + +/// Rolling Adler-32 used by the streaming zlib wrapper. +#[derive(Debug, Clone, Copy)] +pub struct Adler32 { + a: u32, + b: u32, +} + +impl Default for Adler32 { + fn default() -> Self { + Self::new() + } +} + +impl Adler32 { + pub fn new() -> Self { + Self { a: 1, b: 0 } + } + + pub fn update(&mut self, data: &[u8]) { + for chunk in data.chunks(ADLER_BLOCK) { + for &byte in chunk { + self.a += byte as u32; + self.b += self.a; + } + self.a %= ADLER_MOD; + self.b %= ADLER_MOD; + } + } + + pub fn finalize(self) -> u32 { + (self.b << 16) | self.a + } +} + +// --------------------------------------------------------------------------- +// Streaming inflate / deflate wrappers for the zlib container +// --------------------------------------------------------------------------- + +/// Stages of the zlib container during streaming inflation. +enum ZInfStage { + Header, + Body, + Trailer, + Done, +} + +/// Incremental zlib (RFC 1950) decompressor. +pub struct StreamingZlibInflater { + stage: ZInfStage, + header_bytes: Vec, // up to 2 + trailer_bytes: Vec, + inflater: StreamingInflater, + adler: Adler32, + saved_trailer_input: Vec, // bytes seen after the DEFLATE stream finished +} + +impl Default for StreamingZlibInflater { + fn default() -> Self { + Self::new() + } +} + +impl StreamingZlibInflater { + pub fn new() -> Self { + Self { + stage: ZInfStage::Header, + header_bytes: Vec::with_capacity(2), + trailer_bytes: Vec::with_capacity(4), + inflater: StreamingInflater::new(), + adler: Adler32::new(), + saved_trailer_input: Vec::new(), + } + } + + pub fn push(&mut self, data: &[u8]) -> Result> { + let mut input = data; + let mut out = Vec::new(); + if matches!(self.stage, ZInfStage::Header) { + while self.header_bytes.len() < 2 && !input.is_empty() { + self.header_bytes.push(input[0]); + input = &input[1..]; + } + if self.header_bytes.len() == 2 { + let cmf = self.header_bytes[0]; + let flg = self.header_bytes[1]; + let check = (cmf as u16) * 256 + (flg as u16); + if !check.is_multiple_of(31) { + return Err(ZlibError::InvalidHeaderChecksum); + } + let cm = cmf & 0x0F; + if cm != 8 { + return Err(ZlibError::UnsupportedCompressionMethod(cm)); + } + let cinfo = (cmf >> 4) & 0x0F; + if cinfo > 7 { + return Err(ZlibError::InvalidWindowSize(cinfo)); + } + let fdict = (flg >> 5) & 1; + if fdict != 0 { + return Err(ZlibError::PresetDictionaryNotSupported); + } + self.stage = ZInfStage::Body; + } + } + if matches!(self.stage, ZInfStage::Body) { + // Feed bytes one at a time so we know exactly where the DEFLATE + // stream ends. Once `inflater.is_done()`, the remaining input + // belongs to the trailer. + for (idx, &b) in input.iter().enumerate() { + let chunk = inflater_push(&mut self.inflater, &[b])?; + if !chunk.is_empty() { + self.adler.update(&chunk); + out.extend(chunk); + } + if self.inflater.is_done() { + self.stage = ZInfStage::Trailer; + // Anything past this byte is trailer-input. + self.saved_trailer_input + .extend_from_slice(&input[idx + 1..]); + input = &[]; + break; + } + } + } + if matches!(self.stage, ZInfStage::Trailer) { + let mut src: &[u8] = if !self.saved_trailer_input.is_empty() { + let s = std::mem::take(&mut self.saved_trailer_input); + self.trailer_bytes.extend(s); + &[] + } else { + input + }; + while self.trailer_bytes.len() < 4 && !src.is_empty() { + self.trailer_bytes.push(src[0]); + src = &src[1..]; + } + if self.trailer_bytes.len() == 4 { + let expected = ((self.trailer_bytes[0] as u32) << 24) + | ((self.trailer_bytes[1] as u32) << 16) + | ((self.trailer_bytes[2] as u32) << 8) + | (self.trailer_bytes[3] as u32); + let actual = self.adler.finalize(); + if actual != expected { + return Err(ZlibError::ChecksumMismatch { expected, actual }); + } + self.stage = ZInfStage::Done; + } + } + Ok(out) + } + + pub fn finish(&mut self) -> Result> { + if !matches!(self.stage, ZInfStage::Done) { + return Err(ZlibError::MissingTrailer); + } + Ok(Vec::new()) + } +} + +fn inflater_push( + inf: &mut StreamingInflater, + data: &[u8], +) -> std::result::Result, ZlibError> { + inf.push(data).map_err(ZlibError::from) +} + +/// Incremental zlib (RFC 1950) compressor. +pub struct StreamingZlibDeflater { + deflater: StreamingDeflater, + adler: Adler32, + header_emitted: bool, + finished: bool, +} + +impl Default for StreamingZlibDeflater { + fn default() -> Self { + Self::new() + } +} + +impl StreamingZlibDeflater { + pub fn new() -> Self { + Self { + deflater: StreamingDeflater::new(), + adler: Adler32::new(), + header_emitted: false, + finished: false, + } + } + + pub fn push(&mut self, data: &[u8]) -> Vec { + if self.finished { + return Vec::new(); + } + self.adler.update(data); + let mut out = Vec::new(); + if !self.header_emitted { + // CMF=0x78 (deflate, 32 KiB window). FLG=0x9C → FCHECK such that + // (0x78 * 256 + 0x9C) is a multiple of 31. 0x789C = 30876 = 996*31. + out.push(0x78); + out.push(0x9C); + self.header_emitted = true; + } + out.extend(self.deflater.push(data)); + out + } + + pub fn finish(&mut self) -> Vec { + if self.finished { + return Vec::new(); + } + self.finished = true; + let mut out = Vec::new(); + if !self.header_emitted { + out.push(0x78); + out.push(0x9C); + self.header_emitted = true; + } + out.extend(self.deflater.finish()); + let cksum = self.adler.finalize(); + out.push((cksum >> 24) as u8); + out.push((cksum >> 16) as u8); + out.push((cksum >> 8) as u8); + out.push(cksum as u8); + out + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -475,4 +713,83 @@ mod tests { // a = 1 + 255 = 256, b = 0 + 256 = 256 assert_eq!(adler32(&[0xFF]), (256 << 16) | 256); } + + #[test] + fn streaming_zlib_roundtrip_empty() { + let mut d = StreamingZlibDeflater::new(); + let mut comp = d.push(b""); + comp.extend(d.finish()); + let mut i = StreamingZlibInflater::new(); + let out = i.push(&comp).unwrap(); + i.finish().unwrap(); + assert!(out.is_empty()); + } + + #[test] + fn streaming_zlib_roundtrip_basic() { + let payload = b"Hello, streaming zlib!"; + let mut d = StreamingZlibDeflater::new(); + let mut comp = d.push(payload); + comp.extend(d.finish()); + + // Validate one-shot decode succeeds too. + assert_eq!(zlib_decompress(&comp).unwrap(), payload); + + // Now streaming decode byte-by-byte. + let mut i = StreamingZlibInflater::new(); + let mut out = Vec::new(); + for &b in &comp { + out.extend(i.push(&[b]).unwrap()); + } + i.finish().unwrap(); + assert_eq!(out, payload); + } + + #[test] + fn streaming_zlib_roundtrip_chunked() { + let payload: Vec = (0..5000u32).map(|i| (i as u8).wrapping_mul(7)).collect(); + let mut d = StreamingZlibDeflater::new(); + let mut comp = Vec::new(); + for chunk in payload.chunks(13) { + comp.extend(d.push(chunk)); + } + comp.extend(d.finish()); + + let mut i = StreamingZlibInflater::new(); + let mut out = Vec::new(); + for chunk in comp.chunks(7) { + out.extend(i.push(chunk).unwrap()); + } + i.finish().unwrap(); + assert_eq!(out, payload); + } + + #[test] + fn streaming_zlib_inflate_rejects_bad_header() { + let mut i = StreamingZlibInflater::new(); + let err = i.push(&[0x78, 0x00]).unwrap_err(); + assert!(matches!(err, ZlibError::InvalidHeaderChecksum)); + } + + #[test] + fn streaming_zlib_inflate_rejects_bad_checksum() { + let payload = b"hello"; + let mut d = StreamingZlibDeflater::new(); + let mut comp = d.push(payload); + comp.extend(d.finish()); + // Corrupt the last byte (adler-32 LSB). + *comp.last_mut().unwrap() ^= 0xFF; + let mut i = StreamingZlibInflater::new(); + let mut err = None; + for &b in &comp { + match i.push(&[b]) { + Ok(_) => {} + Err(e) => { + err = Some(e); + break; + } + } + } + assert!(matches!(err, Some(ZlibError::ChecksumMismatch { .. }))); + } } diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index 6c8c5fb..ce3e00b 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -10,7 +10,9 @@ path = "src/lib.rs" [dependencies] we-dom = { path = "../dom" } we-css = { path = "../css" } +we-encoding = { path = "../encoding" } we-html = { path = "../html" } +we-image = { path = "../image" } we-style = { path = "../style" } we-net = { path = "../net" } we-url = { path = "../url" } diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 97247c9..1ad78cc 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -281,6 +281,11 @@ pub fn init_builtins(vm: &mut Vm) { // TransformStream, queuing strategies). Must run after the Promise // preamble because the streams implementation depends on `Promise`. crate::streams::init_streams_builtins(vm); + + // Register the built-in transform streams (TextDecoderStream, + // TextEncoderStream, CompressionStream, DecompressionStream). These + // depend on the Streams API foundation having been initialised. + crate::compression_streams::init_transform_streams(vm); } // ── Object.prototype ───────────────────────────────────────── diff --git a/crates/js/src/compression_streams.rs b/crates/js/src/compression_streams.rs new file mode 100644 index 0000000..cf9359d --- /dev/null +++ b/crates/js/src/compression_streams.rs @@ -0,0 +1,928 @@ +//! Native bindings for `TextDecoderStream`, `TextEncoderStream`, +//! `CompressionStream`, `DecompressionStream` — the four built-in transform +//! streams in the Streams API. +//! +//! Each constructor in the corresponding [`PREAMBLE`] JS wraps a Rust-side +//! state object (held in a thread-local registry, keyed by integer id) inside +//! a `TransformStream`. The native methods `__we_text_decoder_*`, +//! `__we_text_encoder_*`, `__we_compress_*`, `__we_decompress_*` mediate +//! byte-level work; the JS shims pass and receive plain JS Arrays of byte +//! values rather than full `Uint8Array` views, and convert at the edges. +//! +//! The bytes flowing in and out cross the JS<->Rust boundary as native byte +//! arrays — see [`read_byte_array`] and [`build_byte_array`]. + +use std::cell::RefCell; +use std::collections::HashMap; + +use we_encoding::streaming::StreamingUtf8Decoder; +use we_image::deflate::{StreamingDeflater, StreamingInflater}; +use we_image::gzip::{StreamingGzipDeflater, StreamingGzipInflater}; +use we_image::zlib::{StreamingZlibDeflater, StreamingZlibInflater}; + +use crate::builtins::make_native; +use crate::vm::{HeapObject, NativeContext, ObjectData, Property, RuntimeError, Value, Vm}; + +// ── Thread-local registry ──────────────────────────────────────────────────── + +enum Compressor { + Raw(StreamingDeflater), + Zlib(StreamingZlibDeflater), + Gzip(StreamingGzipDeflater), +} + +enum Decompressor { + Raw(StreamingInflater), + Zlib(StreamingZlibInflater), + Gzip(StreamingGzipInflater), +} + +struct Encoder; // UTF-8 encoder needs no internal state + +struct State { + next_id: u64, + decoders: HashMap, + encoders: HashMap, + compressors: HashMap, + decompressors: HashMap, +} + +impl State { + fn new() -> Self { + Self { + next_id: 1, + decoders: HashMap::new(), + encoders: HashMap::new(), + compressors: HashMap::new(), + decompressors: HashMap::new(), + } + } + + fn alloc_id(&mut self) -> u64 { + let id = self.next_id; + self.next_id += 1; + id + } +} + +thread_local! { + static STATE: RefCell = RefCell::new(State::new()); +} + +// ── Byte-array marshalling ────────────────────────────────────────────────── + +/// Read a JS Array of byte values (an `Array` of `Number`s) into a `Vec`. +/// Returns `None` if the value isn't an indexable object with a `length`. +fn read_byte_array(ctx: &NativeContext, val: &Value) -> Option> { + let obj_ref = match val { + Value::Object(r) => *r, + _ => return None, + }; + let data = match ctx.gc.get(obj_ref) { + Some(HeapObject::Object(d)) => d, + _ => return None, + }; + let len = data + .get_property("length", ctx.shapes) + .map(|p| p.value.to_number() as usize) + .unwrap_or(0); + let mut out = Vec::with_capacity(len); + for i in 0..len { + let key = i.to_string(); + let byte = data + .get_property(&key, ctx.shapes) + .map(|p| p.value.to_number() as i64) + .unwrap_or(0); + out.push((byte & 0xff) as u8); + } + Some(out) +} + +/// Build a JS Array of byte values from a `&[u8]`. +fn build_byte_array(ctx: &mut NativeContext, bytes: &[u8]) -> Value { + let mut data = ObjectData::new(); + for (i, &b) in bytes.iter().enumerate() { + data.insert_property( + i.to_string(), + Property::data(Value::Number(b as f64)), + ctx.shapes, + ); + } + data.insert_property( + "length".to_string(), + Property { + value: Value::Number(bytes.len() as f64), + writable: true, + enumerable: false, + configurable: false, + }, + ctx.shapes, + ); + Value::Object(ctx.gc.alloc(HeapObject::Object(data))) +} + +fn arg_id(args: &[Value]) -> Option { + args.first().map(|v| v.to_number() as u64) +} + +// ── Text decoder bindings ─────────────────────────────────────────────────── + +fn text_decoder_create(args: &[Value], ctx: &mut NativeContext) -> Result { + let label = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_else(|| "utf-8".to_string()); + let fatal = args.get(1).map(|v| v.to_boolean()).unwrap_or(false); + let ignore_bom = args.get(2).map(|v| v.to_boolean()).unwrap_or(false); + let encoding = we_encoding::lookup(&label) + .ok_or_else(|| RuntimeError::type_error(format!("unsupported encoding: {label}")))?; + if encoding != we_encoding::Encoding::Utf8 { + // The streaming JS shim only wires up UTF-8; other encodings are not + // supported by `TextDecoderStream` in this implementation. + return Err(RuntimeError::type_error(format!( + "TextDecoderStream: encoding {label} not supported" + ))); + } + let id = STATE.with(|s| { + let mut st = s.borrow_mut(); + let id = st.alloc_id(); + st.decoders + .insert(id, StreamingUtf8Decoder::new(fatal, ignore_bom)); + id + }); + Ok(Value::Number(id as f64)) +} + +fn text_decoder_push(args: &[Value], ctx: &mut NativeContext) -> Result { + let id = arg_id(args).ok_or_else(|| RuntimeError::type_error("missing decoder id"))?; + let bytes_val = args.get(1).cloned().unwrap_or(Value::Undefined); + let bytes = read_byte_array(ctx, &bytes_val).unwrap_or_default(); + let result = STATE.with(|s| { + let mut st = s.borrow_mut(); + let dec = st + .decoders + .get_mut(&id) + .ok_or_else(|| RuntimeError::type_error("invalid decoder id"))?; + dec.decode(&bytes) + .map_err(|e| RuntimeError::type_error(e.to_string())) + })?; + Ok(Value::String(result)) +} + +fn text_decoder_flush(args: &[Value], _ctx: &mut NativeContext) -> Result { + let id = arg_id(args).ok_or_else(|| RuntimeError::type_error("missing decoder id"))?; + let result = STATE.with(|s| { + let mut st = s.borrow_mut(); + let dec = st + .decoders + .get_mut(&id) + .ok_or_else(|| RuntimeError::type_error("invalid decoder id"))?; + dec.flush() + .map_err(|e| RuntimeError::type_error(e.to_string())) + })?; + Ok(Value::String(result)) +} + +fn text_decoder_destroy(args: &[Value], _ctx: &mut NativeContext) -> Result { + let id = arg_id(args).ok_or_else(|| RuntimeError::type_error("missing decoder id"))?; + STATE.with(|s| { + s.borrow_mut().decoders.remove(&id); + }); + Ok(Value::Undefined) +} + +// ── Text encoder bindings ─────────────────────────────────────────────────── + +fn text_encoder_create(_args: &[Value], _ctx: &mut NativeContext) -> Result { + let id = STATE.with(|s| { + let mut st = s.borrow_mut(); + let id = st.alloc_id(); + st.encoders.insert(id, Encoder); + id + }); + Ok(Value::Number(id as f64)) +} + +fn text_encoder_push(args: &[Value], ctx: &mut NativeContext) -> Result { + let _id = arg_id(args).ok_or_else(|| RuntimeError::type_error("missing encoder id"))?; + let s = args + .get(1) + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + let bytes = s.into_bytes(); + Ok(build_byte_array(ctx, &bytes)) +} + +fn text_encoder_destroy(args: &[Value], _ctx: &mut NativeContext) -> Result { + let id = arg_id(args).ok_or_else(|| RuntimeError::type_error("missing encoder id"))?; + STATE.with(|s| { + s.borrow_mut().encoders.remove(&id); + }); + Ok(Value::Undefined) +} + +// ── Compressor bindings ───────────────────────────────────────────────────── + +fn parse_format(label: &str) -> Result { + match label { + "deflate" => Ok(Format::Zlib), + "deflate-raw" => Ok(Format::Raw), + "gzip" => Ok(Format::Gzip), + other => Err(RuntimeError::type_error(format!( + "unsupported compression format: {other}" + ))), + } +} + +#[derive(Clone, Copy)] +enum Format { + Raw, + Zlib, + Gzip, +} + +fn compress_create(args: &[Value], ctx: &mut NativeContext) -> Result { + let label = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + let fmt = parse_format(&label)?; + let id = STATE.with(|s| { + let mut st = s.borrow_mut(); + let id = st.alloc_id(); + let c = match fmt { + Format::Raw => Compressor::Raw(StreamingDeflater::new()), + Format::Zlib => Compressor::Zlib(StreamingZlibDeflater::new()), + Format::Gzip => Compressor::Gzip(StreamingGzipDeflater::new()), + }; + st.compressors.insert(id, c); + id + }); + Ok(Value::Number(id as f64)) +} + +fn compress_push(args: &[Value], ctx: &mut NativeContext) -> Result { + let id = arg_id(args).ok_or_else(|| RuntimeError::type_error("missing compressor id"))?; + let bytes_val = args.get(1).cloned().unwrap_or(Value::Undefined); + let bytes = read_byte_array(ctx, &bytes_val).unwrap_or_default(); + let out = STATE.with(|s| { + let mut st = s.borrow_mut(); + let c = st + .compressors + .get_mut(&id) + .ok_or_else(|| RuntimeError::type_error("invalid compressor id"))?; + Ok::, RuntimeError>(match c { + Compressor::Raw(d) => d.push(&bytes), + Compressor::Zlib(d) => d.push(&bytes), + Compressor::Gzip(d) => d.push(&bytes), + }) + })?; + Ok(build_byte_array(ctx, &out)) +} + +fn compress_finish(args: &[Value], ctx: &mut NativeContext) -> Result { + let id = arg_id(args).ok_or_else(|| RuntimeError::type_error("missing compressor id"))?; + let out = STATE.with(|s| { + let mut st = s.borrow_mut(); + let c = st + .compressors + .get_mut(&id) + .ok_or_else(|| RuntimeError::type_error("invalid compressor id"))?; + Ok::, RuntimeError>(match c { + Compressor::Raw(d) => d.finish(), + Compressor::Zlib(d) => d.finish(), + Compressor::Gzip(d) => d.finish(), + }) + })?; + Ok(build_byte_array(ctx, &out)) +} + +fn compress_destroy(args: &[Value], _ctx: &mut NativeContext) -> Result { + let id = arg_id(args).ok_or_else(|| RuntimeError::type_error("missing compressor id"))?; + STATE.with(|s| { + s.borrow_mut().compressors.remove(&id); + }); + Ok(Value::Undefined) +} + +// ── Decompressor bindings ─────────────────────────────────────────────────── + +fn decompress_create(args: &[Value], ctx: &mut NativeContext) -> Result { + let label = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + let fmt = parse_format(&label)?; + let id = STATE.with(|s| { + let mut st = s.borrow_mut(); + let id = st.alloc_id(); + let d = match fmt { + Format::Raw => Decompressor::Raw(StreamingInflater::new()), + Format::Zlib => Decompressor::Zlib(StreamingZlibInflater::new()), + Format::Gzip => Decompressor::Gzip(StreamingGzipInflater::new()), + }; + st.decompressors.insert(id, d); + id + }); + Ok(Value::Number(id as f64)) +} + +fn decompress_push(args: &[Value], ctx: &mut NativeContext) -> Result { + let id = arg_id(args).ok_or_else(|| RuntimeError::type_error("missing decompressor id"))?; + let bytes_val = args.get(1).cloned().unwrap_or(Value::Undefined); + let bytes = read_byte_array(ctx, &bytes_val).unwrap_or_default(); + let out = STATE.with(|s| { + let mut st = s.borrow_mut(); + let d = st + .decompressors + .get_mut(&id) + .ok_or_else(|| RuntimeError::type_error("invalid decompressor id"))?; + match d { + Decompressor::Raw(i) => i + .push(&bytes) + .map_err(|e| RuntimeError::type_error(e.to_string())), + Decompressor::Zlib(i) => i + .push(&bytes) + .map_err(|e| RuntimeError::type_error(e.to_string())), + Decompressor::Gzip(i) => i + .push(&bytes) + .map_err(|e| RuntimeError::type_error(e.to_string())), + } + })?; + Ok(build_byte_array(ctx, &out)) +} + +fn decompress_finish(args: &[Value], _ctx: &mut NativeContext) -> Result { + let id = arg_id(args).ok_or_else(|| RuntimeError::type_error("missing decompressor id"))?; + STATE.with(|s| { + let mut st = s.borrow_mut(); + let d = st + .decompressors + .get_mut(&id) + .ok_or_else(|| RuntimeError::type_error("invalid decompressor id"))?; + match d { + Decompressor::Raw(i) => i + .finish() + .map_err(|e| RuntimeError::type_error(e.to_string()))?, + Decompressor::Zlib(i) => i + .finish() + .map_err(|e| RuntimeError::type_error(e.to_string()))?, + Decompressor::Gzip(i) => i + .finish() + .map_err(|e| RuntimeError::type_error(e.to_string()))?, + }; + Ok::<(), RuntimeError>(()) + })?; + // Streaming inflaters emit any pending output on push; finish only checks + // the trailer/state, so no bytes need to be returned. + Ok(Value::Undefined) +} + +fn decompress_destroy(args: &[Value], _ctx: &mut NativeContext) -> Result { + let id = arg_id(args).ok_or_else(|| RuntimeError::type_error("missing decompressor id"))?; + STATE.with(|s| { + s.borrow_mut().decompressors.remove(&id); + }); + Ok(Value::Undefined) +} + +// ── Registration + JS preamble ────────────────────────────────────────────── + +/// Register all native helpers and run the JS preamble that defines +/// `TextDecoderStream`, `TextEncoderStream`, `CompressionStream`, and +/// `DecompressionStream` as global constructors. +pub fn init_transform_streams(vm: &mut Vm) { + macro_rules! reg { + ($name:expr, $fn:ident) => {{ + let f = make_native(&mut vm.gc, $name, $fn); + vm.set_global($name, Value::Function(f)); + }}; + } + reg!("__we_text_decoder_create", text_decoder_create); + reg!("__we_text_decoder_push", text_decoder_push); + reg!("__we_text_decoder_flush", text_decoder_flush); + reg!("__we_text_decoder_destroy", text_decoder_destroy); + reg!("__we_text_encoder_create", text_encoder_create); + reg!("__we_text_encoder_push", text_encoder_push); + reg!("__we_text_encoder_destroy", text_encoder_destroy); + reg!("__we_compress_create", compress_create); + reg!("__we_compress_push", compress_push); + reg!("__we_compress_finish", compress_finish); + reg!("__we_compress_destroy", compress_destroy); + reg!("__we_decompress_create", decompress_create); + reg!("__we_decompress_push", decompress_push); + reg!("__we_decompress_finish", decompress_finish); + reg!("__we_decompress_destroy", decompress_destroy); + + // Run the JS preamble. + let ast = match crate::parser::Parser::parse(PREAMBLE) { + Ok(a) => a, + Err(e) => { + eprintln!("transform-streams preamble parse error: {e}"); + return; + } + }; + let func = match crate::compiler::compile(&ast) { + Ok(f) => f, + Err(e) => { + eprintln!("transform-streams preamble compile error: {e}"); + return; + } + }; + if let Err(e) = vm.execute(&func) { + eprintln!("transform-streams preamble runtime error: {e}"); + } +} + +const PREAMBLE: &str = r#" +// ── Helpers ─────────────────────────────────────────────────────────────── + +// Convert a Uint8Array view (or generic array-like) into a plain Array of +// byte values, sliced to (byteOffset, byteLength) if applicable. +function _we_view_to_bytes(view) { + var arr = []; + if (view && view.buffer && view.buffer._bytes !== undefined && + view.byteLength !== undefined && view.byteOffset !== undefined) { + var src = view.buffer._bytes; + var off = view.byteOffset; + var n = view.byteLength; + for (var i = 0; i < n; i++) arr.push(src[off + i] & 0xff); + return arr; + } + if (view && view._bytes !== undefined && view.byteLength !== undefined) { + // Plain ArrayBuffer. + var src2 = view._bytes; + for (var j = 0; j < view.byteLength; j++) arr.push(src2[j] & 0xff); + return arr; + } + if (view && view.length !== undefined) { + for (var k = 0; k < view.length; k++) arr.push(view[k] & 0xff); + return arr; + } + return arr; +} + +// Convert a plain Array of byte values into a Uint8Array view. +function _we_bytes_to_view(bytes) { + var ab = ArrayBuffer(bytes.length); + for (var i = 0; i < bytes.length; i++) ab._bytes[i] = bytes[i] & 0xff; + return Uint8Array(ab, 0, bytes.length); +} + +// Note: the engine has a known quirk where a method call whose argument is +// itself a function call (e.g. `controller.enqueue(_we_bytes_to_view(b))`) +// can clobber the `this` register. Every transform below assigns intermediate +// results to a local variable before passing them to `controller.enqueue`. + +// ── TextDecoderStream ───────────────────────────────────────────────────── + +function TextDecoderStream(label, options) { + var encName = (label === undefined) ? 'utf-8' : String(label); + options = options || {}; + var fatal = !!options.fatal; + var ignoreBOM = !!options.ignoreBOM; + var id = __we_text_decoder_create(encName, fatal, ignoreBOM); + var self = Object.create(TextDecoderStream.prototype); + self.encoding = 'utf-8'; + self.fatal = fatal; + self.ignoreBOM = ignoreBOM; + self._id = id; + + var ts = new TransformStream({ + transform: function(chunk, controller) { + var bytes = _we_view_to_bytes(chunk); + var text; + try { text = __we_text_decoder_push(id, bytes); } + catch (e) { controller.error(e); return; } + if (text.length > 0) controller.enqueue(text); + }, + flush: function(controller) { + var tail; + try { tail = __we_text_decoder_flush(id); } + catch (e) { + __we_text_decoder_destroy(id); + controller.error(e); + return; + } + __we_text_decoder_destroy(id); + if (tail && tail.length > 0) controller.enqueue(tail); + } + }); + self.readable = ts.readable; + self.writable = ts.writable; + return self; +} + +// ── TextEncoderStream ───────────────────────────────────────────────────── + +function TextEncoderStream() { + var id = __we_text_encoder_create(); + var self = Object.create(TextEncoderStream.prototype); + self.encoding = 'utf-8'; + self._id = id; + var ts = new TransformStream({ + transform: function(chunk, controller) { + var s = String(chunk); + var bytes; + try { bytes = __we_text_encoder_push(id, s); } + catch (e) { controller.error(e); return; } + if (bytes.length > 0) { + var view = _we_bytes_to_view(bytes); + controller.enqueue(view); + } + }, + flush: function() { + __we_text_encoder_destroy(id); + } + }); + self.readable = ts.readable; + self.writable = ts.writable; + return self; +} + +// ── CompressionStream / DecompressionStream ─────────────────────────────── + +function CompressionStream(format) { + var id = __we_compress_create(String(format)); + var self = Object.create(CompressionStream.prototype); + self.format = format; + self._id = id; + var ts = new TransformStream({ + transform: function(chunk, controller) { + var bytes = _we_view_to_bytes(chunk); + var out; + try { out = __we_compress_push(id, bytes); } + catch (e) { controller.error(e); return; } + if (out.length > 0) { + var view = _we_bytes_to_view(out); + controller.enqueue(view); + } + }, + flush: function(controller) { + var tail; + try { tail = __we_compress_finish(id); } + catch (e) { + __we_compress_destroy(id); + controller.error(e); + return; + } + __we_compress_destroy(id); + if (tail.length > 0) { + var view = _we_bytes_to_view(tail); + controller.enqueue(view); + } + } + }); + self.readable = ts.readable; + self.writable = ts.writable; + return self; +} + +function DecompressionStream(format) { + var id = __we_decompress_create(String(format)); + var self = Object.create(DecompressionStream.prototype); + self.format = format; + self._id = id; + var ts = new TransformStream({ + transform: function(chunk, controller) { + var bytes = _we_view_to_bytes(chunk); + var out; + try { out = __we_decompress_push(id, bytes); } + catch (e) { controller.error(e); return; } + if (out.length > 0) { + var view = _we_bytes_to_view(out); + controller.enqueue(view); + } + }, + flush: function(controller) { + try { __we_decompress_finish(id); } + catch (e) { + __we_decompress_destroy(id); + controller.error(e); + return; + } + __we_decompress_destroy(id); + } + }); + self.readable = ts.readable; + self.writable = ts.writable; + return self; +} +"#; + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::compiler; + use crate::parser::Parser; + use crate::vm::ConsoleOutput; + use std::rc::Rc; + use std::sync::Mutex; + + struct CapturedConsole(Mutex>); + impl ConsoleOutput for CapturedConsole { + fn log(&self, m: &str) { + self.0.lock().unwrap().push(m.to_string()); + } + fn error(&self, m: &str) { + self.0.lock().unwrap().push(format!("ERR:{m}")); + } + fn warn(&self, _: &str) {} + } + struct ArcConsole(Rc); + impl ConsoleOutput for ArcConsole { + fn log(&self, m: &str) { + self.0.log(m); + } + fn error(&self, m: &str) { + self.0.error(m); + } + fn warn(&self, m: &str) { + self.0.warn(m); + } + } + + fn run(source: &str) -> Vec { + let console = Rc::new(CapturedConsole(Mutex::new(Vec::new()))); + let mut vm = Vm::new(); + vm.set_console_output(Box::new(ArcConsole(console.clone()))); + // Vm::new() already invokes init_transform_streams via init_builtins; + // no need to call it again here. + let ast = Parser::parse(source).expect("parse"); + let func = compiler::compile(&ast).expect("compile"); + vm.execute(&func).expect("execute"); + let logs = console.0.lock().unwrap().clone(); + logs + } + + #[test] + fn text_encoder_stream_alone() { + let logs = run(r#" + var enc = new TextEncoderStream(); + var w = enc.writable.getWriter(); + w.write("hi").then(function() {}); + w.close(); + var r = enc.readable.getReader(); + r.read().then(function(res) { + if (res.done) { console.log("done:0"); return; } + console.log("got:" + res.value.byteLength + ":" + res.value.get(0) + ":" + res.value.get(1)); + }); + "#); + assert_eq!(logs, vec!["got:2:104:105".to_string()]); + } + + #[test] + fn text_decoder_stream_decodes_utf8() { + // Feed the byte sequence for "hello" directly to a TextDecoderStream. + // (Cross-TransformStream pipeTo currently hits an unrelated bug in the + // Streams API foundation; the codec itself is tested here.) + let logs = run(r#" + var dec = new TextDecoderStream(); + var w = dec.writable.getWriter(); + var u = Uint8Array([104, 101, 108, 108, 111]); // "hello" + w.write(u); + w.close(); + var r = dec.readable.getReader(); + function loop(acc) { + r.read().then(function(res) { + if (res.done) { console.log("got:" + acc); return; } + loop(acc + res.value); + }); + } + loop(""); + "#); + assert_eq!(logs, vec!["got:hello".to_string()]); + } + + #[test] + fn text_decoder_handles_split_multibyte() { + // Feed 0xC3 in one chunk and 0xA9 in another → produce "é". + let logs = run(r#" + var dec = new TextDecoderStream(); + var w = dec.writable.getWriter(); + // Build two-byte chunks via Uint8Array. + var u1 = Uint8Array([0xC3]); + var u2 = Uint8Array([0xA9]); + w.write(u1); + w.write(u2); + w.close(); + var r = dec.readable.getReader(); + function loop(acc) { + r.read().then(function(res) { + if (res.done) { console.log("text:" + acc); return; } + loop(acc + res.value); + }); + } + loop(""); + "#); + assert_eq!(logs, vec!["text:\u{00E9}".to_string()]); + } + + #[test] + fn compression_stream_invalid_format_throws() { + let logs = run(r#" + try { + new CompressionStream("snappy"); + console.log("no-throw"); + } catch (e) { + console.log("threw"); + } + "#); + assert_eq!(logs, vec!["threw".to_string()]); + } + + #[test] + fn compression_stream_deflate_raw_alone() { + let logs = run(r#" + var cs = new CompressionStream("deflate-raw"); + var w = cs.writable.getWriter(); + var input = Uint8Array([72, 101, 108, 108, 111]); + w.write(input); + w.close(); + var r = cs.readable.getReader(); + function readAll(acc) { + r.read().then(function(res) { + if (res.done) { console.log("len:" + acc.length); return; } + var n = res.value.byteLength; + for (var i = 0; i < n; i++) { + var b = res.value.get(i); + acc.push(b); + } + readAll(acc); + }); + } + readAll([]); + "#); + assert!(logs + .iter() + .any(|l| l.starts_with("len:") && !l.starts_with("len:0"))); + } + + /// JS helpers prepended to compression-stream tests. `readAll` pulls every + /// chunk out of a reader (one chunk at a time) and accumulates the bytes, + /// then invokes `doneCb(acc)`. `bytesToString` converts a byte array to a + /// string. The byte-copy loop uses an intermediate `b` variable because + /// `acc.push(view.get(i))` (a method call whose argument is itself a + /// method call) hits an engine bug and silently no-ops. + const READ_ALL_HELPER: &str = r#" + function readAll(reader, doneCb) { + var acc = []; + function step() { + reader.read().then(function(res) { + if (res.done) { doneCb(acc); return; } + var view = res.value; + var n = view.byteLength; + for (var i = 0; i < n; i++) { + var b = view.get(i); + acc.push(b); + } + step(); + }, function(e) { + var msg = e && e.message ? e.message : "?"; + console.log("READ-ERR:" + msg); + }); + } + step(); + } + function bytesToString(arr) { + var s = ""; + for (var i = 0; i < arr.length; i++) s += String.fromCharCode(arr[i]); + return s; + } + "#; + + fn run_with_helper(extra_source: &str) -> Vec { + let source = format!("{}\n{}", READ_ALL_HELPER, extra_source); + run(&source) + } + + /// Native round-trip: drive compressor and decompressor directly through + /// the native bindings, without involving the Streams API. Verifies the + /// streaming codec end-to-end. + #[test] + fn native_deflate_roundtrip() { + let logs = run(r#" + var id = __we_compress_create("deflate"); + var out1 = __we_compress_push(id, [72, 101, 108, 108, 111]); + var out2 = __we_compress_finish(id); + __we_compress_destroy(id); + + var did = __we_decompress_create("deflate"); + var d1 = __we_decompress_push(did, out1); + var d2 = __we_decompress_push(did, out2); + __we_decompress_finish(did); + __we_decompress_destroy(did); + + var all = []; + for (var i = 0; i < d1.length; i++) all.push(d1[i]); + for (var j = 0; j < d2.length; j++) all.push(d2[j]); + console.log("len:" + all.length); + for (var k = 0; k < all.length; k++) console.log("b:" + all[k]); + "#); + assert_eq!( + logs, + vec!["len:5", "b:72", "b:101", "b:108", "b:108", "b:111"] + .iter() + .map(|s| s.to_string()) + .collect::>() + ); + } + + /// Feed pre-compressed bytes (deflate / zlib) to a DecompressionStream in + /// multiple writes (mimicking the header + body+trailer split that the + /// matching compressor produces) and verify the decompressed output. + /// Bypasses the cross-TransformStream pipeTo path because the underlying + /// Streams API has a bug there (see e2e-smoke ticket). + #[test] + fn decompression_stream_deflate_multi_chunk() { + let logs = run_with_helper( + r#" + var id = __we_compress_create("deflate"); + var out1 = __we_compress_push(id, [72, 101, 108, 108, 111]); + var out2 = __we_compress_finish(id); + __we_compress_destroy(id); + + var ds = new DecompressionStream("deflate"); + var w = ds.writable.getWriter(); + + var ab1 = ArrayBuffer(out1.length); + for (var i = 0; i < out1.length; i++) ab1._bytes[i] = out1[i]; + var view1 = Uint8Array(ab1, 0, out1.length); + var ab2 = ArrayBuffer(out2.length); + for (var j = 0; j < out2.length; j++) ab2._bytes[j] = out2[j]; + var view2 = Uint8Array(ab2, 0, out2.length); + + w.write(view1); + w.write(view2); + w.close(); + readAll(ds.readable.getReader(), function(acc) { + console.log("out:" + bytesToString(acc)); + }); + "#, + ); + assert!(logs.iter().any(|l| l == "out:Hello")); + } + + /// Same as above but for gzip. + #[test] + fn decompression_stream_gzip_multi_chunk() { + let logs = run_with_helper( + r#" + var id = __we_compress_create("gzip"); + var out1 = __we_compress_push(id, [65, 66, 67, 68]); // "ABCD" + var out2 = __we_compress_finish(id); + __we_compress_destroy(id); + + var ds = new DecompressionStream("gzip"); + var w = ds.writable.getWriter(); + + var ab1 = ArrayBuffer(out1.length); + for (var i = 0; i < out1.length; i++) ab1._bytes[i] = out1[i]; + var v1 = Uint8Array(ab1, 0, out1.length); + var ab2 = ArrayBuffer(out2.length); + for (var j = 0; j < out2.length; j++) ab2._bytes[j] = out2[j]; + var v2 = Uint8Array(ab2, 0, out2.length); + + w.write(v1); + w.write(v2); + w.close(); + readAll(ds.readable.getReader(), function(acc) { + console.log("out:" + bytesToString(acc)); + }); + "#, + ); + assert!(logs.iter().any(|l| l == "out:ABCD")); + } + + /// deflate-raw doesn't emit a header or trailer, so the compressor + /// produces a single chunk on finish(). pipeTo handles this path because + /// the source emits exactly one chunk (which the upstream Streams API bug + /// can handle). + #[test] + fn compression_stream_deflate_raw_roundtrip() { + let logs = run_with_helper( + r#" + var cs = new CompressionStream("deflate-raw"); + var ds = new DecompressionStream("deflate-raw"); + var w = cs.writable.getWriter(); + var input = Uint8Array([88, 89, 90]); // "XYZ" + w.write(input); + w.close(); + cs.readable.pipeTo(ds.writable); + readAll(ds.readable.getReader(), function(acc) { + console.log("out:" + bytesToString(acc)); + }); + "#, + ); + assert_eq!(logs, vec!["out:XYZ".to_string()]); + } + + /// Compress through CompressionStream alone and check it produces output. + #[test] + fn compression_stream_deflate_raw_alone_native() { + // Already covered by compression_stream_deflate_raw_alone above; + // intentionally left as a pointer. + } +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 8318b1e..d901640 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -5,6 +5,7 @@ pub mod broadcast_channel; pub mod builtins; pub mod bytecode; pub mod compiler; +pub mod compression_streams; pub mod dom_bridge; pub mod fetch; pub mod gc;