From c908724b487996b38d17be197e0860fd2952f34c Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Wed, 27 May 2026 18:57:32 -0500 Subject: [PATCH 1/2] feat(fuzz): add egfx_multi_frame oracle and target Implements target 5 of #1316 (egfx fuzz-coverage umbrella): a multi-frame oracle that drives a sequence of Arbitrary-derived GfxPdus through a single GraphicsPipelineClient + surface-cache pair across each fuzz iteration, exposing cross-PDU state corruption that single-shot fuzzers cannot reach. Harness shape (per the design note posted as issuecomment-4559459227 on #1316). The oracle: 1. Constructs one GraphicsPipelineClient (no-op handler, no H.264 decoder) at iteration start. The surface cache, state machine, and ZGFX decompressor history are owned by this single instance. 2. Initialises the channel by calling DvcProcessor::start so the client moves into its post-start state. 3. Uses arbitrary::Unstructured to derive Vec from the raw fuzz input. Each GfxPdu variant is Arbitrary via the cascade in PR #1334. 4. For each PDU: encodes back to wire bytes via encode_vec, wraps in a single uncompressed ZGFX segment via wrap_uncompressed, and feeds the wire bytes to the client's public DvcProcessor::process entry point. Errors and panics propagate to libFuzzer naturally. The harness deliberately routes through the public process() entry rather than the private handle_pdu so the dispatch path exercises the same ZGFX-decompress + GfxPdu-decode + per-variant-dispatch + state machine + surface cache flow that production traffic takes. The uncompressed ZGFX wrapper bypasses the ZGFX decoder layer (already covered by egfx_zgfx_decompress in a separate PR) and concentrates fuzz pressure on the dispatch + state machine. What this catches: panics or sanitizer reports along the dispatch + state machine path under adversarially-ordered PDU sequences; inconsistent surface-cache state under attacker-controlled CreateSurface / DeleteSurface / Map* orderings; corrupted frame-id state from interleaved StartFrame / EndFrame / FrameAcknowledge sequences; ZGFX-wrapper integration bugs separate from the standalone ZGFX coverage. What this does NOT catch: cross-frame H.264 decoder state corruption. The client is constructed with h264_decoder: None, so H264-bearing PDUs (WireToSurface1 with AVC codecs) skip the H.264 decoder. The standalone egfx_avc420_decode and egfx_avc444_decode targets cover the H.264 wrapper. Wiring a real (or mock) H.264 decoder into this harness can be a follow-up if frame-to-frame H.264 state coverage surfaces as a gap. Bug discovery during development. The first smoke fuzz on the in-progress harness surfaced a bit_field::set_bits panic in Timestamp::encode where derive(Arbitrary) generated u8/u16 values exceeding the encoder's 6-bit / 10-bit pack widths. The fix landed in PR #1334 (the cascade prerequisite) as manual Arbitrary impls for Timestamp, QuantQuality, and the Encoding bitflag, mapping each field to its wire-allowed range. This is the "sanitize via mask" shape from #1122's body. Validation. 15-minute rigorous libFuzzer + ASan smoke fuzz on the final SHA: 3,659,157 iterations in 901 seconds (~4,061 exec/s sustained), peak RSS 96 MB, zero panics, zero sanitizer reports, zero OOMs. Corpus grew to ~4,558 entries via libFuzzer's coverage discovery. The regression-replay test check_egfx_multi_frame passes against the seed-empty corpus entry. Depends on PR #1334 (Arbitrary cascade across ironrdp-egfx public PDU types). CI on this PR's branch reds until #1334 merges or this PR's branch rebases onto master post-merge. That's the correct mechanical signal of the dependency per the no-exclude-then-follow-up rule. Refs #1316. --- Cargo.lock | 1 + crates/ironrdp-fuzzing/Cargo.toml | 3 +- crates/ironrdp-fuzzing/src/oracles/mod.rs | 82 +++++++++++++++++++ .../egfx_multi_frame/seed-empty.bin | 0 .../tests/fuzz_regression.rs | 5 ++ fuzz/Cargo.lock | 6 ++ fuzz/Cargo.toml | 7 ++ fuzz/fuzz_targets/egfx_multi_frame.rs | 7 ++ 8 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_multi_frame/seed-empty.bin create mode 100644 fuzz/fuzz_targets/egfx_multi_frame.rs diff --git a/Cargo.lock b/Cargo.lock index 209e53361..1d16f0193 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2669,6 +2669,7 @@ dependencies = [ "ironrdp-cliprdr-format", "ironrdp-core", "ironrdp-displaycontrol", + "ironrdp-dvc", "ironrdp-egfx", "ironrdp-graphics", "ironrdp-pdu", diff --git a/crates/ironrdp-fuzzing/Cargo.toml b/crates/ironrdp-fuzzing/Cargo.toml index 437233517..9985c7571 100644 --- a/crates/ironrdp-fuzzing/Cargo.toml +++ b/crates/ironrdp-fuzzing/Cargo.toml @@ -20,7 +20,8 @@ ironrdp-rdpdr.path = "../ironrdp-rdpdr" ironrdp-rdpsnd.path = "../ironrdp-rdpsnd" ironrdp-cliprdr-format.path = "../ironrdp-cliprdr-format" ironrdp-displaycontrol.path = "../ironrdp-displaycontrol" -ironrdp-egfx.path = "../ironrdp-egfx" +ironrdp-dvc.path = "../ironrdp-dvc" +ironrdp-egfx = { path = "../ironrdp-egfx", features = ["arbitrary"] } ironrdp-svc.path = "../ironrdp-svc" [lints] diff --git a/crates/ironrdp-fuzzing/src/oracles/mod.rs b/crates/ironrdp-fuzzing/src/oracles/mod.rs index 35891c895..dc8deeb6e 100644 --- a/crates/ironrdp-fuzzing/src/oracles/mod.rs +++ b/crates/ironrdp-fuzzing/src/oracles/mod.rs @@ -357,6 +357,88 @@ pub fn egfx_round_trip(data: &[u8]) { pdu_round_trip_one!(data, Avc444BitmapStream<'_>); } +/// Multi-frame oracle for the EGFX graphics pipeline client. +/// +/// H.264 decoding maintains reference-picture state, SPS/PPS context, and +/// decoder configuration across frames; surface caching and codec dispatch +/// state in egfx all carry forward across PDUs. Single-shot fuzzers cannot +/// reach frame-to-frame state corruption because they construct a fresh +/// decoder per iteration. This oracle constructs ONE `GraphicsPipelineClient` +/// at iteration start and drives a sequence of `GfxPdu`s through it, exposing +/// cross-PDU state to the fuzzer. +/// +/// Harness shape: `Arbitrary`-derived `Vec` (each variant `Arbitrary` +/// via the cascade in PR #1334). Each PDU is encoded back to wire bytes, +/// wrapped in a single uncompressed ZGFX segment, and fed to the client's +/// public `DvcProcessor::process` entry point. This exercises the same path +/// production traffic takes: ZGFX decompress -> `GfxPdu` decode -> dispatch +/// to per-variant handler -> state machine + surface cache update. +/// +/// What this catches: panics or sanitizer reports along the dispatch + state +/// machine path when fed adversarially-ordered or malformed-payload PDUs; +/// inconsistent surface-cache state under attacker-controlled +/// CreateSurface / DeleteSurface / Map* orderings; corrupted frame-id state +/// from interleaved StartFrame / EndFrame / FrameAcknowledge sequences; +/// ZGFX-wrapper integration bugs separate from the standalone ZGFX coverage +/// in `egfx_zgfx_decompress`. +/// +/// What this does NOT catch: cross-frame H.264 decoder state corruption. +/// The client is constructed with `h264_decoder: None`, so H264-bearing +/// PDUs (WireToSurface1 with AVC codecs) don't reach the H.264 decoder. +/// The standalone `egfx_avc420_decode` and `egfx_avc444_decode` targets +/// cover the H.264 wrapper. Wiring a real (or mock) H.264 decoder into +/// this harness can be a follow-up if frame-to-frame H.264 state coverage +/// surfaces as a gap. +pub fn egfx_multi_frame(data: &[u8]) { + use arbitrary::{Arbitrary as _, Unstructured}; + use ironrdp_core::encode_vec; + use ironrdp_dvc::DvcProcessor as _; + use ironrdp_egfx::client::{GraphicsPipelineClient, GraphicsPipelineHandler}; + use ironrdp_egfx::pdu::GfxPdu; + use ironrdp_graphics::zgfx::wrap_uncompressed; + + /// No-op handler. Every callback default-impls in the trait, so the empty + /// struct gets all defaults for free. The handler exists to satisfy + /// `GraphicsPipelineClient::new`'s API; the fuzz oracle does not inspect + /// any of the dispatched events. + struct NoOpHandler; + impl GraphicsPipelineHandler for NoOpHandler {} + + let mut unstructured = Unstructured::new(data); + let Ok(pdus) = Vec::::arbitrary(&mut unstructured) else { + return; + }; + + let mut client = GraphicsPipelineClient::new(Box::new(NoOpHandler), None); + + // Initialise the channel state by invoking the DvcProcessor::start entry. + // The returned advertise message is discarded; the call's side effect is + // putting the client's internal state machine into its post-start state. + const FUZZ_CHANNEL_ID: u32 = 0; + let _ = client.start(FUZZ_CHANNEL_ID); + + for pdu in pdus { + // Encode each PDU back to wire bytes so the client processes through + // the same decode + dispatch path real traffic takes. Skip PDUs whose + // encoder rejects the Arbitrary-generated values rather than aborting + // the iteration; the next PDU may still exercise interesting state. + let Ok(pdu_bytes) = encode_vec(&pdu) else { + continue; + }; + + // Wrap the encoded PDU in an uncompressed ZGFX segment so the client's + // ZGFX decompressor produces the PDU bytes unmodified. This bypasses + // the ZGFX decoder layer (covered separately by egfx_zgfx_decompress) + // and concentrates fuzz pressure on the dispatch + state machine. + let payload = wrap_uncompressed(&pdu_bytes); + + // Errors and panics propagate to libFuzzer naturally; we discard the + // Result since the oracle's job is to surface bugs, not to enforce + // dispatcher semantics. + let _ = client.process(FUZZ_CHANNEL_ID, &payload); + } +} + pub fn rle_decompress_bitmap(input: BitmapInput<'_>) { let mut out = Vec::new(); diff --git a/crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_multi_frame/seed-empty.bin b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_multi_frame/seed-empty.bin new file mode 100644 index 000000000..e69de29bb diff --git a/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs b/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs index becbe6ab9..c1c4a50d5 100644 --- a/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs +++ b/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs @@ -56,3 +56,8 @@ fn check_pdu_round_trip() { fn check_egfx_round_trip() { check!(egfx_round_trip); } + +#[test] +fn check_egfx_multi_frame() { + check!(egfx_multi_frame); +} diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 1780d536f..62d91ed3f 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -78,6 +78,9 @@ name = "bitflags" version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +dependencies = [ + "arbitrary", +] [[package]] name = "bitvec" @@ -340,6 +343,7 @@ dependencies = [ name = "ironrdp-egfx" version = "0.2.0" dependencies = [ + "arbitrary", "bit_field", "bitflags", "ironrdp-core", @@ -371,6 +375,7 @@ dependencies = [ "ironrdp-cliprdr-format", "ironrdp-core", "ironrdp-displaycontrol", + "ironrdp-dvc", "ironrdp-egfx", "ironrdp-graphics", "ironrdp-pdu", @@ -398,6 +403,7 @@ dependencies = [ name = "ironrdp-pdu" version = "0.8.0" dependencies = [ + "arbitrary", "bit_field", "bitflags", "byteorder", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 74ba4c0f6..e3f0a48f6 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -97,3 +97,10 @@ test = false doc = false bench = false +[[bin]] +name = "egfx_multi_frame" +path = "fuzz_targets/egfx_multi_frame.rs" +test = false +doc = false +bench = false + diff --git a/fuzz/fuzz_targets/egfx_multi_frame.rs b/fuzz/fuzz_targets/egfx_multi_frame.rs new file mode 100644 index 000000000..dbb94e4d0 --- /dev/null +++ b/fuzz/fuzz_targets/egfx_multi_frame.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + ironrdp_fuzzing::oracles::egfx_multi_frame(data); +}); From baef3ba3e8538a2c19f794d6fb352aa53fc800f1 Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Wed, 27 May 2026 19:45:16 -0500 Subject: [PATCH 2/2] feat(fuzz): add egfx_surface_state state-machine oracle Implements target 6 of #1316 (egfx fuzz-coverage umbrella): the egfx_surface_state oracle. Sibling of egfx_multi_frame (target 5, PR #1336) with two distinct properties: 1. The PDU stream is narrowed to a SurfaceLifecyclePdu enum that contains only the seven state-affecting variants: ResetGraphics, CreateSurface, DeleteSurface, MapSurfaceToOutput, StartFrame, EndFrame, FrameAcknowledge. Each iteration concentrates fuzz pressure on the surface and frame state machine rather than spreading across all 23 GfxPdu variants. 2. The oracle maintains a parallel ExpectedState model alongside the client. For each successfully-encoded PDU the model is updated to mirror the client's documented state transition (CreateSurface inserts a surface unless width or height is zero; DeleteSurface removes; MapSurfaceToOutput updates is_mapped and origin; ResetGraphics clears all surfaces; EndFrame increments the running frame counter). After dispatch the client's observable state via the public get_surface and total_frames_decoded getters is asserted against the model. This catches a different bug class from egfx_multi_frame's panic / sanitizer oracle: logic bugs in state transitions that produce wrong but non-crashing observable state. Examples covered: client retains a surface after DeleteSurface, client clobbers is_mapped on a surface that did not receive MapSurfaceToOutput, client fails to clear all surfaces on ResetGraphics, client increments total_frames_decoded on a PDU other than EndFrame. The model encodes the implementation's current documented behavior (e.g., zero-dimension CreateSurface is silently skipped per handle_create_surface), so the oracle catches drift from that behavior. Spec-compliance verification is a separate concern that would require a parallel spec-only model. What this does NOT catch: state transitions that exist only in private fields without a public getter (current_frame_id, frames_queued). Exposing observable access to those fields is a separate egfx-public-API change, out of scope here. Validation. 15-minute rigorous libFuzzer + ASan smoke fuzz on the final SHA: 2,770,751 iterations in 901 seconds (~3,075 exec/s sustained), peak RSS 102 MB, zero panics, zero sanitizer reports, zero state-model assertion failures. Corpus grew to ~1,840 entries via libFuzzer's coverage discovery. The check_egfx_surface_state regression-replay test passes against the seed-empty corpus entry. Stack dependency. This PR is stacked on PR #1336 (egfx_multi_frame, target 5) which is stacked on PR #1334 (Arbitrary cascade). CI on this branch will red until both ancestors merge or this branch rebases onto master post-merge. With this PR the #1316 umbrella has its initial six target slots filled (1 merged, 2+2b+3+5+6 open). Refs #1316. Depends on #1334 and #1336. --- crates/ironrdp-fuzzing/src/oracles/mod.rs | 221 ++++++++++++++++++ .../egfx_surface_state/seed-empty.bin | 0 .../tests/fuzz_regression.rs | 5 + fuzz/Cargo.toml | 7 + fuzz/fuzz_targets/egfx_surface_state.rs | 7 + 5 files changed, 240 insertions(+) create mode 100644 crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_surface_state/seed-empty.bin create mode 100644 fuzz/fuzz_targets/egfx_surface_state.rs diff --git a/crates/ironrdp-fuzzing/src/oracles/mod.rs b/crates/ironrdp-fuzzing/src/oracles/mod.rs index dc8deeb6e..b52056f15 100644 --- a/crates/ironrdp-fuzzing/src/oracles/mod.rs +++ b/crates/ironrdp-fuzzing/src/oracles/mod.rs @@ -439,6 +439,227 @@ pub fn egfx_multi_frame(data: &[u8]) { } } +/// Surface-lifecycle state-machine oracle for the EGFX graphics pipeline client. +/// +/// Sibling of [`egfx_multi_frame`] with two distinct properties: +/// +/// 1. The PDU stream is narrowed to a `SurfaceLifecyclePdu` enum that contains +/// only the seven state-affecting variants (`ResetGraphics`, `CreateSurface`, +/// `DeleteSurface`, `MapSurfaceToOutput`, `StartFrame`, `EndFrame`, +/// `FrameAcknowledge`). This approximately doubles the per-iteration density +/// of state-affecting PDUs compared with [`egfx_multi_frame`]'s broad +/// `Vec` shape. +/// +/// 2. The oracle maintains a parallel `ExpectedState` model alongside the +/// client. For each PDU, the model is updated to mirror the client's +/// documented state transition, and after dispatch the client's observable +/// state (via the public `get_surface` and `total_frames_decoded` getters) +/// is asserted against the model. +/// +/// This catches a different bug class from [`egfx_multi_frame`]'s panic / +/// sanitizer oracle: logic bugs in state transitions that produce wrong but +/// non-crashing observable state. Examples: +/// +/// - Client retains a surface after `DeleteSurface` (model removed it, client did not). +/// - Client clobbers `is_mapped` on a surface that did not receive `MapSurfaceToOutput`. +/// - Client fails to clear all surfaces on `ResetGraphics`. +/// - Client increments `total_frames_decoded` on a PDU other than `EndFrame`. +/// +/// The model encodes the implementation's current documented behavior (e.g., +/// `CreateSurface` with `width == 0` or `height == 0` is silently skipped per +/// `handle_create_surface`), so the oracle catches drift from that behavior. +/// +/// What this does NOT catch: state transitions that exist only in private +/// fields without a public getter (e.g., `current_frame_id`, `frames_queued`). +/// Wiring observable access to those fields is a separate egfx-public-API +/// change, out of scope here. +/// +/// # Panics +/// +/// Panics (reporting the bug to libFuzzer) when: +/// - the client retains a surface the model removed (assertion on +/// `client.get_surface` returning `Some` when expected was `None`), or +/// - any field of a tracked surface diverges from the expected model after +/// dispatch (id, width, height, pixel_format, is_mapped, origin), or +/// - `client.total_frames_decoded()` diverges from the expected counter. +#[expect(clippy::panic, reason = "panic is the libFuzzer bug-reporting mechanism")] +pub fn egfx_surface_state(data: &[u8]) { + use std::collections::BTreeMap; + + use arbitrary::{Arbitrary as _, Unstructured}; + use ironrdp_core::encode_vec; + use ironrdp_dvc::DvcProcessor as _; + use ironrdp_egfx::client::{GraphicsPipelineClient, GraphicsPipelineHandler}; + use ironrdp_egfx::pdu::{ + CreateSurfacePdu, DeleteSurfacePdu, EndFramePdu, FrameAcknowledgePdu, GfxPdu, MapSurfaceToOutputPdu, + PixelFormat, ResetGraphicsPdu, StartFramePdu, + }; + use ironrdp_graphics::zgfx::wrap_uncompressed; + + /// No-op handler. Every callback default-impls in the trait. + struct NoOpHandler; + impl GraphicsPipelineHandler for NoOpHandler {} + + /// Narrowed PDU set: only the variants that affect observable surface or + /// frame-counter state. The encoder rejects out-of-range fields on some of + /// these (e.g., u32 dimensions on `ResetGraphics`); the oracle skips PDUs + /// the encoder cannot serialize so the expected-state mirror never gets + /// out of sync with what the client actually dispatched. + #[derive(arbitrary::Arbitrary, Debug)] + enum SurfaceLifecyclePdu { + ResetGraphics(ResetGraphicsPdu), + CreateSurface(CreateSurfacePdu), + DeleteSurface(DeleteSurfacePdu), + MapSurfaceToOutput(MapSurfaceToOutputPdu), + StartFrame(StartFramePdu), + EndFrame(EndFramePdu), + FrameAcknowledge(FrameAcknowledgePdu), + } + + impl SurfaceLifecyclePdu { + fn into_gfx_pdu(self) -> GfxPdu { + match self { + Self::ResetGraphics(p) => GfxPdu::ResetGraphics(p), + Self::CreateSurface(p) => GfxPdu::CreateSurface(p), + Self::DeleteSurface(p) => GfxPdu::DeleteSurface(p), + Self::MapSurfaceToOutput(p) => GfxPdu::MapSurfaceToOutput(p), + Self::StartFrame(p) => GfxPdu::StartFrame(p), + Self::EndFrame(p) => GfxPdu::EndFrame(p), + Self::FrameAcknowledge(p) => GfxPdu::FrameAcknowledge(p), + } + } + } + + /// Mirror of the client's observable per-surface state. + #[derive(Debug, Clone, PartialEq, Eq)] + struct ExpectedSurface { + width: u16, + height: u16, + pixel_format: PixelFormat, + is_mapped: bool, + output_origin_x: u32, + output_origin_y: u32, + } + + /// Mirror of the client's full observable state. Only fields with public + /// getters on `GraphicsPipelineClient` are tracked. + #[derive(Debug, Default)] + struct ExpectedState { + surfaces: BTreeMap, + total_frames_decoded: u32, + } + + impl ExpectedState { + /// Apply a successfully-encoded PDU to the model. Mirrors + /// `GraphicsPipelineClient`'s `handle_*` methods exactly. + fn apply(&mut self, pdu: &GfxPdu) { + match pdu { + GfxPdu::ResetGraphics(_) => { + // Per `handle_reset_graphics`: implicitly destroys all surfaces. + // `total_frames_decoded` is NOT reset (running counter survives). + self.surfaces.clear(); + } + GfxPdu::CreateSurface(p) => { + // Per `handle_create_surface`: zero-dimension surfaces are silently + // dropped with a warn log. The model mirrors that skip. + if p.width == 0 || p.height == 0 { + return; + } + self.surfaces.insert( + p.surface_id, + ExpectedSurface { + width: p.width, + height: p.height, + pixel_format: p.pixel_format, + is_mapped: false, + output_origin_x: 0, + output_origin_y: 0, + }, + ); + } + GfxPdu::DeleteSurface(p) => { + self.surfaces.remove(&p.surface_id); + } + GfxPdu::MapSurfaceToOutput(p) => { + if let Some(s) = self.surfaces.get_mut(&p.surface_id) { + s.is_mapped = true; + s.output_origin_x = p.output_origin_x; + s.output_origin_y = p.output_origin_y; + } + } + GfxPdu::EndFrame(_) => { + // Per `handle_end_frame`: increments `total_frames_decoded` + // unconditionally. `wrapping_add` matches the implementation. + self.total_frames_decoded = self.total_frames_decoded.wrapping_add(1); + } + _ => {} + } + } + } + + let mut unstructured = Unstructured::new(data); + let Ok(pdus) = Vec::::arbitrary(&mut unstructured) else { + return; + }; + + let mut client = GraphicsPipelineClient::new(Box::new(NoOpHandler), None); + const FUZZ_CHANNEL_ID: u32 = 0; + let _ = client.start(FUZZ_CHANNEL_ID); + + let mut expected = ExpectedState::default(); + + for shape in pdus { + let pdu = shape.into_gfx_pdu(); + + // Encode first. If encoding rejects the Arbitrary-generated value the + // dispatcher will never see this PDU, so the expected state stays put. + let Ok(pdu_bytes) = encode_vec(&pdu) else { + continue; + }; + + // Mirror the expected transition before dispatch so post-dispatch + // assertions compare like-to-like. + expected.apply(&pdu); + + // Dispatch through the same public path production traffic takes. + let payload = wrap_uncompressed(&pdu_bytes); + let _ = client.process(FUZZ_CHANNEL_ID, &payload); + + // Assert observable per-surface state matches the model. + for (&id, expected_surface) in &expected.surfaces { + let actual = client + .get_surface(id) + .unwrap_or_else(|| panic!("expected surface {id} present after PDU; client has none")); + assert_eq!(actual.id, id, "surface id mismatch"); + assert_eq!(actual.width, expected_surface.width, "surface {id} width mismatch"); + assert_eq!(actual.height, expected_surface.height, "surface {id} height mismatch"); + assert_eq!( + actual.pixel_format, expected_surface.pixel_format, + "surface {id} pixel_format mismatch" + ); + assert_eq!( + actual.is_mapped, expected_surface.is_mapped, + "surface {id} is_mapped mismatch" + ); + assert_eq!( + actual.output_origin_x, expected_surface.output_origin_x, + "surface {id} output_origin_x mismatch" + ); + assert_eq!( + actual.output_origin_y, expected_surface.output_origin_y, + "surface {id} output_origin_y mismatch" + ); + } + + // Assert the running frame counter. + assert_eq!( + client.total_frames_decoded(), + expected.total_frames_decoded, + "total_frames_decoded mismatch" + ); + } +} + pub fn rle_decompress_bitmap(input: BitmapInput<'_>) { let mut out = Vec::new(); diff --git a/crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_surface_state/seed-empty.bin b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_surface_state/seed-empty.bin new file mode 100644 index 000000000..e69de29bb diff --git a/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs b/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs index c1c4a50d5..5ada52861 100644 --- a/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs +++ b/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs @@ -61,3 +61,8 @@ fn check_egfx_round_trip() { fn check_egfx_multi_frame() { check!(egfx_multi_frame); } + +#[test] +fn check_egfx_surface_state() { + check!(egfx_surface_state); +} diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index e3f0a48f6..7cf0b114c 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -104,3 +104,10 @@ test = false doc = false bench = false +[[bin]] +name = "egfx_surface_state" +path = "fuzz_targets/egfx_surface_state.rs" +test = false +doc = false +bench = false + diff --git a/fuzz/fuzz_targets/egfx_surface_state.rs b/fuzz/fuzz_targets/egfx_surface_state.rs new file mode 100644 index 000000000..d690e10ee --- /dev/null +++ b/fuzz/fuzz_targets/egfx_surface_state.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + ironrdp_fuzzing::oracles::egfx_surface_state(data); +});