1use crate::detect::Format;
35use crate::error::{MalformedDetail, Result, StryptError, UnsupportedKind};
36use crate::formats::tags::{self, TagError};
37use crate::formats::{MetadataHandler, StripOptions, Stripped};
38use crate::report::{
39 Finding, InspectOptions, MetadataKind, MetadataReport, Note, Retained, RetentionReason,
40 StripReport,
41};
42
43#[derive(Debug, Clone, Copy, Default)]
45pub struct Mp3Handler;
46
47impl MetadataHandler for Mp3Handler {
48 fn name(&self) -> &'static str {
49 Format::Mp3.id()
50 }
51
52 fn format(&self) -> Format {
53 Format::Mp3
54 }
55
56 fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
57 let processed = process(input, options)?;
60 Ok(MetadataReport {
61 format: Format::Mp3,
62 findings: processed.findings,
63 notes: processed.notes,
64 })
65 }
66
67 fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
68 let processed = process(input, &options.inspect)?;
69 Ok(Stripped {
70 report: StripReport {
71 format: Format::Mp3,
72 removed: processed.findings,
73 retained: processed.retained,
74 notes: processed.notes,
75 input_bytes: as_u64(input.len()),
76 output_bytes: as_u64(processed.output.len()),
77 },
78 bytes: processed.output,
79 })
80 }
81}
82
83const MAX_LEADING_PADDING: usize = 4096;
90
91struct Processed {
93 findings: Vec<Finding>,
94 retained: Vec<Retained>,
95 notes: Vec<Note>,
96 output: Vec<u8>,
97}
98
99pub(crate) struct FrameHeader {
101 version: u8,
103 layer: u8,
105 channel_mode: u8,
107}
108
109impl FrameHeader {
110 const LAYER_III: u8 = 1;
113
114 const fn side_info_bytes(&self) -> usize {
119 match (self.version, self.channel_mode) {
120 (3, 3) => 17,
121 (3, _) => 32,
122 (_, 3) => 9,
123 (_, _) => 17,
124 }
125 }
126}
127
128pub(crate) fn frame_header(bytes: &[u8]) -> Option<FrameHeader> {
136 let (first, second, third) = (bytes.first()?, bytes.get(1)?, bytes.get(2)?);
137 if *first != 0xFF || second & 0xE0 != 0xE0 {
138 return None;
139 }
140 let version = (second >> 3) & 0x03;
141 let layer = (second >> 1) & 0x03;
142 if version == 1 || layer == 0 {
144 return None;
145 }
146 if third >> 4 == 0x0F || (third >> 2) & 0x03 == 0x03 {
148 return None;
149 }
150 Some(FrameHeader {
151 version,
152 layer,
153 channel_mode: bytes.get(3)? >> 6,
154 })
155}
156
157fn process(input: &[u8], options: &InspectOptions) -> Result<Processed> {
165 let (head, audio_start) = tags::head(input).map_err(convert)?;
166 let (tail, audio_end) = tags::tail(input, audio_start).map_err(convert)?;
167
168 let mut findings = Vec::new();
169 for tag in head.iter().chain(tail.iter()) {
170 findings.extend(tags::findings(tag, options));
171 }
172
173 let region = input
174 .get(audio_start..audio_end)
175 .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(audio_start)))?;
176 let padding = leading_padding(region)?;
177 let audio = region.get(padding..).unwrap_or_default();
178 if padding > 0 {
179 findings.push(Finding::new(
182 MetadataKind::Other,
183 "zero padding before the first frame",
184 as_u64(padding),
185 ));
186 }
187
188 let Some(header) = frame_header(audio) else {
189 return Err(malformed(
192 MalformedDetail::MissingMarker,
193 as_offset(audio_start.saturating_add(padding)),
194 ));
195 };
196 if header.layer != FrameHeader::LAYER_III {
197 return Err(StryptError::UnsupportedFormat {
198 format: UnsupportedKind::MpegAudioNotLayerThree,
199 });
200 }
201
202 let mut retained = Vec::new();
203 if let Some(marker) = vbr_header(audio, &header) {
204 retained.push(Retained {
208 location: format!("{marker} header frame, which names the encoder"),
209 reason: RetentionReason::RemovalWouldAlterPayload,
210 });
211 }
212
213 let notes = vec![Note::OutOfScopeContent {
216 location: "audio frames, which are copied without being decoded".to_owned(),
217 }];
218
219 Ok(Processed {
220 findings,
221 retained,
222 notes,
223 output: audio.to_vec(),
224 })
225}
226
227fn leading_padding(region: &[u8]) -> Result<usize> {
229 let run = region
230 .iter()
231 .take(MAX_LEADING_PADDING)
232 .take_while(|byte| **byte == 0)
233 .count();
234 if run == 0 || region.get(run).is_some_and(|byte| *byte == 0xFF) {
235 return Ok(run);
236 }
237 Err(malformed(MalformedDetail::UnexpectedMarker, as_offset(run)))
238}
239
240fn vbr_header(audio: &[u8], header: &FrameHeader) -> Option<&'static str> {
245 let at = header.side_info_bytes().saturating_add(4);
246 let marker = audio.get(at..at.saturating_add(4));
247 if marker == Some(b"Xing") {
248 return Some("Xing");
249 }
250 if marker == Some(b"Info") {
251 return Some("Info");
252 }
253 if audio.get(36..40) == Some(b"VBRI") {
254 return Some("VBRI");
255 }
256 None
257}
258
259fn convert(error: TagError) -> StryptError {
261 match error {
262 TagError::Malformed { detail, offset } => malformed(detail, as_offset(offset)),
263 TagError::Limit(limit) => StryptError::LimitExceeded {
264 format: Format::Mp3,
265 limit,
266 },
267 }
268}
269
270fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
272 StryptError::Malformed {
273 format: Format::Mp3,
274 offset,
275 detail,
276 }
277}
278
279fn as_offset(position: usize) -> Option<u64> {
281 u64::try_from(position).ok()
282}
283
284fn as_u64(value: usize) -> u64 {
286 u64::try_from(value).unwrap_or(u64::MAX)
287}
288
289#[cfg(test)]
290mod tests {
291 #![allow(
294 clippy::unwrap_used,
295 clippy::expect_used,
296 clippy::panic,
297 clippy::indexing_slicing,
298 clippy::arithmetic_side_effects
299 )]
300
301 use super::*;
302 use crate::report::MetadataValue;
303
304 const FRAME_BYTES: usize = 417;
307
308 fn frame() -> Vec<u8> {
309 let mut out = vec![0xFF, 0xFB, 0x90, 0xC0];
310 out.resize(FRAME_BYTES, 0);
311 out
312 }
313
314 fn audio() -> Vec<u8> {
315 let mut out = Vec::new();
316 for _ in 0..4 {
317 out.extend_from_slice(&frame());
318 }
319 out
320 }
321
322 fn byte(n: usize) -> u8 {
323 u8::try_from(n & 0x7F).unwrap()
324 }
325
326 fn syncsafe(n: usize) -> [u8; 4] {
327 [byte(n >> 21), byte(n >> 14), byte(n >> 7), byte(n)]
328 }
329
330 fn id3v2(frames: &[(&[u8], &[u8])]) -> Vec<u8> {
331 let mut body = Vec::new();
332 for (id, text) in frames {
333 let mut payload = vec![0x03u8];
334 payload.extend_from_slice(text);
335 body.extend_from_slice(id);
336 body.extend_from_slice(&syncsafe(payload.len()));
337 body.extend_from_slice(&[0, 0]);
338 body.extend_from_slice(&payload);
339 }
340 let mut out = b"ID3\x04\x00\x00".to_vec();
341 out.extend_from_slice(&syncsafe(body.len()));
342 out.extend_from_slice(&body);
343 out
344 }
345
346 fn id3v1(artist: &[u8]) -> Vec<u8> {
347 let mut out = vec![0u8; 128];
348 out[0..3].copy_from_slice(b"TAG");
349 out[33..33 + artist.len()].copy_from_slice(artist);
350 out
351 }
352
353 fn strip_ok(data: &[u8]) -> Stripped {
354 Mp3Handler
355 .strip(data, &StripOptions::default())
356 .expect("strip failed")
357 }
358
359 fn findings(data: &[u8]) -> Vec<Finding> {
360 Mp3Handler
361 .inspect(data, &InspectOptions::names_only())
362 .expect("inspect failed")
363 .findings
364 }
365
366 fn contains(haystack: &[u8], needle: &[u8]) -> bool {
367 haystack.windows(needle.len()).any(|w| w == needle)
368 }
369
370 #[test]
371 fn a_file_with_no_tags_strips_to_a_byte_identical_copy() {
372 let input = audio();
374 let stripped = strip_ok(&input);
375 assert!(stripped.report.removed.is_empty());
376 assert_eq!(stripped.bytes, input);
377 }
378
379 #[test]
380 fn a_head_tag_is_removed_and_itemised() {
381 let mut input = id3v2(&[
382 (b"TPE1", b"SYNTHETIC-ARTIST-0001"),
383 (b"TSSE", b"SYNTHETIC-ENCODER-0002"),
384 ]);
385 input.extend_from_slice(&audio());
386
387 let found = findings(&input);
388 let fields: Vec<&str> = found.iter().filter_map(|f| f.field.as_deref()).collect();
389 assert!(fields.contains(&"TPE1"));
390 assert!(fields.contains(&"TSSE"));
391 let stripped = strip_ok(&input);
392 assert!(!contains(&stripped.bytes, b"SYNTHETIC-ARTIST-0001"));
393 assert_eq!(stripped.bytes, audio(), "the frames did not cross intact");
394 }
395
396 #[test]
397 fn a_tail_tag_is_removed_and_itemised() {
398 let mut input = audio();
399 input.extend_from_slice(&id3v1(b"SYNTHETIC-ARTIST-0003"));
400 let stripped = strip_ok(&input);
401 assert_eq!(stripped.report.removed[0].field.as_deref(), Some("Artist"));
402 assert_eq!(stripped.bytes, audio());
403 }
404
405 #[test]
406 fn tags_at_both_ends_go_in_one_pass() {
407 let mut input = id3v2(&[(b"TIT2", b"SYNTHETIC-TITLE-0004")]);
408 input.extend_from_slice(&audio());
409 input.extend_from_slice(&id3v1(b"SYNTHETIC-ARTIST-0005"));
410 let stripped = strip_ok(&input);
411 assert_eq!(stripped.bytes, audio());
412 assert!(!contains(&stripped.bytes, b"SYNTHETIC"));
413 }
414
415 #[test]
416 fn a_value_is_reported_only_when_the_caller_asks() {
417 let mut input = id3v2(&[(b"TPE1", b"SYNTHETIC-ARTIST-0006")]);
418 input.extend_from_slice(&audio());
419 assert!(findings(&input).iter().all(|f| f.value.is_none()));
420
421 let report = Mp3Handler
422 .inspect(&input, &InspectOptions::with_values())
423 .unwrap();
424 assert!(
425 report
426 .findings
427 .iter()
428 .any(|f| f.value == Some(MetadataValue::Text("SYNTHETIC-ARTIST-0006".to_owned())))
429 );
430 }
431
432 #[test]
433 fn a_vbr_header_stays_and_is_declared() {
434 let mut first = frame();
437 first[4 + 17..4 + 17 + 4].copy_from_slice(b"Xing");
438 first[4 + 17 + 12..4 + 17 + 21].copy_from_slice(b"LAME3.100");
439 let mut input = first;
440 input.extend_from_slice(&audio());
441
442 let stripped = strip_ok(&input);
443 assert_eq!(
444 stripped.report.retained[0].reason,
445 RetentionReason::RemovalWouldAlterPayload
446 );
447 assert!(stripped.report.retained[0].location.starts_with("Xing"));
448 assert!(
449 contains(&stripped.bytes, b"LAME3.100"),
450 "the VBR frame was not copied through"
451 );
452 }
453
454 #[test]
455 fn every_file_says_the_frames_were_not_examined() {
456 let notes = Mp3Handler
457 .inspect(&audio(), &InspectOptions::names_only())
458 .unwrap()
459 .notes;
460 assert!(matches!(
461 notes.first(),
462 Some(Note::OutOfScopeContent { location }) if location.starts_with("audio frames")
463 ));
464 }
465
466 #[test]
467 fn zero_padding_before_the_first_frame_is_dropped_and_reported() {
468 let mut input = id3v2(&[(b"TIT2", b"SYNTHETIC-0007")]);
469 input.extend_from_slice(&[0u8; 64]);
470 input.extend_from_slice(&audio());
471 let stripped = strip_ok(&input);
472 assert!(
473 stripped
474 .report
475 .removed
476 .iter()
477 .any(|f| f.location.starts_with("zero padding")),
478 "the size change went unaccounted for"
479 );
480 assert_eq!(stripped.bytes, audio());
481 }
482
483 #[test]
484 fn anything_other_than_zeros_in_front_of_the_audio_is_refused() {
485 let mut input = id3v2(&[(b"TIT2", b"x")]);
488 input.extend_from_slice(b"SYNTHETIC-HIDDEN-0008");
489 input.extend_from_slice(&audio());
490 assert!(matches!(
491 Mp3Handler.strip(&input, &StripOptions::default()),
492 Err(StryptError::Malformed { .. })
493 ));
494 }
495
496 #[test]
497 fn a_file_that_is_nothing_but_tags_is_refused() {
498 let mut input = id3v2(&[(b"TPE1", b"SYNTHETIC-0009")]);
501 input.extend_from_slice(&id3v1(b"SYNTHETIC-0010"));
502 assert!(matches!(
503 Mp3Handler.strip(&input, &StripOptions::default()),
504 Err(StryptError::Malformed {
505 detail: MalformedDetail::MissingMarker,
506 ..
507 })
508 ));
509 }
510
511 #[test]
512 fn a_reserved_field_in_the_first_frame_header_is_refused() {
513 for (at, value) in [(1u8, 0xEBu8), (1, 0xF9), (2, 0xF0), (2, 0x9C)] {
515 let mut input = audio();
516 input[usize::from(at)] = value;
517 assert!(
518 Mp3Handler.strip(&input, &StripOptions::default()).is_err(),
519 "byte {at} = {value:#04x} was accepted"
520 );
521 }
522 }
523
524 #[test]
525 fn layer_one_and_layer_two_are_refused_by_name() {
526 for second in [0xFDu8, 0xFF] {
529 let mut input = audio();
530 input[1] = second;
531 match Mp3Handler.strip(&input, &StripOptions::default()) {
532 Err(StryptError::UnsupportedFormat { format }) => {
533 assert_eq!(format, UnsupportedKind::MpegAudioNotLayerThree);
534 }
535 other => panic!("byte 1 = {second:#04x} gave {other:?}"),
536 }
537 }
538 }
539
540 #[test]
541 fn stripping_twice_changes_nothing() {
542 let mut input = id3v2(&[(b"TPE1", b"SYNTHETIC-0011"), (b"APIC", b"SYNTHETIC-0012")]);
543 input.extend_from_slice(&audio());
544 input.extend_from_slice(&id3v1(b"SYNTHETIC-0013"));
545 let once = strip_ok(&input).bytes;
546 let twice = strip_ok(&once).bytes;
547 assert_eq!(once, twice, "strip is not idempotent");
548 }
549
550 #[test]
551 fn truncation_at_every_length_is_refused_or_survived_but_never_panics() {
552 let mut input = id3v2(&[(b"TPE1", b"SYNTHETIC-0014")]);
553 input.extend_from_slice(&audio());
554 input.extend_from_slice(&id3v1(b"SYNTHETIC-0015"));
555 for n in 0..=input.len() {
556 let prefix = &input[0..n];
557 let _ = Mp3Handler.inspect(prefix, &InspectOptions::names_only());
558 let _ = Mp3Handler.strip(prefix, &StripOptions::default());
559 }
560 }
561}