1use crate::bytes::Reader;
43use crate::detect::Format;
44use crate::error::{MalformedDetail, ResourceLimit, Result, StryptError};
45use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, exif, xmp};
46use crate::report::{
47 Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, Retained,
48 RetentionReason, StripReport,
49};
50
51#[derive(Debug, Clone, Copy, Default)]
53pub struct JpegHandler;
54
55impl MetadataHandler for JpegHandler {
56 fn name(&self) -> &'static str {
57 Format::Jpeg.id()
58 }
59
60 fn format(&self) -> Format {
61 Format::Jpeg
62 }
63
64 fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
65 let processed = process(input, options, &ParseLimits::default())?;
71 Ok(MetadataReport {
72 format: Format::Jpeg,
73 findings: processed.findings,
74 notes: processed.notes,
75 })
76 }
77
78 fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
79 let processed = process(input, &options.inspect, &options.limits)?;
80 Ok(Stripped {
81 report: StripReport {
82 format: Format::Jpeg,
83 removed: processed.findings,
84 retained: processed.retained,
85 notes: processed.notes,
86 input_bytes: as_u64(input.len()),
87 output_bytes: as_u64(processed.output.len()),
88 },
89 bytes: processed.output,
90 })
91 }
92}
93
94struct Processed {
96 findings: Vec<Finding>,
97 retained: Vec<Retained>,
98 notes: Vec<Note>,
99 output: Vec<u8>,
100}
101
102const fn is_standalone(marker: u8) -> bool {
108 matches!(marker, 0x01 | 0xD0..=0xD7 | 0xD8)
109}
110
111enum Piece<'a> {
113 Standalone(u8),
115 Segment { marker: u8, payload: &'a [u8] },
117 Entropy(&'a [u8]),
119 Trailing(&'a [u8]),
121}
122
123const SOI: u8 = 0xD8;
125const EOI: u8 = 0xD9;
127const SOS: u8 = 0xDA;
129const COM: u8 = 0xFE;
131
132fn walk<'a>(input: &'a [u8], limits: &ParseLimits) -> Result<Vec<Piece<'a>>> {
139 let mut r = Reader::new(input);
140 let mut pieces = Vec::new();
141
142 if r.take(2) != Some(&[0xFF, SOI]) {
143 return Err(malformed(MalformedDetail::MissingMarker, Some(0)));
144 }
145 pieces.push(Piece::Standalone(SOI));
146
147 let mut budget = limits.max_items;
148 let mut saw_scan = false;
149
150 loop {
151 if budget == 0 {
152 return Err(StryptError::LimitExceeded {
153 format: Format::Jpeg,
154 limit: ResourceLimit::ItemCount,
155 });
156 }
157 budget = budget.saturating_sub(1);
158
159 let at = r.position();
160 let mut marker = match r.take(2) {
164 Some([0xFF, m]) => *m,
165 Some(_) | None => return Err(malformed(MalformedDetail::Truncated, as_offset(at))),
169 };
170 while marker == 0xFF {
171 marker = match r.u8() {
172 Some(m) => m,
173 None => return Err(malformed(MalformedDetail::Truncated, as_offset(at))),
174 };
175 }
176 if marker == 0x00 {
177 return Err(malformed(MalformedDetail::UnexpectedMarker, as_offset(at)));
179 }
180
181 if marker == EOI {
182 pieces.push(Piece::Standalone(EOI));
183 break;
184 }
185 if is_standalone(marker) {
186 pieces.push(Piece::Standalone(marker));
187 continue;
188 }
189
190 let declared = r
192 .u16_be()
193 .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(at)))?;
194 let length = declared
195 .checked_sub(2)
196 .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(at)))?;
197 let payload = r
198 .take(usize::from(length))
199 .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(at)))?;
200 pieces.push(Piece::Segment { marker, payload });
201
202 if marker == SOS {
203 saw_scan = true;
204 let start = r.position();
205 let consumed = scan_length(r.peek(r.remaining()).unwrap_or_default());
206 r.skip(consumed)
207 .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
208 let end = r.position();
209 pieces.push(Piece::Entropy(input.get(start..end).unwrap_or_default()));
210 }
211 }
212
213 if !saw_scan {
214 return Err(malformed(MalformedDetail::MissingMarker, None));
217 }
218
219 let rest = r.take_rest();
220 if !rest.is_empty() {
221 pieces.push(Piece::Trailing(rest));
222 }
223 Ok(pieces)
224}
225
226fn scan_length(rest: &[u8]) -> usize {
233 let mut i = 0usize;
234 loop {
235 let Some(offset) = rest
236 .get(i..)
237 .and_then(|s| s.iter().position(|&b| b == 0xFF))
238 else {
239 return rest.len();
240 };
241 let at = i.saturating_add(offset);
242 match rest.get(at.saturating_add(1)) {
243 None => return rest.len(),
245 Some(0x00 | 0xD0..=0xD7) => i = at.saturating_add(2),
248 Some(0xFF) => i = at.saturating_add(1),
250 Some(_) => return at,
251 }
252 }
253}
254
255enum Outcome {
257 Keep,
259 Drop,
261 Replace(Vec<u8>),
263}
264
265struct Decision {
267 outcome: Outcome,
268 findings: Vec<Finding>,
269 retained: Vec<Retained>,
273 notes: Vec<Note>,
274}
275
276impl Decision {
277 const fn keep() -> Self {
278 Self {
279 outcome: Outcome::Keep,
280 findings: Vec::new(),
281 retained: Vec::new(),
282 notes: Vec::new(),
283 }
284 }
285
286 fn kept_on_purpose(location: &'static str, reason: RetentionReason) -> Self {
288 Self {
289 outcome: Outcome::Keep,
290 findings: Vec::new(),
291 retained: vec![Retained {
292 location: location.to_owned(),
293 reason,
294 }],
295 notes: Vec::new(),
296 }
297 }
298
299 fn drop_with(findings: Vec<Finding>) -> Self {
300 Self {
301 outcome: Outcome::Drop,
302 findings,
303 retained: Vec::new(),
304 notes: Vec::new(),
305 }
306 }
307
308 fn drop_one(kind: MetadataKind, location: &str, bytes: u64) -> Self {
309 Self::drop_with(vec![Finding::new(kind, location.to_owned(), bytes)])
310 }
311}
312
313fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
315 let pieces = walk(input, limits)?;
316 let mut out = Processed {
317 findings: Vec::new(),
318 retained: Vec::new(),
319 notes: Vec::new(),
320 output: Vec::with_capacity(input.len()),
321 };
322
323 for piece in pieces {
324 match piece {
325 Piece::Standalone(marker) => {
326 out.output.push(0xFF);
327 out.output.push(marker);
328 }
329 Piece::Entropy(data) => out.output.extend_from_slice(data),
330 Piece::Trailing(data) => {
331 let kind = if data.starts_with(&[0xFF, SOI]) {
336 MetadataKind::Thumbnail
337 } else {
338 MetadataKind::Other
339 };
340 out.findings.push(Finding::new(
341 kind,
342 "trailing data after EOI",
343 as_u64(data.len()),
344 ));
345 }
346 Piece::Segment { marker, payload } => {
347 let decision = decide(marker, payload, options, limits);
348 out.notes.extend(decision.notes);
349 out.retained.extend(decision.retained);
350 match decision.outcome {
351 Outcome::Keep => emit(&mut out.output, marker, payload),
352 Outcome::Drop => out.findings.extend(decision.findings),
353 Outcome::Replace(new_payload) => {
354 out.findings.extend(decision.findings);
355 emit(&mut out.output, marker, &new_payload);
356 }
357 }
358 }
359 }
360 }
361 Ok(out)
362}
363
364fn emit(output: &mut Vec<u8>, marker: u8, payload: &[u8]) {
369 let Ok(length) = u16::try_from(payload.len().saturating_add(2)) else {
370 return;
371 };
372 output.push(0xFF);
373 output.push(marker);
374 output.extend_from_slice(&length.to_be_bytes());
375 output.extend_from_slice(payload);
376}
377
378fn decide(marker: u8, payload: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Decision {
380 let size = as_u64(payload.len());
381 match marker {
382 0xE0 => app0(payload, size),
383 0xE1 => app1(payload, size, options, limits),
384 0xE2 => app2(payload, size),
385 0xE3 => Decision::drop_one(MetadataKind::Other, "APP3 (Meta)", size),
386 0xE4 => Decision::drop_one(MetadataKind::Other, "APP4", size),
387 0xE5 => Decision::drop_one(MetadataKind::Other, "APP5", size),
388 0xE6 => Decision::drop_one(MetadataKind::Other, "APP6", size),
389 0xE7 => Decision::drop_one(MetadataKind::Other, "APP7", size),
390 0xE8 => Decision::drop_one(MetadataKind::Other, "APP8", size),
391 0xE9 => Decision::drop_one(MetadataKind::Other, "APP9", size),
392 0xEA => Decision::drop_one(MetadataKind::Comment, "APP10", size),
395 0xEB => Decision::drop_one(MetadataKind::Other, "APP11", size),
396 0xEC => Decision::drop_one(MetadataKind::SoftwareFingerprint, "APP12 (Ducky)", size),
397 0xED => app13(payload, size),
398 0xEE => app14(payload, size),
399 0xEF => Decision::drop_one(MetadataKind::Other, "APP15", size),
400 COM => comment(payload, size, options),
401 _ => Decision::keep(),
404 }
405}
406
407fn app0(payload: &[u8], size: u64) -> Decision {
409 if payload.starts_with(b"JFXX\0") {
410 return Decision::drop_one(MetadataKind::Thumbnail, "APP0 (JFXX thumbnail)", size);
412 }
413 if !payload.starts_with(b"JFIF\0") {
414 return Decision::drop_one(MetadataKind::Other, "APP0", size);
415 }
416
417 let (Some(&x), Some(&y)) = (payload.get(12), payload.get(13)) else {
421 return Decision::drop_one(MetadataKind::Other, "APP0 (JFIF, malformed)", size);
422 };
423 let pixels = u64::from(x).saturating_mul(u64::from(y));
424 if pixels == 0 {
425 return Decision::kept_on_purpose("APP0 (JFIF)", RetentionReason::RemovalWouldAlterPayload);
426 }
427
428 let mut header = payload.get(0..14).unwrap_or_default().to_vec();
429 zero_thumbnail_dimensions(&mut header);
432 Decision {
433 outcome: Outcome::Replace(header),
434 findings: vec![Finding::new(
435 MetadataKind::Thumbnail,
436 "APP0 (JFIF thumbnail)",
437 pixels.saturating_mul(3),
438 )],
439 retained: vec![Retained {
443 location: "APP0 (JFIF)".to_owned(),
444 reason: RetentionReason::RemovalWouldAlterPayload,
445 }],
446 notes: Vec::new(),
447 }
448}
449
450fn zero_thumbnail_dimensions(header: &mut [u8]) {
452 if let Some(slot) = header.get_mut(12) {
453 *slot = 0;
454 }
455 if let Some(slot) = header.get_mut(13) {
456 *slot = 0;
457 }
458}
459
460fn app1(payload: &[u8], size: u64, options: &InspectOptions, limits: &ParseLimits) -> Decision {
462 if let Some(tiff) = payload.strip_prefix(b"Exif\0\0") {
463 let scanned = exif::scan(tiff, "APP1 (Exif)", options, limits);
464 let findings = if scanned.findings.is_empty() {
465 vec![Finding::new(MetadataKind::Other, "APP1 (Exif)", size)]
467 } else {
468 scanned.findings
469 };
470 return Decision {
471 outcome: Outcome::Drop,
472 findings,
473 retained: Vec::new(),
474 notes: scanned.notes,
475 };
476 }
477 for prefix in [
480 b"http://ns.adobe.com/xap/1.0/\0".as_slice(),
481 b"http://ns.adobe.com/xmp/extension/\0".as_slice(),
482 ] {
483 if let Some(packet) = payload.strip_prefix(prefix) {
484 return Decision::drop_with(xmp::scan(packet, "APP1 (XMP)", options));
485 }
486 }
487 Decision::drop_one(MetadataKind::Other, "APP1", size)
488}
489
490fn app2(payload: &[u8], size: u64) -> Decision {
492 if payload.starts_with(b"ICC_PROFILE\0") {
493 return Decision::drop_one(MetadataKind::ColourProfile, "APP2 (ICC profile)", size);
496 }
497 if payload.starts_with(b"MPF\0") {
498 return Decision::drop_one(MetadataKind::Thumbnail, "APP2 (MPF)", size);
501 }
502 if payload.starts_with(b"FPXR") {
503 return Decision::drop_one(MetadataKind::Other, "APP2 (FlashPix)", size);
504 }
505 Decision::drop_one(MetadataKind::Other, "APP2", size)
506}
507
508fn app13(payload: &[u8], size: u64) -> Decision {
510 if payload.starts_with(b"Photoshop 3.0\0") {
511 return Decision::drop_one(
515 MetadataKind::PersonalIdentity,
516 "APP13 (Photoshop/IPTC)",
517 size,
518 );
519 }
520 Decision::drop_one(MetadataKind::Other, "APP13", size)
521}
522
523fn app14(payload: &[u8], size: u64) -> Decision {
525 if payload.starts_with(b"Adobe") {
526 return Decision::kept_on_purpose(
529 "APP14 (Adobe)",
530 RetentionReason::RemovalWouldAlterPayload,
531 );
532 }
533 Decision::drop_one(MetadataKind::Other, "APP14", size)
534}
535
536fn comment(payload: &[u8], size: u64, options: &InspectOptions) -> Decision {
538 Decision::drop_with(vec![
539 Finding::new(MetadataKind::Comment, "COM", size).with_value(options, || {
540 MetadataValue::Text(
541 String::from_utf8_lossy(payload)
542 .chars()
543 .filter(|c| !c.is_control())
544 .collect(),
545 )
546 }),
547 ])
548}
549
550fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
552 StryptError::Malformed {
553 format: Format::Jpeg,
554 offset,
555 detail,
556 }
557}
558
559fn as_offset(position: usize) -> Option<u64> {
561 u64::try_from(position).ok()
562}
563
564fn as_u64(value: usize) -> u64 {
566 u64::try_from(value).unwrap_or(u64::MAX)
567}
568
569#[cfg(test)]
570mod tests {
571 #![allow(
574 clippy::unwrap_used,
575 clippy::expect_used,
576 clippy::indexing_slicing,
577 clippy::arithmetic_side_effects
578 )]
579
580 use super::*;
581
582 fn jpeg(segments: &[(u8, Vec<u8>)]) -> Vec<u8> {
584 let mut out = vec![0xFF, SOI];
585 for (marker, payload) in segments {
586 out.push(0xFF);
587 out.push(*marker);
588 out.extend_from_slice(&u16::try_from(payload.len() + 2).unwrap().to_be_bytes());
589 out.extend_from_slice(payload);
590 }
591 out.extend_from_slice(&[0xFF, SOS, 0x00, 0x04, 0x01, 0x00]);
594 out.extend_from_slice(&[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56]);
595 out.extend_from_slice(&[0xFF, EOI]);
596 out
597 }
598
599 fn strip_ok(data: &[u8]) -> Stripped {
600 JpegHandler
601 .strip(data, &StripOptions::default())
602 .expect("strip failed")
603 }
604
605 fn findings(data: &[u8]) -> Vec<Finding> {
606 JpegHandler
607 .inspect(data, &InspectOptions::names_only())
608 .expect("inspect failed")
609 .findings
610 }
611
612 fn exif_app1(tag: u16, value: [u8; 4]) -> Vec<u8> {
613 let mut payload = b"Exif\0\0".to_vec();
614 payload.extend_from_slice(b"II\x2A\x00\x08\x00\x00\x00");
615 payload.extend_from_slice(&1u16.to_le_bytes());
616 payload.extend_from_slice(&tag.to_le_bytes());
617 payload.extend_from_slice(&2u16.to_le_bytes()); payload.extend_from_slice(&4u32.to_le_bytes());
619 payload.extend_from_slice(&value);
620 payload.extend_from_slice(&0u32.to_le_bytes());
621 payload
622 }
623
624 #[test]
625 fn the_picture_is_never_touched() {
626 let input = jpeg(&[(0xE1, exif_app1(0x010F, *b"ACME"))]);
629 let output = strip_ok(&input).bytes;
630 let scan_bytes: &[u8] = &[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56];
631 assert!(
632 output.windows(scan_bytes.len()).any(|w| w == scan_bytes),
633 "entropy-coded data did not survive byte for byte"
634 );
635 }
636
637 #[test]
638 fn exif_is_reported_by_tag_and_removed() {
639 let input = jpeg(&[(0xE1, exif_app1(0x010F, *b"ACME"))]);
640 let found = findings(&input);
641 assert_eq!(found[0].field.as_deref(), Some("Make"));
642 assert_eq!(found[0].kind, MetadataKind::DeviceIdentity);
643
644 let output = strip_ok(&input).bytes;
645 assert!(
646 !output.windows(4).any(|w| w == b"ACME"),
647 "the camera make survived the strip"
648 );
649 assert!(findings(&output).is_empty());
650 }
651
652 #[test]
653 fn a_comment_goes_and_the_tables_stay() {
654 let input = jpeg(&[(COM, b"SYNTHETIC-COMMENT".to_vec()), (0xDB, vec![0u8; 8])]);
657 let output = strip_ok(&input).bytes;
658 assert!(!output.windows(9).any(|w| w == b"SYNTHETIC"));
659 assert!(
660 output.windows(2).any(|w| w == [0xFF, 0xDB]),
661 "the quantisation table was dropped along with the comment"
662 );
663 }
664
665 #[test]
666 fn the_jfif_header_stays_and_its_thumbnail_does_not() {
667 let mut jfif = b"JFIF\0\x01\x02\x00\x00\x01\x00\x01".to_vec();
668 jfif.push(2); jfif.push(2); jfif.extend_from_slice(&[0xAB; 12]); let input = jpeg(&[(0xE0, jfif)]);
672
673 let stripped = strip_ok(&input);
674 assert!(
675 stripped
676 .report
677 .removed
678 .iter()
679 .any(|f| f.kind == MetadataKind::Thumbnail)
680 );
681 assert!(
682 stripped
683 .report
684 .retained
685 .iter()
686 .any(|r| r.location == "APP0 (JFIF)"),
687 "the JFIF header should be kept, and the report should say it was"
688 );
689 assert!(!stripped.bytes.windows(4).any(|w| w == [0xAB; 4]));
690 assert!(findings(&stripped.bytes).is_empty());
691 }
692
693 #[test]
694 fn the_adobe_colour_transform_marker_is_kept_and_declared() {
695 let input = jpeg(&[(0xEE, b"Adobe\0\x64\x00\x00\x00\x00\x02".to_vec())]);
698 let stripped = strip_ok(&input);
699 assert!(stripped.bytes.windows(2).any(|w| w == [0xFF, 0xEE]));
700 assert_eq!(stripped.report.retained.len(), 1);
701 assert_eq!(
702 stripped.report.retained[0].reason,
703 RetentionReason::RemovalWouldAlterPayload
704 );
705 }
706
707 #[test]
708 fn data_hidden_after_the_end_of_image_marker_is_removed() {
709 let mut input = jpeg(&[]);
712 input.extend_from_slice(&[0xFF, SOI]);
713 input.extend_from_slice(b"SYNTHETIC-SECOND-IMAGE");
714 let stripped = strip_ok(&input);
715 assert!(!stripped.bytes.windows(9).any(|w| w == b"SYNTHETIC"));
716 assert_eq!(stripped.report.removed[0].kind, MetadataKind::Thumbnail);
717 }
718
719 #[test]
720 fn stripping_twice_changes_nothing() {
721 let input = jpeg(&[
722 (0xE1, exif_app1(0x010F, *b"ACME")),
723 (COM, b"SYNTHETIC-COMMENT".to_vec()),
724 ]);
725 let once = strip_ok(&input).bytes;
726 let twice = strip_ok(&once).bytes;
727 assert_eq!(once, twice, "strip is not idempotent");
728 }
729
730 #[test]
731 fn a_clean_file_produces_no_findings_and_no_edits() {
732 let input = jpeg(&[(0xDB, vec![0u8; 8])]);
733 let stripped = strip_ok(&input);
734 assert!(stripped.report.removed.is_empty());
735 assert_eq!(stripped.bytes, input);
736 }
737
738 #[test]
739 fn a_file_that_ends_without_eoi_is_refused() {
740 let input = jpeg(&[]);
743 let truncated = &input[0..input.len() - 2];
744 assert!(matches!(
745 JpegHandler.strip(truncated, &StripOptions::default()),
746 Err(StryptError::Malformed { .. })
747 ));
748 }
749
750 #[test]
751 fn truncation_at_every_length_is_refused_or_survived_but_never_panics() {
752 let input = jpeg(&[
753 (0xE1, exif_app1(0x8825, [26, 0, 0, 0])),
754 (COM, b"comment".to_vec()),
755 ]);
756 for n in 0..=input.len() {
757 let prefix = &input[0..n];
758 let _ = JpegHandler.inspect(prefix, &InspectOptions::names_only());
759 let _ = JpegHandler.strip(prefix, &StripOptions::default());
760 }
761 }
762
763 #[test]
764 fn a_segment_length_of_zero_is_refused_rather_than_wrapping() {
765 let input = vec![0xFF, SOI, 0xFF, 0xE1, 0x00, 0x00, 0xFF, EOI];
768 assert!(matches!(
769 JpegHandler.inspect(&input, &InspectOptions::names_only()),
770 Err(StryptError::Malformed {
771 detail: MalformedDetail::LengthOutOfRange,
772 ..
773 })
774 ));
775 }
776
777 #[test]
778 fn a_segment_count_beyond_the_limit_is_refused() {
779 let segments: Vec<(u8, Vec<u8>)> = (0..64).map(|_| (0xDB, vec![0u8; 4])).collect();
780 let input = jpeg(&segments);
781 let options = StripOptions {
782 limits: ParseLimits {
783 max_items: 8,
784 ..ParseLimits::default()
785 },
786 ..StripOptions::default()
787 };
788 assert!(matches!(
789 JpegHandler.strip(&input, &options),
790 Err(StryptError::LimitExceeded { .. })
791 ));
792 }
793}