From 137556013f9defe2a1b7148cff881a2ac9c61ec6 Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Mon, 8 May 2023 16:43:53 +0300 Subject: [PATCH] Add support for RDP6 32bpp bitmaps (#118) This adds support for RDP6 32bpp bitmaps. - RDP6 RLE implementation - `RDP6_BITMAP_STREAM` pdu parsing - New CLI arguments to select color depth and color reduction - Unit tests and fuzzing for implemented functionality Test bpp32 mode without lossy compression (RGB colorspace) ``` cargo run -p ironrdp-client -- -u vmbox -p password --color-depth=32 ``` Test bpp32 mode with lossy compression (YCoCg colorspace, subsampling, color loss) ``` cargo run -p ironrdp-client -- -u vmbox -p password --color-depth=32 --lossy-bitmap-compression ``` Issue: ARC-149 --- Cargo.lock | 28 ++ Cargo.toml | 17 +- crates/client/src/config.rs | 27 ++ crates/connector/src/connection.rs | 30 +- crates/connector/src/lib.rs | 8 + crates/graphics/Cargo.toml | 2 + crates/graphics/src/color_conversion.rs | 27 ++ crates/graphics/src/lib.rs | 1 + crates/graphics/src/rdp6/bitmap_stream.rs | 334 ++++++++++++++++++ crates/graphics/src/rdp6/mod.rs | 7 + crates/graphics/src/rdp6/rle.rs | 334 ++++++++++++++++++ .../src/rdp6/test_assets/32x64_rgb_raw.bin | Bin 0 -> 6146 bytes .../src/rdp6/test_assets/32x64_rgb_raw.bmp | Bin 0 -> 6198 bytes .../src/rdp6/test_assets/64x24_argb_rle.bin | Bin 0 -> 1235 bytes .../src/rdp6/test_assets/64x24_argb_rle.bmp | Bin 0 -> 4662 bytes .../src/rdp6/test_assets/64x24_aycocg_rle.bin | Bin 0 -> 1363 bytes .../src/rdp6/test_assets/64x24_aycocg_rle.bmp | Bin 0 -> 4662 bytes .../rdp6/test_assets/64x24_ycocg_rle_ss.bin | Bin 0 -> 608 bytes .../rdp6/test_assets/64x24_ycocg_rle_ss.bmp | Bin 0 -> 4662 bytes .../rdp6/test_assets/64x57_ycocg_rle_ss.bin | Bin 0 -> 980 bytes .../rdp6/test_assets/64x57_ycocg_rle_ss.bmp | Bin 0 -> 10998 bytes .../rdp6/test_assets/64x64_ycocg_raw_ss.bin | Bin 0 -> 6146 bytes .../rdp6/test_assets/64x64_ycocg_raw_ss.bmp | Bin 0 -> 12342 bytes crates/graphics/src/rle.rs | 1 - crates/pdu/Cargo.toml | 1 + crates/pdu/src/basic_output/bitmap.rs | 2 + crates/pdu/src/basic_output/bitmap/rdp6.rs | 271 ++++++++++++++ crates/pdu/src/basic_output/bitmap/tests.rs | 2 +- crates/session/src/fast_path.rs | 19 +- crates/session/src/image.rs | 28 ++ crates/web/src/session.rs | 1 + fuzz/Cargo.toml | 6 + fuzz/fuzz_targets/bitmap_stream.rs | 22 ++ fuzz/fuzz_targets/pdu_decoding.rs | 2 + xtask/src/tasks.rs | 2 +- 35 files changed, 1157 insertions(+), 15 deletions(-) create mode 100644 crates/graphics/src/rdp6/bitmap_stream.rs create mode 100644 crates/graphics/src/rdp6/mod.rs create mode 100644 crates/graphics/src/rdp6/rle.rs create mode 100644 crates/graphics/src/rdp6/test_assets/32x64_rgb_raw.bin create mode 100644 crates/graphics/src/rdp6/test_assets/32x64_rgb_raw.bmp create mode 100644 crates/graphics/src/rdp6/test_assets/64x24_argb_rle.bin create mode 100644 crates/graphics/src/rdp6/test_assets/64x24_argb_rle.bmp create mode 100644 crates/graphics/src/rdp6/test_assets/64x24_aycocg_rle.bin create mode 100644 crates/graphics/src/rdp6/test_assets/64x24_aycocg_rle.bmp create mode 100644 crates/graphics/src/rdp6/test_assets/64x24_ycocg_rle_ss.bin create mode 100644 crates/graphics/src/rdp6/test_assets/64x24_ycocg_rle_ss.bmp create mode 100644 crates/graphics/src/rdp6/test_assets/64x57_ycocg_rle_ss.bin create mode 100644 crates/graphics/src/rdp6/test_assets/64x57_ycocg_rle_ss.bmp create mode 100644 crates/graphics/src/rdp6/test_assets/64x64_ycocg_raw_ss.bin create mode 100644 crates/graphics/src/rdp6/test_assets/64x64_ycocg_raw_ss.bmp create mode 100644 crates/pdu/src/basic_output/bitmap/rdp6.rs create mode 100644 fuzz/fuzz_targets/bitmap_stream.rs diff --git a/Cargo.lock b/Cargo.lock index cf60c7a1..bfd78466 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -346,6 +346,15 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "bmp" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69985ff4f58085ac696454692d0b646a66ad1f9cc9be294c91dc51bb5df511ae" +dependencies = [ + "byteorder", +] + [[package]] name = "bufstream" version = "0.1.4" @@ -912,6 +921,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "dissimilar" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "210ec60ae7d710bed8683e333e9d2855a8a56a3e9892b38bad3bb0d4d29b0d5e" + [[package]] name = "dlib" version = "0.5.0" @@ -969,6 +984,16 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de853764b47027c2e862a995c34978ffa63c1501f2e15f987ba11bd4f9bba193" +[[package]] +name = "expect-test" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d9eafeadd538e68fb28016364c9732d78e420b9ff8853fa5e4058861e9f8d3" +dependencies = [ + "dissimilar", + "once_cell", +] + [[package]] name = "fake-simd" version = "0.1.2" @@ -1543,7 +1568,9 @@ dependencies = [ "bit_field", "bitflags 2.0.2", "bitvec", + "bmp", "byteorder", + "expect-test", "hex-literal", "ironrdp-pdu", "lazy_static", @@ -1575,6 +1602,7 @@ dependencies = [ "bitflags 2.0.2", "byteorder", "der-parser 8.2.0", + "expect-test", "ironrdp-pdu-samples", "lazy_static", "md-5 0.10.5", diff --git a/Cargo.toml b/Cargo.toml index 0e06baf6..451fa139 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,22 +59,23 @@ keywords = ["rdp", "remote-desktop", "network", "client", "protocol"] categories = ["network-programming"] [workspace.dependencies] +expect-test = "1" ironrdp = { version = "0.5", path = "." } -ironrdp-pdu = { version = "0.1", path = "crates/pdu" } +ironrdp-async = { version = "0.1", path = "crates/async" } ironrdp-connector = { version = "0.1", path = "crates/connector" } -ironrdp-session = { version = "0.1", path = "crates/session" } ironrdp-graphics = { version = "0.1", path = "crates/graphics" } ironrdp-input = { version = "0.1", path = "crates/input" } -ironrdp-async = { version = "0.1", path = "crates/async" } -ironrdp-tls = { version = "0.1", path = "crates/tls" } -ironrdp-rdcleanpath = { version = "0.1", path = "crates/rdcleanpath" } -ironrdp-pdu-samples = { path = "crates/pdu-samples" } +ironrdp-pdu = { version = "0.1", path = "crates/pdu" } ironrdp-pdu-generators = { path = "crates/pdu-generators" } +ironrdp-pdu-samples = { path = "crates/pdu-samples" } +ironrdp-rdcleanpath = { version = "0.1", path = "crates/rdcleanpath" } +ironrdp-session = { version = "0.1", path = "crates/session" } ironrdp-session-generators = { path = "crates/session-generators" } -sspi = "0.8.1" -tracing = "0.1.37" +ironrdp-tls = { version = "0.1", path = "crates/tls" } proptest = "1.1.0" rstest = "0.17.0" +sspi = "0.8.1" +tracing = "0.1.37" [profile.dev] opt-level = 1 diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index 991d45ac..dae02f82 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -131,6 +131,15 @@ struct Args { #[clap(long)] small_cache: bool, + /// Enable RDP6 lossy bitmap compression algorithm. Please note that lossy compression + /// only works with 32 bit color depth + #[clap(long)] + lossy_bitmap_compression: bool, + + /// Set required color depth. Currently only 32 and 16 bit color depths are supported + #[clap(long)] + color_depth: Option, + /// Enabled capability versions. Each bit represents enabling a capability version /// starting from V8 to V10_7 #[clap(long, value_parser = parse_hex, default_value_t = 0)] @@ -168,6 +177,23 @@ impl Config { .context("Password prompt")? }; + let bitmap = if let Some(color_depth) = args.color_depth { + if color_depth != 16 && color_depth != 32 { + anyhow::bail!("Invalid color depth. Only 16 and 32 bit color depths are supported."); + } + + if color_depth != 32 && args.lossy_bitmap_compression { + anyhow::bail!("Lossy bitmap compression only works with 32 bit color depth."); + } + + Some(connector::BitmapConfig { + color_depth, + lossy_compression: args.lossy_bitmap_compression, + }) + } else { + None + }; + let graphics = if args.avc444 || args.h264 { Some(connector::GraphicsConfig { avc444: args.avc444, @@ -195,6 +221,7 @@ impl Config { height: DEFAULT_HEIGHT, }, graphics, + bitmap, client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) .map(|version| version.major * 100 + version.minor * 10 + version.patch) .unwrap_or(0) diff --git a/crates/connector/src/connection.rs b/crates/connector/src/connection.rs index 2e3f50cf..5b0dc74c 100644 --- a/crates/connector/src/connection.rs +++ b/crates/connector/src/connection.rs @@ -781,6 +781,18 @@ impl Sequence for ClientConnector { fn create_gcc_blocks(config: &Config, selected_protocol: nego::SecurityProtocol) -> gcc::ClientGccBlocks { use ironrdp_pdu::gcc::*; + let color_depth = config + .bitmap + .as_ref() + .map(|bitmap| match bitmap.color_depth { + 15 => SupportedColorDepths::BPP15, + 16 => SupportedColorDepths::BPP16, + 24 => SupportedColorDepths::BPP24, + 32 => SupportedColorDepths::BPP32, + _ => panic!("Unsupported color depth: {}", bitmap.color_depth), + }) + .unwrap_or(SupportedColorDepths::BPP16); + ClientGccBlocks { core: ClientCoreData { version: RdpVersion::V5_PLUS, @@ -800,7 +812,7 @@ fn create_gcc_blocks(config: &Config, selected_protocol: nego::SecurityProtocol) client_product_id: Some(1), serial_number: Some(0), high_color_depth: Some(HighColorDepth::Bpp24), - supported_color_depths: Some(SupportedColorDepths::BPP16), + supported_color_depths: Some(color_depth), early_capability_flags: { let mut early_capability_flags = ClientEarlyCapabilityFlags::VALID_CONNECTION_TYPE | ClientEarlyCapabilityFlags::SUPPORT_ERR_INFO_PDU; @@ -893,6 +905,20 @@ fn create_client_confirm_active( server_capability_sets.retain(|capability_set| matches!(capability_set, CapabilitySet::MultiFragmentUpdate(_))); + let lossy_bitmap_compression = config + .bitmap + .as_ref() + .map(|bitmap| bitmap.lossy_compression) + .unwrap_or(false); + + let drawing_flags = if lossy_bitmap_compression { + BitmapDrawingFlags::ALLOW_SKIP_ALPHA + | BitmapDrawingFlags::ALLOW_DYNAMIC_COLOR_FIDELITY + | BitmapDrawingFlags::ALLOW_COLOR_SUBSAMPLING + } else { + BitmapDrawingFlags::ALLOW_SKIP_ALPHA + }; + server_capability_sets.extend_from_slice(&[ CapabilitySet::General(General { major_platform_type: config.platform, @@ -906,7 +932,7 @@ fn create_client_confirm_active( desktop_width: config.desktop_size.width, desktop_height: config.desktop_size.height, desktop_resize_flag: false, - drawing_flags: BitmapDrawingFlags::empty(), + drawing_flags, }), CapabilitySet::Order(Order::new( OrderFlags::NEGOTIATE_ORDER_SUPPORT | OrderFlags::ZERO_BOUNDS_DELTAS_SUPPORT, diff --git a/crates/connector/src/lib.rs b/crates/connector/src/lib.rs index bd944625..b3fb9369 100644 --- a/crates/connector/src/lib.rs +++ b/crates/connector/src/lib.rs @@ -39,6 +39,13 @@ pub struct GraphicsConfig { pub capabilities: u32, } +#[derive(Debug, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct BitmapConfig { + pub lossy_compression: bool, + pub color_depth: u32, +} + #[derive(Debug, Clone)] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Config { @@ -56,6 +63,7 @@ pub struct Config { pub keyboard_functional_keys_count: u32, pub ime_file_name: String, pub graphics: Option, + pub bitmap: Option, pub dig_product_id: String, pub client_dir: String, pub platform: capability_sets::MajorPlatformType, diff --git a/crates/graphics/Cargo.toml b/crates/graphics/Cargo.toml index 19746cc5..7f74c3d8 100644 --- a/crates/graphics/Cargo.toml +++ b/crates/graphics/Cargo.toml @@ -23,6 +23,8 @@ bitflags = "2" lazy_static = "1.4.0" [dev-dependencies] +bmp = "0.5" +expect-test.workspace = true hex-literal = "0.3.4" proptest = "1.1.0" rdp-rs = { git = "https://github.com/citronneur/rdp-rs", rev = "7ac880d7efb7f05efef3c84476f7c24f4053e0ea" } diff --git a/crates/graphics/src/color_conversion.rs b/crates/graphics/src/color_conversion.rs index d05187c0..0d1e47d4 100644 --- a/crates/graphics/src/color_conversion.rs +++ b/crates/graphics/src/color_conversion.rs @@ -18,6 +18,10 @@ fn clip(v: i32) -> u8 { min(max(v, 0), 255) as u8 } +fn clip_i16(v: i16) -> u8 { + min(max(v, 0), 255) as u8 +} + #[derive(Debug)] pub struct YCbCrBuffer<'a> { pub y: &'a [i16], @@ -52,6 +56,13 @@ pub struct YCbCr { pub cr: i16, } +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct YCoCg { + pub y: u8, + pub co: i8, + pub cg: i8, +} + #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub struct Rgb { pub r: u8, @@ -79,3 +90,19 @@ impl From for Rgb { Self { r, g, b } } } + +impl From for Rgb { + fn from(YCoCg { y, co, cg }: YCoCg) -> Self { + let y = i16::from(y); + let co = i16::from(co); + let cg = i16::from(cg); + + let t = y - cg / 2; + + let r = clip_i16(t + co / 2); + let g = clip_i16(y + cg / 2); + let b = clip_i16(t - co / 2); + + Self { r, g, b } + } +} diff --git a/crates/graphics/src/lib.rs b/crates/graphics/src/lib.rs index 69d161a8..2d776758 100644 --- a/crates/graphics/src/lib.rs +++ b/crates/graphics/src/lib.rs @@ -2,6 +2,7 @@ pub mod color_conversion; pub mod dwt; pub mod image_processing; pub mod quantization; +pub mod rdp6; pub mod rectangle_processing; pub mod rle; pub mod rlgr; diff --git a/crates/graphics/src/rdp6/bitmap_stream.rs b/crates/graphics/src/rdp6/bitmap_stream.rs new file mode 100644 index 00000000..ce9da29a --- /dev/null +++ b/crates/graphics/src/rdp6/bitmap_stream.rs @@ -0,0 +1,334 @@ +use crate::{ + color_conversion::{Rgb, YCoCg}, + rdp6::rle::{decompress_8bpp_plane, RleError}, +}; +use ironrdp_pdu::{ + bitmap::rdp6::{BitmapStream as BitmapStreamPdu, ColorPlanes}, + decode, Error as PduError, +}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum BitmapDecodeError { + #[error("Failed to decode RDP6 bitmap stream PDU: {0}")] + Pdu(#[from] PduError), + #[error("Failed to perform RLE decompression of RDP6 bitmap stream: {0}")] + Rle(#[from] RleError), + #[error("Color plane data size provided in PDU is not sufficient to reconstruct the bitmap")] + InvalidUncompressedDataSize, +} + +/// Implements decoding of RDP6 bitmap stream PDU (see [`BitmapStreamPdu`]) +#[derive(Debug, Default)] +pub struct BitmapStreamDecoder { + /// Optimization to avoid reallocations, re-use this buffer for all bitmaps in the session + planes_buffer: Vec, +} + +/// Internal implementation of RDP6 bitmap stream PDU decoder for specific image size and format +struct BitmapStreamDecoderImpl<'a> { + bitmap: BitmapStreamPdu<'a>, + image_width: usize, + image_height: usize, + chroma_width: usize, + chroma_height: usize, + full_plane_size: usize, + chroma_plane_size: usize, + uncompressed_planes_size: usize, + color_plane_offsets: [usize; 3], +} + +struct AYCoCgParams { + color_loss_level: u8, + chroma_subsampling: bool, + alpha: bool, +} + +impl<'a> BitmapStreamDecoderImpl<'a> { + pub fn init(bitmap: BitmapStreamPdu<'a>, image_width: usize, image_height: usize) -> Self { + let (chroma_width, chroma_height) = if bitmap.has_subsampled_chroma() { + // When image is subsampled, chroma plane has half the size of the luma plane, however + // its size is rounded up to the nearest greater integer, to take into account odd image + // size (e.g. if width is 3, then chroma plane width is 2, not 1, to take into account + // the odd column which expands to 1 pixel instead of 2 during supersampling) + ((image_width + 1) / 2, (image_height + 1) / 2) + } else { + (image_width, image_height) + }; + + let full_plane_size = image_width * image_height; + let chroma_plane_size = chroma_width * chroma_height; + + let uncompressed_planes_size = if bitmap.has_subsampled_chroma() { + full_plane_size + chroma_plane_size * 2 + } else { + full_plane_size * 3 + }; + + let color_plane_offsets = [0, full_plane_size, full_plane_size + chroma_plane_size]; + + Self { + bitmap, + image_width, + image_height, + chroma_width, + chroma_height, + full_plane_size, + chroma_plane_size, + uncompressed_planes_size, + color_plane_offsets, + } + } + + fn decompress_planes(&'a self, aux_buffer: &'a mut Vec) -> Result<&'a [u8], BitmapDecodeError> { + let planes = if self.bitmap.enable_rle_compression { + // We don't care for the previous content, just resize it to fit the data + aux_buffer.resize(self.uncompressed_planes_size, 0); + let uncompressed_planes_buffer = &mut aux_buffer[..self.uncompressed_planes_size]; + + let compressed = self.bitmap.color_panes_data(); + let mut src_offset = 0; + + // Decompress Alpha plane + if self.bitmap.use_alpha { + // Decompress alpha alpha, but discard it (always 0xFF) + src_offset += decompress_8bpp_plane( + &compressed[src_offset..], + uncompressed_planes_buffer, + self.image_width, + self.image_height, + )?; + } + + // Decompress R/Y plane + src_offset += decompress_8bpp_plane( + &compressed[src_offset..], + &mut uncompressed_planes_buffer[self.color_plane_offsets[0]..], + self.image_width, + self.image_height, + )?; + + // Decompress G/Co plane + src_offset += decompress_8bpp_plane( + &compressed[src_offset..], + &mut uncompressed_planes_buffer[self.color_plane_offsets[1]..], + self.chroma_width, + self.chroma_height, + )?; + + // Decompress B/Cg plane + decompress_8bpp_plane( + &compressed[src_offset..], + &mut uncompressed_planes_buffer[self.color_plane_offsets[2]..], + self.chroma_width, + self.chroma_height, + )?; + + &uncompressed_planes_buffer[..self.uncompressed_planes_size] + } else { + // Discard alpha plane + let color_planes_offset = if self.bitmap.use_alpha { self.full_plane_size } else { 0 }; + + let expected_data_size = color_planes_offset + self.uncompressed_planes_size; + + if self.bitmap.color_panes_data().len() < expected_data_size { + return Err(BitmapDecodeError::InvalidUncompressedDataSize); + } + + &self.bitmap.color_panes_data()[color_planes_offset..] + }; + + Ok(planes) + } + + fn write_argb_planes_to_rgb24(&self, planes: &[u8], dst: &mut Vec) { + // For ARGB comversion is simple - just copy data in correct order + let (r_offset, g_offset, b_offset) = ( + self.color_plane_offsets[0], + self.color_plane_offsets[1], + self.color_plane_offsets[2], + ); + + let r_plane = &planes[r_offset..r_offset + self.full_plane_size]; + let g_plane = &planes[g_offset..g_offset + self.full_plane_size]; + let b_plane = &planes[b_offset..b_offset + self.full_plane_size]; + + for i in 0..self.full_plane_size { + let (r, g, b) = (r_plane[i], g_plane[i], b_plane[i]); + + dst.extend_from_slice(&[r, g, b]); + } + } + + fn write_aycocg_planes_to_rgb24(&self, params: AYCoCgParams, planes: &[u8], dst: &mut Vec) { + // For AYCoCg we need to take color loss level and subsampling into account + let chroma_shift = (params.color_loss_level - 1) as usize; + let sample_shift = params.chroma_subsampling as usize; + + let (y_offset, co_offset, cg_offset) = ( + self.color_plane_offsets[0], + self.color_plane_offsets[1], + self.color_plane_offsets[2], + ); + + let y_plane = &planes[y_offset..y_offset + self.full_plane_size]; + let co_plane = &planes[co_offset..co_offset + self.chroma_plane_size]; + let cg_plane = &planes[cg_offset..cg_offset + self.chroma_plane_size]; + + for (idx, y) in y_plane.iter().copied().enumerate() { + let chroma_row = (idx / self.image_width) >> sample_shift; + let chroma_col = (idx % self.image_width) >> sample_shift; + let chroma_idx = chroma_row * self.chroma_width + chroma_col; + + let co = (co_plane[chroma_idx] << chroma_shift) as i8; + let cg = (cg_plane[chroma_idx] << chroma_shift) as i8; + + let Rgb { r, g, b } = YCoCg { y, co, cg }.into(); + + // As described in 3.1.9.1.2 [MS-RDPEGDI], R and B channels are swapped for + // AYCoCg when 24-bit image is used (no alpha). We swap them back here + if params.alpha { + dst.extend_from_slice(&[r, g, b]); + } else { + dst.extend_from_slice(&[b, g, r]); + } + } + } + + fn decode(self, dst: &mut Vec, aux_buffer: &'a mut Vec) -> Result<(), BitmapDecodeError> { + // Reserve enough space for decoded RGB channels data + dst.reserve(self.image_height * self.image_width * 3); + + match self.bitmap.color_planes { + ColorPlanes::Argb { .. } => { + let color_planes = self.decompress_planes(aux_buffer)?; + self.write_argb_planes_to_rgb24(color_planes, dst); + } + ColorPlanes::AYCoCg { + color_loss_level, + use_chroma_subsampling, + .. + } => { + let params = AYCoCgParams { + color_loss_level, + chroma_subsampling: use_chroma_subsampling, + alpha: self.bitmap.use_alpha, + }; + let color_planes = self.decompress_planes(aux_buffer)?; + self.write_aycocg_planes_to_rgb24(params, color_planes, dst); + } + } + + Ok(()) + } +} + +impl BitmapStreamDecoder { + /// Performs decoding of bitmap stream PDU from `bitmap_data` and writes decoded rgb24 + /// image to `dst` buffer. + pub fn decode_bitmap_stream_to_rgb24( + &mut self, + bitmap_data: &[u8], + dst: &mut Vec, + image_width: usize, + image_height: usize, + ) -> Result<(), BitmapDecodeError> { + let bitmap = decode::(bitmap_data)?; + + let decoder = BitmapStreamDecoderImpl::init(bitmap, image_width, image_height); + + decoder.decode(dst, &mut self.planes_buffer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_decoded_image(pdu: &[u8], expected_bmp: &[u8], width: usize, height: usize) { + let expected_bmp = bmp::from_reader(&mut std::io::Cursor::new(expected_bmp)).unwrap(); + let mut expected_buffer = vec![0; width * height * 3]; + for (idx, (x, y)) in expected_bmp.coordinates().enumerate() { + let pixel = expected_bmp.get_pixel(x, y); + + let offset = idx * 3; + expected_buffer[offset] = pixel.r; + expected_buffer[offset + 1] = pixel.g; + expected_buffer[offset + 2] = pixel.b; + } + + let mut actual = Vec::new(); + + BitmapStreamDecoder::default() + .decode_bitmap_stream_to_rgb24(pdu, &mut actual, width, height) + .unwrap(); + + assert_eq!(actual.as_slice(), expected_buffer.as_slice()); + } + + #[test] + fn decode_32x64_rgb_raw() { + // RGB (No alpha), no RLE + assert_decoded_image( + include_bytes!("test_assets/32x64_rgb_raw.bin"), + include_bytes!("test_assets/32x64_rgb_raw.bmp"), + 32, + 64, + ); + } + + #[test] + fn decode_64x24_argb_rle() { + // ARGB (With alpha), RLE + assert_decoded_image( + include_bytes!("test_assets/64x24_argb_rle.bin"), + include_bytes!("test_assets/64x24_argb_rle.bmp"), + 64, + 24, + ); + } + + #[test] + fn decode_64x24_aycocg_rle() { + // AYCoCg (With alpha), RLE, no chroma subsampling + assert_decoded_image( + include_bytes!("test_assets/64x24_aycocg_rle.bin"), + include_bytes!("test_assets/64x24_aycocg_rle.bmp"), + 64, + 24, + ); + } + + #[test] + fn decode_64x24_ycocg_rle_ss() { + // AYCoCg (No alpha), RLE, with chroma subsampling + assert_decoded_image( + include_bytes!("test_assets/64x24_ycocg_rle_ss.bin"), + include_bytes!("test_assets/64x24_ycocg_rle_ss.bmp"), + 64, + 24, + ); + } + + #[test] + fn decode_64x57_ycocg_rle_ss() { + // AYCoCg (No alpha), RLE, with chroma subsampling + odd resolution + assert_decoded_image( + include_bytes!("test_assets/64x57_ycocg_rle_ss.bin"), + include_bytes!("test_assets/64x57_ycocg_rle_ss.bmp"), + 64, + 57, + ); + } + + #[test] + fn decode_64x64_ycocg_raw_ss() { + // AYCoCg (No alpha), no RLE, with chroma subsampling + assert_decoded_image( + include_bytes!("test_assets/64x64_ycocg_raw_ss.bin"), + include_bytes!("test_assets/64x64_ycocg_raw_ss.bmp"), + 64, + 64, + ); + } +} diff --git a/crates/graphics/src/rdp6/mod.rs b/crates/graphics/src/rdp6/mod.rs new file mode 100644 index 00000000..7f99d1ba --- /dev/null +++ b/crates/graphics/src/rdp6/mod.rs @@ -0,0 +1,7 @@ +//! This module provides the RDP6 bitmap decoder implementation + +pub(crate) mod bitmap_stream; +pub(crate) mod rle; + +pub use bitmap_stream::{BitmapDecodeError, BitmapStreamDecoder}; +pub use rle::RleError; diff --git a/crates/graphics/src/rdp6/rle.rs b/crates/graphics/src/rdp6/rle.rs new file mode 100644 index 00000000..55fff780 --- /dev/null +++ b/crates/graphics/src/rdp6/rle.rs @@ -0,0 +1,334 @@ +use byteorder::ReadBytesExt; +use std::io::{Read, Write}; +use thiserror::Error; + +/// Maximum possible segment size is 47 (run_length = 2, raw_bytes_count = 15), which is treated as +/// special mode segment, which repeats last decoded byte in scanline 32 + raw_bytes_count times +const MAX_DECODED_SEGMENT_SIZE: usize = 47; + +#[derive(Debug, Error)] +pub enum RleError { + #[error("Failed to read RLE-compressed data: {0}")] + ReadCompressedData(#[source] std::io::Error), + + #[error("Failed to write decompressed data: {0}")] + WriteDecompressedData(#[source] std::io::Error), + + #[error("Invalid RLE segment header")] + InvalidSegmentHeader, + + #[error("Decoded scanline segments length exceeds scanline length")] + SegmentDoNotFitScanline, +} + +/// RLE-encoded color plane decoder implementation for RDP6 bitmap stream +#[derive(Debug)] +struct RlePlaneDecoder { + /// RDP6 performs per-scanline encoding, therefore segment decoder require state reset + /// for when each scanline is started (e.g. resetting last decoded byte value to 0) + last_decoded_byte: u8, + + width: usize, + height: usize, + + decoded_data: [u8; MAX_DECODED_SEGMENT_SIZE], + decoded_data_len: usize, +} + +impl RlePlaneDecoder { + pub fn new(width: usize, height: usize) -> Self { + Self { + last_decoded_byte: 0, + width, + height, + decoded_data: [0; MAX_DECODED_SEGMENT_SIZE], + decoded_data_len: 0, + } + } + + fn decompress_next_segment(&mut self, mut src: &[u8]) -> Result { + let control_byte = src.read_u8().map_err(RleError::ReadCompressedData)?; + + if control_byte == 0 { + return Err(RleError::InvalidSegmentHeader); + } + + let rle_bytes_field = control_byte & 0x0F; + let raw_bytes_field = (control_byte >> 4) & 0x0F; + + let (run_length, raw_bytes_count) = match rle_bytes_field { + 1 => (16 + raw_bytes_field as usize, 0), + 2 => (32 + raw_bytes_field as usize, 0), + rle_control => (rle_control as usize, raw_bytes_field as usize), + }; + + self.decoded_data_len = raw_bytes_count + run_length; + + src.read_exact(&mut self.decoded_data[..raw_bytes_count]) + .map_err(RleError::ReadCompressedData)?; + + if raw_bytes_count > 0 { + // save last decoded byte for the next segments decoding + self.last_decoded_byte = self.decoded_data[raw_bytes_count - 1]; + } + + self.decoded_data[raw_bytes_count..self.decoded_data_len].fill(self.last_decoded_byte); + + Ok(raw_bytes_count + 1) + } + + /// Decodes single RLE-encoded scanline, without performing delta transformation + fn decode_scanline(&mut self, src: &[u8], mut dst: &mut [u8]) -> Result { + let mut decoded_columns = 0; + let mut read_bytes = 0; + + self.last_decoded_byte = 0; + + while decoded_columns < self.width { + read_bytes += self.decompress_next_segment(&src[read_bytes..])?; + + if decoded_columns + self.decoded_data_len > self.width { + return Err(RleError::SegmentDoNotFitScanline); + } + + dst.write_all(&self.decoded_data[..self.decoded_data_len]) + .map_err(RleError::WriteDecompressedData)?; + + decoded_columns += self.decoded_data_len; + } + + Ok(read_bytes) + } + + /// Performs delta transformation as described in 3.1.9.2.3 of [MS-RDPEGDI] + fn resolve_scanline_delta(prev_line: &[u8], current_scanline: &mut [u8]) { + assert!(prev_line.len() == current_scanline.len()); + + current_scanline + .iter_mut() + .zip(prev_line.iter()) + .for_each(|(dst, src)| { + let delta = *dst; + let value_above = *src; + + let transformed_delta = if delta % 2 == 1 { + 255u8.wrapping_sub((delta.wrapping_sub(1)) >> 1) + } else { + delta >> 1 + }; + + *dst = value_above.wrapping_add(transformed_delta); + }); + } + + pub fn decode(mut self, src: &[u8], dst: &mut [u8]) -> Result { + let mut read_bytes = 0; + + read_bytes += self.decode_scanline(src, dst)?; + + let (mut prev_scanline, mut dst) = dst.split_at_mut(self.width); + + for _ in 1..self.height { + let current_scanline = &mut dst[..self.width]; + + read_bytes += self.decode_scanline(&src[read_bytes..], current_scanline)?; + Self::resolve_scanline_delta(prev_scanline, current_scanline); + + (prev_scanline, dst) = dst.split_at_mut(self.width); + } + + Ok(read_bytes) + } +} + +/// Performs decompression of 8bpp color plane into slice. +/// Slice must have enough space for decompressed data. +/// Size of data written to dst buffer is exactly equal to `width * height`. +/// +/// Returns number of bytes consumed from src buffer. +pub fn decompress_8bpp_plane( + src: &[u8], + dst: &mut [u8], + width: impl Into, + height: impl Into, +) -> Result { + let width = width.into(); + let height = height.into(); + + RlePlaneDecoder::new(width, height).decode(src, dst) +} + +#[cfg(test)] +mod tests { + use super::*; + + use expect_test::expect; + + /// Performs decompression of 8bpp color plane into vector. Vector will be resized to fit decompressed data. + pub fn decompress( + src: &[u8], + dst: &mut Vec, + width: impl Into, + height: impl Into, + ) -> Result { + let width = width.into(); + let height = height.into(); + // Ensure dest buffer have enough space for decompressed data + dst.resize(width * height, 0); + + decompress_8bpp_plane(src, dst.as_mut_slice(), width, height) + } + + #[test] + fn long_sequence_decode() { + // Example from 3.1.9.2.2 of [MS-RDPEGDI]. + let src = [0x1F, 0x41, 0xF2, 0x52]; + + let width = 100usize; + let height = 1usize; + + let expected = &[0x41u8; 100]; + + let mut actual = Vec::new(); + decompress(&src, &mut actual, width, height).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn multiline_decode() { + // Example from 3.1.9.2.3 of [MS-RDPEGDI]. + let src = [ + 0x13, 0xFF, 0x20, 0xFE, 0xFD, 0x60, 0x01, 0x7D, 0xF5, 0xC2, 0x9A, 0x38, 0x60, 0x01, 0x67, 0x8B, 0xA3, 0x78, + 0xAF, + ]; + + let width = 6usize; + let height = 3usize; + + let expected = &[ + 255, 255, 255, 255, 254, 253, 254, 192, 132, 96, 75, 25, 253, 140, 62, 14, 135, 193, + ]; + + let mut actual = Vec::new(); + decompress(&src, &mut actual, width, height).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn each_scanline_resets_last_decoded_byte() { + let src = [0x17, 0xFF, 0x04, 0x40, 0x01, 0x02, 0x03, 0x04]; + + let width = 8usize; + let height = 2usize; + + let mut actual = Vec::new(); + + let expected = &[ + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 254, 0, 253, 1, + ]; + + decompress(&src, &mut actual, width, height).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn segments_out_of_scanline_produce_error() { + let src = [ + 0x18, 0xFF, // Will produce 9 bytes which is out of bounds for 8x2 image + 0x04, 0x40, 0x01, 0x02, 0x03, 0x04, + ]; + + let width = 8usize; + let height = 2usize; + + let mut actual = Vec::new(); + expect![[r#" + Err( + SegmentDoNotFitScanline, + ) + "#]] + .assert_debug_eq(&decompress(&src, &mut actual, width, height)); + + // Same test, but fail on non-first line + let src = [ + 0x17, 0xFF, 0x18, 0xFF, // Will produce 9 bytes which is out of bounds for 8x2 image + ]; + + let width = 8usize; + let height = 2usize; + + let mut actual = Vec::new(); + expect![[r#" + Err( + SegmentDoNotFitScanline, + ) + "#]] + .assert_debug_eq(&decompress(&src, &mut actual, width, height)); + } + + #[test] + fn insufficient_raw_bytes_handled() { + let src = [0x18]; // Actually require 1 more byte + + let width = 8usize; + let height = 2usize; + + let mut actual = Vec::new(); + expect![[r#" + Err( + ReadCompressedData( + Error { + kind: UnexpectedEof, + message: "failed to fill whole buffer", + }, + ), + ) + "#]] + .assert_debug_eq(&decompress(&src, &mut actual, width, height)); + } + + #[test] + fn empty_buffer_handled() { + let src = []; + + let width = 8usize; + let height = 2usize; + + let mut actual = Vec::new(); + expect![[r#" + Err( + ReadCompressedData( + Error { + kind: UnexpectedEof, + message: "failed to fill whole buffer", + }, + ), + ) + "#]] + .assert_debug_eq(&decompress(&src, &mut actual, width, height)); + } + + #[test] + fn too_small_dest_buffer_handled() { + let src = [0x17, 0xFF, 0x04, 0x40, 0x01, 0x02, 0x03, 0x04]; + + let width = 8usize; + let height = 2usize; + + let mut actual = vec![0u8; 7]; + + expect![[r#" + Err( + WriteDecompressedData( + Error { + kind: WriteZero, + message: "failed to write whole buffer", + }, + ), + ) + "#]] + .assert_debug_eq(&decompress_8bpp_plane(&src, &mut actual, width, height)); + + // Check same failure mode, but on non-first line + } +} diff --git a/crates/graphics/src/rdp6/test_assets/32x64_rgb_raw.bin b/crates/graphics/src/rdp6/test_assets/32x64_rgb_raw.bin new file mode 100644 index 0000000000000000000000000000000000000000..ae0608bc4d32b8ea196da41e035564d54e596fef GIT binary patch literal 6146 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 rAOlH)?CRNG6z literal 0 HcmV?d00001 diff --git a/crates/graphics/src/rdp6/test_assets/32x64_rgb_raw.bmp b/crates/graphics/src/rdp6/test_assets/32x64_rgb_raw.bmp new file mode 100644 index 0000000000000000000000000000000000000000..77a5f94d18ab891ae2849282d88703ef323008a7 GIT binary patch literal 6198 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 literal 0 HcmV?d00001 diff --git a/crates/graphics/src/rdp6/test_assets/64x24_argb_rle.bin b/crates/graphics/src/rdp6/test_assets/64x24_argb_rle.bin new file mode 100644 index 0000000000000000000000000000000000000000..2762c667a53daa3a9ffb09f3af513e0a6f3fa336 GIT binary patch literal 1235 zcmWgmBuE)#T4{0A+S=OI*52OH>a+#JM^CHv2`nZ|Obnh~lPwrN+W(eg$z)<;kov=L zUrS3vEh8f%OhZe9hk;pt8Vjpt55s+ZL;GMRCZ;@ZOWO_xW>FRsAt{+qIXO8I7D38Y;0^y3=BeI49p@dSDCmZBqXGmKm-qqB*T1OZf;g4CMFgJc2O2(CI)3D22~~& zNrwK36Wgnqn3xziP=wLAfg44PU7rc0`6v@FFE2lYV3A}H6Y%$7V_{%bVPXNx za5H`U`0+i+Xp!Ib-#>5RV_{-ofth2%1eVCiPfIc2Vq;*1iG!UY!V}NM!N$VCh9WA) zBoA`iQIJo>nEw3v^NWE+l0hkjiHU`Yi6NAckrBj~7iP{E5)u*?7UJ*`6cj>>E5lYM z1||juCMJeuCU@iG;}a8;lT#~`lT#m>F)@7fG+WvNiHN$5CJZ0#KgfV1LgEF(eHj@U zsb|lgZIY6a<6vOc?_y!qY+$&*b^GDV92^`$r*|JrK#zzJ4h}XBE`JUY5oj#TpFe*t zQ*m)|At)B^v$8TWaBy(2v4EnA8O5W)hWeVaY#yMv&}3pjkqXt<*VbgPXJP``h7@`1 z@k}g`;62K;YSpS0Om%g2HJ}g{c-W8) zgG7t4M!%YxiU=1QQiOpcMwq9ckBf^96yETd1WSl8sjx7~gQE8+)8fU8mol}rwY7kv zLz+pRfekH+WEGg@#e>TFnshh+F1jMi?X8z3?J=wg$P{3^6y7e0<8d>z4SXebH81Bo-NlEbW@d-)E$nbyy2PID^DJd!N@bCyJD5-Hl zV}p~ElbuOQN=gP48^L^hd;&c2LPA1pAiYQd8=R1kl)@1Y&QNHvbn4WZb4ix2=1Gw105 literal 0 HcmV?d00001 diff --git a/crates/graphics/src/rdp6/test_assets/64x24_argb_rle.bmp b/crates/graphics/src/rdp6/test_assets/64x24_argb_rle.bmp new file mode 100644 index 0000000000000000000000000000000000000000..e72fdf659735ded853503e5cfc1ceed14ad0e965 GIT binary patch literal 4662 zcmZ?rH4|b012YB&1`P%V1_uTP1_=fR21YQ+z`!8H!0>_@g24(I7#LEn|HqHaLPHgV z83-bIVFnNqVvt8d|8anSVDJ|P{(!-6F!%)nKf&O~k00`a40uq|wg32$SqK%}4|ear zzkmNi7!VTXd?@|r&!6AFfB%4i?_lul+c!LpmltG+zxodaU4d}_$6tjaQwrPA-*+P8#FOxIKO!#viM;&Z7}fotL=H*nSV^SBFE7LZPQoApk|@4^ z|Ni^;Z)ErX`t=Lh{Xc*H`~(9Z!QjJ(5BS}GZ{rdISuV2XS|M~MLvipDh`0?@M#}8oe9t_^Sdxziscc)olqMZxV{EoZ} zI{PE|;y*(Q-2d&{H*hNc3P}{7KYs>A1`I z)#uPV|Ig9K)SwYbD1*x8g-uayV;dlO@eh^CfQNWt);ex2? z|I?>W;8cuA65qdnhdciL5{7TzzQG;Ou=D@duU}uke*FpzUV_1k7ccO;-{bffk7Hll z4!(8S_4LMMO9Wa`@ekog6#g!pFcl;{`?sjJOzU%PoCg+zrnG; z21ozs?f;^)?d6#+QwUlzHA#2ZJN?5y^bdYjbN51PJRpi7LhgU_<_$O%LzBd-SFb*N z_yEbv@T>vJAfG>j^2pn_Z=XJW`UDIfgTbRmkMO%+QBf3(L=+T+kG7dW(Ci#-1qBh1 zxRQv1I2+mSfA#7WI2A*a#ETa%z=;ASgwY)Q`0?XMVDJzO9z1w}-~DKABZf)xu`M#Ka(YpUbRg+rB*;R;-((z{X(6|AEV*t<8i{-P%x7UpOj2 zPM?WEkne*?Nu3^xw5~7<3lkFqlZt#j}#Dz=VN`QI?5`!C%9QiGg_o)2heMzbkNXaD0Es^C8TMfr*Ken}vyi zfq_xhy*D~CSd&vvf`O%(i9tn(kBfs#fQS2oj1vnJ3lldZ69WUYd~V`=Q+at+?`&^T z29^aZ9BdpMY;0^yjNBhoq?lM3m{=JY7`P1+`5ESO=vZ0EFv&7Aumm$Pu`sbI2+MPS z@L^$LU}9lnWMp>qW?*RR-?C!al2t2g7}%Vdn3xz8B&E4OXt6Lcu`qBkGs+t>GGyh< zw^9(1Fz{LD#lVun1Tu($`vVUX0}B(A02`wuD+7bS;Chf7SXhK+s4y_6Ffp-ksBm$A z04re;5@45RU|^8+UC+kG#=^qGr80|wxtNKKjZeu)k9)HS*hDr921bVcI!r8FY#dB% z903fhE^It}a@-$y7@1fYSXe~-EO;0g1mxDUaWHX!?BW2K!NS7E!NJ4*L5`W3frW*M zMXF#%LRgR1dJYy24la-?24-OL4J09 zMo`LNmXzgW=VoMJK=C&&bKk$iTV)WUZvEtSk!) z&q_u~ZUzP>4hBXBV|GqPSqA0>@t&T(pk(0d&C?>x%fP_E#KQ2HiG@!{NQi@plNppi zA{anUTfoH3%nV{NGjo4nrcwqskS~%M8JK*XU3pZP7*shK_H!~aFf%eU z)-kex^onphGchnRd|>9~1o@kt5hTZ0YtO*Qz;4XQ$iVFaO055xg{ApGXyd=gUn%IWUR5Tt79HWmhc1x7|*RVD`II)?dtLP8=U zB4Qj2yb8?BJZek~|7#f-7`Qmtm>8H?IK;RZ_ykp%7|hri8SI5v7+BO98Mw8X7|t`Y zL!*gBgn=c5iIIVgMNyRn?0hZ;mJo2naH}yfsIV~1X9CA3BNGF^;(aD2aN1{K07bH* P;CYb4m{?diKx!NTsTRdD literal 0 HcmV?d00001 diff --git a/crates/graphics/src/rdp6/test_assets/64x24_aycocg_rle.bmp b/crates/graphics/src/rdp6/test_assets/64x24_aycocg_rle.bmp new file mode 100644 index 0000000000000000000000000000000000000000..112bbb8c5611db2d8138c55c024195a565bb2a61 GIT binary patch literal 4662 zcmZ?rH4|b012YB&1`P%V1_uTP1_=fR21YQ+z`!8H!0>_@g24(I7#L)PIPjwllVVmi zdCe`gT~_0?s=;ktliT`s_YEB$>pQ*IcY3es@LAU4HLJ#L<@C^v>r>aSPhYt)bJ3Q( zS=);Gcb9kXu5Lb9-*l*{@?ax=Ti~)%LR@Glw}0leGmmf~xJF!zy)$DMmfJ3^c3fTW zytdJGeY5+97LN@b9veG7S2TFEWmrYIs0G_A*QHpkUK+n@L)zkv>GL+_&Dc`Vx2vpc zS5@Qw+S>h%Rfn2znU5xp=6ER~uAIIZQ%^s{h0wHPayNAOEUmO%SqpMMvKJOrIVHGg zC`s@^w5Un(&FBeOv?gut`pjt?vLD*e_wymsscXidCy7K+?5M=~OO!sH@&YX1W z5d=*>c^?fCG;m#$+p;RVRW)|VURYG+ROqj3r6Go_y}8iFl zx-3YqJXqh~Tpq4nT~WAYT15ZKU$m|$b$+IXh6G$CRtBcC z;UekX)4Pw}?>KU!W!HtB%X@cE&qhXAO+yx(pKrdb!V2z%mDRRWvn`ALwJSmll3di} zMY$o0y#w{T7shukO>9|`QnxIvVns&z%Iw0`xtU8djUCk?YVeS7*J3i#x~6p;yVG{) zTEn(8o2TaQn3RHy@R$jaYKYQVR${fR0)!w5sV~i}G*GuXRKF_7$VpF1R+tl_GBnvT zugR}uQbfs|n7mnWiM^4bwY~-x3J_WRB&M_BBFUZ8+mGIEK6t%u%c(6>a*!RrV^Ru! zV`YRmJWM2(6j>m9p*PjEEJ&{=%%CRBpx9qK*b}R8&OZUL$A= z+_jjDl{qdS@ z7ch;&6iIMVSXgKY@xsz_%Q<-#<-z)up@ubK1|au`7*qxtmiikeJE}YCN*XB%87T>w zs)`1gDP_3nq`PYC$_rwei6#PfEhZzTV@mD*o0Yq-7Hv4TvNv->PXYuLZ8%oB^CFr# zSh>pL+(iLuON*giSX^w~7-?7@tX~}p@yOOu%Qs(NTOFL6nw#PzH=IW#Qo6lo)Fs7iA z7*~CyHrxx-vOq})Q%1SE9Nntt_mxz`OUum%B{CsmrLYk0fQXQ&a9;}bm{W%^w z?uOEGBIu1VxZ8+vKU^`yBUnjXuEi>@EY9s@Al4G2v#7{wS&7x0Jd5^NV^Ce@uV3m9 z$^xbShQ+@6k=Ck)ih_7T9^x>9=?9`rN{9=q%OQeT-HBgNS)AKhSG*xg2ND+xifm@& zSWL*Un4E208*6H$BqT4&4YwK(W1!rR-**sMJkEwo4R!a!J%Wb;NhEkkNN`b$#N!h3 zr0}>LB1J8iL(C!1k$7}L9Epd7*h?)Ek4wmt!sBv?6t!FqF^4=y;?V_hBpwoCFSSTK aE+J0}kINxa)N(n*9P%8AM;FAAct`-swMQ!e literal 0 HcmV?d00001 diff --git a/crates/graphics/src/rdp6/test_assets/64x24_ycocg_rle_ss.bin b/crates/graphics/src/rdp6/test_assets/64x24_ycocg_rle_ss.bin new file mode 100644 index 0000000000000000000000000000000000000000..ad9b28912c0f1e0ef3d0c50ec0a647fea88ee950 GIT binary patch literal 608 zcmcDH6A%#i|3RL=P?YbJAQ-ZWGKw;A>px&{;_`pM@PNV5hLMqhGyeet1H%J`L=Pq= zCI*iF2Mi4NL3~AHCME_ECSe9H|N9L08H_md?=yg;IQFynbZj|tWJ{Y58v}>ReFpve z3~cw5*7f+vaB)d_G%ZkLFq+Jgwa0*kjg5)HS7SjOgVExw7A_Vx78W)Z1`m#cI0gqM z1_lPDEox05K^CRDrk0APPaAjt;9(H@z_Nggg@uKUt;Wp4%Er#A!^OuhC}!F{1|j1R zJGKujEKF=%JUo0LEFi$4k-;GJAp~R!8w;DdBquvNH}@1)ZeD(QWqt-m0|o{Lp$|@M zOl)jSY%Ipsy3(@z3>}=Z(wf@Z+6+P;WFQ(@)ZH1GB={JPFfi&H>MEaQ00o4QFv|xv zHWoHEHh%_APEKw%IVKOyH|`8V9|YOhz+Pct5jIvdC@l%V5UL$e_>2%*MpT#H85980X>S z5y!|NxRljTnw5c7+J}`v@Nf(}laQsUsjUzf69ePrB7PC$4@G&qjF)+Htgc!4cyu`$0m|j;?Ra3(dn99V+ zSW^emz);J?#KOYFSXaj&7|g`PRLh_W(gO+s6-EZe%S;SROf1z53=E7+wG50~89@?E J)wK*v3;?8*ZdCvP literal 0 HcmV?d00001 diff --git a/crates/graphics/src/rdp6/test_assets/64x24_ycocg_rle_ss.bmp b/crates/graphics/src/rdp6/test_assets/64x24_ycocg_rle_ss.bmp new file mode 100644 index 0000000000000000000000000000000000000000..3f087c7f8d9a94c03bbd0d500b1270d248a7d121 GIT binary patch literal 4662 zcmZ?rH4|b012YB&1`P%V1_uTP1_=fR21YQ+z`!8H!0>_@g24(I7#PF^xWojwgar8h z|Nr}eXz=O7J3)S4QGQN>h+R}PrCsUT4NL+k0X~D6Cc}L=A9gCiRIBMFV zh$#nRChUpo+f~*zucTv6PRo+4rbRvtd%Ws)>6<#U^Gf5fLWGZl4(?CMD@a{_GI{Co z#KlJw79B~LcO-W9L5SnSC+`oPxHqbASLWQ4tM*-5zWef)2h3y&wxKN>&xaO}*Z(bEq{PCXbtd4FX8-tgYtd)BQuwP)Mq{hO~H z+IHo@mJ53}?Amsywz4%LtA?3Zeqh6&L0GLos*YxiKwBuUxbeX8PPiZFbFEvO?^w*7 zBQZ0ML`^#sHR({qgadIsyJEU_bhP#C-F@)ZiG5EmpL~4v{F7@Jo?Sb4`}E;c+g2T3 zzxdFySv%%Tus76}=4FTb0@?9&NdF9iiUl1rV&)u)oOuwETf_VJhxP4?>Do~+@64K2 z8}{uvbo2P$o5%Npq5$mvJ7+*qaOcFHn|n8&TDNG~j7ieG>}bx0b7>v^?EK=fskxBc z8aeS`c>n&8z5{{XdrLazEZK49#)$)uFQ0~l{F7@Jo?N@|{Py+dx39mrbK~XRn~$%a zzjExrzIDrFd0F8OMq|*y{l?bLklY&Hzdy8hZ(#Rc|IXcotuuF=dGPe=*%vo1JimGQ z#jPu^?py<*d$-;`y!-y~{f|!{etvrY*^Nubwyl%pWre372m|g5TDxDApHp0r+oxno zTHm^mt^+|G`vThb__ywkZ(P0nef9X;>nGpcIQ8-N*)O*) ze7$|~&fkFPi|j( zX4?v6bb9OZvzu3**|g&D`o(iQYvqOc2i*Pq{QPciZV_!;LYp^2>Q=9+T^o1Ka_ zA-mr#Z<&x)ksz-i9{0l?PlxgoIrN12xm6To{Lkl;m*8i)v15`X;#cRsS?>E?c{4n`Bi?|w)upgEq_?uUmwnp3fI n3A!KC@uTjChcKxO%qSSl;g9LNQTIc#15L|M%wUI$V1_>c3+|CC literal 0 HcmV?d00001 diff --git a/crates/graphics/src/rdp6/test_assets/64x57_ycocg_rle_ss.bin b/crates/graphics/src/rdp6/test_assets/64x57_ycocg_rle_ss.bin new file mode 100644 index 0000000000000000000000000000000000000000..1125f1b2a5da30dc49ad34d81da062a090ce2add GIT binary patch literal 980 zcmcDP-|8qY#`8%~{-L8h1J5VHPlAl{O!5qjjN**)42_K-lJOJs5~HW->FJr7+1a_5 zSUgQux3DNMFlUC8HoGSIi(Iz{kbGmCVD#!^OiD&&MOe$G|1R zvVe)}?Af!Ynbxjd`+Bfy4 z*O+$f*a4Db;ZWgC+sOy9qM?_gkJW8+gY((5-cQs7~OXoVTd z?9Il*Cl}Ah#m2;dWLF0R6AKF)2M13*7Y7F$3k$NGG6Rzg3&`o*qAU}b?%ut7pXu-4 zzxE6aOsw(@yeS|bGBGGHGU|f`3{9ArSj-r>c&xZsK=Ea|eLI5!6O$Q3`gF@eZWeox zbClOInClxefK+*iM>_i22P!gfDc{jES5nb)W#DFF@n8zxz0-cPDFeT`nhJv}gLiYQ zf3r6y!$NZ*v0tCq%@;DLfyg}%K#tbGF1+Jfl1V z$0tE~rb00WaVALyge-_9%f}+iA}h)w%fQ4mQI(bb3HXrA#a=49pI4a{TfvwMy33N(@X4EDo}=0t&7S zOoH;Z{7ekY4h9Ac46Y0ef&u!x3~c61ObogpQE%`3cx46_Q6_l?Wg&(VR$*abK_)Kk zYKCAY78WIz5*`IPIXQ(`Z5FTqqfC8yVtjmJDFdsrB*R8!esLxyCRsBUSraBGkY{9+ zWrTP_j8T(;NrpjNfI2MYrW2bf@DaA09!Vq+0zlxJXZU}Iz9V2NjBWo2YwabRU- zW_M#?WMI-|WMFp4$>EW3Wnf@pk!N5(=#Zb!E4xvdi9wlxNriz?o;M1& literal 0 HcmV?d00001 diff --git a/crates/graphics/src/rdp6/test_assets/64x57_ycocg_rle_ss.bmp b/crates/graphics/src/rdp6/test_assets/64x57_ycocg_rle_ss.bmp new file mode 100644 index 0000000000000000000000000000000000000000..55fbc0e9b30671d192bb333be7cb784f60c090b5 GIT binary patch literal 10998 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 literal 0 HcmV?d00001 diff --git a/crates/graphics/src/rdp6/test_assets/64x64_ycocg_raw_ss.bmp b/crates/graphics/src/rdp6/test_assets/64x64_ycocg_raw_ss.bmp new file mode 100644 index 0000000000000000000000000000000000000000..4a989c5fdd18d22c39804b7071bd63d83f8d0046 GIT binary patch literal 12342 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

*x`u&q6Ko5 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 { + Argb { + data: &'a [u8], + }, + AYCoCg { + color_loss_level: u8, + use_chroma_subsampling: bool, + data: &'a [u8], + }, +} + +/// Represents `RDP6_BITMAP_STREAM` structure described in [MS-RDPEGDI] 2.2.2.5.1 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BitmapStream<'a> { + pub enable_rle_compression: bool, + pub use_alpha: bool, + pub color_planes: ColorPlanes<'a>, +} + +impl<'a> BitmapStream<'a> { + pub const NAME: &'static str = "Rdp6BitmapStream"; + const FIXED_PART_SIZE: usize = 1; + + pub fn color_panes_data(&self) -> &'a [u8] { + match self.color_planes { + ColorPlanes::Argb { data } => data, + ColorPlanes::AYCoCg { data, .. } => data, + } + } + + pub fn has_subsampled_chroma(&self) -> bool { + match self.color_planes { + ColorPlanes::Argb { .. } => false, + ColorPlanes::AYCoCg { + use_chroma_subsampling, .. + } => use_chroma_subsampling, + } + } +} + +impl<'a> PduDecode<'a> for BitmapStream<'a> { + fn decode(src: &mut ReadCursor<'a>) -> PduResult { + ensure_fixed_part_size!(in: src); + let header = src.read_u8(); + + let color_loss_level = header & 0x07; + let use_chroma_subsampling = (header & 0x08) != 0; + let enable_rle_compression = (header & 0x10) != 0; + let use_alpha = (header & 0x20) == 0; + + let color_planes_size = if !enable_rle_compression { + // Cut padding field if RLE flags is set to 0 + if src.is_empty() { + return Err(PduError::Other { + context: Self::NAME, + reason: "Missing padding byte from zero-size Non-RLE bitmap data", + }); + } + src.len() - NON_RLE_PADDING_SIZE + } else { + src.len() + }; + + let color_planes_data = src.peek_slice(color_planes_size); + + let color_planes = match color_loss_level { + 0 => { + // ARGB color planes + ColorPlanes::Argb { + data: color_planes_data, + } + } + color_loss_level => ColorPlanes::AYCoCg { + color_loss_level, + use_chroma_subsampling, + data: color_planes_data, + }, + }; + + Ok(Self { + enable_rle_compression, + use_alpha, + color_planes, + }) + } +} + +impl<'a> PduEncode for BitmapStream<'a> { + fn encode(&self, dst: &mut WriteCursor<'_>) -> PduResult<()> { + let mut header = ((self.enable_rle_compression as u8) << 4) | ((!self.use_alpha as u8) << 5); + + match self.color_planes { + ColorPlanes::Argb { .. } => { + // ARGB color planes keep cll and cs flags set to 0 + } + ColorPlanes::AYCoCg { + color_loss_level, + use_chroma_subsampling, + .. + } => { + // Add cll and cs flags to header + header |= (color_loss_level & 0x07) | ((use_chroma_subsampling as u8) << 3); + } + } + + ensure_size!(in: dst, size: self.size()); + + dst.write_u8(header); + dst.write_slice(self.color_panes_data()); + + // Write padding + if !self.enable_rle_compression { + dst.write_u8(0); + } + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + if self.enable_rle_compression { + Self::FIXED_PART_SIZE + self.color_panes_data().len() + } else { + Self::FIXED_PART_SIZE + NON_RLE_PADDING_SIZE + self.color_panes_data().len() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{decode, encode_buf, PduEncode}; + use expect_test::{expect, Expect}; + + fn assert_roundtrip(buffer: &[u8], expected: Expect) { + let pdu = decode::(buffer).unwrap(); + expected.assert_debug_eq(&pdu); + assert_eq!(pdu.size(), buffer.len()); + let mut reencoded = vec![]; + encode_buf(&pdu, &mut reencoded).unwrap(); + assert_eq!(reencoded.as_slice(), buffer); + } + + fn assert_parsing_failure(buffer: &[u8], expected: Expect) { + let error = decode::(buffer).err().unwrap(); + expected.assert_debug_eq(&error); + } + + #[test] + fn parsing_valid_data_succeeds() { + // AYCoCg color planes, with RLE + assert_roundtrip( + &[0x3F, 0x01, 0x02, 0x03, 0x04], + expect![[r#" + BitmapStream { + enable_rle_compression: true, + use_alpha: false, + color_planes: AYCoCg { + color_loss_level: 7, + use_chroma_subsampling: true, + data: [ + 1, + 2, + 3, + 4, + ], + }, + } + "#]], + ); + + // RGB color planes, with RLE, with alpha + assert_roundtrip( + &[0x10, 0x01, 0x02, 0x03, 0x04], + expect![[r#" + BitmapStream { + enable_rle_compression: true, + use_alpha: true, + color_planes: Argb { + data: [ + 1, + 2, + 3, + 4, + ], + }, + } + "#]], + ); + + // Without RLE, validate that padding is handled correctly + assert_roundtrip( + &[0x20, 0x01, 0x02, 0x03, 0x00], + expect![[r#" + BitmapStream { + enable_rle_compression: false, + use_alpha: false, + color_planes: Argb { + data: [ + 1, + 2, + 3, + ], + }, + } + "#]], + ); + + // Empty color planes, with RLE + assert_roundtrip( + &[0x10], + expect![[r#" + BitmapStream { + enable_rle_compression: true, + use_alpha: true, + color_planes: Argb { + data: [], + }, + } + "#]], + ); + + // Empty color planes, without RLE + assert_roundtrip( + &[0x00, 0x00], + expect![[r#" + BitmapStream { + enable_rle_compression: false, + use_alpha: true, + color_planes: Argb { + data: [], + }, + } + "#]], + ); + } + + #[test] + pub fn failures_handled_gracefully() { + // Empty buffer + assert_parsing_failure( + &[], + expect![[r#" + NotEnoughBytes { + name: "Rdp6BitmapStream", + received: 0, + expected: 1, + } + "#]], + ); + + // Without RLE, Check that missing padding byte is handled correctly + assert_parsing_failure( + &[0x20], + expect![[r#" + Other { + context: "Rdp6BitmapStream", + reason: "Missing padding byte from zero-size Non-RLE bitmap data", + } + "#]], + ); + } +} diff --git a/crates/pdu/src/basic_output/bitmap/tests.rs b/crates/pdu/src/basic_output/bitmap/tests.rs index 9cf718d8..cd75d021 100644 --- a/crates/pdu/src/basic_output/bitmap/tests.rs +++ b/crates/pdu/src/basic_output/bitmap/tests.rs @@ -64,7 +64,7 @@ fn from_buffer_bitmap_data_parsses_correctly() { } #[test] -fn to_buffer_bitmap_data_serializes_correcly() { +fn to_buffer_bitmap_data_serializes_correctly() { let expected = BITMAP_BUFFER.as_ref(); let mut buffer = vec![0; expected.len()]; BITMAP.to_buffer_consume(&mut buffer.as_mut_slice()).unwrap(); diff --git a/crates/session/src/fast_path.rs b/crates/session/src/fast_path.rs index 2addc307..4ed164c3 100644 --- a/crates/session/src/fast_path.rs +++ b/crates/session/src/fast_path.rs @@ -1,4 +1,4 @@ -use ironrdp_graphics::rle::RlePixelFormat; +use ironrdp_graphics::{rdp6::BitmapStreamDecoder, rle::RlePixelFormat}; use ironrdp_pdu::codecs::rfx::FrameAcknowledgePdu; use ironrdp_pdu::fast_path::{ FastPathError, FastPathHeader, FastPathUpdate, FastPathUpdatePdu, Fragmentation, UpdateCode, @@ -17,6 +17,7 @@ pub struct Processor { complete_data: CompleteData, rfx_handler: rfx::DecodingContext, marker_processor: FrameMarkerProcessor, + bitmap_stream_decoder: BitmapStreamDecoder, } impl Processor { @@ -75,7 +76,20 @@ impl Processor { // Bitmap Compression and stored inside an RDP 6.0 Bitmap Compressed Stream // structure ([MS-RDPEGDI] section 2.2.2.5.1). trace!("32 bpp compressed RDP6_BITMAP_STREAM"); - warn!("RDP6_BITMAP_STREAM is not yet supported"); // TODO: RDP6 32bpp + + match self.bitmap_stream_decoder.decode_bitmap_stream_to_rgb24( + update.bitmap_data, + &mut buf, + update.width as usize, + update.height as usize, + ) { + Ok(()) => { + image.apply_rgb24_bitmap(&buf, &update.rectangle); + } + Err(err) => { + warn!("Invalid RDP6_BITMAP_STREAM: {err}"); + } + } } else { // Compressed bitmaps not in 32 bpp format are compressed using Interleaved // RLE and encapsulated in an RLE Compressed Bitmap Stream structure (section @@ -196,6 +210,7 @@ impl ProcessorBuilder { complete_data: CompleteData::new(), rfx_handler: rfx::DecodingContext::new(), marker_processor: FrameMarkerProcessor::new(self.user_channel_id, self.io_channel_id), + bitmap_stream_decoder: BitmapStreamDecoder::default(), } } } diff --git a/crates/session/src/image.rs b/crates/session/src/image.rs index a39b573e..76c7ad80 100644 --- a/crates/session/src/image.rs +++ b/crates/session/src/image.rs @@ -114,4 +114,32 @@ impl DecodedImage { }) }); } + + // FIXME: this assumes PixelFormat::RgbA32 + pub(crate) fn apply_rgb24_bitmap(&mut self, rgb24: &[u8], update_rectangle: &Rectangle) { + const SRC_COLOR_DEPTH: usize = 3; + const DST_COLOR_DEPTH: usize = 4; + + let image_width = self.width as usize; + let rectangle_width = usize::from(update_rectangle.width()); + let top = usize::from(update_rectangle.top); + let left = usize::from(update_rectangle.left); + + rgb24 + .chunks_exact(rectangle_width * SRC_COLOR_DEPTH) + .rev() + .enumerate() + .for_each(|(row_idx, row)| { + row.chunks_exact(SRC_COLOR_DEPTH) + .enumerate() + .for_each(|(col_idx, src_pixel)| { + let dst_idx = ((top + row_idx) * image_width + left + col_idx) * DST_COLOR_DEPTH; + + // Copy RGB channels as is + self.data[dst_idx..dst_idx + SRC_COLOR_DEPTH].copy_from_slice(src_pixel); + // Set alpha channel to opaque(0xFF) + self.data[dst_idx + 3] = 0xFF; + }) + }); + } } diff --git a/crates/web/src/session.rs b/crates/web/src/session.rs index cffc125f..a40a71cb 100644 --- a/crates/web/src/session.rs +++ b/crates/web/src/session.rs @@ -312,6 +312,7 @@ fn build_config(username: String, password: String, domain: Option) -> c height: DEFAULT_HEIGHT, }, graphics: None, + bitmap: None, client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) .map(|version| version.major * 100 + version.minor * 10 + version.patch) .unwrap_or(0) diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 0b522383..64329b5b 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -30,3 +30,9 @@ name = "rle_decompression" path = "fuzz_targets/rle_decompression.rs" test = false doc = false + +[[bin]] +name = "bitmap_stream" +path = "fuzz_targets/bitmap_stream.rs" +test = false +doc = false diff --git a/fuzz/fuzz_targets/bitmap_stream.rs b/fuzz/fuzz_targets/bitmap_stream.rs new file mode 100644 index 00000000..80e5f758 --- /dev/null +++ b/fuzz/fuzz_targets/bitmap_stream.rs @@ -0,0 +1,22 @@ +#![no_main] + +use ironrdp_graphics::rdp6::BitmapStreamDecoder; +use libfuzzer_sys::fuzz_target; + +#[derive(arbitrary::Arbitrary, Debug)] +struct Input<'a> { + src: &'a [u8], + width: u8, + height: u8, +} + +fuzz_target!(|input: Input<'_>| { + let mut out = Vec::new(); + + let _ = BitmapStreamDecoder::default().decode_bitmap_stream_to_rgb24( + input.src, + &mut out, + input.width as usize, + input.height as usize, + ); +}); diff --git a/fuzz/fuzz_targets/pdu_decoding.rs b/fuzz/fuzz_targets/pdu_decoding.rs index 0500541a..04a3e24c 100644 --- a/fuzz/fuzz_targets/pdu_decoding.rs +++ b/fuzz/fuzz_targets/pdu_decoding.rs @@ -60,4 +60,6 @@ fuzz_target!(|data: &[u8]| { let _ = input::InputEventPdu::from_buffer(data); let _ = input::InputEvent::from_buffer(data); + + let _ = decode::(data); }); diff --git a/xtask/src/tasks.rs b/xtask/src/tasks.rs index db108001..df5fa2d9 100644 --- a/xtask/src/tasks.rs +++ b/xtask/src/tasks.rs @@ -5,7 +5,7 @@ use crate::section::Section; const CARGO: &str = env!("CARGO"); const WASM_PACKAGES: &[&str] = &["ironrdp-web"]; -const FUZZ_TARGETS: &[&str] = &["pdu_decoding", "rle_decompression"]; +const FUZZ_TARGETS: &[&str] = &["pdu_decoding", "rle_decompression", "bitmap_stream"]; pub fn check_formatting(sh: &Shell) -> anyhow::Result<()> { let _s = Section::new("FORMATTING");