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}