diff --git a/crates/ironrdp-egfx/src/server.rs b/crates/ironrdp-egfx/src/server.rs index 81e4185f3..81bc444f9 100644 --- a/crates/ironrdp-egfx/src/server.rs +++ b/crates/ironrdp-egfx/src/server.rs @@ -1356,6 +1356,52 @@ impl GraphicsPipelineServer { Some(frame_id) } + /// Queue a ClearCodec frame for transmission. + /// + /// ClearCodec is a mandatory lossless codec for all EGFX versions. It + /// provides excellent compression for text, UI elements, and icons. + /// + /// `bitmap_data` should be a pre-encoded ClearCodec bitmap stream + /// (as produced by `ironrdp_graphics::clearcodec::ClearCodecEncoder`). + /// + /// Returns `Some(frame_id)` if queued, `None` if backpressure is active + /// or the server is not ready. + pub fn send_clearcodec_frame( + &mut self, + surface_id: u16, + destination_rectangle: InclusiveRectangle, + bitmap_data: Vec, + timestamp_ms: u32, + ) -> Option { + if !self.is_ready() { + return None; + } + if self.should_backpressure() { + self.qoe.record_backpressure(); + return None; + } + + let surface = self.surfaces.get(surface_id)?; + + let timestamp = Self::make_timestamp(timestamp_ms); + let frame_id = self.frames.begin_frame(timestamp); + + self.output_queue + .push_back(GfxPdu::StartFrame(StartFramePdu { timestamp, frame_id })); + + self.output_queue.push_back(GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id, + codec_id: Codec1Type::ClearCodec, + pixel_format: surface.pixel_format, + destination_rectangle, + bitmap_data, + })); + + self.output_queue.push_back(GfxPdu::EndFrame(EndFramePdu { frame_id })); + + Some(frame_id) + } + // ======================================================================== // Output Management // ======================================================================== diff --git a/crates/ironrdp-graphics/src/clearcodec/glyph_cache.rs b/crates/ironrdp-graphics/src/clearcodec/glyph_cache.rs new file mode 100644 index 000000000..519d6b810 --- /dev/null +++ b/crates/ironrdp-graphics/src/clearcodec/glyph_cache.rs @@ -0,0 +1,98 @@ +//! Glyph cache for ClearCodec (MS-RDPEGFX 2.2.4.1). +//! +//! When a bitmap area is <= 1024 pixels, ClearCodec can index it in a +//! 4,000-entry glyph cache. On a cache hit (FLAG_GLYPH_HIT), the previously +//! cached pixel data is reused without retransmission. + +/// Maximum number of glyph cache entries. +pub const GLYPH_CACHE_SIZE: usize = 4_000; + +/// A cached glyph entry: BGRA pixel data with dimensions. +#[derive(Debug, Clone)] +pub struct GlyphEntry { + pub width: u16, + pub height: u16, + /// BGRA pixel data (4 bytes per pixel). + pub pixels: Vec, +} + +/// Glyph cache for ClearCodec bitmap deduplication. +pub struct GlyphCache { + entries: Vec>, +} + +impl GlyphCache { + pub fn new() -> Self { + let mut entries = Vec::with_capacity(GLYPH_CACHE_SIZE); + entries.resize_with(GLYPH_CACHE_SIZE, || None); + Self { entries } + } + + /// Look up a glyph by its cache index. + pub fn get(&self, index: u16) -> Option<&GlyphEntry> { + self.entries.get(usize::from(index)).and_then(|slot| slot.as_ref()) + } + + /// Store a glyph at the given index. + /// + /// Returns `true` if the index was valid and the entry was stored. + pub fn store(&mut self, index: u16, entry: GlyphEntry) -> bool { + let idx = usize::from(index); + if idx < GLYPH_CACHE_SIZE { + self.entries[idx] = Some(entry); + true + } else { + false + } + } + + /// Reset the entire glyph cache, removing all entries. + pub fn reset(&mut self) { + for slot in &mut self.entries { + *slot = None; + } + } +} + +impl Default for GlyphCache { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn store_and_retrieve() { + let mut cache = GlyphCache::new(); + let entry = GlyphEntry { + width: 8, + height: 16, + pixels: vec![0xFF; 8 * 16 * 4], + }; + assert!(cache.store(42, entry)); + let retrieved = cache.get(42).unwrap(); + assert_eq!(retrieved.width, 8); + assert_eq!(retrieved.height, 16); + } + + #[test] + fn get_empty_returns_none() { + let cache = GlyphCache::new(); + assert!(cache.get(0).is_none()); + assert!(cache.get(3999).is_none()); + } + + #[test] + fn reject_out_of_range() { + let mut cache = GlyphCache::new(); + let entry = GlyphEntry { + width: 1, + height: 1, + pixels: vec![0; 4], + }; + assert!(!cache.store(4000, entry)); + } +} diff --git a/crates/ironrdp-graphics/src/clearcodec/mod.rs b/crates/ironrdp-graphics/src/clearcodec/mod.rs new file mode 100644 index 000000000..efe38f923 --- /dev/null +++ b/crates/ironrdp-graphics/src/clearcodec/mod.rs @@ -0,0 +1,743 @@ +//! ClearCodec bitmap decoder and encoder (MS-RDPEGFX 2.2.4.1). +//! +//! ClearCodec is a mandatory lossless codec for EGFX that uses three-layer +//! compositing (residual BGR RLE, bands with V-bar caching, subcodecs) to +//! efficiently encode text, UI elements, and icons. + +mod glyph_cache; +mod vbar_cache; + +pub use self::glyph_cache::{GLYPH_CACHE_SIZE, GlyphCache, GlyphEntry}; +pub use self::vbar_cache::{FullVBar, ShortVBar, VBarCache}; + +/// Glyph cache size as u16 for index arithmetic. GLYPH_CACHE_SIZE=4000 fits in u16. +const GLYPH_CACHE_WRAP: u16 = 4_000; + +use ironrdp_core::{DecodeResult, ReadCursor, invalid_field_err}; +use ironrdp_pdu::codecs::clearcodec::{ + ClearCodecBitmapStream, CompositePayload, FLAG_GLYPH_INDEX, RgbRunSegment, SubcodecId, VBar, decode_bands_layer, + decode_residual_layer, decode_subcodec_layer, encode_residual_layer, +}; + +/// ClearCodec decoder maintaining persistent cache state across frames. +pub struct ClearCodecDecoder { + vbar_cache: VBarCache, + glyph_cache: GlyphCache, +} + +impl ClearCodecDecoder { + pub fn new() -> Self { + Self { + vbar_cache: VBarCache::new(), + glyph_cache: GlyphCache::new(), + } + } + + /// Decode a ClearCodec bitmap stream into BGRA pixel data. + /// + /// The output buffer is `width * height * 4` bytes in BGRA format. + /// The caller is responsible for compositing the result onto the target + /// surface at the destination rectangle. + pub fn decode(&mut self, data: &[u8], width: u16, height: u16) -> DecodeResult> { + let mut src = ReadCursor::new(data); + let stream = ClearCodecBitmapStream::decode(&mut src)?; + + // Handle cache reset + if stream.is_cache_reset() { + self.vbar_cache.reset(); + } + + // Validate glyph index range per spec: 0..3999 inclusive + if let Some(idx) = stream.glyph_index { + if idx >= GLYPH_CACHE_WRAP { + return Err(invalid_field_err!("glyphIndex", "glyph index out of range 0-3999")); + } + } + + let w = usize::from(width); + let h = usize::from(height); + let pixel_count = w + .checked_mul(h) + .ok_or_else(|| invalid_field_err!("dimensions", "width * height overflow"))?; + + // Handle glyph hit: return cached pixel data + if stream.is_glyph_hit() { + let glyph_index = stream + .glyph_index + .ok_or_else(|| invalid_field_err!("flags", "GLYPH_HIT without GLYPH_INDEX"))?; + let entry = self + .glyph_cache + .get(glyph_index) + .ok_or_else(|| invalid_field_err!("glyphIndex", "glyph cache miss on hit"))?; + if entry.width != width || entry.height != height { + return Err(invalid_field_err!("glyphIndex", "cached glyph dimensions mismatch")); + } + return Ok(entry.pixels.clone()); + } + + // Cap allocation to prevent OOM from adversarial dimensions. + // EGFX surfaces are capped at 32767x32767 by the spec, but a single + // ClearCodec tile should never approach that. 8192x8192 (256MB) is a + // generous upper bound for any reasonable tile size. + const MAX_DECODE_PIXELS: usize = 8192 * 8192; + if pixel_count > MAX_DECODE_PIXELS { + return Err(invalid_field_err!("dimensions", "pixel count exceeds decoder maximum")); + } + + // Decode composite payload + let mut output = vec![0u8; pixel_count * 4]; + + if let Some(ref composite) = stream.composite { + self.decode_composite(composite, &mut output, width, height)?; + } + + // Store in glyph cache if applicable (area <= 1024 pixels) + if stream.flags & FLAG_GLYPH_INDEX != 0 { + if let Some(glyph_index) = stream.glyph_index { + if pixel_count <= 1024 { + self.glyph_cache.store( + glyph_index, + GlyphEntry { + width, + height, + pixels: output.clone(), + }, + ); + } + } + } + + Ok(output) + } + + fn decode_composite( + &mut self, + composite: &CompositePayload<'_>, + output: &mut [u8], + width: u16, + _height: u16, + ) -> DecodeResult<()> { + let w = usize::from(width); + + // Layer 1: Residual (BGR RLE) - fills the entire output. + // Cap pixel writes to the output buffer size to prevent CPU-spin DoS + // from adversarial run_length values (FreeRDP CVE GHSA-32q9-m5qr-9j2v). + if !composite.residual_data.is_empty() { + let segments = decode_residual_layer(composite.residual_data)?; + let max_offset = output.len(); + let mut offset = 0; + for seg in &segments { + let pixels_remaining = (max_offset.saturating_sub(offset)) / 4; + let effective_run = u32::try_from(pixels_remaining).unwrap_or(u32::MAX).min(seg.run_length); + for _ in 0..effective_run { + output[offset] = seg.blue; + output[offset + 1] = seg.green; + output[offset + 2] = seg.red; + output[offset + 3] = 0xFF; // Alpha + offset += 4; + } + if offset >= max_offset { + break; + } + } + } + + // Layer 2: Bands (V-bar cached columns) - composite on top + if !composite.bands_data.is_empty() { + let bands = decode_bands_layer(composite.bands_data)?; + for band in &bands { + let band_height = band.y_end - band.y_start + 1; + for (col_offset, vbar) in band.vbars.iter().enumerate() { + let x = usize::from(band.x_start) + col_offset; + if x >= w { + continue; + } + + let full_vbar = + self.resolve_vbar(vbar, band_height, band.blue_bkg, band.green_bkg, band.red_bkg)?; + + // Blit the full V-bar column into the output + let pixel_rows = full_vbar.pixels.len() / 3; + for row in 0..pixel_rows { + let y = usize::from(band.y_start) + row; + let dst_offset = (y * w + x) * 4; + let src_offset = row * 3; + if dst_offset + 3 < output.len() && src_offset + 2 < full_vbar.pixels.len() { + output[dst_offset] = full_vbar.pixels[src_offset]; + output[dst_offset + 1] = full_vbar.pixels[src_offset + 1]; + output[dst_offset + 2] = full_vbar.pixels[src_offset + 2]; + output[dst_offset + 3] = 0xFF; + } + } + } + } + } + + // Layer 3: Subcodecs - composite on top + if !composite.subcodec_data.is_empty() { + let subcodecs = decode_subcodec_layer(composite.subcodec_data)?; + for sub in &subcodecs { + self.decode_subcodec_region(sub, output, width)?; + } + } + + Ok(()) + } + + fn resolve_vbar( + &mut self, + vbar: &VBar<'_>, + band_height: u16, + bg_blue: u8, + bg_green: u8, + bg_red: u8, + ) -> DecodeResult { + match vbar { + VBar::CacheHit { index } => { + let cached = self + .vbar_cache + .get_vbar(*index) + .ok_or_else(|| invalid_field_err!("vbarIndex", "V-bar cache miss on hit"))?; + Ok(cached.clone()) + } + VBar::ShortCacheHit { index, y_on } => { + let cached_short = self + .vbar_cache + .get_short_vbar(*index) + .ok_or_else(|| invalid_field_err!("shortVbarIndex", "short V-bar cache miss on hit"))?; + // Create a modified short vbar with the y_on from this reference + let modified = ShortVBar { + y_on: *y_on, + pixel_count: cached_short.pixel_count, + pixels: cached_short.pixels.clone(), + }; + let full = VBarCache::reconstruct_full_vbar(&modified, band_height, bg_blue, bg_green, bg_red); + // Store reconstructed full V-bar in cache + self.vbar_cache.store_vbar(full.clone()); + Ok(full) + } + VBar::ShortCacheMiss(miss) => { + let short = ShortVBar { + y_on: miss.y_on, + pixel_count: miss.y_off_delta, + pixels: miss.pixel_data.to_vec(), + }; + // Store in short V-bar cache + self.vbar_cache.store_short_vbar(short.clone()); + // Reconstruct and store full V-bar + let full = VBarCache::reconstruct_full_vbar(&short, band_height, bg_blue, bg_green, bg_red); + self.vbar_cache.store_vbar(full.clone()); + Ok(full) + } + } + } + + // NsCodec variant will use decoder state in Phase A7 + #[expect(clippy::unused_self)] + fn decode_subcodec_region( + &self, + sub: &ironrdp_pdu::codecs::clearcodec::Subcodec<'_>, + output: &mut [u8], + surface_width: u16, + ) -> DecodeResult<()> { + let sw = usize::from(surface_width); + let sh = output.len() / (sw * 4).max(1); + + let x_end = usize::from(sub.x_start) + usize::from(sub.width); + let y_end = usize::from(sub.y_start) + usize::from(sub.height); + if x_end > sw || y_end > sh { + return Err(invalid_field_err!("subcodec", "region exceeds surface bounds")); + } + + match sub.codec_id { + SubcodecId::Raw => { + let w = usize::from(sub.width); + let h = usize::from(sub.height); + let expected = w + .checked_mul(h) + .and_then(|v| v.checked_mul(3)) + .ok_or_else(|| invalid_field_err!("bitmapData", "raw subcodec dimensions overflow"))?; + if sub.bitmap_data.len() < expected { + return Err(invalid_field_err!("bitmapData", "raw subcodec data too short")); + } + for row in 0..h { + for col in 0..w { + let x = usize::from(sub.x_start) + col; + let y = usize::from(sub.y_start) + row; + let src_idx = (row * w + col) * 3; + let dst_idx = (y * sw + x) * 4; + output[dst_idx] = sub.bitmap_data[src_idx]; + output[dst_idx + 1] = sub.bitmap_data[src_idx + 1]; + output[dst_idx + 2] = sub.bitmap_data[src_idx + 2]; + output[dst_idx + 3] = 0xFF; + } + } + } + SubcodecId::Rlex => { + let rlex = ironrdp_pdu::codecs::clearcodec::decode_rlex(sub.bitmap_data)?; + let w = usize::from(sub.width); + let region_pixels = usize::from(sub.width) * usize::from(sub.height); + let palette_len = rlex.palette.len(); + let mut px = 0usize; + + for seg in &rlex.segments { + if usize::from(seg.start_index) >= palette_len { + return Err(invalid_field_err!("rlex", "start_index exceeds palette size")); + } + if usize::from(seg.stop_index) >= palette_len { + return Err(invalid_field_err!("rlex", "stop_index exceeds palette size")); + } + + let color = &rlex.palette[usize::from(seg.start_index)]; + for _ in 0..seg.run_length { + if px >= region_pixels { + return Err(invalid_field_err!("rlex", "run exceeds region pixel count")); + } + let x = usize::from(sub.x_start) + px % w; + let y = usize::from(sub.y_start) + px / w; + let dst_idx = (y * sw + x) * 4; + output[dst_idx] = color[0]; + output[dst_idx + 1] = color[1]; + output[dst_idx + 2] = color[2]; + output[dst_idx + 3] = 0xFF; + px += 1; + } + + for palette_idx in seg.start_index..=seg.stop_index { + if px >= region_pixels { + return Err(invalid_field_err!("rlex", "suite exceeds region pixel count")); + } + let color = &rlex.palette[usize::from(palette_idx)]; + let x = usize::from(sub.x_start) + px % w; + let y = usize::from(sub.y_start) + px / w; + let dst_idx = (y * sw + x) * 4; + output[dst_idx] = color[0]; + output[dst_idx + 1] = color[1]; + output[dst_idx + 2] = color[2]; + output[dst_idx + 3] = 0xFF; + px += 1; + } + } + } + SubcodecId::NsCodec => { + // Not yet implemented; encoder avoids generating NSCodec tiles. + } + } + + Ok(()) + } +} + +impl Default for ClearCodecDecoder { + fn default() -> Self { + Self::new() + } +} + +/// ClearCodec encoder for server-side bitmap compression. +/// +/// Encodes BGRA pixel data into ClearCodec bitmap streams using the residual +/// (BGR RLE) layer. The residual-only strategy gives good compression for +/// solid regions and text without requiring V-bar cache synchronization. +pub struct ClearCodecEncoder { + seq_number: u8, + glyph_cache: GlyphCache, + next_glyph_index: u16, +} + +impl ClearCodecEncoder { + pub fn new() -> Self { + Self { + seq_number: 0, + glyph_cache: GlyphCache::new(), + next_glyph_index: 0, + } + } + + /// Encode BGRA pixel data into a ClearCodec bitmap stream. + /// + /// Input: BGRA pixels in row-major order, `width * height * 4` bytes. + /// Returns the wire-format ClearCodec bitmap stream ready for + /// `WireToSurface1Pdu.bitmap_data`. + pub fn encode(&mut self, bgra: &[u8], width: u16, height: u16) -> Vec { + let w = usize::from(width); + let h = usize::from(height); + let pixel_count = w.saturating_mul(h); + let use_glyph = pixel_count <= 1024; + + // Check glyph cache for exact match + if use_glyph { + if let Some((hit_index, _)) = self.find_glyph_match(bgra, width, height) { + return self.encode_glyph_hit(hit_index); + } + } + + // Convert BGRA to BGR run segments + let segments = bgra_to_run_segments(bgra, pixel_count); + let residual_data = encode_residual_layer(&segments); + + let mut flags = 0u8; + let glyph_index = if use_glyph { + flags |= FLAG_GLYPH_INDEX; + let idx = self.next_glyph_index; + self.glyph_cache.store( + idx, + GlyphEntry { + width, + height, + pixels: bgra.to_vec(), + }, + ); + self.next_glyph_index = (idx + 1) % GLYPH_CACHE_WRAP; + Some(idx) + } else { + None + }; + + let seq = self.seq_number; + self.seq_number = seq.wrapping_add(1); + + // Build the wire-format bitmap stream + let mut out = Vec::with_capacity(2 + 2 + 12 + residual_data.len()); + out.push(flags); + out.push(seq); + + if let Some(idx) = glyph_index { + out.extend_from_slice(&idx.to_le_bytes()); + } + + // Composite payload: residual only (bands=0, subcodec=0) + let residual_len = u32::try_from(residual_data.len()).unwrap_or(u32::MAX); + out.extend_from_slice(&residual_len.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); // bandsByteCount + out.extend_from_slice(&0u32.to_le_bytes()); // subcodecByteCount + out.extend_from_slice(&residual_data); + + out + } + + /// Encode a cache reset message (FLAG_CACHE_RESET). + pub fn encode_cache_reset(&mut self) -> Vec { + let seq = self.seq_number; + self.seq_number = seq.wrapping_add(1); + vec![ironrdp_pdu::codecs::clearcodec::FLAG_CACHE_RESET, seq] + } + + fn find_glyph_match(&self, bgra: &[u8], width: u16, height: u16) -> Option<(u16, &GlyphEntry)> { + // Linear scan of recently used glyph indices. + // For small cache usage this is fine; a hash index could be added later. + let search_range = GLYPH_CACHE_WRAP; + for idx in 0..search_range { + if let Some(entry) = self.glyph_cache.get(idx) { + if entry.width == width && entry.height == height && entry.pixels == bgra { + return Some((idx, entry)); + } + } + } + None + } + + fn encode_glyph_hit(&mut self, index: u16) -> Vec { + let seq = self.seq_number; + self.seq_number = seq.wrapping_add(1); + + let flags = FLAG_GLYPH_INDEX | ironrdp_pdu::codecs::clearcodec::FLAG_GLYPH_HIT; + let mut out = Vec::with_capacity(4); + out.push(flags); + out.push(seq); + out.extend_from_slice(&index.to_le_bytes()); + out + } +} + +impl Default for ClearCodecEncoder { + fn default() -> Self { + Self::new() + } +} + +/// Convert BGRA pixels to BGR run-length segments. +fn bgra_to_run_segments(bgra: &[u8], pixel_count: usize) -> Vec { + if pixel_count == 0 { + return Vec::new(); + } + + // Cap to the number of complete pixels actually present in the input + let available_pixels = bgra.len() / 4; + let pixel_count = pixel_count.min(available_pixels); + + let mut segments = Vec::new(); + let mut i = 0; + + while i < pixel_count { + let offset = i * 4; + if offset + 2 >= bgra.len() { + break; + } + + let blue = bgra[offset]; + let green = bgra[offset + 1]; + let red = bgra[offset + 2]; + // Alpha channel is discarded (ClearCodec is always opaque BGR) + + let mut run_length = 1u32; + let mut j = i + 1; + while j < pixel_count { + let jo = j * 4; + if jo + 2 >= bgra.len() { + break; + } + if bgra[jo] == blue && bgra[jo + 1] == green && bgra[jo + 2] == red { + run_length += 1; + j += 1; + } else { + break; + } + } + + segments.push(RgbRunSegment { + blue, + green, + red, + run_length, + }); + i = j; + } + + segments +} + +#[cfg(test)] +mod tests { + use ironrdp_pdu::codecs::clearcodec::{FLAG_CACHE_RESET, FLAG_GLYPH_HIT}; + + use super::*; + + fn make_residual_only_stream(width: u16, height: u16, blue: u8, green: u8, red: u8) -> Vec { + let pixel_count = u32::from(width) * u32::from(height); + let mut data = Vec::new(); + + // Flags=0x00 (no glyph, no cache reset), seq=0x00 + data.push(0x00); + data.push(0x00); + + // Composite payload header + // Residual: 4 bytes (1 run segment: BGR + short run) + let run_length = pixel_count; + let residual = if run_length < 0xFF { + vec![blue, green, red, u8::try_from(run_length).unwrap()] + } else if run_length < 0xFFFF { + let mut v = vec![blue, green, red, 0xFF]; + v.extend_from_slice(&u16::try_from(run_length).unwrap().to_le_bytes()); + v + } else { + let mut v = vec![blue, green, red, 0xFF, 0xFF, 0xFF]; + v.extend_from_slice(&run_length.to_le_bytes()); + v + }; + let residual_len = u32::try_from(residual.len()).unwrap(); + + data.extend_from_slice(&residual_len.to_le_bytes()); // residualByteCount + data.extend_from_slice(&0u32.to_le_bytes()); // bandsByteCount + data.extend_from_slice(&0u32.to_le_bytes()); // subcodecByteCount + data.extend_from_slice(&residual); + + data + } + + #[test] + fn decode_solid_red_4x4() { + let mut decoder = ClearCodecDecoder::new(); + let stream = make_residual_only_stream(4, 4, 0x00, 0x00, 0xFF); // red in BGR + let pixels = decoder.decode(&stream, 4, 4).unwrap(); + assert_eq!(pixels.len(), 4 * 4 * 4); + // Check first pixel: BGRA + assert_eq!(pixels[0], 0x00); // B + assert_eq!(pixels[1], 0x00); // G + assert_eq!(pixels[2], 0xFF); // R + assert_eq!(pixels[3], 0xFF); // A + } + + #[test] + fn glyph_cache_round_trip() { + let mut decoder = ClearCodecDecoder::new(); + + // First decode: GLYPH_INDEX set, stores in glyph cache + let mut stream = Vec::new(); + stream.push(FLAG_GLYPH_INDEX); // flags + stream.push(0x00); // seq + stream.extend_from_slice(&42u16.to_le_bytes()); // glyph_index = 42 + // Composite with 1-pixel residual (white) + let residual = [0xFF, 0xFF, 0xFF, 0x01]; // BGR white, run=1 + stream.extend_from_slice(&4u32.to_le_bytes()); // residual bytes + stream.extend_from_slice(&0u32.to_le_bytes()); // bands bytes + stream.extend_from_slice(&0u32.to_le_bytes()); // subcodec bytes + stream.extend_from_slice(&residual); + + let pixels1 = decoder.decode(&stream, 1, 1).unwrap(); + assert_eq!(pixels1.len(), 4); + + // Second decode: GLYPH_HIT - should return cached data + let mut hit_stream = Vec::new(); + hit_stream.push(FLAG_GLYPH_INDEX | FLAG_GLYPH_HIT); // flags + hit_stream.push(0x01); // seq = 1 + hit_stream.extend_from_slice(&42u16.to_le_bytes()); // glyph_index = 42 + + let pixels2 = decoder.decode(&hit_stream, 1, 1).unwrap(); + assert_eq!(pixels1, pixels2); + } + + #[test] + fn raw_subcodec_decode() { + let mut decoder = ClearCodecDecoder::new(); + let mut stream = Vec::new(); + stream.push(0x00); // flags + stream.push(0x00); // seq + + // Composite: no residual, no bands, 1 raw subcodec region + let mut subcodec_data = Vec::new(); + subcodec_data.extend_from_slice(&0u16.to_le_bytes()); // x_start + subcodec_data.extend_from_slice(&0u16.to_le_bytes()); // y_start + subcodec_data.extend_from_slice(&2u16.to_le_bytes()); // width + subcodec_data.extend_from_slice(&1u16.to_le_bytes()); // height + subcodec_data.extend_from_slice(&6u32.to_le_bytes()); // 2 pixels * 3 bytes + subcodec_data.push(0x00); // SubcodecId::Raw + subcodec_data.extend_from_slice(&[0x00, 0x00, 0xFF]); // pixel 0: red + subcodec_data.extend_from_slice(&[0xFF, 0x00, 0x00]); // pixel 1: blue + + let subcodec_len = u32::try_from(subcodec_data.len()).unwrap(); + stream.extend_from_slice(&0u32.to_le_bytes()); // residual + stream.extend_from_slice(&0u32.to_le_bytes()); // bands + stream.extend_from_slice(&subcodec_len.to_le_bytes()); // subcodec + stream.extend_from_slice(&subcodec_data); + + let pixels = decoder.decode(&stream, 2, 1).unwrap(); + assert_eq!(pixels.len(), 2 * 4); // 2 pixels * BGRA + // Pixel 0: red (BGR: 0x00, 0x00, 0xFF) + assert_eq!(&pixels[0..4], &[0x00, 0x00, 0xFF, 0xFF]); + // Pixel 1: blue (BGR: 0xFF, 0x00, 0x00) + assert_eq!(&pixels[4..8], &[0xFF, 0x00, 0x00, 0xFF]); + } + + #[test] + fn cache_reset_clears_vbar_cursors() { + let mut decoder = ClearCodecDecoder::new(); + // Decode something to advance cursors, then reset + let stream = make_residual_only_stream(1, 1, 0, 0, 0); + decoder.decode(&stream, 1, 1).unwrap(); + + // Cache reset message + let reset_data = [FLAG_CACHE_RESET, 0x01]; // flags=CACHE_RESET, seq=1 + let _ = decoder.decode(&reset_data, 0, 0); // zero dimensions, but cache reset still processed + } + + // --- Encoder tests --- + + #[test] + fn encode_solid_color_round_trip() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + + // 4x4 solid red (BGRA: 0,0,255,255) + let bgra: Vec = (0..16).flat_map(|_| [0x00, 0x00, 0xFF, 0xFF]).collect(); + + let wire = enc.encode(&bgra, 4, 4); + let result = dec.decode(&wire, 4, 4).unwrap(); + + assert_eq!(result, bgra); + } + + #[test] + fn encode_two_color_stripe_round_trip() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + + // 4x1: 2 red + 2 blue pixels + let mut bgra = Vec::new(); + bgra.extend_from_slice(&[0x00, 0x00, 0xFF, 0xFF]); // red + bgra.extend_from_slice(&[0x00, 0x00, 0xFF, 0xFF]); // red + bgra.extend_from_slice(&[0xFF, 0x00, 0x00, 0xFF]); // blue + bgra.extend_from_slice(&[0xFF, 0x00, 0x00, 0xFF]); // blue + + let wire = enc.encode(&bgra, 4, 1); + let result = dec.decode(&wire, 4, 1).unwrap(); + + assert_eq!(result, bgra); + } + + #[test] + fn encode_glyph_cache_hit() { + let mut encoder = ClearCodecEncoder::new(); + + // Small 1x1 pixel (fits glyph cache: area=1 <= 1024) + let bgra = vec![0xFF, 0x00, 0x00, 0xFF]; // blue + + let first = encoder.encode(&bgra, 1, 1); + let second = encoder.encode(&bgra, 1, 1); + + // Second encode should be a glyph hit (shorter) + assert!( + second.len() < first.len(), + "glyph hit should be shorter than full encode" + ); + + // Both should decode to the same pixels + let mut decoder = ClearCodecDecoder::new(); + let p1 = decoder.decode(&first, 1, 1).unwrap(); + let p2 = decoder.decode(&second, 1, 1).unwrap(); + assert_eq!(p1, p2); + assert_eq!(p1, bgra); + } + + #[test] + fn encode_sequence_numbers_increment() { + let mut encoder = ClearCodecEncoder::new(); + let bgra = vec![0x00, 0x00, 0x00, 0xFF]; // 1x1 black + + let e1 = encoder.encode(&bgra, 1, 1); + let e2 = encoder.encode(&bgra, 1, 1); + + // Seq numbers are at byte offset 1 + // First frame starts with glyph_index flag + seq=0 + assert_eq!(e1[1], 0x00); + // Second is glyph hit: seq=1 + assert_eq!(e2[1], 0x01); + } + + #[test] + fn encode_cache_reset() { + let mut encoder = ClearCodecEncoder::new(); + let reset = encoder.encode_cache_reset(); + + let mut decoder = ClearCodecDecoder::new(); + let _ = decoder.decode(&reset, 0, 0); + // Just verifies it doesn't error + } + + #[test] + fn bgra_to_run_segments_compresses_runs() { + // 8 identical pixels should produce 1 segment with run_length=8 + let bgra: Vec = (0..8).flat_map(|_| [0xAA, 0xBB, 0xCC, 0xFF]).collect(); + let segments = bgra_to_run_segments(&bgra, 8); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].run_length, 8); + assert_eq!(segments[0].blue, 0xAA); + assert_eq!(segments[0].green, 0xBB); + assert_eq!(segments[0].red, 0xCC); + } + + #[test] + fn bgra_to_run_segments_unique_pixels() { + // 3 different pixels produce 3 segments + let bgra = vec![ + 0x01, 0x02, 0x03, 0xFF, // pixel 1 + 0x04, 0x05, 0x06, 0xFF, // pixel 2 + 0x07, 0x08, 0x09, 0xFF, // pixel 3 + ]; + let segments = bgra_to_run_segments(&bgra, 3); + assert_eq!(segments.len(), 3); + for seg in &segments { + assert_eq!(seg.run_length, 1); + } + } +} diff --git a/crates/ironrdp-graphics/src/clearcodec/vbar_cache.rs b/crates/ironrdp-graphics/src/clearcodec/vbar_cache.rs new file mode 100644 index 000000000..37a51667a --- /dev/null +++ b/crates/ironrdp-graphics/src/clearcodec/vbar_cache.rs @@ -0,0 +1,206 @@ +//! V-Bar caching for ClearCodec bands layer. +//! +//! The V-bar cache uses two ring buffers: +//! - **V-Bar Storage**: 32,768 full V-bars (complete column pixel data for a band height) +//! - **Short V-Bar Storage**: 16,384 short V-bars (only the non-background portion) +//! +//! Cache cursors advance linearly and wrap around, implementing LRU eviction +//! as specified in MS-RDPEGFX 3.3.8.1. + +use ironrdp_pdu::codecs::clearcodec::{SHORT_VBAR_CACHE_SIZE, VBAR_CACHE_SIZE}; + +// VBAR_CACHE_SIZE (32,768) and SHORT_VBAR_CACHE_SIZE (16,384) as u16 for cursor wrapping. +const VBAR_WRAP: u16 = 32_768; +const SHORT_VBAR_WRAP: u16 = 16_384; + +/// A full V-bar: column of BGR pixels for the full band height. +#[derive(Debug, Clone)] +pub struct FullVBar { + /// BGR pixel data, length = band_height * 3. + pub pixels: Vec, +} + +/// A short V-bar: only the non-background pixels within a column. +#[derive(Debug, Clone)] +pub struct ShortVBar { + /// First row index where pixel data starts. + pub y_on: u8, + /// Number of pixel rows with color data. + pub pixel_count: u8, + /// BGR pixel data, length = pixel_count * 3. + pub pixels: Vec, +} + +/// Combined V-bar cache state. +pub struct VBarCache { + /// Full V-bar storage (32,768 entries, ring buffer). + vbar_storage: Vec>, + /// Short V-bar storage (16,384 entries, ring buffer). + short_vbar_storage: Vec>, + /// Current write cursor for V-bar storage (wraps at 32767). + vbar_cursor: u16, + /// Current write cursor for short V-bar storage (wraps at 16383). + short_vbar_cursor: u16, +} + +impl VBarCache { + pub fn new() -> Self { + let mut vbar_storage = Vec::with_capacity(VBAR_CACHE_SIZE); + vbar_storage.resize_with(VBAR_CACHE_SIZE, || None); + + let mut short_vbar_storage = Vec::with_capacity(SHORT_VBAR_CACHE_SIZE); + short_vbar_storage.resize_with(SHORT_VBAR_CACHE_SIZE, || None); + + Self { + vbar_storage, + short_vbar_storage, + vbar_cursor: 0, + short_vbar_cursor: 0, + } + } + + /// Reset both caches (when FLAG_CACHE_RESET is received). + pub fn reset(&mut self) { + self.vbar_cursor = 0; + self.short_vbar_cursor = 0; + // Per spec, only cursors reset. Existing entries become stale + // but the cursor reset means new entries overwrite from index 0. + } + + /// Get a full V-bar from cache by index. + pub fn get_vbar(&self, index: u16) -> Option<&FullVBar> { + self.vbar_storage.get(usize::from(index)).and_then(|slot| slot.as_ref()) + } + + /// Get a short V-bar from cache by index. + pub fn get_short_vbar(&self, index: u16) -> Option<&ShortVBar> { + self.short_vbar_storage + .get(usize::from(index)) + .and_then(|slot| slot.as_ref()) + } + + /// Store a short V-bar and return its cache index. + pub fn store_short_vbar(&mut self, short_vbar: ShortVBar) -> u16 { + let index = self.short_vbar_cursor; + self.short_vbar_storage[usize::from(index)] = Some(short_vbar); + self.short_vbar_cursor = (index + 1) % SHORT_VBAR_WRAP; + index + } + + /// Store a full V-bar and return its cache index. + pub fn store_vbar(&mut self, vbar: FullVBar) -> u16 { + let index = self.vbar_cursor; + self.vbar_storage[usize::from(index)] = Some(vbar); + self.vbar_cursor = (index + 1) % VBAR_WRAP; + index + } + + /// Reconstruct a full V-bar from a short V-bar and background color. + /// + /// The full V-bar has: + /// - Background color above y_on + /// - Short V-bar pixel data from y_on to y_on + pixel_count + /// - Background color below y_on + pixel_count + pub fn reconstruct_full_vbar( + short_vbar: &ShortVBar, + band_height: u16, + bg_blue: u8, + bg_green: u8, + bg_red: u8, + ) -> FullVBar { + let height = usize::from(band_height); + let mut pixels = Vec::with_capacity(height * 3); + + // Background above y_on + for _ in 0..usize::from(short_vbar.y_on) { + pixels.push(bg_blue); + pixels.push(bg_green); + pixels.push(bg_red); + } + + // Pixel data from short V-bar + pixels.extend_from_slice(&short_vbar.pixels); + + // Background below y_on + pixel_count + let bottom_start = usize::from(short_vbar.y_on) + usize::from(short_vbar.pixel_count); + for _ in bottom_start..height { + pixels.push(bg_blue); + pixels.push(bg_green); + pixels.push(bg_red); + } + + FullVBar { pixels } + } +} + +impl Default for VBarCache { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn store_and_retrieve_vbar() { + let mut cache = VBarCache::new(); + let vbar = FullVBar { + pixels: vec![0xFF, 0x00, 0x00], + }; + let idx = cache.store_vbar(vbar); + assert_eq!(idx, 0); + let retrieved = cache.get_vbar(0).unwrap(); + assert_eq!(retrieved.pixels, vec![0xFF, 0x00, 0x00]); + } + + #[test] + fn cursor_wraps() { + let mut cache = VBarCache::new(); + // Store VBAR_CACHE_SIZE entries, cursor should wrap to 0 + for i in 0..VBAR_CACHE_SIZE { + let idx = cache.store_vbar(FullVBar { + pixels: vec![u8::try_from(i & 0xFF).unwrap()], + }); + assert_eq!(idx, u16::try_from(i).unwrap()); + } + // Next store should be at index 0 (wrapped) + let idx = cache.store_vbar(FullVBar { pixels: vec![0xAA] }); + assert_eq!(idx, 0); + } + + #[test] + fn reconstruct_full_vbar() { + let short = ShortVBar { + y_on: 1, + pixel_count: 2, + pixels: vec![0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00], // 2 pixels BGR + }; + let full = VBarCache::reconstruct_full_vbar(&short, 4, 0xAA, 0xBB, 0xCC); + // Height=4: 1 bg row, 2 data rows, 1 bg row + assert_eq!(full.pixels.len(), 12); // 4 * 3 + // Row 0: background + assert_eq!(&full.pixels[0..3], &[0xAA, 0xBB, 0xCC]); + // Row 1-2: pixel data + assert_eq!(&full.pixels[3..9], &[0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00]); + // Row 3: background + assert_eq!(&full.pixels[9..12], &[0xAA, 0xBB, 0xCC]); + } + + #[test] + fn reset_resets_cursors() { + let mut cache = VBarCache::new(); + cache.store_vbar(FullVBar { pixels: vec![0x01] }); + cache.store_short_vbar(ShortVBar { + y_on: 0, + pixel_count: 0, + pixels: vec![], + }); + assert_eq!(cache.vbar_cursor, 1); + assert_eq!(cache.short_vbar_cursor, 1); + cache.reset(); + assert_eq!(cache.vbar_cursor, 0); + assert_eq!(cache.short_vbar_cursor, 0); + } +} diff --git a/crates/ironrdp-graphics/src/lib.rs b/crates/ironrdp-graphics/src/lib.rs index 02d4104ad..47380ecbd 100644 --- a/crates/ironrdp-graphics/src/lib.rs +++ b/crates/ironrdp-graphics/src/lib.rs @@ -2,6 +2,7 @@ #![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] #![allow(clippy::arithmetic_side_effects)] // FIXME: remove +pub mod clearcodec; pub mod color_conversion; pub mod diff; pub mod dwt; diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/bands.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/bands.rs new file mode 100644 index 000000000..d70e1e751 --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/bands.rs @@ -0,0 +1,249 @@ +//! ClearCodec Layer 2: Bands (V-Bar Cached Columns) ([MS-RDPEGFX] 2.2.4.1.1.2). +//! +//! Bands encode rectangular strips of a bitmap using cached vertical column +//! data ("V-bars"). Each band covers a horizontal extent and contains one +//! V-bar per x-coordinate column. V-bars reference a two-level cache +//! (full V-bar storage + short V-bar storage) to exploit recurring vertical +//! column patterns typical of text glyphs. + +use ironrdp_core::{DecodeResult, ReadCursor, ensure_size, invalid_field_err}; + +/// Maximum band height per the spec. +pub const MAX_BAND_HEIGHT: u16 = 52; + +/// Number of entries in the full V-bar storage. +pub const VBAR_CACHE_SIZE: usize = 32_768; + +/// Number of entries in the short V-bar storage. +pub const SHORT_VBAR_CACHE_SIZE: usize = 16_384; + +/// A decoded band structure. +#[derive(Debug, Clone)] +pub struct Band<'a> { + pub x_start: u16, + pub x_end: u16, + pub y_start: u16, + pub y_end: u16, + /// Background color (BGR). + pub blue_bkg: u8, + pub green_bkg: u8, + pub red_bkg: u8, + /// One V-bar per column from x_start to x_end (inclusive). + pub vbars: Vec>, +} + +impl Band<'_> { + const NAME: &'static str = "ClearCodecBand"; + /// Band header: 4 x u16 + 3 x u8 = 11 bytes. + const HEADER_SIZE: usize = 11; +} + +/// A V-bar reference within a band. +/// +/// Discriminated by the top 2 bits of the first u16 word: +/// - `1x` (bit 15 set): full V-bar cache hit (15-bit index) +/// - `01` (bits 15:14 = 01): short V-bar cache hit (14-bit index + yOn offset) +/// - `00` (bits 15:14 = 00): short V-bar cache miss (inline pixel data) +#[derive(Debug, Clone)] +pub enum VBar<'a> { + /// Full V-bar cache hit. Index into V-Bar Storage (0..32767). + CacheHit { index: u16 }, + /// Short V-bar cache hit. Index into Short V-Bar Storage (0..16383) + /// plus a `yOn` offset byte for vertical positioning. + ShortCacheHit { index: u16, y_on: u8 }, + /// Short V-bar cache miss. Contains inline pixel data. + ShortCacheMiss(ShortVBarCacheMiss<'a>), +} + +/// Inline short V-bar data from a cache miss. +#[derive(Debug, Clone)] +pub struct ShortVBarCacheMiss<'a> { + /// First pixel row within the band where color data starts (shortVBarYOn). + pub y_on: u8, + /// Number of pixel rows with color data (`shortVBarYOff - shortVBarYOn`). + pub y_off_delta: u8, + /// Raw BGR pixel data: `y_off_delta * 3` bytes. + pub pixel_data: &'a [u8], +} + +/// Decode all bands from the bands layer data. +pub fn decode_bands_layer<'a>(data: &'a [u8]) -> DecodeResult>> { + let mut bands = Vec::new(); + let mut src = ReadCursor::new(data); + + while src.len() >= Band::HEADER_SIZE { + let band = decode_single_band(&mut src)?; + bands.push(band); + } + + Ok(bands) +} + +fn decode_single_band<'a>(src: &mut ReadCursor<'a>) -> DecodeResult> { + ensure_size!(ctx: Band::NAME, in: src, size: Band::HEADER_SIZE); + + let x_start = src.read_u16(); + let x_end = src.read_u16(); + let y_start = src.read_u16(); + let y_end = src.read_u16(); + let blue_bkg = src.read_u8(); + let green_bkg = src.read_u8(); + let red_bkg = src.read_u8(); + + // Validate band height + let height = y_end + .checked_sub(y_start) + .and_then(|h| h.checked_add(1)) + .ok_or_else(|| invalid_field_err!("yEnd", "yEnd < yStart"))?; + + if height > MAX_BAND_HEIGHT { + return Err(invalid_field_err!("bandHeight", "band height exceeds 52")); + } + + if x_end < x_start { + return Err(invalid_field_err!("xEnd", "xEnd < xStart")); + } + + let column_count = usize::from(x_end - x_start + 1); + let mut vbars = Vec::with_capacity(column_count); + + for _ in 0..column_count { + let vbar = decode_vbar(src, height)?; + vbars.push(vbar); + } + + Ok(Band { + x_start, + x_end, + y_start, + y_end, + blue_bkg, + green_bkg, + red_bkg, + vbars, + }) +} + +fn decode_vbar<'a>(src: &mut ReadCursor<'a>, band_height: u16) -> DecodeResult> { + ensure_size!(ctx: "VBar", in: src, size: 2); + let first_word = src.read_u16(); + + // Top bit set: full V-bar cache hit + if first_word & 0x8000 != 0 { + let index = first_word & 0x7FFF; + return Ok(VBar::CacheHit { index }); + } + + // Bit 14 set (bit 15 clear): short V-bar cache hit + if first_word & 0x4000 != 0 { + let index = first_word & 0x3FFF; + ensure_size!(ctx: "ShortVBarCacheHit", in: src, size: 1); + let y_on = src.read_u8(); + return Ok(VBar::ShortCacheHit { index, y_on }); + } + + // Both top bits clear: short V-bar cache miss + // Per MS-RDPEGFX 2.2.4.1.1.2.1.1.3 (SHORT_VBAR_CACHE_MISS): + // bits 13:6 = shortVBarYOn (8 bits): row where Short V-Bar begins + // bits 5:0 = shortVBarYOff (6 bits): row where Short V-Bar ends + // Pixel count = shortVBarYOff - shortVBarYOn + let y_on = u8::try_from(first_word >> 6).expect("top 2 bits are clear, so shifted value fits in u8"); + let y_off = u8::try_from(first_word & 0x3F).expect("masked to 6 bits, always fits in u8"); + + if y_off < y_on { + return Err(invalid_field_err!("shortVBarCacheMiss", "shortVBarYOff < shortVBarYOn")); + } + + if u16::from(y_off) > band_height { + return Err(invalid_field_err!( + "shortVBarCacheMiss", + "shortVBarYOff exceeds band height" + )); + } + + let pixel_count = y_off - y_on; + let pixel_byte_count = usize::from(pixel_count) * 3; + ensure_size!(ctx: "ShortVBarCacheMiss", in: src, size: pixel_byte_count); + let pixel_data = src.read_slice(pixel_byte_count); + + Ok(VBar::ShortCacheMiss(ShortVBarCacheMiss { + y_on, + y_off_delta: pixel_count, + pixel_data, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_vbar_cache_hit() { + // Bit 15 set, index = 42 + let data = (0x8000u16 | 42).to_le_bytes(); + let mut cursor = ReadCursor::new(&data); + let vbar = decode_vbar(&mut cursor, 10).unwrap(); + match vbar { + VBar::CacheHit { index } => assert_eq!(index, 42), + _ => panic!("expected CacheHit"), + } + } + + #[test] + fn decode_vbar_short_cache_hit() { + // Bit 14 set, bit 15 clear, index = 100, yOn = 5 + let mut data = Vec::new(); + data.extend_from_slice(&(0x4000u16 | 100).to_le_bytes()); + data.push(5); // yOn + let mut cursor = ReadCursor::new(&data); + let vbar = decode_vbar(&mut cursor, 10).unwrap(); + match vbar { + VBar::ShortCacheHit { index, y_on } => { + assert_eq!(index, 100); + assert_eq!(y_on, 5); + } + _ => panic!("expected ShortCacheHit"), + } + } + + #[test] + fn decode_vbar_short_cache_miss() { + // Both top bits clear: y_on=2, y_off=5, pixel_count = y_off - y_on = 3 + let y_on: u16 = 2; + let y_off: u16 = 5; + let first_word = (y_on << 6) | y_off; + let mut data = Vec::new(); + data.extend_from_slice(&first_word.to_le_bytes()); + // 3 pixels * 3 bytes = 9 bytes BGR data + data.extend_from_slice(&[0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF]); + let mut cursor = ReadCursor::new(&data); + let vbar = decode_vbar(&mut cursor, 10).unwrap(); + match vbar { + VBar::ShortCacheMiss(miss) => { + assert_eq!(miss.y_on, 2); + assert_eq!(miss.y_off_delta, 3); // pixel_count = y_off - y_on = 5 - 2 = 3 + assert_eq!(miss.pixel_data.len(), 9); + } + _ => panic!("expected ShortCacheMiss"), + } + } + + #[test] + fn decode_band_validates_height() { + // Band with height > 52 should fail + let mut data = Vec::new(); + data.extend_from_slice(&0u16.to_le_bytes()); // x_start + data.extend_from_slice(&0u16.to_le_bytes()); // x_end = 0 (1 column) + data.extend_from_slice(&0u16.to_le_bytes()); // y_start + data.extend_from_slice(&52u16.to_le_bytes()); // y_end = 52, height = 53 > MAX + data.extend_from_slice(&[0, 0, 0]); // bkg BGR + let result = decode_bands_layer(&data); + assert!(result.is_err()); + } + + #[test] + fn decode_empty_bands_layer() { + let bands = decode_bands_layer(&[]).unwrap(); + assert!(bands.is_empty()); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/mod.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/mod.rs new file mode 100644 index 000000000..a94e61a9b --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/mod.rs @@ -0,0 +1,203 @@ +//! ClearCodec bitmap compression codec (MS-RDPEGFX 2.2.4.1). +//! +//! ClearCodec is a mandatory lossless codec for all EGFX versions (V8-V10.7). +//! It uses a three-layer composite architecture: residual (BGR RLE), bands +//! (V-bar cached columns), and subcodec (raw / NSCodec / RLEX). +//! +//! The codec is transported inside `WireToSurface1Pdu` with `codecId = 0x0008`. + +mod bands; +mod residual; +mod rlex; +mod subcodec; + +use ironrdp_core::{DecodeResult, ReadCursor, cast_length, ensure_size, invalid_field_err}; + +pub use self::bands::{ + Band, MAX_BAND_HEIGHT, SHORT_VBAR_CACHE_SIZE, ShortVBarCacheMiss, VBAR_CACHE_SIZE, VBar, decode_bands_layer, +}; +pub use self::residual::{RgbRunSegment, decode_residual_layer, encode_residual_layer}; +pub use self::rlex::{MAX_PALETTE_COUNT, RlexData, RlexSegment, decode_rlex}; +pub use self::subcodec::{Subcodec, SubcodecId, decode_subcodec_layer}; + +// --- Flag constants --- + +/// `glyphIndex` field is present (bitmap area <= 1024 pixels). +pub const FLAG_GLYPH_INDEX: u8 = 0x01; +/// Use cached glyph at `glyphIndex`; no composite payload follows. +pub const FLAG_GLYPH_HIT: u8 = 0x02; +/// Reset V-Bar and Short V-Bar storage cursors to 0. +pub const FLAG_CACHE_RESET: u8 = 0x04; + +// --- Top-level bitmap stream --- + +/// Decoded ClearCodec bitmap stream ([MS-RDPEGFX] 2.2.4.1). +#[derive(Debug, Clone)] +pub struct ClearCodecBitmapStream<'a> { + /// Combination of `FLAG_GLYPH_INDEX`, `FLAG_GLYPH_HIT`, `FLAG_CACHE_RESET`. + pub flags: u8, + /// Sequence number (wraps 0xFF -> 0x00). + pub seq_number: u8, + /// Glyph cache index, present when `FLAG_GLYPH_INDEX` is set. + pub glyph_index: Option, + /// Composite payload (three layers), absent when `FLAG_GLYPH_HIT` is set. + pub composite: Option>, +} + +impl<'a> ClearCodecBitmapStream<'a> { + const NAME: &'static str = "ClearCodecBitmapStream"; + + /// Decode the complete bitmap stream from raw bytes. + pub fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_size!(ctx: Self::NAME, in: src, size: 2); + let flags = src.read_u8(); + let seq_number = src.read_u8(); + + let glyph_index = if flags & FLAG_GLYPH_INDEX != 0 { + ensure_size!(ctx: Self::NAME, in: src, size: 2); + Some(src.read_u16()) + } else { + None + }; + + // GLYPH_HIT means use cached glyph; no payload follows. + let composite = if flags & FLAG_GLYPH_HIT != 0 { + None + } else if src.is_empty() { + // No composite payload (valid for cache reset only messages) + None + } else { + Some(CompositePayload::decode(src)?) + }; + + Ok(Self { + flags, + seq_number, + glyph_index, + composite, + }) + } + + pub fn has_glyph_index(&self) -> bool { + self.flags & FLAG_GLYPH_INDEX != 0 + } + + pub fn is_glyph_hit(&self) -> bool { + self.flags & FLAG_GLYPH_HIT != 0 + } + + pub fn is_cache_reset(&self) -> bool { + self.flags & FLAG_CACHE_RESET != 0 + } +} + +// --- Composite payload (3 layers) --- + +/// The three-layer composite payload ([MS-RDPEGFX] 2.2.4.1.1). +/// +/// Layers are applied in order: residual -> bands -> subcodec. +/// Each layer composites on top of the previous result. +#[derive(Debug, Clone)] +pub struct CompositePayload<'a> { + /// Raw bytes for the residual (BGR RLE) layer. + pub residual_data: &'a [u8], + /// Raw bytes for the bands (V-bar cached columns) layer. + pub bands_data: &'a [u8], + /// Raw bytes for the subcodec layer. + pub subcodec_data: &'a [u8], +} + +impl<'a> CompositePayload<'a> { + const NAME: &'static str = "CompositePayload"; + + /// Header: 3 x u32 byte counts. + const HEADER_SIZE: usize = 12; + + pub fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_size!(ctx: Self::NAME, in: src, size: Self::HEADER_SIZE); + + let residual_byte_count: usize = cast_length!("residualByteCount", src.read_u32())?; + let bands_byte_count: usize = cast_length!("bandsByteCount", src.read_u32())?; + let subcodec_byte_count: usize = cast_length!("subcodecByteCount", src.read_u32())?; + + let total = residual_byte_count + .checked_add(bands_byte_count) + .and_then(|s| s.checked_add(subcodec_byte_count)) + .ok_or_else(|| invalid_field_err!("byteCount", "layer byte counts overflow"))?; + + ensure_size!(ctx: Self::NAME, in: src, size: total); + + let residual_data = src.read_slice(residual_byte_count); + let bands_data = src.read_slice(bands_byte_count); + let subcodec_data = src.read_slice(subcodec_byte_count); + + Ok(Self { + residual_data, + bands_data, + subcodec_data, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_glyph_hit() { + // flags=0x03 (GLYPH_INDEX | GLYPH_HIT), seq=0x05, glyphIndex=0x0042 + let data = [0x03, 0x05, 0x42, 0x00]; + let mut cursor = ReadCursor::new(&data); + let stream = ClearCodecBitmapStream::decode(&mut cursor).unwrap(); + assert!(stream.has_glyph_index()); + assert!(stream.is_glyph_hit()); + assert!(!stream.is_cache_reset()); + assert_eq!(stream.seq_number, 5); + assert_eq!(stream.glyph_index, Some(0x0042)); + assert!(stream.composite.is_none()); + } + + #[test] + fn decode_cache_reset_only() { + // flags=0x04 (CACHE_RESET), seq=0x00, no glyph, no composite + let data = [0x04, 0x00]; + let mut cursor = ReadCursor::new(&data); + let stream = ClearCodecBitmapStream::decode(&mut cursor).unwrap(); + assert!(stream.is_cache_reset()); + assert!(!stream.has_glyph_index()); + assert!(stream.composite.is_none()); + } + + #[test] + fn decode_composite_payload_empty_layers() { + // flags=0x00, seq=0x01, composite with all-zero byte counts + let data = [ + 0x00, 0x01, // flags, seq + 0x00, 0x00, 0x00, 0x00, // residualByteCount = 0 + 0x00, 0x00, 0x00, 0x00, // bandsByteCount = 0 + 0x00, 0x00, 0x00, 0x00, // subcodecByteCount = 0 + ]; + let mut cursor = ReadCursor::new(&data); + let stream = ClearCodecBitmapStream::decode(&mut cursor).unwrap(); + let composite = stream.composite.unwrap(); + assert!(composite.residual_data.is_empty()); + assert!(composite.bands_data.is_empty()); + assert!(composite.subcodec_data.is_empty()); + } + + #[test] + fn decode_composite_with_residual_data() { + // flags=0x00, seq=0x02, residual=4 bytes, bands=0, subcodec=0 + let data = [ + 0x00, 0x02, // flags, seq + 0x04, 0x00, 0x00, 0x00, // residualByteCount = 4 + 0x00, 0x00, 0x00, 0x00, // bandsByteCount = 0 + 0x00, 0x00, 0x00, 0x00, // subcodecByteCount = 0 + 0xFF, 0x00, 0x00, 0x01, // 4 bytes of residual data + ]; + let mut cursor = ReadCursor::new(&data); + let stream = ClearCodecBitmapStream::decode(&mut cursor).unwrap(); + let composite = stream.composite.unwrap(); + assert_eq!(composite.residual_data, &[0xFF, 0x00, 0x00, 0x01]); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/residual.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/residual.rs new file mode 100644 index 000000000..5fdca0563 --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/residual.rs @@ -0,0 +1,199 @@ +//! ClearCodec Layer 1: Residual (BGR RLE) ([MS-RDPEGFX] 2.2.4.1.1.1). +//! +//! The residual layer encodes the background of the bitmap as a sequence of +//! run-length-encoded BGR pixel runs. This forms the base layer onto which +//! bands and subcodec regions are composited. + +use ironrdp_core::{DecodeResult, ReadCursor, ensure_size}; + +/// A single BGR run-length segment. +/// +/// The run length uses a variable-length encoding: +/// - `factor1 < 0xFF`: run = factor1 +/// - `factor1 == 0xFF && factor2 < 0xFFFF`: run = factor2 +/// - `factor1 == 0xFF && factor2 == 0xFFFF`: run = factor3 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RgbRunSegment { + pub blue: u8, + pub green: u8, + pub red: u8, + pub run_length: u32, +} + +impl RgbRunSegment { + const NAME: &'static str = "RgbRunSegment"; + + /// Minimum segment size: 3 bytes color + 1 byte factor1. + const MIN_SIZE: usize = 4; +} + +/// Decode all residual run segments from the residual layer data. +/// +/// Returns the sequence of run segments. The caller is responsible for +/// expanding them into a pixel buffer of `width * height` pixels. +pub fn decode_residual_layer(data: &[u8]) -> DecodeResult> { + let mut segments = Vec::new(); + let mut src = ReadCursor::new(data); + + while src.len() >= RgbRunSegment::MIN_SIZE { + let blue = src.read_u8(); + let green = src.read_u8(); + let red = src.read_u8(); + let factor1 = src.read_u8(); + + let run_length = if factor1 < 0xFF { + u32::from(factor1) + } else { + ensure_size!(ctx: RgbRunSegment::NAME, in: src, size: 2); + let factor2 = src.read_u16(); + if factor2 < 0xFFFF { + u32::from(factor2) + } else { + ensure_size!(ctx: RgbRunSegment::NAME, in: src, size: 4); + src.read_u32() + } + }; + + segments.push(RgbRunSegment { + blue, + green, + red, + run_length, + }); + } + + Ok(segments) +} + +/// Encode residual layer data from a sequence of BGR run segments. +/// +/// Writes the variable-length encoded run segments into a Vec. +/// +/// # Panics +/// +/// Cannot panic. Internal `expect()` calls are guarded by range checks. +pub fn encode_residual_layer(segments: &[RgbRunSegment]) -> Vec { + let mut buf = Vec::with_capacity(segments.len() * 4); + + for seg in segments { + buf.push(seg.blue); + buf.push(seg.green); + buf.push(seg.red); + + if seg.run_length < 0xFF { + buf.push(u8::try_from(seg.run_length).expect("guarded by < 0xFF check")); + } else if seg.run_length < 0xFFFF { + buf.push(0xFF); + buf.extend_from_slice( + &u16::try_from(seg.run_length) + .expect("guarded by < 0xFFFF check") + .to_le_bytes(), + ); + } else { + buf.push(0xFF); + buf.extend_from_slice(&0xFFFFu16.to_le_bytes()); + buf.extend_from_slice(&seg.run_length.to_le_bytes()); + } + } + + buf +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_single_short_run() { + // Blue=0x10, Green=0x20, Red=0x30, run=5 + let data = [0x10, 0x20, 0x30, 0x05]; + let segments = decode_residual_layer(&data).unwrap(); + assert_eq!(segments.len(), 1); + assert_eq!( + segments[0], + RgbRunSegment { + blue: 0x10, + green: 0x20, + red: 0x30, + run_length: 5 + } + ); + } + + #[test] + fn decode_medium_run() { + // run_length = 300 (0x012C), needs factor2 + let data = [0x00, 0x00, 0x00, 0xFF, 0x2C, 0x01]; + let segments = decode_residual_layer(&data).unwrap(); + assert_eq!(segments[0].run_length, 300); + } + + #[test] + fn decode_long_run() { + // run_length = 70000 (0x00011170), needs factor3 + let data = [0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x70, 0x11, 0x01, 0x00]; + let segments = decode_residual_layer(&data).unwrap(); + assert_eq!(segments[0].run_length, 70000); + } + + #[test] + fn decode_multiple_segments() { + // Two short runs + let data = [ + 0xFF, 0x00, 0x00, 0x03, // blue pixel, run=3 + 0x00, 0xFF, 0x00, 0x02, // green pixel, run=2 + ]; + let segments = decode_residual_layer(&data).unwrap(); + assert_eq!(segments.len(), 2); + assert_eq!(segments[0].run_length, 3); + assert_eq!(segments[1].run_length, 2); + } + + #[test] + fn round_trip_short() { + let original = vec![ + RgbRunSegment { + blue: 0xAA, + green: 0xBB, + red: 0xCC, + run_length: 42, + }, + RgbRunSegment { + blue: 0x00, + green: 0x00, + red: 0x00, + run_length: 0, + }, + ]; + let encoded = encode_residual_layer(&original); + let decoded = decode_residual_layer(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn round_trip_all_sizes() { + let original = vec![ + RgbRunSegment { + blue: 0, + green: 0, + red: 0, + run_length: 100, + }, // short + RgbRunSegment { + blue: 0, + green: 0, + red: 0, + run_length: 1000, + }, // medium + RgbRunSegment { + blue: 0, + green: 0, + red: 0, + run_length: 100_000, + }, // long + ]; + let encoded = encode_residual_layer(&original); + let decoded = decode_residual_layer(&encoded).unwrap(); + assert_eq!(decoded, original); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/rlex.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/rlex.rs new file mode 100644 index 000000000..c2a5d7701 --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/rlex.rs @@ -0,0 +1,214 @@ +//! ClearCodec RLEX subcodec ([MS-RDPEGFX] 2.2.4.1.1.3.1.3). +//! +//! RLEX is a palette-indexed RLE codec with gradient "suite" encoding. +//! It encodes each pixel as a pair: a "run" of repeated color followed +//! by a "suite" (sequential palette walk from startIndex to stopIndex). + +use ironrdp_core::{DecodeResult, ReadCursor, ensure_size, invalid_field_err}; + +/// Maximum palette size per spec. +pub const MAX_PALETTE_COUNT: u8 = 127; + +/// A decoded RLEX segment (run + suite). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RlexSegment { + /// Palette index to repeat for the run portion. + pub start_index: u8, + /// Last palette index in the suite walk. + pub stop_index: u8, + /// Number of pixels in the run (repeated start color). + pub run_length: u32, +} + +/// Decoded RLEX data: palette + segments. +#[derive(Debug, Clone)] +pub struct RlexData { + /// BGR palette entries (3 bytes each). + pub palette: Vec<[u8; 3]>, + /// Sequence of run+suite segments. + pub segments: Vec, +} + +/// Decode RLEX subcodec data. +/// +/// The data format: +/// ```text +/// paletteCount(u8) | paletteEntries[paletteCount * 3 bytes BGR] +/// segments[]: packed bit fields +/// ``` +/// +/// Bit widths derived from palette count: +/// - `stop_index_bits = floor(log2(palette_count - 1)) + 1` +/// - `suite_depth_bits = 8 - stop_index_bits` +pub fn decode_rlex(data: &[u8]) -> DecodeResult { + let mut src = ReadCursor::new(data); + + ensure_size!(ctx: "RlexPalette", in: src, size: 1); + let palette_count = src.read_u8(); + + if palette_count == 0 { + return Err(invalid_field_err!("paletteCount", "palette count is 0")); + } + + if palette_count > MAX_PALETTE_COUNT { + return Err(invalid_field_err!("paletteCount", "palette count exceeds 127")); + } + + let palette_byte_count = usize::from(palette_count) * 3; + ensure_size!(ctx: "RlexPalette", in: src, size: palette_byte_count); + + let mut palette = Vec::with_capacity(usize::from(palette_count)); + for _ in 0..palette_count { + let b = src.read_u8(); + let g = src.read_u8(); + let r = src.read_u8(); + palette.push([b, g, r]); + } + + // Compute bit widths + let stop_index_bits = if palette_count <= 1 { + // Edge case: only 1 palette entry + 0 + } else { + bit_length(u32::from(palette_count - 1)) + }; + let suite_depth_bits = 8u8.saturating_sub(stop_index_bits); + + // Decode segments from remaining bytes + let mut segments = Vec::new(); + let remaining = src.len(); + + if stop_index_bits == 0 { + // Single palette entry: no stop/suite bits, only run lengths + // Each byte is a run length factor for palette[0] + decode_single_palette_segments(&mut src, &mut segments)?; + } else { + decode_multi_palette_segments(remaining, &mut src, stop_index_bits, suite_depth_bits, &mut segments)?; + } + + Ok(RlexData { palette, segments }) +} + +fn decode_single_palette_segments(src: &mut ReadCursor<'_>, segments: &mut Vec) -> DecodeResult<()> { + while !src.is_empty() { + let run_length = decode_run_length(src)?; + segments.push(RlexSegment { + start_index: 0, + stop_index: 0, + run_length, + }); + } + Ok(()) +} + +fn decode_multi_palette_segments( + _remaining: usize, + src: &mut ReadCursor<'_>, + stop_index_bits: u8, + suite_depth_bits: u8, + segments: &mut Vec, +) -> DecodeResult<()> { + let stop_mask = (1u8 << stop_index_bits) - 1; + let depth_mask = (1u8 << suite_depth_bits) - 1; + + while !src.is_empty() { + let packed = src.read_u8(); + let stop_index = packed & stop_mask; + let suite_depth = (packed >> stop_index_bits) & depth_mask; + + let start_index = stop_index.saturating_sub(suite_depth); + + let run_length = decode_run_length(src)?; + + segments.push(RlexSegment { + start_index, + stop_index, + run_length, + }); + } + + Ok(()) +} + +/// Decode a variable-length run length value. +/// Uses the same variable-length scheme as the residual layer. +fn decode_run_length(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(ctx: "RlexRunLength", in: src, size: 1); + let factor1 = src.read_u8(); + + if factor1 < 0xFF { + return Ok(u32::from(factor1)); + } + + ensure_size!(ctx: "RlexRunLength", in: src, size: 2); + let factor2 = src.read_u16(); + + if factor2 < 0xFFFF { + return Ok(u32::from(factor2)); + } + + ensure_size!(ctx: "RlexRunLength", in: src, size: 4); + Ok(src.read_u32()) +} + +/// Compute the number of bits needed to represent a value (floor(log2(n)) + 1). +fn bit_length(n: u32) -> u8 { + if n == 0 { + return 0; + } + // Result is 1..=32 for non-zero n, always fits in u8 + u8::try_from(32 - n.leading_zeros()).expect("bit length of u32 always fits in u8") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bit_length_values() { + assert_eq!(bit_length(0), 0); + assert_eq!(bit_length(1), 1); + assert_eq!(bit_length(2), 2); + assert_eq!(bit_length(3), 2); + assert_eq!(bit_length(4), 3); + assert_eq!(bit_length(7), 3); + assert_eq!(bit_length(126), 7); + } + + #[test] + fn decode_rlex_two_palette() { + // palette_count=2, palette=[black, white] + // stop_index_bits = bit_length(1) = 1 + // suite_depth_bits = 8 - 1 = 7 + let mut data = Vec::new(); + data.push(2); // palette_count + data.extend_from_slice(&[0x00, 0x00, 0x00]); // black BGR + data.extend_from_slice(&[0xFF, 0xFF, 0xFF]); // white BGR + // Segment: packed byte, stop_index=0 (1 bit), suite_depth=0 (7 bits), run=5 + data.push(0x00); // packed: stop=0, depth=0 + data.push(5); // run_length=5 + // Segment: stop_index=1, suite_depth=0, run=3 + data.push(0x01); // packed: stop=1, depth=0 + data.push(3); // run_length=3 + + let rlex = decode_rlex(&data).unwrap(); + assert_eq!(rlex.palette.len(), 2); + assert_eq!(rlex.segments.len(), 2); + assert_eq!(rlex.segments[0].stop_index, 0); + assert_eq!(rlex.segments[0].run_length, 5); + assert_eq!(rlex.segments[1].stop_index, 1); + assert_eq!(rlex.segments[1].run_length, 3); + } + + #[test] + fn reject_zero_palette() { + let data = [0x00]; // palette_count = 0 + assert!(decode_rlex(&data).is_err()); + } + + #[test] + fn reject_too_large_palette() { + let data = [128]; // palette_count = 128 > 127 + assert!(decode_rlex(&data).is_err()); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/subcodec.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/subcodec.rs new file mode 100644 index 000000000..fcad5eeff --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/subcodec.rs @@ -0,0 +1,180 @@ +//! ClearCodec Layer 3: Subcodecs ([MS-RDPEGFX] 2.2.4.1.1.3). +//! +//! The subcodec layer encodes rectangular regions using one of three methods: +//! raw BGR pixels, NSCodec, or RLEX. Each subcodec region specifies its +//! position, dimensions, and the codec used to compress its bitmap data. + +use ironrdp_core::{DecodeResult, ReadCursor, cast_length, ensure_size, invalid_field_err}; + +/// Subcodec identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum SubcodecId { + /// Uncompressed BGR pixels. + Raw = 0x00, + /// NSCodec bitmap compression (MS-RDPNSC). + NsCodec = 0x01, + /// Palette-indexed RLE with gradient suite encoding. + Rlex = 0x02, +} + +impl SubcodecId { + fn from_u8(val: u8) -> DecodeResult { + match val { + 0x00 => Ok(Self::Raw), + 0x01 => Ok(Self::NsCodec), + 0x02 => Ok(Self::Rlex), + _ => Err(invalid_field_err!("subCodecId", "unknown subcodec ID")), + } + } +} + +/// A decoded subcodec region. +#[derive(Debug, Clone)] +pub struct Subcodec<'a> { + pub x_start: u16, + pub y_start: u16, + pub width: u16, + pub height: u16, + pub codec_id: SubcodecId, + /// Raw bitmap data for this region, interpreted according to `codec_id`. + pub bitmap_data: &'a [u8], +} + +impl Subcodec<'_> { + const NAME: &'static str = "ClearCodecSubcodec"; + + /// Header: 4 x u16 + u32 + u8 = 13 bytes. + const HEADER_SIZE: usize = 13; +} + +/// Decode all subcodec regions from the subcodec layer data. +pub fn decode_subcodec_layer<'a>(data: &'a [u8]) -> DecodeResult>> { + let mut regions = Vec::new(); + let mut src = ReadCursor::new(data); + + while src.len() >= Subcodec::HEADER_SIZE { + let region = decode_single_subcodec(&mut src)?; + regions.push(region); + } + + Ok(regions) +} + +fn decode_single_subcodec<'a>(src: &mut ReadCursor<'a>) -> DecodeResult> { + ensure_size!(ctx: Subcodec::NAME, in: src, size: Subcodec::HEADER_SIZE); + + let x_start = src.read_u16(); + let y_start = src.read_u16(); + let width = src.read_u16(); + let height = src.read_u16(); + let bitmap_data_byte_count: usize = cast_length!("bitmapDataByteCount", src.read_u32())?; + let codec_id_raw = src.read_u8(); + let codec_id = SubcodecId::from_u8(codec_id_raw)?; + + if width == 0 || height == 0 { + return Err(invalid_field_err!("dimensions", "subcodec region has zero dimension")); + } + + ensure_size!(ctx: Subcodec::NAME, in: src, size: bitmap_data_byte_count); + let bitmap_data = src.read_slice(bitmap_data_byte_count); + + Ok(Subcodec { + x_start, + y_start, + width, + height, + codec_id, + bitmap_data, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_raw_subcodec() { + // Region at (10, 20), 2x2 pixels, raw BGR = 12 bytes + let mut data = Vec::new(); + data.extend_from_slice(&10u16.to_le_bytes()); // x_start + data.extend_from_slice(&20u16.to_le_bytes()); // y_start + data.extend_from_slice(&2u16.to_le_bytes()); // width + data.extend_from_slice(&2u16.to_le_bytes()); // height + data.extend_from_slice(&12u32.to_le_bytes()); // bitmapDataByteCount = 2*2*3 = 12 + data.push(0x00); // subCodecId = Raw + // 4 pixels BGR + data.extend_from_slice(&[0xFF, 0x00, 0x00]); // blue + data.extend_from_slice(&[0x00, 0xFF, 0x00]); // green + data.extend_from_slice(&[0x00, 0x00, 0xFF]); // red + data.extend_from_slice(&[0xFF, 0xFF, 0xFF]); // white + + let regions = decode_subcodec_layer(&data).unwrap(); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].x_start, 10); + assert_eq!(regions[0].y_start, 20); + assert_eq!(regions[0].width, 2); + assert_eq!(regions[0].height, 2); + assert_eq!(regions[0].codec_id, SubcodecId::Raw); + assert_eq!(regions[0].bitmap_data.len(), 12); + } + + #[test] + fn reject_zero_dimensions() { + let mut data = Vec::new(); + data.extend_from_slice(&0u16.to_le_bytes()); // x_start + data.extend_from_slice(&0u16.to_le_bytes()); // y_start + data.extend_from_slice(&0u16.to_le_bytes()); // width = 0 (invalid) + data.extend_from_slice(&1u16.to_le_bytes()); // height + data.extend_from_slice(&0u32.to_le_bytes()); // bitmapDataByteCount + data.push(0x00); // subCodecId + assert!(decode_subcodec_layer(&data).is_err()); + } + + #[test] + fn reject_unknown_subcodec() { + let mut data = Vec::new(); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0u32.to_le_bytes()); + data.push(0x03); // unknown subcodec + assert!(decode_subcodec_layer(&data).is_err()); + } + + #[test] + fn decode_multiple_subcodecs() { + let mut data = Vec::new(); + // First region: 1x1 raw + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&3u32.to_le_bytes()); + data.push(0x00); // Raw + data.extend_from_slice(&[0xFF, 0xFF, 0xFF]); + + // Second region: 1x1 RLEX (minimal: palette_count=1 + run) + data.extend_from_slice(&5u16.to_le_bytes()); + data.extend_from_slice(&5u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&5u32.to_le_bytes()); + data.push(0x02); // RLEX + data.push(1); // palette_count + data.extend_from_slice(&[0x00, 0x00, 0x00]); // palette entry + data.push(1); // run_length + + let regions = decode_subcodec_layer(&data).unwrap(); + assert_eq!(regions.len(), 2); + assert_eq!(regions[0].codec_id, SubcodecId::Raw); + assert_eq!(regions[1].codec_id, SubcodecId::Rlex); + } + + #[test] + fn decode_empty_layer() { + let regions = decode_subcodec_layer(&[]).unwrap(); + assert!(regions.is_empty()); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/mod.rs b/crates/ironrdp-pdu/src/codecs/mod.rs index 6fb4906b5..df6e592c6 100644 --- a/crates/ironrdp-pdu/src/codecs/mod.rs +++ b/crates/ironrdp-pdu/src/codecs/mod.rs @@ -1 +1,2 @@ +pub mod clearcodec; pub mod rfx; diff --git a/crates/ironrdp-testsuite-core/tests/graphics/clearcodec.rs b/crates/ironrdp-testsuite-core/tests/graphics/clearcodec.rs new file mode 100644 index 000000000..a0275083b --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/graphics/clearcodec.rs @@ -0,0 +1,539 @@ +use ironrdp_core::ReadCursor; +use ironrdp_graphics::clearcodec::{ClearCodecDecoder, ClearCodecEncoder}; +use ironrdp_pdu::codecs::clearcodec::{ + ClearCodecBitmapStream, FLAG_CACHE_RESET, FLAG_GLYPH_HIT, FLAG_GLYPH_INDEX, RgbRunSegment, encode_residual_layer, +}; + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Build a residual-only ClearCodec stream (no bands, no subcodec). +fn make_residual_stream(seq: u8, flags: u8, glyph_index: Option, residual: &[u8]) -> Vec { + let mut data = Vec::new(); + data.push(flags); + data.push(seq); + if let Some(idx) = glyph_index { + data.extend_from_slice(&idx.to_le_bytes()); + } + let residual_len = u32::try_from(residual.len()).unwrap(); + data.extend_from_slice(&residual_len.to_le_bytes()); + data.extend_from_slice(&0u32.to_le_bytes()); // bands + data.extend_from_slice(&0u32.to_le_bytes()); // subcodec + data.extend_from_slice(residual); + data +} + +/// Build a solid-color residual payload for width*height pixels. +fn make_solid_residual(b: u8, g: u8, r: u8, pixel_count: u32) -> Vec { + encode_residual_layer(&[RgbRunSegment { + blue: b, + green: g, + red: r, + run_length: pixel_count, + }]) +} + +/// Build BGRA pixel data for a solid color. +fn solid_bgra(b: u8, g: u8, r: u8, pixel_count: usize) -> Vec { + (0..pixel_count).flat_map(|_| [b, g, r, 0xFF]).collect() +} + +// ============================================================================ +// Codec Round-Trip (encode -> decode, pixel-perfect) +// ============================================================================ + +#[test] +fn round_trip_1x1_single_pixel() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x00, 0x00, 0x00, 1); + let wire = enc.encode(&bgra, 1, 1); + let result = dec.decode(&wire, 1, 1).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_4x4_solid_color() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x00, 0x00, 0xFF, 16); + let wire = enc.encode(&bgra, 4, 4); + let result = dec.decode(&wire, 4, 4).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_checkerboard_alternating_pixels() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let mut bgra = Vec::with_capacity(16 * 4); + for i in 0..16 { + if i % 2 == 0 { + bgra.extend_from_slice(&[0x00, 0x00, 0x00, 0xFF]); + } else { + bgra.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]); + } + } + let wire = enc.encode(&bgra, 4, 4); + let result = dec.decode(&wire, 4, 4).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_8x1_all_unique_colors() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra: Vec = (0..8u8).flat_map(|i| [i * 30, i * 20, i * 10, 0xFF]).collect(); + let wire = enc.encode(&bgra, 8, 1); + let result = dec.decode(&wire, 8, 1).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_100x100_triggers_medium_run_encoding() { + // 10,000 pixels requires factor2 (u16) encoding tier in residual layer + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x42, 0x84, 0xC6, 10_000); + let wire = enc.encode(&bgra, 100, 100); + let result = dec.decode(&wire, 100, 100).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_asymmetric_1x1000() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0xAB, 0xCD, 0xEF, 1000); + let wire = enc.encode(&bgra, 1, 1000); + let result = dec.decode(&wire, 1, 1000).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_asymmetric_1000x1() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x11, 0x22, 0x33, 1000); + let wire = enc.encode(&bgra, 1000, 1); + let result = dec.decode(&wire, 1000, 1).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_at_glyph_cache_boundary_1024_pixels() { + // 32x32 = 1024 pixels: maximum size eligible for glyph caching + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x80, 0x80, 0x80, 1024); + let wire = enc.encode(&bgra, 32, 32); + let result = dec.decode(&wire, 32, 32).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_over_glyph_threshold_no_caching() { + // 33x32 = 1056 pixels: too large for glyph caching + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x80, 0x80, 0x80, 1056); + let wire = enc.encode(&bgra, 33, 32); + let result = dec.decode(&wire, 33, 32).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_two_color_stripe() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let mut bgra = Vec::new(); + for _ in 0..50 { + bgra.extend_from_slice(&[0x00, 0x00, 0xFF, 0xFF]); + } + for _ in 0..50 { + bgra.extend_from_slice(&[0xFF, 0x00, 0x00, 0xFF]); + } + let wire = enc.encode(&bgra, 100, 1); + let result = dec.decode(&wire, 100, 1).unwrap(); + assert_eq!(result, bgra); +} + +// ============================================================================ +// Adversarial Input (no panic, no hang, correct errors) +// ============================================================================ + +#[test] +fn adversarial_residual_max_run_length_completes_quickly() { + // run_length = u32::MAX in a 1x1 surface: must not spin for 4B iterations + let mut dec = ClearCodecDecoder::new(); + let residual = [0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + let stream = make_residual_stream(0, 0, None, &residual); + let result = dec.decode(&stream, 1, 1).unwrap(); + assert_eq!(result.len(), 4); + assert_eq!(&result[..3], &[0xFF, 0x00, 0x00]); // BGR written correctly +} + +#[test] +fn adversarial_residual_zero_run_produces_empty_output() { + let mut dec = ClearCodecDecoder::new(); + let residual = [0xFF, 0x00, 0x00, 0x00]; // run = 0 + let stream = make_residual_stream(0, 0, None, &residual); + let result = dec.decode(&stream, 1, 1).unwrap(); + assert_eq!(result, vec![0; 4]); // output stays zeroed +} + +#[test] +fn adversarial_glyph_hit_for_uncached_index() { + let mut dec = ClearCodecDecoder::new(); + let mut data = vec![FLAG_GLYPH_INDEX | FLAG_GLYPH_HIT, 0x00]; + data.extend_from_slice(&42u16.to_le_bytes()); + assert!(dec.decode(&data, 1, 1).is_err()); +} + +#[test] +fn adversarial_glyph_hit_without_glyph_index_flag() { + let mut dec = ClearCodecDecoder::new(); + let data = [FLAG_GLYPH_HIT, 0x00]; + assert!(dec.decode(&data, 1, 1).is_err()); +} + +#[test] +fn adversarial_glyph_index_out_of_spec_range() { + let mut dec = ClearCodecDecoder::new(); + // glyphIndex = 4000 (spec requires 0-3999) + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(0, FLAG_GLYPH_INDEX, Some(4000), &residual); + assert!(dec.decode(&stream, 1, 1).is_err()); +} + +#[test] +fn adversarial_glyph_index_max_u16() { + let mut dec = ClearCodecDecoder::new(); + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(0, FLAG_GLYPH_INDEX, Some(u16::MAX), &residual); + assert!(dec.decode(&stream, 1, 1).is_err()); +} + +#[test] +fn adversarial_composite_byte_count_overflow() { + let mut data = vec![0x00, 0x00]; // flags, seq + // residualByteCount + bandsByteCount overflows usize + data.extend_from_slice(&0xFFFFFFFFu32.to_le_bytes()); + data.extend_from_slice(&1u32.to_le_bytes()); + data.extend_from_slice(&0u32.to_le_bytes()); + let mut cursor = ReadCursor::new(&data); + assert!(ClearCodecBitmapStream::decode(&mut cursor).is_err()); +} + +#[test] +fn adversarial_sequence_number_wraps_at_256() { + let mut dec = ClearCodecDecoder::new(); + // Drive sequence through 0..255 and back to 0 (wrapping) + for seq in 0..=255u8 { + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(seq, 0, None, &residual); + dec.decode(&stream, 1, 1).unwrap(); + } + // Wrap back to 0 + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(0, 0, None, &residual); + dec.decode(&stream, 1, 1).unwrap(); +} + +#[test] +fn adversarial_stream_truncated_to_1_byte() { + let data = [0x00]; + let mut cursor = ReadCursor::new(&data); + assert!(ClearCodecBitmapStream::decode(&mut cursor).is_err()); +} + +#[test] +fn adversarial_stream_empty() { + let data = []; + let mut cursor = ReadCursor::new(&data); + assert!(ClearCodecBitmapStream::decode(&mut cursor).is_err()); +} + +// ============================================================================ +// Cache State Management +// ============================================================================ + +#[test] +fn glyph_cache_store_then_hit() { + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0xFF, 0x00, 0x00, 1); + + // Frame 1: store glyph at index 42 + let residual = make_solid_residual(0xFF, 0x00, 0x00, 1); + let stream1 = make_residual_stream(0, FLAG_GLYPH_INDEX, Some(42), &residual); + let p1 = dec.decode(&stream1, 1, 1).unwrap(); + assert_eq!(p1, bgra); + + // Frame 2: glyph hit at index 42 + let mut stream2 = vec![FLAG_GLYPH_INDEX | FLAG_GLYPH_HIT, 0x01]; + stream2.extend_from_slice(&42u16.to_le_bytes()); + let p2 = dec.decode(&stream2, 1, 1).unwrap(); + assert_eq!(p2, bgra); +} + +#[test] +fn glyph_cache_overwrite_at_same_index() { + let mut dec = ClearCodecDecoder::new(); + + // Store red at index 0 + let red_residual = make_solid_residual(0x00, 0x00, 0xFF, 1); + let stream1 = make_residual_stream(0, FLAG_GLYPH_INDEX, Some(0), &red_residual); + dec.decode(&stream1, 1, 1).unwrap(); + + // Overwrite with blue at index 0 + let blue_residual = make_solid_residual(0xFF, 0x00, 0x00, 1); + let stream2 = make_residual_stream(1, FLAG_GLYPH_INDEX, Some(0), &blue_residual); + dec.decode(&stream2, 1, 1).unwrap(); + + // Hit should return blue + let mut stream3 = vec![FLAG_GLYPH_INDEX | FLAG_GLYPH_HIT, 0x02]; + stream3.extend_from_slice(&0u16.to_le_bytes()); + let result = dec.decode(&stream3, 1, 1).unwrap(); + assert_eq!(result[0], 0xFF); // blue channel +} + +#[test] +fn cache_reset_does_not_panic() { + let mut dec = ClearCodecDecoder::new(); + let residual = make_solid_residual(0, 0, 0, 1); + let stream1 = make_residual_stream(0, 0, None, &residual); + dec.decode(&stream1, 1, 1).unwrap(); + + let stream2 = [FLAG_CACHE_RESET, 0x01]; + let _ = dec.decode(&stream2, 0, 0); +} + +#[test] +fn encoder_glyph_hit_produces_smaller_output() { + let mut enc = ClearCodecEncoder::new(); + let bgra = solid_bgra(0xAA, 0xBB, 0xCC, 1); + + let first = enc.encode(&bgra, 1, 1); + let second = enc.encode(&bgra, 1, 1); // should be glyph hit + + assert!(second.len() < first.len(), "glyph hit should be smaller"); +} + +#[test] +fn encoder_glyph_miss_after_content_change() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let red = solid_bgra(0x00, 0x00, 0xFF, 1); + let blue = solid_bgra(0xFF, 0x00, 0x00, 1); + + let first = enc.encode(&red, 1, 1); + let second = enc.encode(&blue, 1, 1); // different content, full encode + + // Verify both are full encodes (not glyph hits) by checking they + // contain a composite header (minimum 14 bytes: flags + seq + 3*u32) + assert!(second.len() >= 14, "changed content should produce a full encode"); + + // Verify they decode to the correct distinct colors + let decoded_red = dec.decode(&first, 1, 1).unwrap(); + let decoded_blue = dec.decode(&second, 1, 1).unwrap(); + assert_eq!(decoded_red, red); + assert_eq!(decoded_blue, blue); +} + +#[test] +fn encoder_sequence_numbers_increment_correctly() { + let mut enc = ClearCodecEncoder::new(); + let bgra = solid_bgra(0, 0, 0, 1); + + let e1 = enc.encode(&bgra, 1, 1); + let e2 = enc.encode(&bgra, 1, 1); // glyph hit + let e3 = enc.encode(&solid_bgra(0xFF, 0xFF, 0xFF, 1), 1, 1); // different + + assert_eq!(e1[1], 0); // seq byte at offset 1 + assert_eq!(e2[1], 1); + assert_eq!(e3[1], 2); +} + +#[test] +fn encoder_cache_reset_round_trips() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let reset = enc.encode_cache_reset(); + let _ = dec.decode(&reset, 0, 0); +} + +// ============================================================================ +// Compression Quality +// ============================================================================ + +#[test] +fn solid_color_compresses_below_30_bytes() { + let mut enc = ClearCodecEncoder::new(); + let bgra = solid_bgra(0x42, 0x84, 0xC6, 10_000); + let wire = enc.encode(&bgra, 100, 100); + // 10,000 pixels = 40,000 bytes raw. Solid color: header + 1 run segment. + assert!( + wire.len() < 30, + "solid 100x100 should compress to <30 bytes, got {}", + wire.len() + ); +} + +#[test] +fn unique_pixels_do_not_expand_beyond_raw() { + let mut enc = ClearCodecEncoder::new(); + let bgra: Vec = (0..100u8) + .flat_map(|i| [i, i.wrapping_mul(2), i.wrapping_mul(3), 0xFF]) + .collect(); + let wire = enc.encode(&bgra, 100, 1); + // Worst case: each pixel is unique, 1 segment per pixel. + // Should not be larger than raw + header overhead. + assert!(wire.len() < bgra.len() + 50); +} + +// ============================================================================ +// Multi-Frame Session Simulation +// ============================================================================ + +#[test] +fn session_10_frames_mixed_colors() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + + let colors: Vec<(u8, u8, u8)> = vec![ + (0, 0, 0), + (0xFF, 0, 0), + (0, 0xFF, 0), + (0, 0, 0xFF), + (0xFF, 0xFF, 0), + (0xFF, 0, 0xFF), + (0, 0xFF, 0xFF), + (0x80, 0x80, 0x80), + (0xFF, 0xFF, 0xFF), + (0, 0, 0), + ]; + + for (b, g, r) in &colors { + let bgra = solid_bgra(*b, *g, *r, 4); + let wire = enc.encode(&bgra, 2, 2); + let result = dec.decode(&wire, 2, 2).unwrap(); + assert_eq!(result, bgra); + } +} + +#[test] +fn session_repeated_frames_hit_glyph_cache() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0xDE, 0xAD, 0xBE, 4); + + let wire1 = enc.encode(&bgra, 2, 2); + let len1 = wire1.len(); + dec.decode(&wire1, 2, 2).unwrap(); + + // Subsequent encodes should be glyph hits (smaller) + for _ in 0..5 { + let wire = enc.encode(&bgra, 2, 2); + assert!(wire.len() < len1, "repeated frame should use glyph cache"); + let result = dec.decode(&wire, 2, 2).unwrap(); + assert_eq!(result, bgra); + } +} + +#[test] +fn session_encoder_decoder_stay_synchronized_across_50_frames() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + + for i in 0u8..50 { + let bgra = solid_bgra(i, i.wrapping_mul(3), i.wrapping_mul(7), 9); + let wire = enc.encode(&bgra, 3, 3); + let result = dec.decode(&wire, 3, 3).unwrap(); + assert_eq!(result, bgra, "mismatch at frame {i}"); + } +} + +// ============================================================================ +// Bands Layer Compositing (integration through decoder) +// ============================================================================ + +#[test] +fn decode_stream_with_bands_layer_short_vbar_cache_miss() { + // Construct a minimal ClearCodec stream with a bands layer containing + // one band, one column, using a ShortVBarCacheMiss. This exercises the + // full decode_composite -> resolve_vbar -> blit path. + let mut dec = ClearCodecDecoder::new(); + + // Surface: 4 pixels wide, 4 pixels tall + let width: u16 = 4; + let height: u16 = 4; + + // Build bands layer data: one band covering column 1, rows 0-3 + let mut bands_data = Vec::new(); + bands_data.extend_from_slice(&1u16.to_le_bytes()); // x_start = 1 + bands_data.extend_from_slice(&1u16.to_le_bytes()); // x_end = 1 (1 column) + bands_data.extend_from_slice(&0u16.to_le_bytes()); // y_start = 0 + bands_data.extend_from_slice(&3u16.to_le_bytes()); // y_end = 3 (height = 4) + bands_data.extend_from_slice(&[0x00, 0x00, 0x00]); // background BGR = black + + // V-bar: ShortCacheMiss with y_on=1, y_off=3 (2 pixels at rows 1-2) + // bits 13:6 = y_on (1), bits 5:0 = y_off (3) + let vbar_word: u16 = (1 << 6) | 3; + bands_data.extend_from_slice(&vbar_word.to_le_bytes()); + // 2 pixels * 3 bytes = 6 bytes of BGR pixel data (red) + bands_data.extend_from_slice(&[0x00, 0x00, 0xFF]); // row 1: red + bands_data.extend_from_slice(&[0x00, 0x00, 0xFF]); // row 2: red + + // Build the full stream: no residual, bands only, no subcodec + let mut stream = Vec::new(); + stream.push(0x00); // flags + stream.push(0x00); // seq + stream.extend_from_slice(&0u32.to_le_bytes()); // residualByteCount = 0 + let bands_len = u32::try_from(bands_data.len()).unwrap(); + stream.extend_from_slice(&bands_len.to_le_bytes()); // bandsByteCount + stream.extend_from_slice(&0u32.to_le_bytes()); // subcodecByteCount = 0 + stream.extend_from_slice(&bands_data); + + let pixels = dec.decode(&stream, width, height).unwrap(); + assert_eq!(pixels.len(), usize::from(width) * usize::from(height) * 4); + + // Check column 1, row 1: should be red (from short V-bar pixel data) + let row1_col1 = (usize::from(width) + 1) * 4; + assert_eq!(pixels[row1_col1], 0x00, "blue channel at (1,1)"); + assert_eq!(pixels[row1_col1 + 1], 0x00, "green channel at (1,1)"); + assert_eq!(pixels[row1_col1 + 2], 0xFF, "red channel at (1,1)"); + + // Check column 1, row 0: should be background (black, from band bkg) + let row0_col1 = 4; // row=0, col=1 -> offset 4 + assert_eq!(pixels[row0_col1], 0x00, "blue channel at (1,0)"); + assert_eq!(pixels[row0_col1 + 1], 0x00, "green channel at (1,0)"); + assert_eq!(pixels[row0_col1 + 2], 0x00, "red channel at (1,0)"); + + // Check column 1, row 3: should also be background + let idx3 = (3 * usize::from(width) + 1) * 4; + assert_eq!(pixels[idx3], 0x00, "blue channel at (1,3)"); + assert_eq!(pixels[idx3 + 1], 0x00, "green channel at (1,3)"); + assert_eq!(pixels[idx3 + 2], 0x00, "red channel at (1,3)"); +} + +#[test] +fn adversarial_large_dimensions_rejected() { + // 65535x65535 would allocate ~17GB. The decoder should reject it. + let mut dec = ClearCodecDecoder::new(); + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(0, 0, None, &residual); + assert!(dec.decode(&stream, u16::MAX, u16::MAX).is_err()); +} + +#[test] +fn large_but_reasonable_dimensions_accepted() { + // 1920x1080 = 2,073,600 pixels should work fine + let mut dec = ClearCodecDecoder::new(); + let residual = make_solid_residual(0x42, 0x42, 0x42, 1920 * 1080); + let stream = make_residual_stream(0, 0, None, &residual); + let result = dec.decode(&stream, 1920, 1080).unwrap(); + assert_eq!(result.len(), 1920 * 1080 * 4); + // Spot-check first pixel + assert_eq!(&result[..4], &[0x42, 0x42, 0x42, 0xFF]); +} diff --git a/crates/ironrdp-testsuite-core/tests/graphics/mod.rs b/crates/ironrdp-testsuite-core/tests/graphics/mod.rs index 50aa61f6e..9846336c2 100644 --- a/crates/ironrdp-testsuite-core/tests/graphics/mod.rs +++ b/crates/ironrdp-testsuite-core/tests/graphics/mod.rs @@ -1,3 +1,4 @@ +mod clearcodec; mod color_conversion; mod dwt; mod image_processing;