Skip to main content

strypt_core/formats/
png.rs

1//! PNG.
2//!
3//! The format screenshots arrive in, which is why it matters more than its reputation
4//! suggests. A screenshot is taken on the machine that took it, by software that frequently
5//! writes its own name into the file, and thumbnailers write the *full path of the original*
6//! into a `tEXt` chunk — `Thumb::URI` names a home directory, and a home directory names a
7//! person.
8//!
9//! # Chunk surgery, never re-encoding
10//!
11//! A PNG is an eight-byte signature followed by a flat list of chunks, each carrying its own
12//! length, four-byte type, payload, and CRC (ISO/IEC 15948 §5). The picture is in `IDAT`;
13//! everything identifying is in ancillary chunks around it. This handler walks that list,
14//! drops the chunks that carry metadata, and copies the rest through **as raw bytes** —
15//! length, type, payload, and CRC verbatim. A clean file therefore strips to a byte-identical
16//! copy of itself, and idempotence follows from the design rather than from a test passing.
17//!
18//! Re-encoding would be indefensible here in a way it is not even for JPEG: PNG is lossless,
19//! so a user who chose it chose exactness.
20//!
21//! # Compressed text is removed, not inflated
22//!
23//! `zTXt` is compressed by definition and `iTXt` is compressed when its flag says so, and
24//! **this handler still contains no decompressor** (ADR-0022). Everything that decides what
25//! goes is outside the compression: the keyword, the compression flag, the language tag, and
26//! the translated keyword are all uncompressed (W3C PNG Third Edition §11.3.3.3, §11.3.3.4),
27//! `iCCP`'s profile name is uncompressed, and `eXIf` is a raw TIFF block the shared reader in
28//! [`crate::formats::exif`] handles directly. The chunk is removed whole either way.
29//!
30//! What is lost is report *granularity*: an XMP packet in an uncompressed `iTXt` is broken
31//! down by property, and the same packet compressed is one finding. That is the same trade
32//! the PDF handler already makes for a `FlateDecode`d metadata stream, and taking a
33//! decompression-bomb surface to improve a listing was not worth it in either place.
34//!
35//! # CRCs are copied, never recomputed
36//!
37//! A chunk's CRC covers only its own type and payload, and no chunk that survives here is
38//! ever altered — so every CRC written out is still the one that was correct on the way in,
39//! and nothing in this crate needs a CRC implementation. They are not *checked* either:
40//! strypt is not a decoder, and refusing a file because some earlier tool left a stale CRC
41//! would help nobody. What is checked on every chunk is its declared length, because that is
42//! the field a hostile file lies about.
43
44use crate::bytes::{Reader, u32_to_usize};
45use crate::detect::Format;
46use crate::error::{MalformedDetail, ResourceLimit, Result, StryptError};
47use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, exif, xmp};
48use crate::report::{
49    Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, Retained,
50    RetentionReason, StripReport,
51};
52
53/// Removal of metadata from PNG images.
54#[derive(Debug, Clone, Copy, Default)]
55pub struct PngHandler;
56
57impl MetadataHandler for PngHandler {
58    fn name(&self) -> &'static str {
59        Format::Png.id()
60    }
61
62    fn format(&self) -> Format {
63        Format::Png
64    }
65
66    fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
67        // Inspection runs the identical pass that stripping does and throws the output away,
68        // so "everything `strip` removes is something `inspect` can see" is true by
69        // construction rather than by two code paths agreeing to stay in step — which is what
70        // makes the pipeline's verification pass mean anything (`docs/ARCHITECTURE.md` §3).
71        let processed = process(input, options, &ParseLimits::default())?;
72        Ok(MetadataReport {
73            format: Format::Png,
74            findings: processed.findings,
75            notes: processed.notes,
76        })
77    }
78
79    fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
80        let processed = process(input, &options.inspect, &options.limits)?;
81        Ok(Stripped {
82            report: StripReport {
83                format: Format::Png,
84                removed: processed.findings,
85                retained: processed.retained,
86                notes: processed.notes,
87                input_bytes: as_u64(input.len()),
88                output_bytes: as_u64(processed.output.len()),
89            },
90            bytes: processed.output,
91        })
92    }
93}
94
95/// The PNG signature. The CR-LF-EOF-LF tail exists to catch exactly the transfer corruption
96/// that would otherwise truncate a file silently (ISO/IEC 15948 §5.2).
97const SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
98
99/// The largest a chunk may declare itself to be: §5.3 caps a chunk's length at 2³¹−1, so the
100/// high bit being set is a lying length field rather than a very large chunk.
101const MAX_CHUNK_LENGTH: u32 = 0x7FFF_FFFF;
102
103/// One chunk, and the exact bytes it occupied.
104struct Chunk<'a> {
105    /// The four-byte type code.
106    kind: [u8; 4],
107    /// The payload, without the length, type, or CRC around it.
108    data: &'a [u8],
109    /// The whole chunk as it appeared, including its length and CRC. Kept chunks are written
110    /// out from this, which is what makes the copy exact.
111    raw: &'a [u8],
112}
113
114impl Chunk<'_> {
115    /// True when the chunk is ancillary: §5.4 puts that in bit 5 of the first type byte, so a
116    /// lowercase first letter means "a decoder that does not understand this may drop it".
117    const fn is_ancillary(&self) -> bool {
118        matches!(self.kind.first(), Some(b) if b.is_ascii_lowercase())
119    }
120}
121
122/// The result of one pass over a file: what was found, and what the sanitised file looks like.
123struct Processed {
124    findings: Vec<Finding>,
125    retained: Vec<Retained>,
126    notes: Vec<Note>,
127    output: Vec<u8>,
128}
129
130/// Split `input` into its chunks, plus anything after the final `IEND`.
131///
132/// Every length in the file was chosen by whoever made it, so every one is read through
133/// [`Reader`] and every failure is a typed error rather than a panic. A file that does not
134/// parse is refused whole: there is no path here that returns a partial chunk list for a
135/// caller to strip and write out.
136fn walk<'a>(input: &'a [u8], limits: &ParseLimits) -> Result<(Vec<Chunk<'a>>, &'a [u8])> {
137    let mut r = Reader::new(input);
138    if r.take(SIGNATURE.len()) != Some(&SIGNATURE) {
139        return Err(malformed(MalformedDetail::MissingMarker, Some(0)));
140    }
141
142    let mut chunks: Vec<Chunk<'a>> = Vec::new();
143    let mut budget = limits.max_items;
144
145    loop {
146        if budget == 0 {
147            return Err(StryptError::LimitExceeded {
148                format: Format::Png,
149                limit: ResourceLimit::ItemCount,
150            });
151        }
152        budget = budget.saturating_sub(1);
153
154        let start = r.position();
155        if r.is_empty() {
156            // The file ran out before `IEND`. Refused rather than completed: emitting a
157            // repaired copy of a damaged file would hand the user something that is not what
158            // they gave us, presented as a clean version of it.
159            return Err(malformed(MalformedDetail::Truncated, as_offset(start)));
160        }
161
162        let declared = r
163            .u32_be()
164            .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
165        if declared > MAX_CHUNK_LENGTH {
166            return Err(malformed(
167                MalformedDetail::LengthOutOfRange,
168                as_offset(start),
169            ));
170        }
171        let length = u32_to_usize(declared)
172            .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(start)))?;
173
174        let kind: [u8; 4] = r
175            .take(4)
176            .and_then(|k| k.try_into().ok())
177            .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
178        if !kind.iter().all(u8::is_ascii_alphabetic) {
179            // §5.4 makes every type byte a letter. A non-letter here means the walk is no
180            // longer where it thinks it is, and continuing would be slicing arbitrary bytes
181            // out of a file while reporting confidently about them.
182            return Err(malformed(
183                MalformedDetail::UnexpectedMarker,
184                as_offset(start),
185            ));
186        }
187
188        let data = r
189            .take(length)
190            .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(start)))?;
191        r.skip(4)
192            .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
193        let raw = input.get(start..r.position()).unwrap_or_default();
194
195        if chunks.is_empty() && &kind != b"IHDR" {
196            // §5.6: `IHDR` is first. Anything else means this is not a PNG whose structure we
197            // have understood, and a "cleaned" copy of it would be a guess.
198            return Err(malformed(MalformedDetail::MissingMarker, as_offset(start)));
199        }
200
201        chunks.push(Chunk { kind, data, raw });
202        if &kind == b"IEND" {
203            break;
204        }
205    }
206
207    Ok((chunks, r.take_rest()))
208}
209
210/// What to do with one chunk.
211enum Outcome {
212    /// Copy it through unchanged.
213    Keep,
214    /// Remove it entirely.
215    Drop,
216}
217
218/// A decision about one chunk, with what to tell the user about it.
219struct Decision {
220    outcome: Outcome,
221    findings: Vec<Finding>,
222    /// Anything kept on purpose. Separate from `findings` because the verification pass
223    /// requires that nothing `inspect` reports as a finding survives a strip — a chunk that is
224    /// deliberately kept has to be declared, not reported as removed.
225    retained: Vec<Retained>,
226    notes: Vec<Note>,
227}
228
229impl Decision {
230    const fn keep() -> Self {
231        Self {
232            outcome: Outcome::Keep,
233            findings: Vec::new(),
234            retained: Vec::new(),
235            notes: Vec::new(),
236        }
237    }
238
239    /// Copy the chunk through, and say in the report that it was a deliberate choice.
240    fn kept_on_purpose(location: &'static str, reason: RetentionReason) -> Self {
241        Self {
242            outcome: Outcome::Keep,
243            findings: Vec::new(),
244            retained: vec![Retained {
245                location: location.to_owned(),
246                reason,
247            }],
248            notes: Vec::new(),
249        }
250    }
251
252    fn drop_with(findings: Vec<Finding>) -> Self {
253        Self {
254            outcome: Outcome::Drop,
255            findings,
256            retained: Vec::new(),
257            notes: Vec::new(),
258        }
259    }
260
261    fn drop_one(kind: MetadataKind, location: impl Into<String>, bytes: u64) -> Self {
262        Self::drop_with(vec![Finding::new(kind, location, bytes)])
263    }
264}
265
266/// Walk `input`, decide about every chunk, and build the sanitised file.
267fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
268    let (chunks, trailing) = walk(input, limits)?;
269    let mut out = Processed {
270        findings: Vec::new(),
271        retained: Vec::new(),
272        notes: Vec::new(),
273        output: Vec::with_capacity(input.len()),
274    };
275    out.output.extend_from_slice(&SIGNATURE);
276
277    for chunk in chunks {
278        let decision = decide(&chunk, options, limits);
279        out.notes.extend(decision.notes);
280        out.retained.extend(decision.retained);
281        match decision.outcome {
282            Outcome::Keep => out.output.extend_from_slice(chunk.raw),
283            Outcome::Drop => out.findings.extend(decision.findings),
284        }
285    }
286
287    if !trailing.is_empty() {
288        // Nothing reads past `IEND`, and few users know anything can be there. It is a
289        // convenient place to keep a second copy of an image whose visible version was
290        // cropped.
291        let kind = if trailing.starts_with(&SIGNATURE) {
292            MetadataKind::Thumbnail
293        } else {
294            MetadataKind::Other
295        };
296        out.findings.push(Finding::new(
297            kind,
298            "trailing data after IEND",
299            as_u64(trailing.len()),
300        ));
301    }
302
303    Ok(out)
304}
305
306/// Decide about one chunk.
307fn decide(chunk: &Chunk<'_>, options: &InspectOptions, limits: &ParseLimits) -> Decision {
308    let size = as_u64(chunk.data.len());
309    match &chunk.kind {
310        // Three groups, all copied through byte for byte:
311        //
312        // - the image itself, its palette, and the two chunks that delimit the file;
313        // - rendering — transparency, the colour-space description, the bit-depth hint, the
314        //   background colour, the palette histogram, and the HDR mastering chunks. Every one
315        //   is a fixed-shape structure that names no person, no place, and no device, and
316        //   several of them change how the image looks if they go;
317        // - APNG, where `fdAT` holds every frame after the first. These three *are* the
318        //   payload for an animation, and dropping them would turn it silently into a still.
319        b"IHDR" | b"PLTE" | b"IDAT" | b"IEND" | b"tRNS" | b"gAMA" | b"cHRM" | b"sRGB" | b"sBIT"
320        | b"bKGD" | b"hIST" | b"cICP" | b"mDCV" | b"cLLI" | b"acTL" | b"fcTL" | b"fdAT" => {
321            Decision::keep()
322        }
323        // Physical pixel dimensions — the aspect ratio and the DPI. Kept for the reason the
324        // JPEG handler keeps `APP0`, and declared for the same reason: it is the chunk a
325        // careful user is most likely to expect to have gone.
326        b"pHYs" => Decision::kept_on_purpose("pHYs", RetentionReason::RemovalWouldAlterPayload),
327        b"tEXt" => text(chunk.data, "tEXt", options),
328        b"zTXt" => compressed_text(chunk.data, size),
329        b"iTXt" => international_text(chunk.data, size, options),
330        b"tIME" => time(chunk.data, size, options),
331        b"eXIf" => exif_chunk(chunk.data, size, options, limits),
332        b"iCCP" => icc_profile(chunk.data, size),
333        // The one standard rendering chunk that is removed: a suggested palette's *name* is
334        // arbitrary text, so the chunk is a text carrier, and the palette itself is advisory
335        // data used only by decoders that cannot display the image at full depth.
336        b"sPLT" => Decision::drop_one(MetadataKind::Other, "sPLT", size),
337        _ if chunk.is_ancillary() => {
338            // A private ancillary chunk can hold anything at all, and a scrubber that copies
339            // through what it does not understand is not scrubbing.
340            Decision::drop_one(MetadataKind::Other, xmp::name_of(&chunk.kind), size)
341        }
342        _ => {
343            // An unknown *critical* chunk: whoever wrote the file marked it as needed in
344            // order to interpret the image (§5.4). We cannot know what it holds or what
345            // depends on it, so it stays and the report says plainly that its bytes went by
346            // unexamined. A conforming decoder already refuses a file like this, so keeping
347            // the chunk leaves it exactly as unreadable as it arrived — and dropping it to
348            // make the file open would be deciding what the document is on the user's
349            // behalf.
350            Decision {
351                outcome: Outcome::Keep,
352                findings: Vec::new(),
353                retained: Vec::new(),
354                notes: vec![Note::UnparsedRegion {
355                    location: xmp::name_of(&chunk.kind),
356                    bytes: size,
357                }],
358            }
359        }
360    }
361}
362
363/// Keywords worth classifying by name, so that the report ranks them the way the threat model
364/// does rather than filing everything under "text".
365///
366/// The first block is the set §11.3.3.1 registers. The rest are what real tools actually
367/// write: `ImageMagick` stores whole Exif and IPTC blocks as hex text under `Raw profile type`
368/// keywords, and freedesktop thumbnailers write the **full path of the original file** into
369/// `Thumb::URI` — a home directory names a person.
370const KEYWORDS: &[(&[u8], MetadataKind)] = &[
371    (b"Author", MetadataKind::PersonalIdentity),
372    (b"Copyright", MetadataKind::PersonalIdentity),
373    (b"Creation Time", MetadataKind::Timestamp),
374    (b"Software", MetadataKind::SoftwareFingerprint),
375    (b"Source", MetadataKind::DeviceIdentity),
376    (b"Title", MetadataKind::Comment),
377    (b"Description", MetadataKind::Comment),
378    (b"Comment", MetadataKind::Comment),
379    (b"Disclaimer", MetadataKind::Comment),
380    (b"Warning", MetadataKind::Comment),
381    (b"Raw profile type exif", MetadataKind::DeviceIdentity),
382    (b"Raw profile type APP1", MetadataKind::DeviceIdentity),
383    (b"Raw profile type iptc", MetadataKind::PersonalIdentity),
384    (b"Raw profile type 8bim", MetadataKind::SoftwareFingerprint),
385    (b"Raw profile type icc", MetadataKind::ColourProfile),
386    (b"Raw profile type xmp", MetadataKind::Other),
387    (b"Thumb::URI", MetadataKind::PersonalIdentity),
388    (b"Thumb::MTime", MetadataKind::Timestamp),
389    (b"date:create", MetadataKind::Timestamp),
390    (b"date:modify", MetadataKind::Timestamp),
391    (b"date:timestamp", MetadataKind::Timestamp),
392];
393
394/// The keyword under which XMP is stored in a text chunk, per the XMP specification part 3.
395const XMP_KEYWORD: &[u8] = b"XML:com.adobe.xmp";
396
397/// What a keyword says the chunk is. Unrecognised keywords are still removed — a
398/// vendor-invented key is no less identifying for being non-standard.
399fn kind_of(keyword: &[u8]) -> MetadataKind {
400    KEYWORDS
401        .iter()
402        .find(|(name, _)| *name == keyword)
403        .map_or(MetadataKind::Other, |(_, kind)| *kind)
404}
405
406/// Split a chunk payload at its first NUL: the keyword, and everything after it.
407///
408/// A payload with no NUL at all is malformed, and is treated as all keyword and no text. It is
409/// being removed either way, so refusing the file over it would cost the user their strip to
410/// make a point about a chunk that is already going.
411fn split_keyword(data: &[u8]) -> (&[u8], &[u8]) {
412    match data.iter().position(|&b| b == 0) {
413        Some(at) => (
414            data.get(..at).unwrap_or_default(),
415            data.get(at.saturating_add(1)..).unwrap_or_default(),
416        ),
417        None => (data, &[]),
418    }
419}
420
421/// `tEXt`: an uncompressed keyword and its Latin-1 text.
422fn text(data: &[u8], location: &'static str, options: &InspectOptions) -> Decision {
423    let (keyword, value) = split_keyword(data);
424    if keyword == XMP_KEYWORD {
425        return Decision::drop_with(xmp::scan(value, "tEXt (XMP)", options));
426    }
427    Decision::drop_with(vec![
428        Finding::new(kind_of(keyword), location, as_u64(value.len()))
429            .with_field(xmp::name_of(keyword))
430            .with_value(options, || MetadataValue::Text(xmp::name_of(value))),
431    ])
432}
433
434/// `zTXt`: a keyword, a compression method, and compressed text.
435///
436/// The text is never inflated (ADR-0022). The keyword is uncompressed and says what the chunk
437/// is, which is what the report needs; the chunk goes whole regardless.
438fn compressed_text(data: &[u8], size: u64) -> Decision {
439    let (keyword, _) = split_keyword(data);
440    Decision::drop_with(vec![
441        Finding::new(kind_of(keyword), "zTXt", size).with_field(xmp::name_of(keyword)), // No `with_value`: the value is behind the compression, and the report says so by
442                                                                                        // naming the field rather than by pretending the value was not there.
443    ])
444}
445
446/// `iTXt`: a keyword, a compression flag and method, a language tag, a translated keyword, and
447/// UTF-8 text that is compressed only when the flag says so (§11.3.3.4).
448fn international_text(data: &[u8], size: u64, options: &InspectOptions) -> Decision {
449    let (keyword, rest) = split_keyword(data);
450    let compressed = matches!(rest.first(), Some(1));
451    // Compression flag, compression method, then two NUL-terminated strings.
452    let after_flags = rest.get(2..).unwrap_or_default();
453    let (_language, rest) = split_keyword(after_flags);
454    let (_translated, value) = split_keyword(rest);
455
456    if keyword == XMP_KEYWORD {
457        if compressed {
458            // The packet is found and removed; only the itemisation is lost. Named the way the
459            // PDF handler names a `FlateDecode`d metadata stream, so the two read alike.
460            return Decision::drop_with(vec![
461                Finding::new(MetadataKind::Other, "iTXt (XMP)", size)
462                    .with_field("Metadata (compressed)"),
463            ]);
464        }
465        return Decision::drop_with(xmp::scan(value, "iTXt (XMP)", options));
466    }
467
468    let finding = Finding::new(kind_of(keyword), "iTXt", size).with_field(xmp::name_of(keyword));
469    Decision::drop_with(vec![if compressed {
470        finding
471    } else {
472        finding.with_value(options, || MetadataValue::Text(xmp::name_of(value)))
473    }])
474}
475
476/// `tIME`: the moment the image was last changed, to the second (§11.3.5.1).
477fn time(data: &[u8], size: u64, options: &InspectOptions) -> Decision {
478    let mut r = Reader::new(data);
479    let stamp = (|| {
480        let year = r.u16_be()?;
481        let (month, day) = (r.u8()?, r.u8()?);
482        let (hour, minute, second) = (r.u8()?, r.u8()?, r.u8()?);
483        Some(format!(
484            "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
485        ))
486    })();
487    Decision::drop_with(vec![
488        Finding::new(MetadataKind::Timestamp, "tIME", size)
489            .with_field("tIME")
490            .with_value(options, || match stamp {
491                Some(text) => MetadataValue::Text(text),
492                None => MetadataValue::Opaque { bytes: size },
493            }),
494    ])
495}
496
497/// `eXIf`: a raw TIFF block, byte-order mark first, with no `Exif\0\0` introducer.
498fn exif_chunk(data: &[u8], size: u64, options: &InspectOptions, limits: &ParseLimits) -> Decision {
499    let scanned = exif::scan(data, "eXIf", options, limits);
500    let findings = if scanned.findings.is_empty() {
501        // An Exif block that named nothing is still an Exif block, and it is still going.
502        vec![Finding::new(MetadataKind::Other, "eXIf", size)]
503    } else {
504        scanned.findings
505    };
506    Decision {
507        outcome: Outcome::Drop,
508        findings,
509        retained: Vec::new(),
510        notes: scanned.notes,
511    }
512}
513
514/// `iCCP`: an embedded ICC colour profile, named in the clear and compressed after that.
515fn icc_profile(data: &[u8], size: u64) -> Decision {
516    let (name, _) = split_keyword(data);
517    // The profile name is the identifying part and it is not compressed: a per-device profile
518    // is a fingerprint, and its name routinely carries the vendor or the model.
519    Decision::drop_with(vec![
520        Finding::new(MetadataKind::ColourProfile, "iCCP", size).with_field(xmp::name_of(name)),
521    ])
522}
523
524/// A malformed-file error for this format.
525fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
526    StryptError::Malformed {
527        format: Format::Png,
528        offset,
529        detail,
530    }
531}
532
533/// A byte position as a reportable offset.
534fn as_offset(position: usize) -> Option<u64> {
535    u64::try_from(position).ok()
536}
537
538/// Widen a length for reporting. Saturating: a report field is not worth failing a strip over.
539fn as_u64(value: usize) -> u64 {
540    u64::try_from(value).unwrap_or(u64::MAX)
541}
542
543#[cfg(test)]
544mod tests {
545    // Test code is never reachable from untrusted bytes, which is the boundary the
546    // panic-freedom lints exist to police (ADR-0006).
547    #![allow(
548        clippy::unwrap_used,
549        clippy::expect_used,
550        clippy::indexing_slicing,
551        clippy::arithmetic_side_effects
552    )]
553
554    use super::*;
555
556    /// The CRC-32 of a chunk's type and data, as §5.5 defines it.
557    ///
558    /// Present only in the tests: the handler never needs one, because it never alters a chunk
559    /// it keeps. Written the slow bitwise way — this is test scaffolding, not a hot path.
560    fn crc32(bytes: &[u8]) -> u32 {
561        let mut crc = 0xFFFF_FFFFu32;
562        for byte in bytes {
563            crc ^= u32::from(*byte);
564            for _ in 0..8 {
565                crc = if crc & 1 == 1 {
566                    (crc >> 1) ^ 0xEDB8_8320
567                } else {
568                    crc >> 1
569                };
570            }
571        }
572        crc ^ 0xFFFF_FFFF
573    }
574
575    fn chunk(kind: [u8; 4], data: &[u8]) -> Vec<u8> {
576        let mut out = u32::try_from(data.len()).unwrap().to_be_bytes().to_vec();
577        let mut body = kind.to_vec();
578        body.extend_from_slice(data);
579        out.extend_from_slice(&body);
580        out.extend_from_slice(&crc32(&body).to_be_bytes());
581        out
582    }
583
584    /// A minimal but structurally real PNG: signature, IHDR, the given chunks, IDAT, IEND.
585    fn png(extra: &[Vec<u8>]) -> Vec<u8> {
586        let mut ihdr = 1u32.to_be_bytes().to_vec();
587        ihdr.extend_from_slice(&1u32.to_be_bytes());
588        ihdr.extend_from_slice(&[8, 0, 0, 0, 0]);
589
590        let mut out = SIGNATURE.to_vec();
591        out.extend_from_slice(&chunk(*b"IHDR", &ihdr));
592        for c in extra {
593            out.extend_from_slice(c);
594        }
595        out.extend_from_slice(&chunk(*b"IDAT", b"SYNTHETIC-PIXELS"));
596        out.extend_from_slice(&chunk(*b"IEND", b""));
597        out
598    }
599
600    fn text_chunk(kind: [u8; 4], keyword: &str, value: &[u8]) -> Vec<u8> {
601        let mut data = keyword.as_bytes().to_vec();
602        data.push(0);
603        data.extend_from_slice(value);
604        chunk(kind, &data)
605    }
606
607    fn strip_ok(data: &[u8]) -> Stripped {
608        PngHandler
609            .strip(data, &StripOptions::default())
610            .expect("strip failed")
611    }
612
613    fn findings(data: &[u8]) -> Vec<Finding> {
614        PngHandler
615            .inspect(data, &InspectOptions::names_only())
616            .expect("inspect failed")
617            .findings
618    }
619
620    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
621        haystack.windows(needle.len()).any(|w| w == needle)
622    }
623
624    #[test]
625    fn the_picture_is_never_touched() {
626        let input = png(&[text_chunk(*b"tEXt", "Author", b"SYNTHETIC-AUTHOR")]);
627        let output = strip_ok(&input).bytes;
628        assert!(
629            contains(&output, b"SYNTHETIC-PIXELS"),
630            "the image data did not survive byte for byte"
631        );
632    }
633
634    #[test]
635    fn a_clean_file_strips_to_a_byte_identical_copy() {
636        // Stronger than "no findings": kept chunks are copied raw, so nothing is re-serialised
637        // and there is no opportunity for a rewrite to change a file that had nothing wrong.
638        let input = png(&[]);
639        let stripped = strip_ok(&input);
640        assert!(stripped.report.removed.is_empty());
641        assert_eq!(stripped.bytes, input);
642    }
643
644    #[test]
645    fn text_chunks_are_reported_by_keyword_and_removed() {
646        let input = png(&[
647            text_chunk(*b"tEXt", "Author", b"SYNTHETIC-AUTHOR-0001"),
648            text_chunk(*b"tEXt", "Software", b"SYNTHETIC-SOFTWARE-0002"),
649        ]);
650        let found = findings(&input);
651        assert_eq!(found[0].field.as_deref(), Some("Author"));
652        assert_eq!(found[0].kind, MetadataKind::PersonalIdentity);
653        assert_eq!(found[1].kind, MetadataKind::SoftwareFingerprint);
654
655        let output = strip_ok(&input).bytes;
656        assert!(!contains(&output, b"SYNTHETIC-AUTHOR-0001"));
657        assert!(findings(&output).is_empty());
658    }
659
660    #[test]
661    fn a_thumbnailers_source_path_is_reported_as_identifying() {
662        // `Thumb::URI` holds the full path of the original file, so it names a home directory,
663        // and a home directory names a person. Ranking it as "other text" would bury it.
664        let input = png(&[text_chunk(
665            *b"tEXt",
666            "Thumb::URI",
667            b"file:///home/SYNTHETIC-USER-0003/photo.png",
668        )]);
669        let found = findings(&input);
670        assert_eq!(found[0].kind, MetadataKind::PersonalIdentity);
671        assert!(!contains(&strip_ok(&input).bytes, b"SYNTHETIC-USER-0003"));
672    }
673
674    #[test]
675    fn compressed_text_is_removed_without_being_inflated() {
676        // The point of ADR-0022: the keyword is readable, the chunk goes, and no decompressor
677        // is involved anywhere in reaching that outcome.
678        let mut data = b"Comment".to_vec();
679        data.push(0);
680        data.push(0); // compression method: zlib
681        data.extend_from_slice(&[0x78, 0x9C, 0xFF, 0xFF, 0xFF, 0xFF]);
682        let input = png(&[chunk(*b"zTXt", &data)]);
683
684        let found = findings(&input);
685        assert_eq!(found[0].field.as_deref(), Some("Comment"));
686        assert_eq!(found[0].value, None);
687        assert!(!contains(&strip_ok(&input).bytes, b"zTXt"));
688    }
689
690    #[test]
691    fn an_uncompressed_xmp_packet_is_itemised_and_a_compressed_one_is_not() {
692        let packet = b"<x:xmpmeta><dc:creator>SYNTHETIC-XMP-0004</dc:creator></x:xmpmeta>";
693        let mut uncompressed = b"XML:com.adobe.xmp".to_vec();
694        uncompressed.extend_from_slice(&[0, 0, 0, 0, 0]); // flag 0, method, empty tags
695        uncompressed.extend_from_slice(packet);
696        let itemised = findings(&png(&[chunk(*b"iTXt", &uncompressed)]));
697        assert_eq!(itemised[0].field.as_deref(), Some("dc:creator"));
698        assert_eq!(itemised[0].kind, MetadataKind::PersonalIdentity);
699
700        let mut compressed = b"XML:com.adobe.xmp".to_vec();
701        compressed.extend_from_slice(&[0, 1, 0, 0, 0]); // flag 1: the text is deflated
702        compressed.extend_from_slice(&[0x78, 0x9C, 0x01]);
703        let lumped = findings(&png(&[chunk(*b"iTXt", &compressed)]));
704        assert_eq!(lumped.len(), 1);
705        assert_eq!(lumped[0].field.as_deref(), Some("Metadata (compressed)"));
706    }
707
708    #[test]
709    fn rendering_chunks_stay_and_the_physical_size_is_declared() {
710        // A handler that dropped every ancillary chunk would break transparency and colour.
711        let input = png(&[
712            chunk(*b"gAMA", &45455u32.to_be_bytes()),
713            chunk(*b"tRNS", &[0, 0, 0]),
714            chunk(*b"pHYs", &[0, 0, 0x0B, 0x13, 0, 0, 0x0B, 0x13, 1]),
715        ]);
716        let stripped = strip_ok(&input);
717        assert!(contains(&stripped.bytes, b"gAMA"));
718        assert!(contains(&stripped.bytes, b"tRNS"));
719        assert!(contains(&stripped.bytes, b"pHYs"));
720        assert_eq!(stripped.report.retained.len(), 1);
721        assert_eq!(stripped.report.retained[0].location, "pHYs");
722    }
723
724    #[test]
725    fn an_unknown_ancillary_chunk_goes_and_an_unknown_critical_one_is_declared() {
726        let input = png(&[
727            chunk(*b"prVW", b"SYNTHETIC-PREVIEW-0005"),
728            chunk(*b"VeND", b"SYNTHETIC-CRITICAL-0006"),
729        ]);
730        let stripped = strip_ok(&input);
731        assert!(!contains(&stripped.bytes, b"SYNTHETIC-PREVIEW-0005"));
732        assert!(
733            contains(&stripped.bytes, b"SYNTHETIC-CRITICAL-0006"),
734            "an unknown critical chunk must be copied through, not decided about"
735        );
736        assert!(matches!(
737            stripped.report.notes.first(),
738            Some(Note::UnparsedRegion { location, .. }) if location == "VeND"
739        ));
740    }
741
742    #[test]
743    fn data_hidden_after_the_end_chunk_is_removed() {
744        let mut input = png(&[]);
745        input.extend_from_slice(b"SYNTHETIC-APPENDED-0007");
746        let stripped = strip_ok(&input);
747        assert!(!contains(&stripped.bytes, b"SYNTHETIC-APPENDED-0007"));
748        assert_eq!(
749            stripped.report.removed[0].location,
750            "trailing data after IEND"
751        );
752    }
753
754    #[test]
755    fn a_second_image_after_the_end_chunk_is_reported_as_a_thumbnail() {
756        let mut input = png(&[]);
757        input.extend_from_slice(&png(&[]));
758        assert_eq!(
759            strip_ok(&input).report.removed[0].kind,
760            MetadataKind::Thumbnail
761        );
762    }
763
764    #[test]
765    fn the_time_chunk_is_removed_and_its_value_withheld_by_default() {
766        let input = png(&[chunk(*b"tIME", &[0x07, 0xEA, 8, 19, 12, 30, 45])]);
767        let named = findings(&input);
768        assert_eq!(named[0].kind, MetadataKind::Timestamp);
769        assert_eq!(
770            named[0].value, None,
771            "a default inspection withholds values"
772        );
773
774        let with_values = PngHandler
775            .inspect(&input, &InspectOptions::with_values())
776            .unwrap();
777        assert_eq!(
778            with_values.findings[0].value,
779            Some(MetadataValue::Text("2026-08-19T12:30:45Z".to_owned()))
780        );
781    }
782
783    #[test]
784    fn the_exif_chunk_goes_through_the_shared_reader() {
785        let mut tiff = b"II\x2A\x00\x08\x00\x00\x00".to_vec();
786        tiff.extend_from_slice(&1u16.to_le_bytes());
787        tiff.extend_from_slice(&0x010Fu16.to_le_bytes()); // Make
788        tiff.extend_from_slice(&2u16.to_le_bytes()); // ASCII
789        tiff.extend_from_slice(&4u32.to_le_bytes());
790        tiff.extend_from_slice(b"ACME");
791        tiff.extend_from_slice(&0u32.to_le_bytes());
792        let input = png(&[chunk(*b"eXIf", &tiff)]);
793
794        let found = findings(&input);
795        assert_eq!(found[0].field.as_deref(), Some("Make"));
796        assert!(!contains(&strip_ok(&input).bytes, b"ACME"));
797    }
798
799    #[test]
800    fn stripping_twice_changes_nothing() {
801        let input = png(&[
802            text_chunk(*b"tEXt", "Author", b"SYNTHETIC-AUTHOR-0001"),
803            chunk(*b"tIME", &[0x07, 0xEA, 8, 19, 12, 30, 45]),
804        ]);
805        let once = strip_ok(&input).bytes;
806        let twice = strip_ok(&once).bytes;
807        assert_eq!(once, twice, "strip is not idempotent");
808    }
809
810    #[test]
811    fn a_file_without_an_end_chunk_is_refused() {
812        // Fail closed. Completing a damaged file would hand the user something that is not
813        // what they gave us, presented as a clean version of it.
814        let input = png(&[]);
815        let truncated = &input[0..input.len() - 12];
816        assert!(matches!(
817            PngHandler.strip(truncated, &StripOptions::default()),
818            Err(StryptError::Malformed { .. })
819        ));
820    }
821
822    #[test]
823    fn a_file_that_does_not_begin_with_the_header_chunk_is_refused() {
824        let mut input = SIGNATURE.to_vec();
825        input.extend_from_slice(&text_chunk(*b"tEXt", "Author", b"first"));
826        input.extend_from_slice(&chunk(*b"IEND", b""));
827        assert!(matches!(
828            PngHandler.inspect(&input, &InspectOptions::names_only()),
829            Err(StryptError::Malformed {
830                detail: MalformedDetail::MissingMarker,
831                ..
832            })
833        ));
834    }
835
836    #[test]
837    fn a_length_beyond_the_end_of_the_file_is_refused_rather_than_clamped() {
838        let mut input = png(&[]);
839        // Overwrite the IHDR length with one that runs past the end of the file.
840        input[8..12].copy_from_slice(&0x7FFF_0000u32.to_be_bytes());
841        assert!(matches!(
842            PngHandler.inspect(&input, &InspectOptions::names_only()),
843            Err(StryptError::Malformed {
844                detail: MalformedDetail::LengthOutOfRange,
845                ..
846            })
847        ));
848    }
849
850    #[test]
851    fn a_length_with_the_high_bit_set_is_refused() {
852        // §5.3 caps a chunk at 2³¹−1, so this is a lying length field, not a large chunk.
853        let mut input = png(&[]);
854        input[8..12].copy_from_slice(&0xFFFF_FFFFu32.to_be_bytes());
855        assert!(matches!(
856            PngHandler.inspect(&input, &InspectOptions::names_only()),
857            Err(StryptError::Malformed {
858                detail: MalformedDetail::LengthOutOfRange,
859                ..
860            })
861        ));
862    }
863
864    #[test]
865    fn a_chunk_type_that_is_not_letters_is_refused() {
866        let input = png(&[chunk(*b"\x00\x01\x02\x03", b"")]);
867        assert!(matches!(
868            PngHandler.inspect(&input, &InspectOptions::names_only()),
869            Err(StryptError::Malformed {
870                detail: MalformedDetail::UnexpectedMarker,
871                ..
872            })
873        ));
874    }
875
876    #[test]
877    fn truncation_at_every_length_is_refused_or_survived_but_never_panics() {
878        let input = png(&[
879            text_chunk(*b"tEXt", "Author", b"SYNTHETIC-AUTHOR-0001"),
880            chunk(*b"tIME", &[0x07, 0xEA, 8, 19, 12, 30, 45]),
881        ]);
882        for n in 0..=input.len() {
883            let prefix = &input[0..n];
884            let _ = PngHandler.inspect(prefix, &InspectOptions::names_only());
885            let _ = PngHandler.strip(prefix, &StripOptions::default());
886        }
887    }
888
889    #[test]
890    fn a_chunk_count_beyond_the_limit_is_refused() {
891        let extra: Vec<Vec<u8>> = (0..64)
892            .map(|_| text_chunk(*b"tEXt", "Comment", b"x"))
893            .collect();
894        let input = png(&extra);
895        let options = StripOptions {
896            limits: ParseLimits {
897                max_items: 8,
898                ..ParseLimits::default()
899            },
900            ..StripOptions::default()
901        };
902        assert!(matches!(
903            PngHandler.strip(&input, &options),
904            Err(StryptError::LimitExceeded { .. })
905        ));
906    }
907
908    #[test]
909    fn a_text_chunk_with_no_null_separator_is_removed_rather_than_refused() {
910        // Malformed, and already going. Refusing the whole file over it would cost the user
911        // their strip to make a point about a chunk that is on its way out.
912        let input = png(&[chunk(*b"tEXt", b"SYNTHETIC-NO-SEPARATOR-0008")]);
913        let stripped = strip_ok(&input);
914        assert!(!contains(&stripped.bytes, b"SYNTHETIC-NO-SEPARATOR-0008"));
915    }
916}