use crate::bytes::Reader;
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 JpegHandler;
impl MetadataHandler for JpegHandler {
fn name(&self) -> &'static str {
Format::Jpeg.id()
}
fn format(&self) -> Format {
Format::Jpeg
}
fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
let processed = process(input, options, &ParseLimits::default())?;
Ok(MetadataReport {
format: Format::Jpeg,
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::Jpeg,
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,
})
}
}
struct Processed {
findings: Vec<Finding>,
retained: Vec<Retained>,
notes: Vec<Note>,
output: Vec<u8>,
}
const fn is_standalone(marker: u8) -> bool {
matches!(marker, 0x01 | 0xD0..=0xD7 | 0xD8)
}
enum Piece<'a> {
Standalone(u8),
Segment { marker: u8, payload: &'a [u8] },
Entropy(&'a [u8]),
Trailing(&'a [u8]),
}
const SOI: u8 = 0xD8;
const EOI: u8 = 0xD9;
const SOS: u8 = 0xDA;
const COM: u8 = 0xFE;
fn walk<'a>(input: &'a [u8], limits: &ParseLimits) -> Result<Vec<Piece<'a>>> {
let mut r = Reader::new(input);
let mut pieces = Vec::new();
if r.take(2) != Some(&[0xFF, SOI]) {
return Err(malformed(MalformedDetail::MissingMarker, Some(0)));
}
pieces.push(Piece::Standalone(SOI));
let mut budget = limits.max_items;
let mut saw_scan = false;
loop {
if budget == 0 {
return Err(StryptError::LimitExceeded {
format: Format::Jpeg,
limit: ResourceLimit::ItemCount,
});
}
budget = budget.saturating_sub(1);
let at = r.position();
let mut marker = match r.take(2) {
Some([0xFF, m]) => *m,
Some(_) | None => return Err(malformed(MalformedDetail::Truncated, as_offset(at))),
};
while marker == 0xFF {
marker = match r.u8() {
Some(m) => m,
None => return Err(malformed(MalformedDetail::Truncated, as_offset(at))),
};
}
if marker == 0x00 {
return Err(malformed(MalformedDetail::UnexpectedMarker, as_offset(at)));
}
if marker == EOI {
pieces.push(Piece::Standalone(EOI));
break;
}
if is_standalone(marker) {
pieces.push(Piece::Standalone(marker));
continue;
}
let declared = r
.u16_be()
.ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(at)))?;
let length = declared
.checked_sub(2)
.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(at)))?;
let payload = r
.take(usize::from(length))
.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(at)))?;
pieces.push(Piece::Segment { marker, payload });
if marker == SOS {
saw_scan = true;
let start = r.position();
let consumed = scan_length(r.peek(r.remaining()).unwrap_or_default());
r.skip(consumed)
.ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
let end = r.position();
pieces.push(Piece::Entropy(input.get(start..end).unwrap_or_default()));
}
}
if !saw_scan {
return Err(malformed(MalformedDetail::MissingMarker, None));
}
let rest = r.take_rest();
if !rest.is_empty() {
pieces.push(Piece::Trailing(rest));
}
Ok(pieces)
}
fn scan_length(rest: &[u8]) -> usize {
let mut i = 0usize;
loop {
let Some(offset) = rest
.get(i..)
.and_then(|s| s.iter().position(|&b| b == 0xFF))
else {
return rest.len();
};
let at = i.saturating_add(offset);
match rest.get(at.saturating_add(1)) {
None => return rest.len(),
Some(0x00 | 0xD0..=0xD7) => i = at.saturating_add(2),
Some(0xFF) => i = at.saturating_add(1),
Some(_) => return at,
}
}
}
enum Outcome {
Keep,
Drop,
Replace(Vec<u8>),
}
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: &str, bytes: u64) -> Self {
Self::drop_with(vec![Finding::new(kind, location.to_owned(), bytes)])
}
}
fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
let pieces = walk(input, limits)?;
let mut out = Processed {
findings: Vec::new(),
retained: Vec::new(),
notes: Vec::new(),
output: Vec::with_capacity(input.len()),
};
for piece in pieces {
match piece {
Piece::Standalone(marker) => {
out.output.push(0xFF);
out.output.push(marker);
}
Piece::Entropy(data) => out.output.extend_from_slice(data),
Piece::Trailing(data) => {
let kind = if data.starts_with(&[0xFF, SOI]) {
MetadataKind::Thumbnail
} else {
MetadataKind::Other
};
out.findings.push(Finding::new(
kind,
"trailing data after EOI",
as_u64(data.len()),
));
}
Piece::Segment { marker, payload } => {
let decision = decide(marker, payload, options, limits);
out.notes.extend(decision.notes);
out.retained.extend(decision.retained);
match decision.outcome {
Outcome::Keep => emit(&mut out.output, marker, payload),
Outcome::Drop => out.findings.extend(decision.findings),
Outcome::Replace(new_payload) => {
out.findings.extend(decision.findings);
emit(&mut out.output, marker, &new_payload);
}
}
}
}
}
Ok(out)
}
fn emit(output: &mut Vec<u8>, marker: u8, payload: &[u8]) {
let Ok(length) = u16::try_from(payload.len().saturating_add(2)) else {
return;
};
output.push(0xFF);
output.push(marker);
output.extend_from_slice(&length.to_be_bytes());
output.extend_from_slice(payload);
}
fn decide(marker: u8, payload: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Decision {
let size = as_u64(payload.len());
match marker {
0xE0 => app0(payload, size),
0xE1 => app1(payload, size, options, limits),
0xE2 => app2(payload, size),
0xE3 => Decision::drop_one(MetadataKind::Other, "APP3 (Meta)", size),
0xE4 => Decision::drop_one(MetadataKind::Other, "APP4", size),
0xE5 => Decision::drop_one(MetadataKind::Other, "APP5", size),
0xE6 => Decision::drop_one(MetadataKind::Other, "APP6", size),
0xE7 => Decision::drop_one(MetadataKind::Other, "APP7", size),
0xE8 => Decision::drop_one(MetadataKind::Other, "APP8", size),
0xE9 => Decision::drop_one(MetadataKind::Other, "APP9", size),
0xEA => Decision::drop_one(MetadataKind::Comment, "APP10", size),
0xEB => Decision::drop_one(MetadataKind::Other, "APP11", size),
0xEC => Decision::drop_one(MetadataKind::SoftwareFingerprint, "APP12 (Ducky)", size),
0xED => app13(payload, size),
0xEE => app14(payload, size),
0xEF => Decision::drop_one(MetadataKind::Other, "APP15", size),
COM => comment(payload, size, options),
_ => Decision::keep(),
}
}
fn app0(payload: &[u8], size: u64) -> Decision {
if payload.starts_with(b"JFXX\0") {
return Decision::drop_one(MetadataKind::Thumbnail, "APP0 (JFXX thumbnail)", size);
}
if !payload.starts_with(b"JFIF\0") {
return Decision::drop_one(MetadataKind::Other, "APP0", size);
}
let (Some(&x), Some(&y)) = (payload.get(12), payload.get(13)) else {
return Decision::drop_one(MetadataKind::Other, "APP0 (JFIF, malformed)", size);
};
let pixels = u64::from(x).saturating_mul(u64::from(y));
if pixels == 0 {
return Decision::kept_on_purpose("APP0 (JFIF)", RetentionReason::RemovalWouldAlterPayload);
}
let mut header = payload.get(0..14).unwrap_or_default().to_vec();
zero_thumbnail_dimensions(&mut header);
Decision {
outcome: Outcome::Replace(header),
findings: vec![Finding::new(
MetadataKind::Thumbnail,
"APP0 (JFIF thumbnail)",
pixels.saturating_mul(3),
)],
retained: vec![Retained {
location: "APP0 (JFIF)".to_owned(),
reason: RetentionReason::RemovalWouldAlterPayload,
}],
notes: Vec::new(),
}
}
fn zero_thumbnail_dimensions(header: &mut [u8]) {
if let Some(slot) = header.get_mut(12) {
*slot = 0;
}
if let Some(slot) = header.get_mut(13) {
*slot = 0;
}
}
fn app1(payload: &[u8], size: u64, options: &InspectOptions, limits: &ParseLimits) -> Decision {
if let Some(tiff) = payload.strip_prefix(b"Exif\0\0") {
let scanned = exif::scan(tiff, "APP1 (Exif)", options, limits);
let findings = if scanned.findings.is_empty() {
vec![Finding::new(MetadataKind::Other, "APP1 (Exif)", size)]
} else {
scanned.findings
};
return Decision {
outcome: Outcome::Drop,
findings,
retained: Vec::new(),
notes: scanned.notes,
};
}
for prefix in [
b"http://ns.adobe.com/xap/1.0/\0".as_slice(),
b"http://ns.adobe.com/xmp/extension/\0".as_slice(),
] {
if let Some(packet) = payload.strip_prefix(prefix) {
return Decision::drop_with(xmp::scan(packet, "APP1 (XMP)", options));
}
}
Decision::drop_one(MetadataKind::Other, "APP1", size)
}
fn app2(payload: &[u8], size: u64) -> Decision {
if payload.starts_with(b"ICC_PROFILE\0") {
return Decision::drop_one(MetadataKind::ColourProfile, "APP2 (ICC profile)", size);
}
if payload.starts_with(b"MPF\0") {
return Decision::drop_one(MetadataKind::Thumbnail, "APP2 (MPF)", size);
}
if payload.starts_with(b"FPXR") {
return Decision::drop_one(MetadataKind::Other, "APP2 (FlashPix)", size);
}
Decision::drop_one(MetadataKind::Other, "APP2", size)
}
fn app13(payload: &[u8], size: u64) -> Decision {
if payload.starts_with(b"Photoshop 3.0\0") {
return Decision::drop_one(
MetadataKind::PersonalIdentity,
"APP13 (Photoshop/IPTC)",
size,
);
}
Decision::drop_one(MetadataKind::Other, "APP13", size)
}
fn app14(payload: &[u8], size: u64) -> Decision {
if payload.starts_with(b"Adobe") {
return Decision::kept_on_purpose(
"APP14 (Adobe)",
RetentionReason::RemovalWouldAlterPayload,
);
}
Decision::drop_one(MetadataKind::Other, "APP14", size)
}
fn comment(payload: &[u8], size: u64, options: &InspectOptions) -> Decision {
Decision::drop_with(vec![
Finding::new(MetadataKind::Comment, "COM", size).with_value(options, || {
MetadataValue::Text(
String::from_utf8_lossy(payload)
.chars()
.filter(|c| !c.is_control())
.collect(),
)
}),
])
}
fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
StryptError::Malformed {
format: Format::Jpeg,
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 jpeg(segments: &[(u8, Vec<u8>)]) -> Vec<u8> {
let mut out = vec![0xFF, SOI];
for (marker, payload) in segments {
out.push(0xFF);
out.push(*marker);
out.extend_from_slice(&u16::try_from(payload.len() + 2).unwrap().to_be_bytes());
out.extend_from_slice(payload);
}
out.extend_from_slice(&[0xFF, SOS, 0x00, 0x04, 0x01, 0x00]);
out.extend_from_slice(&[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56]);
out.extend_from_slice(&[0xFF, EOI]);
out
}
fn strip_ok(data: &[u8]) -> Stripped {
JpegHandler
.strip(data, &StripOptions::default())
.expect("strip failed")
}
fn findings(data: &[u8]) -> Vec<Finding> {
JpegHandler
.inspect(data, &InspectOptions::names_only())
.expect("inspect failed")
.findings
}
fn exif_app1(tag: u16, value: [u8; 4]) -> Vec<u8> {
let mut payload = b"Exif\0\0".to_vec();
payload.extend_from_slice(b"II\x2A\x00\x08\x00\x00\x00");
payload.extend_from_slice(&1u16.to_le_bytes());
payload.extend_from_slice(&tag.to_le_bytes());
payload.extend_from_slice(&2u16.to_le_bytes()); payload.extend_from_slice(&4u32.to_le_bytes());
payload.extend_from_slice(&value);
payload.extend_from_slice(&0u32.to_le_bytes());
payload
}
#[test]
fn the_picture_is_never_touched() {
let input = jpeg(&[(0xE1, exif_app1(0x010F, *b"ACME"))]);
let output = strip_ok(&input).bytes;
let scan_bytes: &[u8] = &[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56];
assert!(
output.windows(scan_bytes.len()).any(|w| w == scan_bytes),
"entropy-coded data did not survive byte for byte"
);
}
#[test]
fn exif_is_reported_by_tag_and_removed() {
let input = jpeg(&[(0xE1, exif_app1(0x010F, *b"ACME"))]);
let found = findings(&input);
assert_eq!(found[0].field.as_deref(), Some("Make"));
assert_eq!(found[0].kind, MetadataKind::DeviceIdentity);
let output = strip_ok(&input).bytes;
assert!(
!output.windows(4).any(|w| w == b"ACME"),
"the camera make survived the strip"
);
assert!(findings(&output).is_empty());
}
#[test]
fn a_comment_goes_and_the_tables_stay() {
let input = jpeg(&[(COM, b"SYNTHETIC-COMMENT".to_vec()), (0xDB, vec![0u8; 8])]);
let output = strip_ok(&input).bytes;
assert!(!output.windows(9).any(|w| w == b"SYNTHETIC"));
assert!(
output.windows(2).any(|w| w == [0xFF, 0xDB]),
"the quantisation table was dropped along with the comment"
);
}
#[test]
fn the_jfif_header_stays_and_its_thumbnail_does_not() {
let mut jfif = b"JFIF\0\x01\x02\x00\x00\x01\x00\x01".to_vec();
jfif.push(2); jfif.push(2); jfif.extend_from_slice(&[0xAB; 12]); let input = jpeg(&[(0xE0, jfif)]);
let stripped = strip_ok(&input);
assert!(
stripped
.report
.removed
.iter()
.any(|f| f.kind == MetadataKind::Thumbnail)
);
assert!(
stripped
.report
.retained
.iter()
.any(|r| r.location == "APP0 (JFIF)"),
"the JFIF header should be kept, and the report should say it was"
);
assert!(!stripped.bytes.windows(4).any(|w| w == [0xAB; 4]));
assert!(findings(&stripped.bytes).is_empty());
}
#[test]
fn the_adobe_colour_transform_marker_is_kept_and_declared() {
let input = jpeg(&[(0xEE, b"Adobe\0\x64\x00\x00\x00\x00\x02".to_vec())]);
let stripped = strip_ok(&input);
assert!(stripped.bytes.windows(2).any(|w| w == [0xFF, 0xEE]));
assert_eq!(stripped.report.retained.len(), 1);
assert_eq!(
stripped.report.retained[0].reason,
RetentionReason::RemovalWouldAlterPayload
);
}
#[test]
fn data_hidden_after_the_end_of_image_marker_is_removed() {
let mut input = jpeg(&[]);
input.extend_from_slice(&[0xFF, SOI]);
input.extend_from_slice(b"SYNTHETIC-SECOND-IMAGE");
let stripped = strip_ok(&input);
assert!(!stripped.bytes.windows(9).any(|w| w == b"SYNTHETIC"));
assert_eq!(stripped.report.removed[0].kind, MetadataKind::Thumbnail);
}
#[test]
fn stripping_twice_changes_nothing() {
let input = jpeg(&[
(0xE1, exif_app1(0x010F, *b"ACME")),
(COM, b"SYNTHETIC-COMMENT".to_vec()),
]);
let once = strip_ok(&input).bytes;
let twice = strip_ok(&once).bytes;
assert_eq!(once, twice, "strip is not idempotent");
}
#[test]
fn a_clean_file_produces_no_findings_and_no_edits() {
let input = jpeg(&[(0xDB, vec![0u8; 8])]);
let stripped = strip_ok(&input);
assert!(stripped.report.removed.is_empty());
assert_eq!(stripped.bytes, input);
}
#[test]
fn a_file_that_ends_without_eoi_is_refused() {
let input = jpeg(&[]);
let truncated = &input[0..input.len() - 2];
assert!(matches!(
JpegHandler.strip(truncated, &StripOptions::default()),
Err(StryptError::Malformed { .. })
));
}
#[test]
fn truncation_at_every_length_is_refused_or_survived_but_never_panics() {
let input = jpeg(&[
(0xE1, exif_app1(0x8825, [26, 0, 0, 0])),
(COM, b"comment".to_vec()),
]);
for n in 0..=input.len() {
let prefix = &input[0..n];
let _ = JpegHandler.inspect(prefix, &InspectOptions::names_only());
let _ = JpegHandler.strip(prefix, &StripOptions::default());
}
}
#[test]
fn a_segment_length_of_zero_is_refused_rather_than_wrapping() {
let input = vec![0xFF, SOI, 0xFF, 0xE1, 0x00, 0x00, 0xFF, EOI];
assert!(matches!(
JpegHandler.inspect(&input, &InspectOptions::names_only()),
Err(StryptError::Malformed {
detail: MalformedDetail::LengthOutOfRange,
..
})
));
}
#[test]
fn a_segment_count_beyond_the_limit_is_refused() {
let segments: Vec<(u8, Vec<u8>)> = (0..64).map(|_| (0xDB, vec![0u8; 4])).collect();
let input = jpeg(&segments);
let options = StripOptions {
limits: ParseLimits {
max_items: 8,
..ParseLimits::default()
},
..StripOptions::default()
};
assert!(matches!(
JpegHandler.strip(&input, &options),
Err(StryptError::LimitExceeded { .. })
));
}
}