1use 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#[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 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
95const SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
98
99const MAX_CHUNK_LENGTH: u32 = 0x7FFF_FFFF;
102
103struct Chunk<'a> {
105 kind: [u8; 4],
107 data: &'a [u8],
109 raw: &'a [u8],
112}
113
114impl Chunk<'_> {
115 const fn is_ancillary(&self) -> bool {
118 matches!(self.kind.first(), Some(b) if b.is_ascii_lowercase())
119 }
120}
121
122struct Processed {
124 findings: Vec<Finding>,
125 retained: Vec<Retained>,
126 notes: Vec<Note>,
127 output: Vec<u8>,
128}
129
130fn 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 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 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 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
210enum Outcome {
212 Keep,
214 Drop,
216}
217
218struct Decision {
220 outcome: Outcome,
221 findings: Vec<Finding>,
222 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 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
266fn 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 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
306fn decide(chunk: &Chunk<'_>, options: &InspectOptions, limits: &ParseLimits) -> Decision {
308 let size = as_u64(chunk.data.len());
309 match &chunk.kind {
310 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 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 b"sPLT" => Decision::drop_one(MetadataKind::Other, "sPLT", size),
337 _ if chunk.is_ancillary() => {
338 Decision::drop_one(MetadataKind::Other, xmp::name_of(&chunk.kind), size)
341 }
342 _ => {
343 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
363const 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
394const XMP_KEYWORD: &[u8] = b"XML:com.adobe.xmp";
396
397fn kind_of(keyword: &[u8]) -> MetadataKind {
400 KEYWORDS
401 .iter()
402 .find(|(name, _)| *name == keyword)
403 .map_or(MetadataKind::Other, |(_, kind)| *kind)
404}
405
406fn 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
421fn 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
434fn 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)), ])
444}
445
446fn 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 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 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
476fn 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
497fn 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 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
514fn icc_profile(data: &[u8], size: u64) -> Decision {
516 let (name, _) = split_keyword(data);
517 Decision::drop_with(vec![
520 Finding::new(MetadataKind::ColourProfile, "iCCP", size).with_field(xmp::name_of(name)),
521 ])
522}
523
524fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
526 StryptError::Malformed {
527 format: Format::Png,
528 offset,
529 detail,
530 }
531}
532
533fn as_offset(position: usize) -> Option<u64> {
535 u64::try_from(position).ok()
536}
537
538fn as_u64(value: usize) -> u64 {
540 u64::try_from(value).unwrap_or(u64::MAX)
541}
542
543#[cfg(test)]
544mod tests {
545 #![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 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 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 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 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 let mut data = b"Comment".to_vec();
679 data.push(0);
680 data.push(0); 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]); 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]); 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 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()); tiff.extend_from_slice(&2u16.to_le_bytes()); 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 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 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 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 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}