use crate::bytes::Reader;
use crate::detect::Format;
use crate::error::{MalformedDetail, ResourceLimit, Result, StryptError};
use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, xmp};
use crate::report::{
Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, Retained,
RetentionReason, StripReport,
};
#[derive(Debug, Clone, Copy, Default)]
pub struct GifHandler;
impl MetadataHandler for GifHandler {
fn name(&self) -> &'static str {
Format::Gif.id()
}
fn format(&self) -> Format {
Format::Gif
}
fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
let processed = process(input, options, &ParseLimits::default())?;
Ok(MetadataReport {
format: Format::Gif,
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::Gif,
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 SIGNATURES: [&[u8; 6]; 2] = [b"GIF87a", b"GIF89a"];
const TRAILER: u8 = 0x3B;
const EXTENSION_INTRODUCER: u8 = 0x21;
const IMAGE_SEPARATOR: u8 = 0x2C;
const LABEL_PLAIN_TEXT: u8 = 0x01;
const LABEL_GRAPHIC_CONTROL: u8 = 0xF9;
const LABEL_COMMENT: u8 = 0xFE;
const LABEL_APPLICATION: u8 = 0xFF;
const APPLICATION_IDENTIFIER_LEN: usize = 11;
const LOOP_EXTENSIONS: [&[u8; APPLICATION_IDENTIFIER_LEN]; 2] = [b"NETSCAPE2.0", b"ANIMEXTS1.0"];
const XMP_IDENTIFIER: &[u8] = b"XMP DataXMP";
const APPLICATION_KINDS: &[(&[u8], MetadataKind)] = &[
(b"ICCRGBG1012", MetadataKind::ColourProfile),
(b"MGK8BIM0000", MetadataKind::SoftwareFingerprint),
(b"MGKIPTC0000", MetadataKind::PersonalIdentity),
(b"ImageMagick", MetadataKind::SoftwareFingerprint),
(b"Adobe Gif", MetadataKind::SoftwareFingerprint),
];
struct Block<'a> {
kind: BlockKind,
body: &'a [u8],
head: &'a [u8],
raw: &'a [u8],
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum BlockKind {
Extension(u8),
Image,
}
struct Processed {
findings: Vec<Finding>,
retained: Vec<Retained>,
notes: Vec<Note>,
output: Vec<u8>,
}
fn walk<'a>(input: &'a [u8], limits: &ParseLimits) -> Result<(&'a [u8], Vec<Block<'a>>, &'a [u8])> {
let mut r = Reader::new(input);
let signature = r
.take(6)
.ok_or_else(|| malformed(MalformedDetail::Truncated, Some(0)))?;
if !SIGNATURES.iter().any(|candidate| *candidate == signature) {
return Err(malformed(MalformedDetail::MissingMarker, Some(0)));
}
let descriptor = r
.take(7)
.ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(6)))?;
let packed = descriptor.get(4).copied().unwrap_or_default();
if packed & 0x80 != 0 {
let entries = colour_table_bytes(packed);
r.skip(entries)
.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(13)))?;
}
let prefix = input.get(0..r.position()).unwrap_or_default();
let mut blocks: Vec<Block<'a>> = Vec::new();
let mut budget = limits.max_items;
loop {
let start = r.position();
let introducer = r
.u8()
.ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
if introducer == TRAILER {
break;
}
spend(&mut budget)?;
let (kind, head, body) = match introducer {
EXTENSION_INTRODUCER => {
let label = r
.u8()
.ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
let (head, body) = sub_blocks(&mut r, input, &mut budget, start)?;
(BlockKind::Extension(label), head, body)
}
IMAGE_SEPARATOR => {
let descriptor = r
.take(9)
.ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
let packed = descriptor.get(8).copied().unwrap_or_default();
if packed & 0x80 != 0 {
r.skip(colour_table_bytes(packed)).ok_or_else(|| {
malformed(MalformedDetail::LengthOutOfRange, as_offset(start))
})?;
}
r.skip(1)
.ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
let _ = sub_blocks(&mut r, input, &mut budget, start)?;
(BlockKind::Image, &[][..], &[][..])
}
_ => {
return Err(malformed(
MalformedDetail::UnexpectedMarker,
as_offset(start),
));
}
};
blocks.push(Block {
kind,
body,
head,
raw: input.get(start..r.position()).unwrap_or_default(),
});
}
Ok((prefix, blocks, r.take_rest()))
}
fn colour_table_bytes(packed: u8) -> usize {
let n = u32::from(packed & 0b0000_0111);
3usize.saturating_mul(1usize << n.saturating_add(1))
}
fn sub_blocks<'a>(
r: &mut Reader<'a>,
input: &'a [u8],
budget: &mut u32,
block_start: usize,
) -> Result<(&'a [u8], &'a [u8])> {
let span_start = r.position();
let mut head: &[u8] = &[];
loop {
spend(budget)?;
let at = r.position();
let length = r
.u8()
.ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(block_start)))?;
if length == 0 {
let span = input.get(span_start..at).unwrap_or_default();
return Ok((head, span));
}
let data = r
.take(usize::from(length))
.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(at)))?;
if head.is_empty() {
head = data;
}
}
}
fn spend(budget: &mut u32) -> Result<()> {
if *budget == 0 {
return Err(StryptError::LimitExceeded {
format: Format::Gif,
limit: ResourceLimit::ItemCount,
});
}
*budget = budget.saturating_sub(1);
Ok(())
}
enum Outcome {
Keep,
Drop,
}
struct Decision {
outcome: Outcome,
findings: Vec<Finding>,
retained: Vec<Retained>,
}
impl Decision {
const fn keep() -> Self {
Self {
outcome: Outcome::Keep,
findings: Vec::new(),
retained: 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,
}],
}
}
fn drop_with(findings: Vec<Finding>) -> Self {
Self {
outcome: Outcome::Drop,
findings,
retained: Vec::new(),
}
}
const fn drop_silently() -> Self {
Self {
outcome: Outcome::Drop,
findings: Vec::new(),
retained: Vec::new(),
}
}
}
fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
let (prefix, blocks, 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(prefix);
for (index, block) in blocks.iter().enumerate() {
let next = blocks.get(index.saturating_add(1));
let decision = decide(block, next, options);
out.retained.extend(decision.retained);
match decision.outcome {
Outcome::Keep => out.output.extend_from_slice(block.raw),
Outcome::Drop => out.findings.extend(decision.findings),
}
}
out.output.push(TRAILER);
if !trailing.is_empty() {
let kind = if SIGNATURES
.iter()
.any(|signature| trailing.starts_with(signature.as_slice()))
{
MetadataKind::Thumbnail
} else {
MetadataKind::Other
};
out.findings.push(Finding::new(
kind,
"trailing data after the trailer",
as_u64(trailing.len()),
));
}
Ok(out)
}
fn decide(block: &Block<'_>, next: Option<&Block<'_>>, options: &InspectOptions) -> Decision {
let size = as_u64(block.body.len());
match block.kind {
BlockKind::Image => Decision::keep(),
BlockKind::Extension(LABEL_GRAPHIC_CONTROL) => {
if matches!(
next.map(|b| b.kind),
Some(BlockKind::Extension(LABEL_PLAIN_TEXT))
) {
return Decision::drop_silently();
}
Decision::keep()
}
BlockKind::Extension(LABEL_COMMENT) => Decision::drop_with(vec![
Finding::new(MetadataKind::Comment, "Comment Extension", size)
.with_field("Comment")
.with_value(options, || MetadataValue::Text(xmp::name_of(block.body))),
]),
BlockKind::Extension(LABEL_PLAIN_TEXT) => Decision::drop_with(vec![
Finding::new(MetadataKind::Comment, "Plain Text Extension", size)
.with_field("PlainText"),
]),
BlockKind::Extension(LABEL_APPLICATION) => application(block, size, options),
BlockKind::Extension(label) => {
Decision::drop_with(vec![Finding::new(
MetadataKind::Other,
format!("Extension 0x{label:02X}"),
size,
)])
}
}
}
fn application(block: &Block<'_>, size: u64, options: &InspectOptions) -> Decision {
let identifier = block
.head
.get(0..APPLICATION_IDENTIFIER_LEN)
.unwrap_or(block.head);
if LOOP_EXTENSIONS
.iter()
.any(|candidate| candidate.as_slice() == identifier)
{
return Decision::kept_on_purpose(
"Application Extension (loop count)",
RetentionReason::RemovalWouldAlterPayload,
);
}
if identifier == XMP_IDENTIFIER {
return Decision::drop_with(xmp::scan(
block.body,
"Application Extension (XMP)",
options,
));
}
let kind = APPLICATION_KINDS
.iter()
.find(|(candidate, _)| *candidate == identifier)
.map_or(MetadataKind::Other, |(_, kind)| *kind);
Decision::drop_with(vec![
Finding::new(kind, "Application Extension", size).with_field(xmp::name_of(identifier)),
])
}
fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
StryptError::Malformed {
format: Format::Gif,
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 chain(payload: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
for part in payload.chunks(255) {
out.push(u8::try_from(part.len()).unwrap());
out.extend_from_slice(part);
}
out.push(0);
out
}
fn extension(label: u8, payload: &[u8]) -> Vec<u8> {
let mut out = vec![EXTENSION_INTRODUCER, label];
out.extend_from_slice(&chain(payload));
out
}
fn application_extension(identifier: &[u8], data: &[u8]) -> Vec<u8> {
let mut out = vec![EXTENSION_INTRODUCER, LABEL_APPLICATION, 11];
out.extend_from_slice(identifier);
out.extend_from_slice(&chain(data));
out
}
fn image() -> Vec<u8> {
let mut out = vec![IMAGE_SEPARATOR];
out.extend_from_slice(&[0, 0, 0, 0, 1, 0, 1, 0, 0]);
out.push(2);
out.extend_from_slice(&chain(b"SYNTHETIC-PIXELS"));
out
}
fn graphic_control() -> Vec<u8> {
extension(LABEL_GRAPHIC_CONTROL, &[0x04, 0x0A, 0x00, 0x00])
}
fn gif(blocks: &[Vec<u8>]) -> Vec<u8> {
let mut out = b"GIF89a".to_vec();
out.extend_from_slice(&[1, 0, 1, 0, 0x00, 0, 0]);
for block in blocks {
out.extend_from_slice(block);
}
out.push(TRAILER);
out
}
fn strip_ok(data: &[u8]) -> Stripped {
GifHandler
.strip(data, &StripOptions::default())
.expect("strip failed")
}
fn findings(data: &[u8]) -> Vec<Finding> {
GifHandler
.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 = gif(&[extension(LABEL_COMMENT, b"SYNTHETIC-COMMENT-0001"), image()]);
let output = strip_ok(&input).bytes;
assert!(
contains(&output, b"SYNTHETIC-PIXELS"),
"the compressed image data did not survive byte for byte"
);
}
#[test]
fn a_clean_file_strips_to_a_byte_identical_copy() {
let input = gif(&[image()]);
let stripped = strip_ok(&input);
assert!(stripped.report.removed.is_empty());
assert_eq!(stripped.bytes, input);
}
#[test]
fn a_global_colour_table_is_carried_across() {
let mut input = b"GIF89a".to_vec();
input.extend_from_slice(&[1, 0, 1, 0, 0x80, 0, 0]); input.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
input.extend_from_slice(&extension(LABEL_COMMENT, b"SYNTHETIC-COMMENT-0002"));
input.extend_from_slice(&image());
input.push(TRAILER);
let output = strip_ok(&input).bytes;
assert!(contains(&output, &[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]));
assert!(!contains(&output, b"SYNTHETIC-COMMENT-0002"));
}
#[test]
fn a_comment_is_reported_and_removed_and_its_text_withheld_by_default() {
let input = gif(&[extension(LABEL_COMMENT, b"SYNTHETIC-COMMENT-0003"), image()]);
let found = findings(&input);
assert_eq!(found[0].kind, MetadataKind::Comment);
assert_eq!(found[0].field.as_deref(), Some("Comment"));
assert_eq!(
found[0].value, None,
"a default inspection withholds values"
);
let with_values = GifHandler
.inspect(&input, &InspectOptions::with_values())
.unwrap();
assert_eq!(
with_values.findings[0].value,
Some(MetadataValue::Text("SYNTHETIC-COMMENT-0003".to_owned()))
);
assert!(!contains(
&strip_ok(&input).bytes,
b"SYNTHETIC-COMMENT-0003"
));
}
#[test]
fn the_loop_extension_survives_and_says_so() {
let input = gif(&[
application_extension(b"NETSCAPE2.0", &[0x01, 0x00, 0x00]),
image(),
]);
let stripped = strip_ok(&input);
assert!(contains(&stripped.bytes, b"NETSCAPE2.0"));
assert!(stripped.report.removed.is_empty());
assert_eq!(
stripped.report.retained[0].location,
"Application Extension (loop count)"
);
}
#[test]
fn the_older_loop_spelling_survives_too() {
let input = gif(&[
application_extension(b"ANIMEXTS1.0", &[0x01, 0x00, 0x00]),
image(),
]);
assert!(contains(&strip_ok(&input).bytes, b"ANIMEXTS1.0"));
}
#[test]
fn an_unknown_application_extension_does_not_survive_by_being_unknown() {
let input = gif(&[
application_extension(b"VENDORX1.0\0", b"SYNTHETIC-VENDOR-0004"),
image(),
]);
let found = findings(&input);
assert_eq!(found[0].kind, MetadataKind::Other);
assert_eq!(found[0].location, "Application Extension");
assert!(!contains(&strip_ok(&input).bytes, b"SYNTHETIC-VENDOR-0004"));
}
#[test]
fn an_imagemagick_iptc_block_is_ranked_as_naming_a_person() {
let input = gif(&[
application_extension(b"MGKIPTC0000", b"\x1c\x02\x50SYNTHETIC-BYLINE-0005"),
image(),
]);
assert_eq!(findings(&input)[0].kind, MetadataKind::PersonalIdentity);
assert!(!contains(&strip_ok(&input).bytes, b"SYNTHETIC-BYLINE-0005"));
}
#[test]
fn an_xmp_packet_is_itemised_by_property() {
let mut packet = b"<x:xmpmeta><dc:creator>SYNTHETIC-XMP-0006</dc:creator>".to_vec();
packet.extend_from_slice(b"<xmp:CreatorTool>SYNTHETIC-TOOL</xmp:CreatorTool></x:xmpmeta>");
let input = gif(&[application_extension(b"XMP DataXMP", &packet), image()]);
let fields: Vec<String> = findings(&input)
.into_iter()
.filter_map(|f| f.field)
.collect();
assert!(fields.iter().any(|f| f == "dc:creator"));
assert!(fields.iter().any(|f| f == "xmp:CreatorTool"));
assert!(!contains(&strip_ok(&input).bytes, b"SYNTHETIC-XMP-0006"));
}
#[test]
fn a_graphic_control_block_stays_with_its_image_and_goes_with_its_plain_text() {
let kept = gif(&[graphic_control(), image()]);
assert_eq!(strip_ok(&kept).bytes, kept);
let mut plain_text = vec![EXTENSION_INTRODUCER, LABEL_PLAIN_TEXT, 12];
plain_text.extend_from_slice(&[0; 12]);
plain_text.extend_from_slice(&chain(b"SYNTHETIC-PLAINTEXT-0007"));
let input = gif(&[graphic_control(), plain_text, image()]);
let stripped = strip_ok(&input);
assert!(!contains(&stripped.bytes, b"SYNTHETIC-PLAINTEXT-0007"));
assert_eq!(
stripped.bytes,
gif(&[image()]),
"the orphaned graphic control block was left behind"
);
assert_eq!(stripped.report.removed.len(), 1);
assert_eq!(stripped.report.removed[0].location, "Plain Text Extension");
}
#[test]
fn an_extension_under_an_undefined_label_is_removed() {
let input = gif(&[extension(0x42, b"SYNTHETIC-UNKNOWN-0008"), image()]);
let found = findings(&input);
assert_eq!(found[0].location, "Extension 0x42");
assert!(!contains(
&strip_ok(&input).bytes,
b"SYNTHETIC-UNKNOWN-0008"
));
}
#[test]
fn data_hidden_after_the_trailer_is_removed() {
let mut input = gif(&[image()]);
input.extend_from_slice(b"SYNTHETIC-APPENDED-0009");
let stripped = strip_ok(&input);
assert!(!contains(&stripped.bytes, b"SYNTHETIC-APPENDED-0009"));
assert_eq!(
stripped.report.removed[0].location,
"trailing data after the trailer"
);
}
#[test]
fn a_second_image_after_the_trailer_is_reported_as_a_thumbnail() {
let mut input = gif(&[image()]);
input.extend_from_slice(&gif(&[image()]));
assert_eq!(
strip_ok(&input).report.removed[0].kind,
MetadataKind::Thumbnail
);
}
#[test]
fn stripping_twice_changes_nothing() {
let input = gif(&[
extension(LABEL_COMMENT, b"SYNTHETIC-COMMENT-0010"),
application_extension(b"NETSCAPE2.0", &[0x01, 0x00, 0x00]),
graphic_control(),
image(),
]);
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_a_trailer_is_refused() {
let input = gif(&[image()]);
let truncated = &input[0..input.len() - 1];
assert!(matches!(
GifHandler.strip(truncated, &StripOptions::default()),
Err(StryptError::Malformed {
detail: MalformedDetail::Truncated,
..
})
));
}
#[test]
fn a_block_introducer_the_format_does_not_define_is_refused() {
let input = gif(&[vec![0x99, 0x00]]);
assert!(matches!(
GifHandler.inspect(&input, &InspectOptions::names_only()),
Err(StryptError::Malformed {
detail: MalformedDetail::UnexpectedMarker,
..
})
));
}
#[test]
fn a_sub_block_length_running_past_the_end_of_the_file_is_refused() {
let mut input = gif(&[extension(LABEL_COMMENT, b"short"), image()]);
let at = 13 + 2;
input[at] = 0xFF;
assert!(matches!(
GifHandler.inspect(&input, &InspectOptions::names_only()),
Err(StryptError::Malformed {
detail: MalformedDetail::LengthOutOfRange,
..
})
));
}
#[test]
fn a_header_that_is_not_a_gif_signature_is_refused() {
assert!(matches!(
GifHandler.inspect(
b"GIF88a\x01\x00\x01\x00\x00\x00\x00\x3B",
&InspectOptions::names_only()
),
Err(StryptError::Malformed {
detail: MalformedDetail::MissingMarker,
..
})
));
}
#[test]
fn a_block_count_beyond_the_limit_is_refused() {
let mut blocks: Vec<Vec<u8>> = (0..64).map(|_| extension(LABEL_COMMENT, b"x")).collect();
blocks.push(image());
let input = gif(&blocks);
let options = StripOptions {
limits: ParseLimits {
max_items: 8,
..ParseLimits::default()
},
..StripOptions::default()
};
assert!(matches!(
GifHandler.strip(&input, &options),
Err(StryptError::LimitExceeded { .. })
));
}
#[test]
fn truncation_at_every_length_is_refused_or_survived_but_never_panics() {
let input = gif(&[
extension(LABEL_COMMENT, b"SYNTHETIC-COMMENT-0011"),
application_extension(b"XMP DataXMP", b"<x:xmpmeta><dc:creator>x</dc:creator>"),
graphic_control(),
image(),
]);
for n in 0..=input.len() {
let prefix = &input[0..n];
let _ = GifHandler.inspect(prefix, &InspectOptions::names_only());
let _ = GifHandler.strip(prefix, &StripOptions::default());
}
}
}