Skip to main content

stenoxide_core/image_io/
envelope.rs

1//! The file envelope of a container: what the wrapper says, as opposed to what
2//! the pixels say.
3//!
4//! # Why the wrapper is part of the problem
5//!
6//! Everything else in this crate works to make the *samples* of a stego image
7//! inseparable from those of a cover. None of it touches the file those samples
8//! are packed into, and that file talks. A PNG is a signature followed by a
9//! chain of chunks — four bytes of length, four of type, the data, four of
10//! CRC — of which only `IDAT` carries pixels. The rest describe how to read
11//! them, or describe where they came from, or are simply absent; and which of
12//! the three is the case is itself a signature of the program that wrote the
13//! file.
14//!
15//! Two properties give a re-encoded container away without any steganalysis at
16//! all:
17//!
18//! - **The auxiliary chunks that are missing.** A PNG exported by an editor
19//!   almost always carries `gAMA`, `sRGB` or `pHYs`, often an `iCCP` profile and
20//!   some text. A file that carries none of them is already unusual.
21//! - **How the pixel stream is cut up.** `IDAT` may be split into as many chunks
22//!   as the encoder likes, and libpng — which sits under most of the world's
23//!   photographic software — emits 8192-byte chunks. An encoder that writes the
24//!   whole image as a single `IDAT` produces a file that can be told apart from
25//!   almost everything else with a hex viewer.
26//!
27//! A [`PngEnvelope`] is what a container's own wrapper looked like, kept
28//! alongside its samples so that the file written back out can be shaped like
29//! the file that was read.
30//!
31//! # What is copied, and what is never copied
32//!
33//! The rule is a whitelist, and it is not configurable:
34//!
35//! - **Copied**: the chunks that say how to interpret the samples — `gAMA`,
36//!   `sRGB`, `cHRM`, `pHYs`, `iCCP`, `sBIT`. None of them names a person, a
37//!   place, a camera or a program.
38//! - **Never copied**: everything that carries provenance or personal data —
39//!   `eXIf`, `tEXt`, `iTXt`, `zTXt`, `tIME`. There is no flag that turns this
40//!   on, because the stego image is the file that gets sent to somebody else,
41//!   and the coordinates of the photographer's house have no business travelling
42//!   with it.
43//! - **Anything else is dropped.** An unrecognised chunk is not copied, so a
44//!   chunk type that did not exist when this was written cannot smuggle anything
45//!   out by default.
46//!
47//! That leaves an observable residue: an export whose text chunk has gone
48//! missing is not quite an ordinary export. It is a trade accepted deliberately,
49//! and it is documented in the README rather than hidden.
50//!
51//! # Reading is best effort, and never a gate
52//!
53//! Parsing happens on files supplied by whoever sent them, including on the
54//! extraction path where the image may be hostile. Nothing here allocates on the
55//! strength of a declared length: a chunk is only copied once its bytes have
56//! been seen to exist, and one that is too large to be plausible is skipped
57//! rather than reserved for.
58//!
59//! Failure is silent by construction. A file this module cannot make sense of
60//! yields an empty envelope, never an error: the decoder is the authority on
61//! whether a PNG is valid, and a second opinion here could only make a container
62//! that used to be accepted stop being accepted.
63
64use std::collections::HashMap;
65
66/// Bytes of the PNG signature that precede the first chunk.
67pub(crate) const SIGNATURE_LEN: usize = 8;
68
69/// Bytes of a chunk header: the big-endian length, then the four type bytes.
70const CHUNK_HEADER_LEN: usize = 8;
71
72/// Bytes of the CRC that closes every chunk.
73const CHUNK_CRC_LEN: usize = 4;
74
75/// Largest technical chunk that is worth copying, in bytes.
76///
77/// Only `iCCP` can plausibly be large, and a colour profile runs to a few
78/// kilobytes — the biggest ones published are a couple of megabytes. Four
79/// mebibytes is well past every real profile and far short of what a file
80/// crafted to make this crate hold a large buffer would declare. A chunk beyond
81/// the limit is skipped, which costs fidelity on a file that was already
82/// anomalous.
83const MAX_PRESERVED_CHUNK_BYTES: usize = 4 * 1024 * 1024;
84
85/// Smallest `IDAT` split this module will reproduce, in bytes.
86///
87/// A file whose pixel stream is cut into fragments smaller than this is either
88/// broken or built to make the encoder emit hundreds of thousands of chunks. The
89/// default profile is used instead.
90const MIN_IDAT_CHUNK_SIZE: usize = 512;
91
92/// Largest `IDAT` split this module will reproduce, in bytes.
93///
94/// The encoder holds one chunk in memory at a time, so this figure is an
95/// allocation as much as it is a layout. Thirty-two mebibytes is four thousand
96/// times what libpng writes and above every encoder that splits at all; past it
97/// the shape being imitated is "one enormous `IDAT`", which is the anomaly this
98/// module exists to stop producing.
99const MAX_IDAT_CHUNK_SIZE: usize = 32 * 1024 * 1024;
100
101/// `IDAT` split of the default profile, in bytes.
102///
103/// libpng's own, and therefore the most common layout in existence: it is what
104/// sits under the photographic software most containers come out of.
105const DEFAULT_IDAT_CHUNK_SIZE: usize = 8192;
106
107/// A chunk that describes how to read the samples, and nothing else.
108///
109/// The whitelist as a closed type: a chunk that is not one of these has no
110/// representation here, so no code path can copy one by accident.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum TechnicalChunk {
113    /// `gAMA` — the display gamma the samples were encoded against.
114    Gamma,
115    /// `sRGB` — a declaration that the samples are sRGB, and with what intent.
116    Srgb,
117    /// `cHRM` — the chromaticities of the primaries and of the white point.
118    Chromaticities,
119    /// `pHYs` — the physical size of a pixel.
120    PhysicalDimensions,
121    /// `iCCP` — an embedded ICC colour profile.
122    ///
123    /// The one whitelisted chunk that can be large, and the one whose contents
124    /// are not a handful of integers. It is still a description of colour: a
125    /// profile identifies a device class or a working space, not an owner.
126    IccProfile,
127    /// `sBIT` — how many bits of each sample the original actually used.
128    ///
129    /// Copied verbatim like the rest. Worth knowing that a container declaring
130    /// fewer significant bits than its samples carry is a poor container for
131    /// this purpose, because the payload lives in exactly the bits such a chunk
132    /// claims are meaningless.
133    SignificantBits,
134}
135
136impl TechnicalChunk {
137    /// Every whitelisted chunk, in no significant order.
138    const ALL: [TechnicalChunk; 6] = [
139        TechnicalChunk::Gamma,
140        TechnicalChunk::Srgb,
141        TechnicalChunk::Chromaticities,
142        TechnicalChunk::PhysicalDimensions,
143        TechnicalChunk::IccProfile,
144        TechnicalChunk::SignificantBits,
145    ];
146
147    /// The four type bytes this chunk is written with.
148    pub fn type_code(self) -> [u8; 4] {
149        match self {
150            TechnicalChunk::Gamma => *b"gAMA",
151            TechnicalChunk::Srgb => *b"sRGB",
152            TechnicalChunk::Chromaticities => *b"cHRM",
153            TechnicalChunk::PhysicalDimensions => *b"pHYs",
154            TechnicalChunk::IccProfile => *b"iCCP",
155            TechnicalChunk::SignificantBits => *b"sBIT",
156        }
157    }
158
159    /// The chunk's name, for a report a person reads.
160    pub fn name(self) -> &'static str {
161        match self {
162            TechnicalChunk::Gamma => "gAMA",
163            TechnicalChunk::Srgb => "sRGB",
164            TechnicalChunk::Chromaticities => "cHRM",
165            TechnicalChunk::PhysicalDimensions => "pHYs",
166            TechnicalChunk::IccProfile => "iCCP",
167            TechnicalChunk::SignificantBits => "sBIT",
168        }
169    }
170
171    /// Recognises a chunk type, or refuses it.
172    ///
173    /// The only way a chunk becomes copyable. Everything not on the list —
174    /// `eXIf` and the text chunks included — returns `None` here and is dropped
175    /// by the caller.
176    fn from_type_code(code: [u8; 4]) -> Option<Self> {
177        TechnicalChunk::ALL
178            .into_iter()
179            .find(|candidate| candidate.type_code() == code)
180    }
181}
182
183/// One whitelisted chunk, with the bytes it carried.
184#[derive(Debug, Clone)]
185pub struct PreservedChunk {
186    /// Which chunk this is.
187    kind: TechnicalChunk,
188    /// Its payload, without the length, the type or the CRC.
189    data: Vec<u8>,
190}
191
192impl PreservedChunk {
193    /// Which chunk this is.
194    pub fn kind(&self) -> TechnicalChunk {
195        self.kind
196    }
197
198    /// Bytes of payload, CRC and header excluded.
199    pub fn len(&self) -> usize {
200        self.data.len()
201    }
202
203    /// Whether the chunk carries no payload at all.
204    pub fn is_empty(&self) -> bool {
205        self.data.is_empty()
206    }
207
208    /// The payload, for the encoder that writes it back out.
209    pub(crate) fn data(&self) -> &[u8] {
210        &self.data
211    }
212}
213
214/// The shape of the file a container arrived in.
215///
216/// Travels with the samples it was read from — see
217/// [`crate::image_io::buffer::ImageBuffer::envelope`] — so that the writing side
218/// can reproduce it without any intermediate layer having to carry it.
219#[derive(Debug, Clone)]
220pub struct PngEnvelope {
221    /// The whitelisted chunks, in the order the file listed them.
222    chunks: Vec<PreservedChunk>,
223    /// Bytes of compressed data per `IDAT` chunk.
224    idat_chunk_size: usize,
225    /// How many `IDAT` chunks the file was cut into.
226    idat_chunk_count: usize,
227    /// Ancillary chunks that were seen and will not be reproduced.
228    discarded_chunks: usize,
229}
230
231impl PngEnvelope {
232    /// The profile used for a container this crate drew itself.
233    ///
234    /// [`crate::generate`] has no original to copy from, so the wrapper is
235    /// chosen rather than observed: the `IDAT` split libpng uses, and the
236    /// technical chunks an ordinary export writes. The point is not to pass for
237    /// any particular program — it is that two generated containers should stop
238    /// sharing one peculiar signature that belongs to no other software.
239    ///
240    /// The values are the sRGB ones, which is what the samples are: an 8-bit
241    /// RGB texture with no colour management of its own.
242    pub(crate) fn synthesised() -> Self {
243        // Gamma 1/2.2, written as the PNG fixed-point value: the figure libpng
244        // pairs with an sRGB declaration.
245        let gamma = 45_455u32.to_be_bytes().to_vec();
246
247        // Rendering intent 0, perceptual. The value written by nearly every
248        // exporter that emits this chunk at all.
249        let srgb = vec![0u8];
250
251        // The sRGB primaries and white point, in the order the format fixes:
252        // white x/y, red x/y, green x/y, blue x/y, each scaled by 100000.
253        let chromaticities = [
254            31_270u32, 32_900, 64_000, 33_000, 30_000, 60_000, 15_000, 6_000,
255        ]
256        .iter()
257        .flat_map(|value| value.to_be_bytes())
258        .collect();
259
260        // 2835 pixels per metre on both axes, which is 72 dpi, with the unit
261        // byte set to metres.
262        let mut physical = Vec::with_capacity(9);
263        physical.extend_from_slice(&2835u32.to_be_bytes());
264        physical.extend_from_slice(&2835u32.to_be_bytes());
265        physical.push(1);
266
267        Self {
268            chunks: vec![
269                PreservedChunk {
270                    kind: TechnicalChunk::Gamma,
271                    data: gamma,
272                },
273                PreservedChunk {
274                    kind: TechnicalChunk::Chromaticities,
275                    data: chromaticities,
276                },
277                PreservedChunk {
278                    kind: TechnicalChunk::Srgb,
279                    data: srgb,
280                },
281                PreservedChunk {
282                    kind: TechnicalChunk::PhysicalDimensions,
283                    data: physical,
284                },
285            ],
286            idat_chunk_size: DEFAULT_IDAT_CHUNK_SIZE,
287            idat_chunk_count: 0,
288            discarded_chunks: 0,
289        }
290    }
291
292    /// Reads the envelope of a PNG file from its bytes.
293    ///
294    /// Best effort throughout: a truncated file, a chunk whose declared length
295    /// runs past the end of the buffer, or bytes that are not a PNG at all end
296    /// the walk and yield whatever was understood up to that point. Nothing here
297    /// can refuse a container — see the module documentation for why that
298    /// matters.
299    pub(crate) fn read(bytes: &[u8]) -> Self {
300        let mut envelope = Self {
301            chunks: Vec::new(),
302            idat_chunk_size: DEFAULT_IDAT_CHUNK_SIZE,
303            idat_chunk_count: 0,
304            discarded_chunks: 0,
305        };
306
307        let mut idat_lengths: Vec<usize> = Vec::new();
308        let mut offset = SIGNATURE_LEN;
309
310        while let Some((code, data, next)) = read_chunk(bytes, offset) {
311            offset = next;
312
313            match &code {
314                b"IEND" => break,
315                b"IDAT" => idat_lengths.push(data.len()),
316                _ => envelope.record(code, data, idat_lengths.is_empty()),
317            }
318        }
319
320        envelope.idat_chunk_count = idat_lengths.len();
321        if let Some(size) = dominant_length(&idat_lengths) {
322            if (MIN_IDAT_CHUNK_SIZE..=MAX_IDAT_CHUNK_SIZE).contains(&size) {
323                envelope.idat_chunk_size = size;
324            } else if size > MAX_IDAT_CHUNK_SIZE {
325                envelope.idat_chunk_size = MAX_IDAT_CHUNK_SIZE;
326            }
327        }
328
329        envelope
330    }
331
332    /// Files one chunk that is neither `IDAT` nor `IEND`.
333    ///
334    /// `before_pixels` says whether the chunk was found ahead of the first
335    /// `IDAT`. The whitelisted chunks all belong there, and one found in the
336    /// tail of a file is either a decoration this crate does not reproduce or a
337    /// malformed stream; either way it is counted and dropped.
338    fn record(&mut self, code: [u8; 4], data: &[u8], before_pixels: bool) {
339        let preservable = TechnicalChunk::from_type_code(code)
340            .filter(|_| before_pixels)
341            .filter(|_| data.len() <= MAX_PRESERVED_CHUNK_BYTES)
342            // The format allows one of each, and a file with two `gAMA` chunks
343            // is malformed. The first wins, so a duplicate cannot make this
344            // crate write a file stranger than the one it read.
345            .filter(|kind| !self.chunks.iter().any(|chunk| chunk.kind == *kind));
346
347        match preservable {
348            Some(kind) => self.chunks.push(PreservedChunk {
349                kind,
350                data: data.to_vec(),
351            }),
352            // Only ancillary chunks are counted as dropped. A critical one — a
353            // palette, say — is not something this crate chose to discard; it is
354            // something the layout it decoded to no longer has any use for.
355            None => {
356                if code[0].is_ascii_lowercase() {
357                    self.discarded_chunks += 1;
358                }
359            }
360        }
361    }
362
363    /// The whitelisted chunks, in the order they will be written.
364    pub fn preserved_chunks(&self) -> &[PreservedChunk] {
365        &self.chunks
366    }
367
368    /// Bytes of compressed pixel data per `IDAT` chunk.
369    pub fn idat_chunk_size(&self) -> usize {
370        self.idat_chunk_size
371    }
372
373    /// How many `IDAT` chunks the container was read from.
374    ///
375    /// Zero for an envelope that was chosen rather than observed, which is the
376    /// case for every container this crate draws itself.
377    pub fn idat_chunk_count(&self) -> usize {
378        self.idat_chunk_count
379    }
380
381    /// Ancillary chunks that were present and will not be reproduced.
382    ///
383    /// The size of the residue, in the only unit that can be counted without
384    /// keeping the chunks themselves: this is what a reader would notice missing
385    /// from the file, and most of it is the metadata that is dropped on purpose.
386    pub fn discarded_chunks(&self) -> usize {
387        self.discarded_chunks
388    }
389}
390
391/// Reads one chunk at `offset`, returning its type, payload and the offset of
392/// the next chunk.
393///
394/// Returns `None` at the end of the file and at the first byte that cannot be
395/// trusted: a header that does not fit, a length that no `usize` can hold, or a
396/// payload that runs past the end of the buffer. The declared length is checked
397/// against the bytes that actually exist *before* the payload is looked at, so a
398/// chunk claiming four gibibytes costs nothing but the comparison.
399///
400/// Readable inside the crate rather than private to this module: the writing
401/// side reads back the pixel stream it has just compressed, and one walk over a
402/// chunk chain is enough for both directions.
403pub(crate) fn read_chunk(bytes: &[u8], offset: usize) -> Option<([u8; 4], &[u8], usize)> {
404    let header = bytes.get(offset..offset.checked_add(CHUNK_HEADER_LEN)?)?;
405
406    let length = u32::from_be_bytes(header.get(..4)?.try_into().ok()?);
407    let code: [u8; 4] = header.get(4..)?.try_into().ok()?;
408
409    // The format caps a chunk at `i32::MAX`, and a 32-bit host caps it lower
410    // still. Either way the conversion is checked rather than assumed.
411    let length = usize::try_from(length).ok()?;
412
413    let start = offset.checked_add(CHUNK_HEADER_LEN)?;
414    let end = start.checked_add(length)?;
415    let next = end.checked_add(CHUNK_CRC_LEN)?;
416
417    // The CRC has to be there for the chunk to be complete, but it is not
418    // verified: the decoder is the authority on a damaged stream, and a second
419    // opinion here could only disagree with it.
420    if next > bytes.len() {
421        return None;
422    }
423
424    Some((code, bytes.get(start..end)?, next))
425}
426
427/// The length most `IDAT` chunks share.
428///
429/// An encoder that splits at all writes every chunk at its buffer size except
430/// the last, so the mode is the size that was configured. The first chunk is not
431/// a safe answer on its own — some encoders emit a slightly short one, because
432/// the zlib header goes in ahead of the pixel data — and neither is the maximum,
433/// which a single outsized chunk would decide by itself.
434///
435/// Ties go to the larger length, so that a file cut into exactly two chunks
436/// reports the full one rather than the remainder.
437fn dominant_length(lengths: &[usize]) -> Option<usize> {
438    let mut counts: HashMap<usize, usize> = HashMap::new();
439    for &length in lengths {
440        *counts.entry(length).or_insert(0) += 1;
441    }
442
443    counts
444        .into_iter()
445        .max_by_key(|&(length, count)| (count, length))
446        .map(|(length, _)| length)
447}
448
449#[cfg(test)]
450mod tests {
451    // The crate-wide bans on panicking helpers reach into `cfg(test)` code as
452    // well. A test that cannot panic cannot fail, so they are lifted here and
453    // only here.
454    #![allow(clippy::expect_used)]
455    #![allow(clippy::panic)]
456
457    use super::*;
458
459    /// Builds a PNG-shaped byte stream out of `(type, payload)` pairs.
460    ///
461    /// The CRC is filled with zeros: nothing in this module reads it, and a
462    /// fixture that had to compute one would be testing the CRC rather than the
463    /// walk.
464    fn file(chunks: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
465        let mut bytes = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
466
467        for (code, data) in chunks {
468            bytes.extend_from_slice(&(data.len() as u32).to_be_bytes());
469            bytes.extend_from_slice(*code);
470            bytes.extend_from_slice(data);
471            bytes.extend_from_slice(&[0, 0, 0, 0]);
472        }
473
474        bytes
475    }
476
477    /// An `IHDR` payload of the length the format fixes. Its contents do not
478    /// matter here: this module never reads them.
479    fn ihdr() -> (&'static [u8; 4], Vec<u8>) {
480        (b"IHDR", vec![0u8; 13])
481    }
482
483    /// The technical chunks are kept, in file order, and nothing else is.
484    #[test]
485    fn the_whitelist_is_copied_and_the_rest_is_dropped() {
486        let bytes = file(&[
487            ihdr(),
488            (b"sRGB", vec![0]),
489            (b"gAMA", 45_455u32.to_be_bytes().to_vec()),
490            (b"eXIf", vec![9; 400]),
491            (b"pHYs", vec![1; 9]),
492            (b"tEXt", b"Software\0Adobe".to_vec()),
493            (b"iTXt", vec![7; 20]),
494            (b"tIME", vec![0; 7]),
495            (b"zTXt", vec![3; 12]),
496            (b"IDAT", vec![0; 8192]),
497            (b"IDAT", vec![0; 100]),
498            (b"IEND", Vec::new()),
499        ]);
500
501        let envelope = PngEnvelope::read(&bytes);
502        let kinds: Vec<TechnicalChunk> = envelope
503            .preserved_chunks()
504            .iter()
505            .map(PreservedChunk::kind)
506            .collect();
507
508        assert_eq!(
509            kinds,
510            vec![
511                TechnicalChunk::Srgb,
512                TechnicalChunk::Gamma,
513                TechnicalChunk::PhysicalDimensions,
514            ]
515        );
516
517        // The payload travels with the chunk, byte for byte.
518        assert_eq!(
519            envelope.preserved_chunks()[1].data(),
520            &45_455u32.to_be_bytes()
521        );
522        assert_eq!(envelope.preserved_chunks()[0].len(), 1);
523        assert!(!envelope.preserved_chunks()[0].is_empty());
524
525        // Five ancillary chunks were seen and refused: eXIf, tEXt, iTXt, tIME
526        // and zTXt. IHDR, IDAT and IEND are critical and are not counted.
527        assert_eq!(envelope.discarded_chunks(), 5);
528        assert_eq!(envelope.idat_chunk_count(), 2);
529        assert_eq!(envelope.idat_chunk_size(), 8192);
530    }
531
532    /// Nothing that identifies a person or a program is representable at all.
533    #[test]
534    fn no_identifying_chunk_can_be_recognised() {
535        for code in [b"eXIf", b"tEXt", b"iTXt", b"zTXt", b"tIME"] {
536            assert_eq!(
537                TechnicalChunk::from_type_code(*code),
538                None,
539                "{} must never be copyable",
540                String::from_utf8_lossy(code)
541            );
542        }
543
544        for kind in TechnicalChunk::ALL {
545            assert_eq!(
546                TechnicalChunk::from_type_code(kind.type_code()),
547                Some(kind),
548                "{} must round-trip through its type code",
549                kind.name()
550            );
551            assert_eq!(kind.name().as_bytes(), kind.type_code());
552        }
553    }
554
555    /// A chunk found after the pixel data is not reproduced, whatever it is.
556    ///
557    /// The layout of the file `In the Spotlight` and many other exports: the
558    /// text chunk sits between the last `IDAT` and `IEND`.
559    #[test]
560    fn a_chunk_behind_the_pixels_is_not_copied() {
561        let bytes = file(&[
562            ihdr(),
563            (b"IDAT", vec![0; 4096]),
564            (b"pHYs", vec![1; 9]),
565            (b"iTXt", vec![2; 30]),
566            (b"IEND", Vec::new()),
567        ]);
568
569        let envelope = PngEnvelope::read(&bytes);
570
571        assert!(envelope.preserved_chunks().is_empty());
572        assert_eq!(envelope.discarded_chunks(), 2);
573    }
574
575    /// A repeated chunk is copied once, and the first occurrence is the one.
576    #[test]
577    fn a_duplicated_chunk_is_written_once() {
578        let bytes = file(&[
579            ihdr(),
580            (b"gAMA", vec![1, 1, 1, 1]),
581            (b"gAMA", vec![2, 2, 2, 2]),
582            (b"IDAT", vec![0; 512]),
583            (b"IEND", Vec::new()),
584        ]);
585
586        let envelope = PngEnvelope::read(&bytes);
587
588        assert_eq!(envelope.preserved_chunks().len(), 1);
589        assert_eq!(envelope.preserved_chunks()[0].data(), &[1, 1, 1, 1]);
590        assert_eq!(envelope.discarded_chunks(), 1);
591    }
592
593    /// A colour profile larger than any real one is skipped rather than held.
594    #[test]
595    fn an_implausible_profile_is_not_kept() {
596        let bytes = file(&[
597            ihdr(),
598            (b"iCCP", vec![0; MAX_PRESERVED_CHUNK_BYTES + 1]),
599            (b"IDAT", vec![0; 1024]),
600            (b"IEND", Vec::new()),
601        ]);
602
603        let envelope = PngEnvelope::read(&bytes);
604
605        assert!(envelope.preserved_chunks().is_empty());
606        assert_eq!(envelope.discarded_chunks(), 1);
607
608        // One byte smaller and it is a profile like any other.
609        let bytes = file(&[
610            ihdr(),
611            (b"iCCP", vec![0; MAX_PRESERVED_CHUNK_BYTES]),
612            (b"IDAT", vec![0; 1024]),
613            (b"IEND", Vec::new()),
614        ]);
615        assert_eq!(PngEnvelope::read(&bytes).preserved_chunks().len(), 1);
616    }
617
618    /// A declared length that runs past the end of the file reserves nothing.
619    ///
620    /// The case this parser exists to survive: on the extraction path the image
621    /// is supplied by whoever sent it, and a chunk claiming four gibibytes must
622    /// cost a comparison rather than an allocation.
623    #[test]
624    fn an_absurd_length_ends_the_walk() {
625        let mut bytes = file(&[ihdr()]);
626        bytes.extend_from_slice(&u32::MAX.to_be_bytes());
627        bytes.extend_from_slice(b"iCCP");
628        bytes.extend_from_slice(&[0; 16]);
629
630        let envelope = PngEnvelope::read(&bytes);
631
632        assert!(envelope.preserved_chunks().is_empty());
633        assert_eq!(envelope.idat_chunk_count(), 0);
634        assert_eq!(envelope.idat_chunk_size(), DEFAULT_IDAT_CHUNK_SIZE);
635    }
636
637    /// Bytes that are not a PNG, and a file that stops mid-chunk, both yield an
638    /// envelope rather than a failure.
639    #[test]
640    fn an_unreadable_file_yields_the_default_profile() {
641        for bytes in [
642            Vec::new(),
643            b"not a png at all".to_vec(),
644            vec![0x89, b'P', b'N', b'G'],
645            // A complete header promising a payload that is not there.
646            {
647                let mut truncated = file(&[ihdr(), (b"gAMA", vec![0; 4])]);
648                truncated.truncate(truncated.len() - 5);
649                truncated
650            },
651        ] {
652            let envelope = PngEnvelope::read(&bytes);
653
654            assert_eq!(envelope.idat_chunk_size(), DEFAULT_IDAT_CHUNK_SIZE);
655            assert_eq!(envelope.idat_chunk_count(), 0);
656        }
657    }
658
659    /// Everything behind `IEND` is outside the file.
660    #[test]
661    fn trailing_bytes_after_the_end_marker_are_ignored() {
662        let bytes = file(&[
663            ihdr(),
664            (b"gAMA", vec![0; 4]),
665            (b"IDAT", vec![0; 1024]),
666            (b"IEND", Vec::new()),
667            (b"pHYs", vec![1; 9]),
668            (b"IDAT", vec![0; 1024]),
669        ]);
670
671        let envelope = PngEnvelope::read(&bytes);
672
673        assert_eq!(envelope.preserved_chunks().len(), 1);
674        assert_eq!(envelope.idat_chunk_count(), 1);
675    }
676
677    /// The split is the length most chunks share, not the first and not the
678    /// largest.
679    #[test]
680    fn the_idat_split_is_the_length_the_chunks_agree_on() {
681        // The layout of a real export: a short first chunk, a long run at the
682        // configured size, and a remainder at the end.
683        let mut chunks = vec![ihdr(), (b"IDAT", vec![0; 65_445])];
684        chunks.extend((0..4).map(|_| (b"IDAT", vec![0; 65_524])));
685        chunks.push((b"IDAT", vec![0; 62_588]));
686        chunks.push((b"IEND", Vec::new()));
687
688        let envelope = PngEnvelope::read(&file(&chunks));
689
690        assert_eq!(envelope.idat_chunk_size(), 65_524);
691        assert_eq!(envelope.idat_chunk_count(), 6);
692
693        // Two chunks, one full and one remainder: the tie goes to the full one.
694        assert_eq!(dominant_length(&[8192, 300]), Some(8192));
695        assert_eq!(dominant_length(&[]), None);
696    }
697
698    /// Splits outside the range this crate will reproduce fall back or clamp.
699    #[test]
700    fn an_unreproducible_split_is_bounded() {
701        let tiny = file(&[
702            ihdr(),
703            (b"IDAT", vec![0; 4]),
704            (b"IDAT", vec![0; 4]),
705            (b"IEND", Vec::new()),
706        ]);
707        assert_eq!(
708            PngEnvelope::read(&tiny).idat_chunk_size(),
709            DEFAULT_IDAT_CHUNK_SIZE
710        );
711
712        // A single enormous `IDAT` is the shape this module exists to stop
713        // producing, so it is capped rather than repeated.
714        let mut huge = file(&[ihdr()]);
715        huge.extend_from_slice(&((MAX_IDAT_CHUNK_SIZE + 1) as u32).to_be_bytes());
716        huge.extend_from_slice(b"IDAT");
717        huge.resize(huge.len() + MAX_IDAT_CHUNK_SIZE + 1 + CHUNK_CRC_LEN, 0);
718
719        let envelope = PngEnvelope::read(&huge);
720        assert_eq!(envelope.idat_chunk_count(), 1);
721        assert_eq!(envelope.idat_chunk_size(), MAX_IDAT_CHUNK_SIZE);
722    }
723
724    /// The chosen profile is the one a container drawn by this crate wears.
725    #[test]
726    fn the_default_profile_looks_like_an_ordinary_export() {
727        let envelope = PngEnvelope::synthesised();
728        let kinds: Vec<&str> = envelope
729            .preserved_chunks()
730            .iter()
731            .map(|chunk| chunk.kind().name())
732            .collect();
733
734        assert_eq!(kinds, vec!["gAMA", "cHRM", "sRGB", "pHYs"]);
735        assert_eq!(envelope.idat_chunk_size(), DEFAULT_IDAT_CHUNK_SIZE);
736        assert_eq!(envelope.idat_chunk_count(), 0);
737        assert_eq!(envelope.discarded_chunks(), 0);
738
739        // Each chunk is exactly as long as the format says it must be.
740        let lengths: Vec<usize> = envelope
741            .preserved_chunks()
742            .iter()
743            .map(PreservedChunk::len)
744            .collect();
745        assert_eq!(lengths, vec![4, 32, 1, 9]);
746    }
747}