Skip to main content

strypt_core/formats/
jpeg.rs

1//! JPEG.
2//!
3//! The format most likely to be handed to this tool by someone in danger. A photograph taken
4//! on a phone and sent to a newsroom carries, by default, the coordinates it was taken at, the
5//! serial number of the device that took it, the moment it was taken to the second, and a
6//! thumbnail that predates whatever cropping was done afterwards.
7//!
8//! # Segment surgery, never re-encoding
9//!
10//! A JPEG is a sequence of marker segments — two-byte marker, two-byte length, payload —
11//! wrapped around entropy-coded scan data that holds the actual picture. Everything
12//! identifying lives in the `APPn` and `COM` segments; none of it lives in the scan data.
13//!
14//! So this handler never decodes an image and never re-encodes one. It walks the segment list,
15//! drops the segments that carry metadata, and copies the scan data through byte for byte. A
16//! decode-and-re-encode round trip would be far less code and would quietly destroy image
17//! quality on every pass — for a photojournalist whose picture is the evidence, that is
18//! damage, not a side effect (`docs/PRD.md` §8.1). It would also change the pixels of a file
19//! that someone may need to demonstrate is unaltered.
20//!
21//! # What is deliberately kept
22//!
23//! Two segments survive, and both are reported in the strip report's `retained` list rather
24//! than left to be noticed:
25//!
26//! - **`APP0` (JFIF)**, minus any thumbnail inside it. It carries the pixel aspect ratio, and
27//!   dropping it silently changes how a non-square-pixel image displays.
28//! - **`APP14` (Adobe)**, which declares the colour transform. A CMYK or YCCK JPEG whose
29//!   `APP14` has been removed is rendered with inverted or wrong colours by many decoders.
30//!
31//! Both are fixed-shape structures that name no person, no place, and no device.
32//!
33//! # What is removed even though it affects rendering
34//!
35//! Exif `Orientation` and the `APP2` ICC colour profile both go. Each changes how the image
36//! displays: an image that relied on `Orientation` may appear rotated afterwards, and a
37//! wide-gamut image without its profile is interpreted as sRGB. They are removed anyway,
38//! because both are also identifying — a profile routinely names the device or vendor that
39//! made it — and because leaving them would make strypt remove less than mat2 does for the
40//! same file. The limitation is documented rather than hidden; see `CHANGELOG.md`.
41
42use crate::bytes::Reader;
43use crate::detect::Format;
44use crate::error::{MalformedDetail, ResourceLimit, Result, StryptError};
45use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, exif, xmp};
46use crate::report::{
47    Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, Retained,
48    RetentionReason, StripReport,
49};
50
51/// Removal of metadata from JPEG images.
52#[derive(Debug, Clone, Copy, Default)]
53pub struct JpegHandler;
54
55impl MetadataHandler for JpegHandler {
56    fn name(&self) -> &'static str {
57        Format::Jpeg.id()
58    }
59
60    fn format(&self) -> Format {
61        Format::Jpeg
62    }
63
64    fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
65        // Inspection runs the identical pass that stripping does and throws the output away.
66        // That makes "everything `strip` removes is something `inspect` can see" true by
67        // construction rather than by two code paths agreeing to stay in step — which is the
68        // only thing that makes the pipeline's verification pass meaningful
69        // (`docs/ARCHITECTURE.md` §3).
70        let processed = process(input, options, &ParseLimits::default())?;
71        Ok(MetadataReport {
72            format: Format::Jpeg,
73            findings: processed.findings,
74            notes: processed.notes,
75        })
76    }
77
78    fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
79        let processed = process(input, &options.inspect, &options.limits)?;
80        Ok(Stripped {
81            report: StripReport {
82                format: Format::Jpeg,
83                removed: processed.findings,
84                retained: processed.retained,
85                notes: processed.notes,
86                input_bytes: as_u64(input.len()),
87                output_bytes: as_u64(processed.output.len()),
88            },
89            bytes: processed.output,
90        })
91    }
92}
93
94/// The result of one pass over a file: what was found, and what the sanitised file looks like.
95struct Processed {
96    findings: Vec<Finding>,
97    retained: Vec<Retained>,
98    notes: Vec<Note>,
99    output: Vec<u8>,
100}
101
102/// Markers that stand alone: no length field and no payload follows them.
103///
104/// ITU-T T.81 §B.1.1.3. `RST0`–`RST7` appear *inside* entropy-coded data rather than between
105/// segments, and are consumed by the scan walker; they are listed here so that a stray one
106/// between segments is copied through rather than read as a segment with a length.
107const fn is_standalone(marker: u8) -> bool {
108    matches!(marker, 0x01 | 0xD0..=0xD7 | 0xD8)
109}
110
111/// One structural piece of the file, in the order it appears.
112enum Piece<'a> {
113    /// A marker with no payload.
114    Standalone(u8),
115    /// A marker, its length, and its payload — the payload only, without the length field.
116    Segment { marker: u8, payload: &'a [u8] },
117    /// Entropy-coded scan data. Copied through untouched; the picture lives here.
118    Entropy(&'a [u8]),
119    /// Bytes after the final `EOI` marker.
120    Trailing(&'a [u8]),
121}
122
123/// Start of image.
124const SOI: u8 = 0xD8;
125/// End of image.
126const EOI: u8 = 0xD9;
127/// Start of scan: the last segment before entropy-coded data.
128const SOS: u8 = 0xDA;
129/// Comment.
130const COM: u8 = 0xFE;
131
132/// Split `input` into its pieces.
133///
134/// Every length in the file was chosen by whoever made it, so every one is read through
135/// [`Reader`] and every failure is a typed error rather than a panic. A file that does not
136/// parse is refused whole: there is no path here that returns a partial piece list for a
137/// caller to strip and write out.
138fn walk<'a>(input: &'a [u8], limits: &ParseLimits) -> Result<Vec<Piece<'a>>> {
139    let mut r = Reader::new(input);
140    let mut pieces = Vec::new();
141
142    if r.take(2) != Some(&[0xFF, SOI]) {
143        return Err(malformed(MalformedDetail::MissingMarker, Some(0)));
144    }
145    pieces.push(Piece::Standalone(SOI));
146
147    let mut budget = limits.max_items;
148    let mut saw_scan = false;
149
150    loop {
151        if budget == 0 {
152            return Err(StryptError::LimitExceeded {
153                format: Format::Jpeg,
154                limit: ResourceLimit::ItemCount,
155            });
156        }
157        budget = budget.saturating_sub(1);
158
159        let at = r.position();
160        // A marker may be preceded by any number of 0xFF fill bytes (T.81 §B.1.1.2). Real
161        // encoders rarely emit them; some hardware ones do, and a decoder that refuses is
162        // simply wrong about the file.
163        let mut marker = match r.take(2) {
164            Some([0xFF, m]) => *m,
165            // Running out here means the file ended without an EOI. It is refused rather than
166            // completed: emitting a repaired copy of a damaged file would hand the user
167            // something that is not what they gave us, presented as a clean version of it.
168            Some(_) | None => return Err(malformed(MalformedDetail::Truncated, as_offset(at))),
169        };
170        while marker == 0xFF {
171            marker = match r.u8() {
172                Some(m) => m,
173                None => return Err(malformed(MalformedDetail::Truncated, as_offset(at))),
174            };
175        }
176        if marker == 0x00 {
177            // A stuffed byte outside entropy-coded data: this is not where we think we are.
178            return Err(malformed(MalformedDetail::UnexpectedMarker, as_offset(at)));
179        }
180
181        if marker == EOI {
182            pieces.push(Piece::Standalone(EOI));
183            break;
184        }
185        if is_standalone(marker) {
186            pieces.push(Piece::Standalone(marker));
187            continue;
188        }
189
190        // T.81 §B.1.1.4: the length field counts itself, so it can never be less than two.
191        let declared = r
192            .u16_be()
193            .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(at)))?;
194        let length = declared
195            .checked_sub(2)
196            .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(at)))?;
197        let payload = r
198            .take(usize::from(length))
199            .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(at)))?;
200        pieces.push(Piece::Segment { marker, payload });
201
202        if marker == SOS {
203            saw_scan = true;
204            let start = r.position();
205            let consumed = scan_length(r.peek(r.remaining()).unwrap_or_default());
206            r.skip(consumed)
207                .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
208            let end = r.position();
209            pieces.push(Piece::Entropy(input.get(start..end).unwrap_or_default()));
210        }
211    }
212
213    if !saw_scan {
214        // No scan means no picture. Something that parses as a marker list but contains no
215        // image is not a file this handler should be emitting a "cleaned" version of.
216        return Err(malformed(MalformedDetail::MissingMarker, None));
217    }
218
219    let rest = r.take_rest();
220    if !rest.is_empty() {
221        pieces.push(Piece::Trailing(rest));
222    }
223    Ok(pieces)
224}
225
226/// How many bytes of entropy-coded data follow, up to the next real marker.
227///
228/// Inside the scan, `0xFF` is escaped as `0xFF 0x00`, restart markers `0xFF 0xD0`–`0xFF 0xD7`
229/// are part of the stream, and runs of `0xFF` are fill. Anything else after an `0xFF` ends the
230/// scan — which is how a progressive JPEG's several scans, with their tables in between, are
231/// walked without special-casing progressive mode at all.
232fn scan_length(rest: &[u8]) -> usize {
233    let mut i = 0usize;
234    loop {
235        let Some(offset) = rest
236            .get(i..)
237            .and_then(|s| s.iter().position(|&b| b == 0xFF))
238        else {
239            return rest.len();
240        };
241        let at = i.saturating_add(offset);
242        match rest.get(at.saturating_add(1)) {
243            // Truncated after a trailing 0xFF: let the caller run out and report it.
244            None => return rest.len(),
245            // A stuffed 0xFF, or a restart marker: both are part of the stream, and both are
246            // two bytes long.
247            Some(0x00 | 0xD0..=0xD7) => i = at.saturating_add(2),
248            // Fill byte; the next byte may still be the marker.
249            Some(0xFF) => i = at.saturating_add(1),
250            Some(_) => return at,
251        }
252    }
253}
254
255/// What to do with one segment.
256enum Outcome {
257    /// Copy it through unchanged, silently. Structural segments: tables, frame headers, scans.
258    Keep,
259    /// Remove it entirely.
260    Drop,
261    /// Copy it through with a different payload.
262    Replace(Vec<u8>),
263}
264
265/// A decision about one segment, with what to tell the user about it.
266struct Decision {
267    outcome: Outcome,
268    findings: Vec<Finding>,
269    /// Anything kept on purpose. Separate from `findings` because the verification pass
270    /// requires that nothing `inspect` reports as a finding survives a strip — a segment that
271    /// is deliberately kept has to be declared, not reported as removed.
272    retained: Vec<Retained>,
273    notes: Vec<Note>,
274}
275
276impl Decision {
277    const fn keep() -> Self {
278        Self {
279            outcome: Outcome::Keep,
280            findings: Vec::new(),
281            retained: Vec::new(),
282            notes: Vec::new(),
283        }
284    }
285
286    /// Copy the segment through, and say in the report that it was a deliberate choice.
287    fn kept_on_purpose(location: &'static str, reason: RetentionReason) -> Self {
288        Self {
289            outcome: Outcome::Keep,
290            findings: Vec::new(),
291            retained: vec![Retained {
292                location: location.to_owned(),
293                reason,
294            }],
295            notes: Vec::new(),
296        }
297    }
298
299    fn drop_with(findings: Vec<Finding>) -> Self {
300        Self {
301            outcome: Outcome::Drop,
302            findings,
303            retained: Vec::new(),
304            notes: Vec::new(),
305        }
306    }
307
308    fn drop_one(kind: MetadataKind, location: &str, bytes: u64) -> Self {
309        Self::drop_with(vec![Finding::new(kind, location.to_owned(), bytes)])
310    }
311}
312
313/// Walk `input`, decide about every piece, and build the sanitised file.
314fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
315    let pieces = walk(input, limits)?;
316    let mut out = Processed {
317        findings: Vec::new(),
318        retained: Vec::new(),
319        notes: Vec::new(),
320        output: Vec::with_capacity(input.len()),
321    };
322
323    for piece in pieces {
324        match piece {
325            Piece::Standalone(marker) => {
326                out.output.push(0xFF);
327                out.output.push(marker);
328            }
329            Piece::Entropy(data) => out.output.extend_from_slice(data),
330            Piece::Trailing(data) => {
331                // Everything after EOI is data no decoder reads and no user knows is there.
332                // In practice it is where a phone's multi-picture extension keeps a second
333                // full-resolution frame — an unredacted copy of the picture, past the end of
334                // the picture.
335                let kind = if data.starts_with(&[0xFF, SOI]) {
336                    MetadataKind::Thumbnail
337                } else {
338                    MetadataKind::Other
339                };
340                out.findings.push(Finding::new(
341                    kind,
342                    "trailing data after EOI",
343                    as_u64(data.len()),
344                ));
345            }
346            Piece::Segment { marker, payload } => {
347                let decision = decide(marker, payload, options, limits);
348                out.notes.extend(decision.notes);
349                out.retained.extend(decision.retained);
350                match decision.outcome {
351                    Outcome::Keep => emit(&mut out.output, marker, payload),
352                    Outcome::Drop => out.findings.extend(decision.findings),
353                    Outcome::Replace(new_payload) => {
354                        out.findings.extend(decision.findings);
355                        emit(&mut out.output, marker, &new_payload);
356                    }
357                }
358            }
359        }
360    }
361    Ok(out)
362}
363
364/// Write one segment: marker, length including itself, payload.
365///
366/// The length cannot overflow: a payload only ever arrives here having come out of a `u16`
367/// length field, or having been shortened from one.
368fn emit(output: &mut Vec<u8>, marker: u8, payload: &[u8]) {
369    let Ok(length) = u16::try_from(payload.len().saturating_add(2)) else {
370        return;
371    };
372    output.push(0xFF);
373    output.push(marker);
374    output.extend_from_slice(&length.to_be_bytes());
375    output.extend_from_slice(payload);
376}
377
378/// Decide about one segment.
379fn decide(marker: u8, payload: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Decision {
380    let size = as_u64(payload.len());
381    match marker {
382        0xE0 => app0(payload, size),
383        0xE1 => app1(payload, size, options, limits),
384        0xE2 => app2(payload, size),
385        0xE3 => Decision::drop_one(MetadataKind::Other, "APP3 (Meta)", size),
386        0xE4 => Decision::drop_one(MetadataKind::Other, "APP4", size),
387        0xE5 => Decision::drop_one(MetadataKind::Other, "APP5", size),
388        0xE6 => Decision::drop_one(MetadataKind::Other, "APP6", size),
389        0xE7 => Decision::drop_one(MetadataKind::Other, "APP7", size),
390        0xE8 => Decision::drop_one(MetadataKind::Other, "APP8", size),
391        0xE9 => Decision::drop_one(MetadataKind::Other, "APP9", size),
392        // APP10 is the Active Pictures comment segment; APP12 is where several camera makers
393        // put "Ducky" and "PictureInfo" blocks, which name the camera and its settings.
394        0xEA => Decision::drop_one(MetadataKind::Comment, "APP10", size),
395        0xEB => Decision::drop_one(MetadataKind::Other, "APP11", size),
396        0xEC => Decision::drop_one(MetadataKind::SoftwareFingerprint, "APP12 (Ducky)", size),
397        0xED => app13(payload, size),
398        0xEE => app14(payload, size),
399        0xEF => Decision::drop_one(MetadataKind::Other, "APP15", size),
400        COM => comment(payload, size, options),
401        // Quantisation and Huffman tables, frame headers, scan headers, restart intervals:
402        // the file is not an image without them, and none of them names anybody.
403        _ => Decision::keep(),
404    }
405}
406
407/// `APP0`: the JFIF header, and its optional embedded thumbnail.
408fn app0(payload: &[u8], size: u64) -> Decision {
409    if payload.starts_with(b"JFXX\0") {
410        // The JFIF extension segment exists to carry a thumbnail and nothing else.
411        return Decision::drop_one(MetadataKind::Thumbnail, "APP0 (JFXX thumbnail)", size);
412    }
413    if !payload.starts_with(b"JFIF\0") {
414        return Decision::drop_one(MetadataKind::Other, "APP0", size);
415    }
416
417    // JFIF 1.02 §3: the thumbnail dimensions are the thirteenth and fourteenth bytes of the
418    // payload, followed by 3·X·Y bytes of uncompressed RGB. A JFIF thumbnail is rare and, when
419    // present, is a pre-crop copy of the image like any other.
420    let (Some(&x), Some(&y)) = (payload.get(12), payload.get(13)) else {
421        return Decision::drop_one(MetadataKind::Other, "APP0 (JFIF, malformed)", size);
422    };
423    let pixels = u64::from(x).saturating_mul(u64::from(y));
424    if pixels == 0 {
425        return Decision::kept_on_purpose("APP0 (JFIF)", RetentionReason::RemovalWouldAlterPayload);
426    }
427
428    let mut header = payload.get(0..14).unwrap_or_default().to_vec();
429    // Zero the dimensions rather than merely truncating the segment: a reader that trusted
430    // them would walk off the end of what is left otherwise.
431    zero_thumbnail_dimensions(&mut header);
432    Decision {
433        outcome: Outcome::Replace(header),
434        findings: vec![Finding::new(
435            MetadataKind::Thumbnail,
436            "APP0 (JFIF thumbnail)",
437            pixels.saturating_mul(3),
438        )],
439        // The header that is left behind is still a deliberate retention, and is reported as
440        // one — otherwise a file whose JFIF segment held a thumbnail would say less about what
441        // was kept than a file whose JFIF segment did not.
442        retained: vec![Retained {
443            location: "APP0 (JFIF)".to_owned(),
444            reason: RetentionReason::RemovalWouldAlterPayload,
445        }],
446        notes: Vec::new(),
447    }
448}
449
450/// Set the JFIF thumbnail dimensions to zero, in a header already known to be 14 bytes.
451fn zero_thumbnail_dimensions(header: &mut [u8]) {
452    if let Some(slot) = header.get_mut(12) {
453        *slot = 0;
454    }
455    if let Some(slot) = header.get_mut(13) {
456        *slot = 0;
457    }
458}
459
460/// `APP1`: Exif, XMP, and anything else that claimed the segment.
461fn app1(payload: &[u8], size: u64, options: &InspectOptions, limits: &ParseLimits) -> Decision {
462    if let Some(tiff) = payload.strip_prefix(b"Exif\0\0") {
463        let scanned = exif::scan(tiff, "APP1 (Exif)", options, limits);
464        let findings = if scanned.findings.is_empty() {
465            // An Exif block that named nothing is still an Exif block, and it is still going.
466            vec![Finding::new(MetadataKind::Other, "APP1 (Exif)", size)]
467        } else {
468            scanned.findings
469        };
470        return Decision {
471            outcome: Outcome::Drop,
472            findings,
473            retained: Vec::new(),
474            notes: scanned.notes,
475        };
476    }
477    // The XMP packet is introduced by a namespace URI and a NUL. The extension segment carries
478    // packets too large for one APP1 and is handled the same way.
479    for prefix in [
480        b"http://ns.adobe.com/xap/1.0/\0".as_slice(),
481        b"http://ns.adobe.com/xmp/extension/\0".as_slice(),
482    ] {
483        if let Some(packet) = payload.strip_prefix(prefix) {
484            return Decision::drop_with(xmp::scan(packet, "APP1 (XMP)", options));
485        }
486    }
487    Decision::drop_one(MetadataKind::Other, "APP1", size)
488}
489
490/// `APP2`: ICC colour profiles, `FlashPix`, and the multi-picture extension.
491fn app2(payload: &[u8], size: u64) -> Decision {
492    if payload.starts_with(b"ICC_PROFILE\0") {
493        // An ICC profile names the device or vendor it was made for in its description tag,
494        // and many phones embed a per-device profile.
495        return Decision::drop_one(MetadataKind::ColourProfile, "APP2 (ICC profile)", size);
496    }
497    if payload.starts_with(b"MPF\0") {
498        // Multi-Picture Format: an index of *further whole images* stored in the same file,
499        // typically a full-resolution frame the camera kept alongside the one you can see.
500        return Decision::drop_one(MetadataKind::Thumbnail, "APP2 (MPF)", size);
501    }
502    if payload.starts_with(b"FPXR") {
503        return Decision::drop_one(MetadataKind::Other, "APP2 (FlashPix)", size);
504    }
505    Decision::drop_one(MetadataKind::Other, "APP2", size)
506}
507
508/// `APP13`: Photoshop image resource blocks, which is where IPTC captions live.
509fn app13(payload: &[u8], size: u64) -> Decision {
510    if payload.starts_with(b"Photoshop 3.0\0") {
511        // The IPTC block inside carries by-line, credit, city, and country fields — written by
512        // a person, about a person, and frequently the most directly identifying thing in a
513        // press photograph.
514        return Decision::drop_one(
515            MetadataKind::PersonalIdentity,
516            "APP13 (Photoshop/IPTC)",
517            size,
518        );
519    }
520    Decision::drop_one(MetadataKind::Other, "APP13", size)
521}
522
523/// `APP14`: the Adobe colour-transform marker.
524fn app14(payload: &[u8], size: u64) -> Decision {
525    if payload.starts_with(b"Adobe") {
526        // Kept: it declares whether the components are YCbCr, YCCK, or CMYK. Remove it from a
527        // CMYK file and many decoders render it inverted. It names no person and no device.
528        return Decision::kept_on_purpose(
529            "APP14 (Adobe)",
530            RetentionReason::RemovalWouldAlterPayload,
531        );
532    }
533    Decision::drop_one(MetadataKind::Other, "APP14", size)
534}
535
536/// `COM`: a free-text comment, which is exactly as free as it sounds.
537fn comment(payload: &[u8], size: u64, options: &InspectOptions) -> Decision {
538    Decision::drop_with(vec![
539        Finding::new(MetadataKind::Comment, "COM", size).with_value(options, || {
540            MetadataValue::Text(
541                String::from_utf8_lossy(payload)
542                    .chars()
543                    .filter(|c| !c.is_control())
544                    .collect(),
545            )
546        }),
547    ])
548}
549
550/// A malformed-file error for this format.
551fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
552    StryptError::Malformed {
553        format: Format::Jpeg,
554        offset,
555        detail,
556    }
557}
558
559/// A byte position as a reportable offset.
560fn as_offset(position: usize) -> Option<u64> {
561    u64::try_from(position).ok()
562}
563
564/// Widen a length for reporting. Saturating: a report field is not worth failing a strip over.
565fn as_u64(value: usize) -> u64 {
566    u64::try_from(value).unwrap_or(u64::MAX)
567}
568
569#[cfg(test)]
570mod tests {
571    // Test code is never reachable from untrusted bytes, which is the boundary the
572    // panic-freedom lints exist to police (ADR-0006).
573    #![allow(
574        clippy::unwrap_used,
575        clippy::expect_used,
576        clippy::indexing_slicing,
577        clippy::arithmetic_side_effects
578    )]
579
580    use super::*;
581
582    /// A minimal but structurally real JPEG: SOI, the given segments, a scan, EOI.
583    fn jpeg(segments: &[(u8, Vec<u8>)]) -> Vec<u8> {
584        let mut out = vec![0xFF, SOI];
585        for (marker, payload) in segments {
586            out.push(0xFF);
587            out.push(*marker);
588            out.extend_from_slice(&u16::try_from(payload.len() + 2).unwrap().to_be_bytes());
589            out.extend_from_slice(payload);
590        }
591        // SOS with a two-byte header, then entropy data containing a stuffed 0xFF and a
592        // restart marker, so that the scan walker is actually exercised.
593        out.extend_from_slice(&[0xFF, SOS, 0x00, 0x04, 0x01, 0x00]);
594        out.extend_from_slice(&[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56]);
595        out.extend_from_slice(&[0xFF, EOI]);
596        out
597    }
598
599    fn strip_ok(data: &[u8]) -> Stripped {
600        JpegHandler
601            .strip(data, &StripOptions::default())
602            .expect("strip failed")
603    }
604
605    fn findings(data: &[u8]) -> Vec<Finding> {
606        JpegHandler
607            .inspect(data, &InspectOptions::names_only())
608            .expect("inspect failed")
609            .findings
610    }
611
612    fn exif_app1(tag: u16, value: [u8; 4]) -> Vec<u8> {
613        let mut payload = b"Exif\0\0".to_vec();
614        payload.extend_from_slice(b"II\x2A\x00\x08\x00\x00\x00");
615        payload.extend_from_slice(&1u16.to_le_bytes());
616        payload.extend_from_slice(&tag.to_le_bytes());
617        payload.extend_from_slice(&2u16.to_le_bytes()); // ASCII
618        payload.extend_from_slice(&4u32.to_le_bytes());
619        payload.extend_from_slice(&value);
620        payload.extend_from_slice(&0u32.to_le_bytes());
621        payload
622    }
623
624    #[test]
625    fn the_picture_is_never_touched() {
626        // The entropy-coded data is the photograph. If a byte of it ever changes, the handler
627        // has re-encoded something, and the promise in this module's header is broken.
628        let input = jpeg(&[(0xE1, exif_app1(0x010F, *b"ACME"))]);
629        let output = strip_ok(&input).bytes;
630        let scan_bytes: &[u8] = &[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56];
631        assert!(
632            output.windows(scan_bytes.len()).any(|w| w == scan_bytes),
633            "entropy-coded data did not survive byte for byte"
634        );
635    }
636
637    #[test]
638    fn exif_is_reported_by_tag_and_removed() {
639        let input = jpeg(&[(0xE1, exif_app1(0x010F, *b"ACME"))]);
640        let found = findings(&input);
641        assert_eq!(found[0].field.as_deref(), Some("Make"));
642        assert_eq!(found[0].kind, MetadataKind::DeviceIdentity);
643
644        let output = strip_ok(&input).bytes;
645        assert!(
646            !output.windows(4).any(|w| w == b"ACME"),
647            "the camera make survived the strip"
648        );
649        assert!(findings(&output).is_empty());
650    }
651
652    #[test]
653    fn a_comment_goes_and_the_tables_stay() {
654        // 0xDB is a quantisation table: structural, unidentifying, and required for the image
655        // to decode. A handler that dropped every segment it did not recognise would break it.
656        let input = jpeg(&[(COM, b"SYNTHETIC-COMMENT".to_vec()), (0xDB, vec![0u8; 8])]);
657        let output = strip_ok(&input).bytes;
658        assert!(!output.windows(9).any(|w| w == b"SYNTHETIC"));
659        assert!(
660            output.windows(2).any(|w| w == [0xFF, 0xDB]),
661            "the quantisation table was dropped along with the comment"
662        );
663    }
664
665    #[test]
666    fn the_jfif_header_stays_and_its_thumbnail_does_not() {
667        let mut jfif = b"JFIF\0\x01\x02\x00\x00\x01\x00\x01".to_vec();
668        jfif.push(2); // thumbnail width
669        jfif.push(2); // thumbnail height
670        jfif.extend_from_slice(&[0xAB; 12]); // 3 · 2 · 2 bytes of RGB
671        let input = jpeg(&[(0xE0, jfif)]);
672
673        let stripped = strip_ok(&input);
674        assert!(
675            stripped
676                .report
677                .removed
678                .iter()
679                .any(|f| f.kind == MetadataKind::Thumbnail)
680        );
681        assert!(
682            stripped
683                .report
684                .retained
685                .iter()
686                .any(|r| r.location == "APP0 (JFIF)"),
687            "the JFIF header should be kept, and the report should say it was"
688        );
689        assert!(!stripped.bytes.windows(4).any(|w| w == [0xAB; 4]));
690        assert!(findings(&stripped.bytes).is_empty());
691    }
692
693    #[test]
694    fn the_adobe_colour_transform_marker_is_kept_and_declared() {
695        // Removing it renders CMYK files with inverted colours. Keeping it silently would be
696        // the wrong half of the trade: the user is told.
697        let input = jpeg(&[(0xEE, b"Adobe\0\x64\x00\x00\x00\x00\x02".to_vec())]);
698        let stripped = strip_ok(&input);
699        assert!(stripped.bytes.windows(2).any(|w| w == [0xFF, 0xEE]));
700        assert_eq!(stripped.report.retained.len(), 1);
701        assert_eq!(
702            stripped.report.retained[0].reason,
703            RetentionReason::RemovalWouldAlterPayload
704        );
705    }
706
707    #[test]
708    fn data_hidden_after_the_end_of_image_marker_is_removed() {
709        // Where a phone's multi-picture extension keeps a second full-resolution frame. No
710        // decoder shows it; every forensic tool finds it.
711        let mut input = jpeg(&[]);
712        input.extend_from_slice(&[0xFF, SOI]);
713        input.extend_from_slice(b"SYNTHETIC-SECOND-IMAGE");
714        let stripped = strip_ok(&input);
715        assert!(!stripped.bytes.windows(9).any(|w| w == b"SYNTHETIC"));
716        assert_eq!(stripped.report.removed[0].kind, MetadataKind::Thumbnail);
717    }
718
719    #[test]
720    fn stripping_twice_changes_nothing() {
721        let input = jpeg(&[
722            (0xE1, exif_app1(0x010F, *b"ACME")),
723            (COM, b"SYNTHETIC-COMMENT".to_vec()),
724        ]);
725        let once = strip_ok(&input).bytes;
726        let twice = strip_ok(&once).bytes;
727        assert_eq!(once, twice, "strip is not idempotent");
728    }
729
730    #[test]
731    fn a_clean_file_produces_no_findings_and_no_edits() {
732        let input = jpeg(&[(0xDB, vec![0u8; 8])]);
733        let stripped = strip_ok(&input);
734        assert!(stripped.report.removed.is_empty());
735        assert_eq!(stripped.bytes, input);
736    }
737
738    #[test]
739    fn a_file_that_ends_without_eoi_is_refused() {
740        // Fail closed. Completing a damaged file would hand the user something that is not
741        // what they gave us, presented as a clean version of it.
742        let input = jpeg(&[]);
743        let truncated = &input[0..input.len() - 2];
744        assert!(matches!(
745            JpegHandler.strip(truncated, &StripOptions::default()),
746            Err(StryptError::Malformed { .. })
747        ));
748    }
749
750    #[test]
751    fn truncation_at_every_length_is_refused_or_survived_but_never_panics() {
752        let input = jpeg(&[
753            (0xE1, exif_app1(0x8825, [26, 0, 0, 0])),
754            (COM, b"comment".to_vec()),
755        ]);
756        for n in 0..=input.len() {
757            let prefix = &input[0..n];
758            let _ = JpegHandler.inspect(prefix, &InspectOptions::names_only());
759            let _ = JpegHandler.strip(prefix, &StripOptions::default());
760        }
761    }
762
763    #[test]
764    fn a_segment_length_of_zero_is_refused_rather_than_wrapping() {
765        // Declared length below the two bytes the field itself occupies. Subtracting without a
766        // check would wrap to 65534 on a release build without overflow checks.
767        let input = vec![0xFF, SOI, 0xFF, 0xE1, 0x00, 0x00, 0xFF, EOI];
768        assert!(matches!(
769            JpegHandler.inspect(&input, &InspectOptions::names_only()),
770            Err(StryptError::Malformed {
771                detail: MalformedDetail::LengthOutOfRange,
772                ..
773            })
774        ));
775    }
776
777    #[test]
778    fn a_segment_count_beyond_the_limit_is_refused() {
779        let segments: Vec<(u8, Vec<u8>)> = (0..64).map(|_| (0xDB, vec![0u8; 4])).collect();
780        let input = jpeg(&segments);
781        let options = StripOptions {
782            limits: ParseLimits {
783                max_items: 8,
784                ..ParseLimits::default()
785            },
786            ..StripOptions::default()
787        };
788        assert!(matches!(
789            JpegHandler.strip(&input, &options),
790            Err(StryptError::LimitExceeded { .. })
791        ));
792    }
793}