use crate::bytes::{Reader, u32_to_usize};
use crate::detect::Format;
use crate::error::{MalformedDetail, ResourceLimit, Result, StryptError};
use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, exif, xmp};
use crate::report::{
Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, Retained,
RetentionReason, StripReport,
};
#[derive(Debug, Clone, Copy, Default)]
pub struct PngHandler;
impl MetadataHandler for PngHandler {
fn name(&self) -> &'static str {
Format::Png.id()
}
fn format(&self) -> Format {
Format::Png
}
fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
let processed = process(input, options, &ParseLimits::default())?;
Ok(MetadataReport {
format: Format::Png,
findings: processed.findings,
notes: processed.notes,
})
}
fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
let processed = process(input, &options.inspect, &options.limits)?;
Ok(Stripped {
report: StripReport {
format: Format::Png,
removed: processed.findings,
retained: processed.retained,
notes: processed.notes,
input_bytes: as_u64(input.len()),
output_bytes: as_u64(processed.output.len()),
},
bytes: processed.output,
})
}
}
const SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
const MAX_CHUNK_LENGTH: u32 = 0x7FFF_FFFF;
struct Chunk<'a> {
kind: [u8; 4],
data: &'a [u8],
raw: &'a [u8],
}
impl Chunk<'_> {
const fn is_ancillary(&self) -> bool {
matches!(self.kind.first(), Some(b) if b.is_ascii_lowercase())
}
}
struct Processed {
findings: Vec<Finding>,
retained: Vec<Retained>,
notes: Vec<Note>,
output: Vec<u8>,
}
fn walk<'a>(input: &'a [u8], limits: &ParseLimits) -> Result<(Vec<Chunk<'a>>, &'a [u8])> {
let mut r = Reader::new(input);
if r.take(SIGNATURE.len()) != Some(&SIGNATURE) {
return Err(malformed(MalformedDetail::MissingMarker, Some(0)));
}
let mut chunks: Vec<Chunk<'a>> = Vec::new();
let mut budget = limits.max_items;
loop {
if budget == 0 {
return Err(StryptError::LimitExceeded {
format: Format::Png,
limit: ResourceLimit::ItemCount,
});
}
budget = budget.saturating_sub(1);
let start = r.position();
if r.is_empty() {
return Err(malformed(MalformedDetail::Truncated, as_offset(start)));
}
let declared = r
.u32_be()
.ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
if declared > MAX_CHUNK_LENGTH {
return Err(malformed(
MalformedDetail::LengthOutOfRange,
as_offset(start),
));
}
let length = u32_to_usize(declared)
.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(start)))?;
let kind: [u8; 4] = r
.take(4)
.and_then(|k| k.try_into().ok())
.ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
if !kind.iter().all(u8::is_ascii_alphabetic) {
return Err(malformed(
MalformedDetail::UnexpectedMarker,
as_offset(start),
));
}
let data = r
.take(length)
.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(start)))?;
r.skip(4)
.ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
let raw = input.get(start..r.position()).unwrap_or_default();
if chunks.is_empty() && &kind != b"IHDR" {
return Err(malformed(MalformedDetail::MissingMarker, as_offset(start)));
}
chunks.push(Chunk { kind, data, raw });
if &kind == b"IEND" {
break;
}
}
Ok((chunks, r.take_rest()))
}
enum Outcome {
Keep,
Drop,
}
struct Decision {
outcome: Outcome,
findings: Vec<Finding>,
retained: Vec<Retained>,
notes: Vec<Note>,
}
impl Decision {
const fn keep() -> Self {
Self {
outcome: Outcome::Keep,
findings: Vec::new(),
retained: Vec::new(),
notes: Vec::new(),
}
}
fn kept_on_purpose(location: &'static str, reason: RetentionReason) -> Self {
Self {
outcome: Outcome::Keep,
findings: Vec::new(),
retained: vec![Retained {
location: location.to_owned(),
reason,
}],
notes: Vec::new(),
}
}
fn drop_with(findings: Vec<Finding>) -> Self {
Self {
outcome: Outcome::Drop,
findings,
retained: Vec::new(),
notes: Vec::new(),
}
}
fn drop_one(kind: MetadataKind, location: impl Into<String>, bytes: u64) -> Self {
Self::drop_with(vec![Finding::new(kind, location, bytes)])
}
}
fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
let (chunks, trailing) = walk(input, limits)?;
let mut out = Processed {
findings: Vec::new(),
retained: Vec::new(),
notes: Vec::new(),
output: Vec::with_capacity(input.len()),
};
out.output.extend_from_slice(&SIGNATURE);
for chunk in chunks {
let decision = decide(&chunk, options, limits);
out.notes.extend(decision.notes);
out.retained.extend(decision.retained);
match decision.outcome {
Outcome::Keep => out.output.extend_from_slice(chunk.raw),
Outcome::Drop => out.findings.extend(decision.findings),
}
}
if !trailing.is_empty() {
let kind = if trailing.starts_with(&SIGNATURE) {
MetadataKind::Thumbnail
} else {
MetadataKind::Other
};
out.findings.push(Finding::new(
kind,
"trailing data after IEND",
as_u64(trailing.len()),
));
}
Ok(out)
}
fn decide(chunk: &Chunk<'_>, options: &InspectOptions, limits: &ParseLimits) -> Decision {
let size = as_u64(chunk.data.len());
match &chunk.kind {
b"IHDR" | b"PLTE" | b"IDAT" | b"IEND" | b"tRNS" | b"gAMA" | b"cHRM" | b"sRGB" | b"sBIT"
| b"bKGD" | b"hIST" | b"cICP" | b"mDCV" | b"cLLI" | b"acTL" | b"fcTL" | b"fdAT" => {
Decision::keep()
}
b"pHYs" => Decision::kept_on_purpose("pHYs", RetentionReason::RemovalWouldAlterPayload),
b"tEXt" => text(chunk.data, "tEXt", options),
b"zTXt" => compressed_text(chunk.data, size),
b"iTXt" => international_text(chunk.data, size, options),
b"tIME" => time(chunk.data, size, options),
b"eXIf" => exif_chunk(chunk.data, size, options, limits),
b"iCCP" => icc_profile(chunk.data, size),
b"sPLT" => Decision::drop_one(MetadataKind::Other, "sPLT", size),
_ if chunk.is_ancillary() => {
Decision::drop_one(MetadataKind::Other, xmp::name_of(&chunk.kind), size)
}
_ => {
Decision {
outcome: Outcome::Keep,
findings: Vec::new(),
retained: Vec::new(),
notes: vec![Note::UnparsedRegion {
location: xmp::name_of(&chunk.kind),
bytes: size,
}],
}
}
}
}
const KEYWORDS: &[(&[u8], MetadataKind)] = &[
(b"Author", MetadataKind::PersonalIdentity),
(b"Copyright", MetadataKind::PersonalIdentity),
(b"Creation Time", MetadataKind::Timestamp),
(b"Software", MetadataKind::SoftwareFingerprint),
(b"Source", MetadataKind::DeviceIdentity),
(b"Title", MetadataKind::Comment),
(b"Description", MetadataKind::Comment),
(b"Comment", MetadataKind::Comment),
(b"Disclaimer", MetadataKind::Comment),
(b"Warning", MetadataKind::Comment),
(b"Raw profile type exif", MetadataKind::DeviceIdentity),
(b"Raw profile type APP1", MetadataKind::DeviceIdentity),
(b"Raw profile type iptc", MetadataKind::PersonalIdentity),
(b"Raw profile type 8bim", MetadataKind::SoftwareFingerprint),
(b"Raw profile type icc", MetadataKind::ColourProfile),
(b"Raw profile type xmp", MetadataKind::Other),
(b"Thumb::URI", MetadataKind::PersonalIdentity),
(b"Thumb::MTime", MetadataKind::Timestamp),
(b"date:create", MetadataKind::Timestamp),
(b"date:modify", MetadataKind::Timestamp),
(b"date:timestamp", MetadataKind::Timestamp),
];
const XMP_KEYWORD: &[u8] = b"XML:com.adobe.xmp";
fn kind_of(keyword: &[u8]) -> MetadataKind {
KEYWORDS
.iter()
.find(|(name, _)| *name == keyword)
.map_or(MetadataKind::Other, |(_, kind)| *kind)
}
fn split_keyword(data: &[u8]) -> (&[u8], &[u8]) {
match data.iter().position(|&b| b == 0) {
Some(at) => (
data.get(..at).unwrap_or_default(),
data.get(at.saturating_add(1)..).unwrap_or_default(),
),
None => (data, &[]),
}
}
fn text(data: &[u8], location: &'static str, options: &InspectOptions) -> Decision {
let (keyword, value) = split_keyword(data);
if keyword == XMP_KEYWORD {
return Decision::drop_with(xmp::scan(value, "tEXt (XMP)", options));
}
Decision::drop_with(vec![
Finding::new(kind_of(keyword), location, as_u64(value.len()))
.with_field(xmp::name_of(keyword))
.with_value(options, || MetadataValue::Text(xmp::name_of(value))),
])
}
fn compressed_text(data: &[u8], size: u64) -> Decision {
let (keyword, _) = split_keyword(data);
Decision::drop_with(vec![
Finding::new(kind_of(keyword), "zTXt", size).with_field(xmp::name_of(keyword)), ])
}
fn international_text(data: &[u8], size: u64, options: &InspectOptions) -> Decision {
let (keyword, rest) = split_keyword(data);
let compressed = matches!(rest.first(), Some(1));
let after_flags = rest.get(2..).unwrap_or_default();
let (_language, rest) = split_keyword(after_flags);
let (_translated, value) = split_keyword(rest);
if keyword == XMP_KEYWORD {
if compressed {
return Decision::drop_with(vec![
Finding::new(MetadataKind::Other, "iTXt (XMP)", size)
.with_field("Metadata (compressed)"),
]);
}
return Decision::drop_with(xmp::scan(value, "iTXt (XMP)", options));
}
let finding = Finding::new(kind_of(keyword), "iTXt", size).with_field(xmp::name_of(keyword));
Decision::drop_with(vec![if compressed {
finding
} else {
finding.with_value(options, || MetadataValue::Text(xmp::name_of(value)))
}])
}
fn time(data: &[u8], size: u64, options: &InspectOptions) -> Decision {
let mut r = Reader::new(data);
let stamp = (|| {
let year = r.u16_be()?;
let (month, day) = (r.u8()?, r.u8()?);
let (hour, minute, second) = (r.u8()?, r.u8()?, r.u8()?);
Some(format!(
"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
))
})();
Decision::drop_with(vec![
Finding::new(MetadataKind::Timestamp, "tIME", size)
.with_field("tIME")
.with_value(options, || match stamp {
Some(text) => MetadataValue::Text(text),
None => MetadataValue::Opaque { bytes: size },
}),
])
}
fn exif_chunk(data: &[u8], size: u64, options: &InspectOptions, limits: &ParseLimits) -> Decision {
let scanned = exif::scan(data, "eXIf", options, limits);
let findings = if scanned.findings.is_empty() {
vec![Finding::new(MetadataKind::Other, "eXIf", size)]
} else {
scanned.findings
};
Decision {
outcome: Outcome::Drop,
findings,
retained: Vec::new(),
notes: scanned.notes,
}
}
fn icc_profile(data: &[u8], size: u64) -> Decision {
let (name, _) = split_keyword(data);
Decision::drop_with(vec![
Finding::new(MetadataKind::ColourProfile, "iCCP", size).with_field(xmp::name_of(name)),
])
}
fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
StryptError::Malformed {
format: Format::Png,
offset,
detail,
}
}
fn as_offset(position: usize) -> Option<u64> {
u64::try_from(position).ok()
}
fn as_u64(value: usize) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::arithmetic_side_effects
)]
use super::*;
fn crc32(bytes: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for byte in bytes {
crc ^= u32::from(*byte);
for _ in 0..8 {
crc = if crc & 1 == 1 {
(crc >> 1) ^ 0xEDB8_8320
} else {
crc >> 1
};
}
}
crc ^ 0xFFFF_FFFF
}
fn chunk(kind: [u8; 4], data: &[u8]) -> Vec<u8> {
let mut out = u32::try_from(data.len()).unwrap().to_be_bytes().to_vec();
let mut body = kind.to_vec();
body.extend_from_slice(data);
out.extend_from_slice(&body);
out.extend_from_slice(&crc32(&body).to_be_bytes());
out
}
fn png(extra: &[Vec<u8>]) -> Vec<u8> {
let mut ihdr = 1u32.to_be_bytes().to_vec();
ihdr.extend_from_slice(&1u32.to_be_bytes());
ihdr.extend_from_slice(&[8, 0, 0, 0, 0]);
let mut out = SIGNATURE.to_vec();
out.extend_from_slice(&chunk(*b"IHDR", &ihdr));
for c in extra {
out.extend_from_slice(c);
}
out.extend_from_slice(&chunk(*b"IDAT", b"SYNTHETIC-PIXELS"));
out.extend_from_slice(&chunk(*b"IEND", b""));
out
}
fn text_chunk(kind: [u8; 4], keyword: &str, value: &[u8]) -> Vec<u8> {
let mut data = keyword.as_bytes().to_vec();
data.push(0);
data.extend_from_slice(value);
chunk(kind, &data)
}
fn strip_ok(data: &[u8]) -> Stripped {
PngHandler
.strip(data, &StripOptions::default())
.expect("strip failed")
}
fn findings(data: &[u8]) -> Vec<Finding> {
PngHandler
.inspect(data, &InspectOptions::names_only())
.expect("inspect failed")
.findings
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
#[test]
fn the_picture_is_never_touched() {
let input = png(&[text_chunk(*b"tEXt", "Author", b"SYNTHETIC-AUTHOR")]);
let output = strip_ok(&input).bytes;
assert!(
contains(&output, b"SYNTHETIC-PIXELS"),
"the image data did not survive byte for byte"
);
}
#[test]
fn a_clean_file_strips_to_a_byte_identical_copy() {
let input = png(&[]);
let stripped = strip_ok(&input);
assert!(stripped.report.removed.is_empty());
assert_eq!(stripped.bytes, input);
}
#[test]
fn text_chunks_are_reported_by_keyword_and_removed() {
let input = png(&[
text_chunk(*b"tEXt", "Author", b"SYNTHETIC-AUTHOR-0001"),
text_chunk(*b"tEXt", "Software", b"SYNTHETIC-SOFTWARE-0002"),
]);
let found = findings(&input);
assert_eq!(found[0].field.as_deref(), Some("Author"));
assert_eq!(found[0].kind, MetadataKind::PersonalIdentity);
assert_eq!(found[1].kind, MetadataKind::SoftwareFingerprint);
let output = strip_ok(&input).bytes;
assert!(!contains(&output, b"SYNTHETIC-AUTHOR-0001"));
assert!(findings(&output).is_empty());
}
#[test]
fn a_thumbnailers_source_path_is_reported_as_identifying() {
let input = png(&[text_chunk(
*b"tEXt",
"Thumb::URI",
b"file:///home/SYNTHETIC-USER-0003/photo.png",
)]);
let found = findings(&input);
assert_eq!(found[0].kind, MetadataKind::PersonalIdentity);
assert!(!contains(&strip_ok(&input).bytes, b"SYNTHETIC-USER-0003"));
}
#[test]
fn compressed_text_is_removed_without_being_inflated() {
let mut data = b"Comment".to_vec();
data.push(0);
data.push(0); data.extend_from_slice(&[0x78, 0x9C, 0xFF, 0xFF, 0xFF, 0xFF]);
let input = png(&[chunk(*b"zTXt", &data)]);
let found = findings(&input);
assert_eq!(found[0].field.as_deref(), Some("Comment"));
assert_eq!(found[0].value, None);
assert!(!contains(&strip_ok(&input).bytes, b"zTXt"));
}
#[test]
fn an_uncompressed_xmp_packet_is_itemised_and_a_compressed_one_is_not() {
let packet = b"<x:xmpmeta><dc:creator>SYNTHETIC-XMP-0004</dc:creator></x:xmpmeta>";
let mut uncompressed = b"XML:com.adobe.xmp".to_vec();
uncompressed.extend_from_slice(&[0, 0, 0, 0, 0]); uncompressed.extend_from_slice(packet);
let itemised = findings(&png(&[chunk(*b"iTXt", &uncompressed)]));
assert_eq!(itemised[0].field.as_deref(), Some("dc:creator"));
assert_eq!(itemised[0].kind, MetadataKind::PersonalIdentity);
let mut compressed = b"XML:com.adobe.xmp".to_vec();
compressed.extend_from_slice(&[0, 1, 0, 0, 0]); compressed.extend_from_slice(&[0x78, 0x9C, 0x01]);
let lumped = findings(&png(&[chunk(*b"iTXt", &compressed)]));
assert_eq!(lumped.len(), 1);
assert_eq!(lumped[0].field.as_deref(), Some("Metadata (compressed)"));
}
#[test]
fn rendering_chunks_stay_and_the_physical_size_is_declared() {
let input = png(&[
chunk(*b"gAMA", &45455u32.to_be_bytes()),
chunk(*b"tRNS", &[0, 0, 0]),
chunk(*b"pHYs", &[0, 0, 0x0B, 0x13, 0, 0, 0x0B, 0x13, 1]),
]);
let stripped = strip_ok(&input);
assert!(contains(&stripped.bytes, b"gAMA"));
assert!(contains(&stripped.bytes, b"tRNS"));
assert!(contains(&stripped.bytes, b"pHYs"));
assert_eq!(stripped.report.retained.len(), 1);
assert_eq!(stripped.report.retained[0].location, "pHYs");
}
#[test]
fn an_unknown_ancillary_chunk_goes_and_an_unknown_critical_one_is_declared() {
let input = png(&[
chunk(*b"prVW", b"SYNTHETIC-PREVIEW-0005"),
chunk(*b"VeND", b"SYNTHETIC-CRITICAL-0006"),
]);
let stripped = strip_ok(&input);
assert!(!contains(&stripped.bytes, b"SYNTHETIC-PREVIEW-0005"));
assert!(
contains(&stripped.bytes, b"SYNTHETIC-CRITICAL-0006"),
"an unknown critical chunk must be copied through, not decided about"
);
assert!(matches!(
stripped.report.notes.first(),
Some(Note::UnparsedRegion { location, .. }) if location == "VeND"
));
}
#[test]
fn data_hidden_after_the_end_chunk_is_removed() {
let mut input = png(&[]);
input.extend_from_slice(b"SYNTHETIC-APPENDED-0007");
let stripped = strip_ok(&input);
assert!(!contains(&stripped.bytes, b"SYNTHETIC-APPENDED-0007"));
assert_eq!(
stripped.report.removed[0].location,
"trailing data after IEND"
);
}
#[test]
fn a_second_image_after_the_end_chunk_is_reported_as_a_thumbnail() {
let mut input = png(&[]);
input.extend_from_slice(&png(&[]));
assert_eq!(
strip_ok(&input).report.removed[0].kind,
MetadataKind::Thumbnail
);
}
#[test]
fn the_time_chunk_is_removed_and_its_value_withheld_by_default() {
let input = png(&[chunk(*b"tIME", &[0x07, 0xEA, 8, 19, 12, 30, 45])]);
let named = findings(&input);
assert_eq!(named[0].kind, MetadataKind::Timestamp);
assert_eq!(
named[0].value, None,
"a default inspection withholds values"
);
let with_values = PngHandler
.inspect(&input, &InspectOptions::with_values())
.unwrap();
assert_eq!(
with_values.findings[0].value,
Some(MetadataValue::Text("2026-08-19T12:30:45Z".to_owned()))
);
}
#[test]
fn the_exif_chunk_goes_through_the_shared_reader() {
let mut tiff = b"II\x2A\x00\x08\x00\x00\x00".to_vec();
tiff.extend_from_slice(&1u16.to_le_bytes());
tiff.extend_from_slice(&0x010Fu16.to_le_bytes()); tiff.extend_from_slice(&2u16.to_le_bytes()); tiff.extend_from_slice(&4u32.to_le_bytes());
tiff.extend_from_slice(b"ACME");
tiff.extend_from_slice(&0u32.to_le_bytes());
let input = png(&[chunk(*b"eXIf", &tiff)]);
let found = findings(&input);
assert_eq!(found[0].field.as_deref(), Some("Make"));
assert!(!contains(&strip_ok(&input).bytes, b"ACME"));
}
#[test]
fn stripping_twice_changes_nothing() {
let input = png(&[
text_chunk(*b"tEXt", "Author", b"SYNTHETIC-AUTHOR-0001"),
chunk(*b"tIME", &[0x07, 0xEA, 8, 19, 12, 30, 45]),
]);
let once = strip_ok(&input).bytes;
let twice = strip_ok(&once).bytes;
assert_eq!(once, twice, "strip is not idempotent");
}
#[test]
fn a_file_without_an_end_chunk_is_refused() {
let input = png(&[]);
let truncated = &input[0..input.len() - 12];
assert!(matches!(
PngHandler.strip(truncated, &StripOptions::default()),
Err(StryptError::Malformed { .. })
));
}
#[test]
fn a_file_that_does_not_begin_with_the_header_chunk_is_refused() {
let mut input = SIGNATURE.to_vec();
input.extend_from_slice(&text_chunk(*b"tEXt", "Author", b"first"));
input.extend_from_slice(&chunk(*b"IEND", b""));
assert!(matches!(
PngHandler.inspect(&input, &InspectOptions::names_only()),
Err(StryptError::Malformed {
detail: MalformedDetail::MissingMarker,
..
})
));
}
#[test]
fn a_length_beyond_the_end_of_the_file_is_refused_rather_than_clamped() {
let mut input = png(&[]);
input[8..12].copy_from_slice(&0x7FFF_0000u32.to_be_bytes());
assert!(matches!(
PngHandler.inspect(&input, &InspectOptions::names_only()),
Err(StryptError::Malformed {
detail: MalformedDetail::LengthOutOfRange,
..
})
));
}
#[test]
fn a_length_with_the_high_bit_set_is_refused() {
let mut input = png(&[]);
input[8..12].copy_from_slice(&0xFFFF_FFFFu32.to_be_bytes());
assert!(matches!(
PngHandler.inspect(&input, &InspectOptions::names_only()),
Err(StryptError::Malformed {
detail: MalformedDetail::LengthOutOfRange,
..
})
));
}
#[test]
fn a_chunk_type_that_is_not_letters_is_refused() {
let input = png(&[chunk(*b"\x00\x01\x02\x03", b"")]);
assert!(matches!(
PngHandler.inspect(&input, &InspectOptions::names_only()),
Err(StryptError::Malformed {
detail: MalformedDetail::UnexpectedMarker,
..
})
));
}
#[test]
fn truncation_at_every_length_is_refused_or_survived_but_never_panics() {
let input = png(&[
text_chunk(*b"tEXt", "Author", b"SYNTHETIC-AUTHOR-0001"),
chunk(*b"tIME", &[0x07, 0xEA, 8, 19, 12, 30, 45]),
]);
for n in 0..=input.len() {
let prefix = &input[0..n];
let _ = PngHandler.inspect(prefix, &InspectOptions::names_only());
let _ = PngHandler.strip(prefix, &StripOptions::default());
}
}
#[test]
fn a_chunk_count_beyond_the_limit_is_refused() {
let extra: Vec<Vec<u8>> = (0..64)
.map(|_| text_chunk(*b"tEXt", "Comment", b"x"))
.collect();
let input = png(&extra);
let options = StripOptions {
limits: ParseLimits {
max_items: 8,
..ParseLimits::default()
},
..StripOptions::default()
};
assert!(matches!(
PngHandler.strip(&input, &options),
Err(StryptError::LimitExceeded { .. })
));
}
#[test]
fn a_text_chunk_with_no_null_separator_is_removed_rather_than_refused() {
let input = png(&[chunk(*b"tEXt", b"SYNTHETIC-NO-SEPARATOR-0008")]);
let stripped = strip_ok(&input);
assert!(!contains(&stripped.bytes, b"SYNTHETIC-NO-SEPARATOR-0008"));
}
}