Skip to content

Commit 59b3de3

Browse files
committed
feat(pdu,graphics): add ClearCodec bitmap compression codec
Add ClearCodec (MS-RDPEGFX 2.2.4.1) codec support across two crates: ironrdp-pdu: Wire-format decode/encode for all ClearCodec layers (residual BGR RLE, bands with V-bar caching, subcodec dispatch including RLEX palette-indexed RLE). Full round-trip test coverage. ironrdp-graphics: ClearCodecDecoder with persistent V-bar and glyph caches. ClearCodecEncoder with residual-only encoding and glyph deduplication for server-side bitmap compression. Hardening pass before maintainer review (local fuzz oracles surfaced three bug classes that escaped the initial review): - Per-dimension cap at 8192 on decode, replacing the previous per- pixel-count cap at 8192*8192. The pixel-count form accepted degenerate aspect ratios (e.g. 63961x771 = 49M pixels, under the 67M cap) that allocated ~197MB from a few attacker-controlled bytes. The per-dimension form rejects implausible tile shapes regardless of total area while preserving headroom for 8K displays (7680x4320). MS-RDPEGFX caps surfaces at 32767x32767 but does not mandate a separate tile cap; 8192 is a defensive choice that fits realistic tile sizes. - Fix integer overflow at bands::decode_band on `usize::from(x_end - x_start + 1)`. When x_end = u16::MAX and x_start = 0, the `+ 1` overflowed the u16 arithmetic before the usize cast and triggered a release-mode panic under overflow-checks. The existing `if x_end < x_start` guard prevents underflow but not the +1 overflow. Fix is to cast to usize first: `usize::from(x_end - x_start) + 1`. - Document the alpha contract on ClearCodecEncoder::encode and ClearCodecDecoder::decode. ClearCodec is lossless on the three color channels (B, G, R) per spec; the wire format does not transmit alpha. The encoder reads B/G/R from each input pixel and discards alpha; the decoder fills alpha with 0xFF unconditionally. Callers needing alpha must transport it separately. Previously the doc-comment claim of "pixel-perfect fidelity" was ambiguous; alpha-channel mismatches in any round-trip with non-0xFF alpha inputs are by design, not bugs. Local fuzz infrastructure (clearcodec_decode + clearcodec_round_trip oracles) follows in a separate PR once this lands; the codec's public API is already fuzz-friendly so no prep is needed here. A separate spec-driven audit of input-validation completeness (per-layer stream-length pre-checks in subcodec / residual / bands) is queued as follow-up work; deferring from this PR pending review of MS-RDPEGFX 2.2.4.1. Closes #1158 ClearCodec subtask.
1 parent 879ffed commit 59b3de3

12 files changed

Lines changed: 2657 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//! Glyph cache for ClearCodec (MS-RDPEGFX 2.2.4.1).
2+
//!
3+
//! When a bitmap area is <= 1024 pixels, ClearCodec can index it in a
4+
//! 4,000-entry glyph cache. On a cache hit (FLAG_GLYPH_HIT), the previously
5+
//! cached pixel data is reused without retransmission.
6+
7+
/// Maximum number of glyph cache entries.
8+
pub const GLYPH_CACHE_SIZE: usize = 4_000;
9+
10+
/// A cached glyph entry: BGRA pixel data with dimensions.
11+
#[derive(Debug, Clone)]
12+
pub struct GlyphEntry {
13+
pub width: u16,
14+
pub height: u16,
15+
/// BGRA pixel data (4 bytes per pixel).
16+
pub pixels: Vec<u8>,
17+
}
18+
19+
/// Glyph cache for ClearCodec bitmap deduplication.
20+
pub struct GlyphCache {
21+
entries: Vec<Option<GlyphEntry>>,
22+
}
23+
24+
impl GlyphCache {
25+
pub fn new() -> Self {
26+
let mut entries = Vec::with_capacity(GLYPH_CACHE_SIZE);
27+
entries.resize_with(GLYPH_CACHE_SIZE, || None);
28+
Self { entries }
29+
}
30+
31+
/// Look up a glyph by its cache index.
32+
pub fn get(&self, index: u16) -> Option<&GlyphEntry> {
33+
self.entries.get(usize::from(index)).and_then(|slot| slot.as_ref())
34+
}
35+
36+
/// Store a glyph at the given index.
37+
///
38+
/// Returns `true` if the index was valid and the entry was stored.
39+
pub fn store(&mut self, index: u16, entry: GlyphEntry) -> bool {
40+
let idx = usize::from(index);
41+
if idx < GLYPH_CACHE_SIZE {
42+
self.entries[idx] = Some(entry);
43+
true
44+
} else {
45+
false
46+
}
47+
}
48+
49+
/// Reset the entire glyph cache, removing all entries.
50+
pub fn reset(&mut self) {
51+
for slot in &mut self.entries {
52+
*slot = None;
53+
}
54+
}
55+
}
56+
57+
impl Default for GlyphCache {
58+
fn default() -> Self {
59+
Self::new()
60+
}
61+
}
62+
63+
#[cfg(test)]
64+
mod tests {
65+
use super::*;
66+
67+
#[test]
68+
fn store_and_retrieve() {
69+
let mut cache = GlyphCache::new();
70+
let entry = GlyphEntry {
71+
width: 8,
72+
height: 16,
73+
pixels: vec![0xFF; 8 * 16 * 4],
74+
};
75+
assert!(cache.store(42, entry));
76+
let retrieved = cache.get(42).unwrap();
77+
assert_eq!(retrieved.width, 8);
78+
assert_eq!(retrieved.height, 16);
79+
}
80+
81+
#[test]
82+
fn get_empty_returns_none() {
83+
let cache = GlyphCache::new();
84+
assert!(cache.get(0).is_none());
85+
assert!(cache.get(3999).is_none());
86+
}
87+
88+
#[test]
89+
fn reject_out_of_range() {
90+
let mut cache = GlyphCache::new();
91+
let entry = GlyphEntry {
92+
width: 1,
93+
height: 1,
94+
pixels: vec![0; 4],
95+
};
96+
assert!(!cache.store(4000, entry));
97+
}
98+
}

0 commit comments

Comments
 (0)