Skip to main content

zencodec_testkit/
adversarial.rs

1//! Explicit acceptance oracles. These checks never infer correctness from file
2//! length, capability flags, or agreement between two implementations alone.
3use super::*;
4use zencodec::animation::FrameDuration;
5use zencodec::encode::DynEncoderConfig;
6
7/// A specific error that constitutes an expected refusal.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ExpectedError {
10    Unsupported(UnsupportedOperation),
11    Category(ErrorCategory),
12}
13impl ExpectedError {
14    /// Match through the error chain, including erased errors. Unknown causes
15    /// fail rather than satisfying an arbitrary expected rejection.
16    pub fn matches(self, error: &(dyn std::error::Error + 'static)) -> bool {
17        match self {
18            Self::Unsupported(op) => error.unsupported_operation() == Some(&op),
19            Self::Category(category) => error.error_category() == Some(category),
20        }
21    }
22}
23
24/// Caller-specified expected result at a structural prefix boundary.
25pub enum PrefixOutcome<'a> {
26    /// Complete/recoverable payload, with an independently specified pixel oracle.
27    Accept(PixelSlice<'a>),
28    Reject(ExpectedError),
29}
30
31/// A meaningful prefix boundary, not a fraction of the total file length.
32pub struct PrefixCase<'a> {
33    pub end: usize,
34    pub outcome: PrefixOutcome<'a>,
35}
36
37/// Exercise explicit complete-payload/truncation boundaries through one-shot and
38/// sink dispatch. Successful recovery must produce the expected pixels. The caller
39/// supplies strict/compatibility configuration separately. No prefix is silently
40/// omitted; out-of-range fixture boundaries are errors.
41pub fn check_decode_prefix_cases<D: DecoderConfig + 'static>(
42    decoder: D,
43    bytes: &[u8],
44    cases: &[PrefixCase<'_>],
45) -> Conformance {
46    const CHECK: &str = "structural decode prefixes";
47    if cases.is_empty() {
48        return Err(fail(CHECK, "supply explicit prefix cases"));
49    }
50    for case in cases {
51        let input = bytes
52            .get(..case.end)
53            .ok_or_else(|| fail(CHECK, "prefix exceeds fixture"))?;
54        for sink_path in [false, true] {
55            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
56                let job = decoder.dyn_job();
57                if sink_path {
58                    let mut sink = CollectSink::default();
59                    job.push_decode(Cow::Borrowed(input), &mut sink, &[])?;
60                    Ok(sink.into_pixels().map_err(|e| fail(CHECK, e)))
61                } else {
62                    let output = job.into_decoder(Cow::Borrowed(input), &[])?.decode()?;
63                    Ok(Ok(grab(output.pixels())))
64                }
65            }));
66            let result: Result<Result<Pixels, Failure>, zencodec::decode::BoxedError> = result
67                .map_err(|_| fail(CHECK, format!("panic at {} (sink={sink_path})", case.end)))?;
68            match (&case.outcome, result) {
69                (PrefixOutcome::Accept(expected), Ok(pixels)) => {
70                    if pixels? != grab_ref(expected) {
71                        return Err(fail(CHECK, format!("wrong pixels at {}", case.end)));
72                    }
73                }
74                (PrefixOutcome::Reject(expected), Err(error))
75                    if expected.matches(error.as_ref()) => {}
76                (_, result) => {
77                    return Err(fail(
78                        CHECK,
79                        format!(
80                            "unexpected result at {} (sink={sink_path}): {result:?}",
81                            case.end
82                        ),
83                    ));
84                }
85            }
86        }
87    }
88    Ok(())
89}
90
91/// One frame submission and its independent encoded result. Accepted frames have
92/// an expected native decode representation; this can differ from input for an
93/// explicitly lossy encode. Use distinct pixels to expose frame reordering.
94pub struct TimingCase<'a> {
95    pub pixels: PixelSlice<'a>,
96    pub requested: FrameDuration,
97    pub expected: Result<(FrameDuration, PixelSlice<'a>), ExpectedError>,
98}
99
100/// Strict counterpart to `check_animation_timing`: requires typed errors and
101/// verifies independently specified frame pixels, as well as duration and index.
102/// Accepted frames on both sides of a rejection expose timing/state corruption.
103pub fn check_animation_cases<E, D>(
104    enc: E,
105    dec: D,
106    exact: bool,
107    cases: &[TimingCase<'_>],
108) -> Conformance
109where
110    E: EncoderConfig + 'static,
111    D: DecoderConfig + 'static,
112    <E::Job as EncodeJob>::Enc: Encoder + Send,
113    <E::Job as EncodeJob>::AnimationFrameEnc: AnimationFrameEncoder,
114{
115    const CHECK: &str = "explicit animation cases";
116    let accepted: Vec<_> = cases
117        .iter()
118        .filter_map(|c| c.expected.as_ref().ok())
119        .collect();
120    if accepted.len() < 2 {
121        return Err(fail(CHECK, "supply at least two accepted frames"));
122    }
123    // Both paths are erased here; the blanket shim invokes the generic primitive.
124    // Existing check_animation_timing separately verifies generic/erased parity.
125    let mut encoder = enc
126        .dyn_job()
127        .into_animation_frame_encoder()
128        .map_err(|e| fail(CHECK, e.to_string()))?;
129    for (index, case) in cases.iter().enumerate() {
130        if exact
131            && case
132                .expected
133                .as_ref()
134                .is_ok_and(|(duration, _)| *duration != case.requested)
135        {
136            return Err(fail(CHECK, "exact fixture expects rounding"));
137        }
138        let pixels = case.pixels.sub_rows(0, case.pixels.rows());
139        let result = if exact {
140            encoder
141                .push_frame_exact(pixels, case.requested, None)
142                .map(|()| case.requested)
143        } else {
144            encoder.push_frame_timed(pixels, case.requested, None)
145        };
146        match (&case.expected, result) {
147            (Ok((duration, _)), Ok(actual)) if *duration == actual => (),
148            (Err(expected), Err(error)) if expected.matches(error.as_ref()) => (),
149            (_, result) => {
150                return Err(fail(
151                    CHECK,
152                    format!("frame {index}: unexpected admission {result:?}"),
153                ));
154            }
155        }
156    }
157    let output = encoder
158        .finish(None)
159        .map_err(|e| fail(CHECK, e.to_string()))?;
160    let durations: Vec<_> = accepted.iter().map(|(duration, _)| *duration).collect();
161    check_animation_durations(dec.clone(), output.data(), &durations)?;
162    let mut decoder = dec
163        .dyn_job()
164        .into_animation_frame_decoder(Cow::Borrowed(output.data()), &[])
165        .map_err(|e| fail(CHECK, e.to_string()))?;
166    for (index, (_, expected)) in accepted.iter().enumerate() {
167        let frame = decoder
168            .render_next_frame_owned(None)
169            .map_err(|e| fail(CHECK, e.to_string()))?
170            .ok_or_else(|| fail(CHECK, "missing frame"))?;
171        if grab(frame.pixels()) != grab_ref(expected) {
172            return Err(fail(
173                CHECK,
174                format!("frame {index}: pixels differ from independent oracle"),
175            ));
176        }
177    }
178    Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn typed_rejection_does_not_accept_unrelated_errors() {
187        let expected = ExpectedError::Unsupported(UnsupportedOperation::AnimationTiming);
188        assert!(expected.matches(&UnsupportedOperation::AnimationTiming));
189        assert!(!expected.matches(&UnsupportedOperation::AnimationEncode));
190        assert!(!expected.matches(&std::io::Error::other("internal failure")));
191    }
192
193    #[test]
194    fn distinct_frame_oracles_detect_reordering_and_wrong_pixels() {
195        let a = TestImage::rgba8_gradient_seeded(4, 3, 0);
196        let b = TestImage::rgba8_gradient_seeded(4, 3, 37);
197        let ms = FrameDuration::from_millis;
198        let cases = [
199            TimingCase {
200                pixels: a.as_slice(),
201                requested: ms(40),
202                expected: Ok((ms(40), a.as_slice())),
203            },
204            TimingCase {
205                pixels: b.as_slice(),
206                requested: ms(60),
207                expected: Ok((ms(60), b.as_slice())),
208            },
209        ];
210        check_animation_cases(
211            ReferenceEncoderConfig::new(),
212            ReferenceDecoderConfig,
213            true,
214            &cases,
215        )
216        .unwrap();
217        let wrong = [
218            TimingCase {
219                pixels: a.as_slice(),
220                requested: ms(40),
221                expected: Ok((ms(40), b.as_slice())),
222            },
223            TimingCase {
224                pixels: b.as_slice(),
225                requested: ms(60),
226                expected: Ok((ms(60), a.as_slice())),
227            },
228        ];
229        assert!(
230            check_animation_cases(
231                ReferenceEncoderConfig::new(),
232                ReferenceDecoderConfig,
233                true,
234                &wrong
235            )
236            .is_err()
237        );
238    }
239
240    #[test]
241    fn structural_prefixes_allow_complete_payload_before_a_large_trailer() {
242        let image = TestImage::rgb8_gradient(3, 2);
243        let bytes = MinimalEncoderConfig::new()
244            .encode(image.as_slice())
245            .unwrap();
246        let complete = bytes.data().len();
247        let mut trailing = bytes.data().to_vec();
248        trailing.resize(complete * 8, 0);
249        let cases = [
250            PrefixCase {
251                end: 0,
252                outcome: PrefixOutcome::Reject(ExpectedError::Category(ErrorCategory::Image(
253                    zencodec::ImageError::Malformed,
254                ))),
255            },
256            PrefixCase {
257                end: complete,
258                outcome: PrefixOutcome::Accept(image.as_slice()),
259            },
260        ];
261        check_decode_prefix_cases(MinimalDecoderConfig::new(), &trailing, &cases).unwrap();
262        // No file-length heuristic: required payload loss is rejected even near EOF.
263        let cases = [PrefixCase {
264            end: complete - 1,
265            outcome: PrefixOutcome::Reject(ExpectedError::Category(ErrorCategory::Image(
266                zencodec::ImageError::Malformed,
267            ))),
268        }];
269        check_decode_prefix_cases(MinimalDecoderConfig::new(), bytes.data(), &cases).unwrap();
270        let wrong = TestImage::rgb8_gradient(4, 2);
271        let cases = [PrefixCase {
272            end: complete,
273            outcome: PrefixOutcome::Accept(wrong.as_slice()),
274        }];
275        assert!(
276            check_decode_prefix_cases(MinimalDecoderConfig::new(), bytes.data(), &cases).is_err()
277        );
278        let cases = [PrefixCase {
279            end: complete - 1,
280            outcome: PrefixOutcome::Accept(image.as_slice()),
281        }];
282        assert!(
283            check_decode_prefix_cases(MinimalDecoderConfig::new(), bytes.data(), &cases).is_err()
284        );
285    }
286}