use crate::detect::Format;
use crate::error::{MalformedDetail, Result, StryptError, UnsupportedKind};
use crate::formats::tags::{self, TagError};
use crate::formats::{MetadataHandler, StripOptions, Stripped};
use crate::report::{
Finding, InspectOptions, MetadataKind, MetadataReport, Note, Retained, RetentionReason,
StripReport,
};
#[derive(Debug, Clone, Copy, Default)]
pub struct Mp3Handler;
impl MetadataHandler for Mp3Handler {
fn name(&self) -> &'static str {
Format::Mp3.id()
}
fn format(&self) -> Format {
Format::Mp3
}
fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
let processed = process(input, options)?;
Ok(MetadataReport {
format: Format::Mp3,
findings: processed.findings,
notes: processed.notes,
})
}
fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
let processed = process(input, &options.inspect)?;
Ok(Stripped {
report: StripReport {
format: Format::Mp3,
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 MAX_LEADING_PADDING: usize = 4096;
struct Processed {
findings: Vec<Finding>,
retained: Vec<Retained>,
notes: Vec<Note>,
output: Vec<u8>,
}
pub(crate) struct FrameHeader {
version: u8,
layer: u8,
channel_mode: u8,
}
impl FrameHeader {
const LAYER_III: u8 = 1;
const fn side_info_bytes(&self) -> usize {
match (self.version, self.channel_mode) {
(3, 3) => 17,
(3, _) => 32,
(_, 3) => 9,
(_, _) => 17,
}
}
}
pub(crate) fn frame_header(bytes: &[u8]) -> Option<FrameHeader> {
let (first, second, third) = (bytes.first()?, bytes.get(1)?, bytes.get(2)?);
if *first != 0xFF || second & 0xE0 != 0xE0 {
return None;
}
let version = (second >> 3) & 0x03;
let layer = (second >> 1) & 0x03;
if version == 1 || layer == 0 {
return None;
}
if third >> 4 == 0x0F || (third >> 2) & 0x03 == 0x03 {
return None;
}
Some(FrameHeader {
version,
layer,
channel_mode: bytes.get(3)? >> 6,
})
}
fn process(input: &[u8], options: &InspectOptions) -> Result<Processed> {
let (head, audio_start) = tags::head(input).map_err(convert)?;
let (tail, audio_end) = tags::tail(input, audio_start).map_err(convert)?;
let mut findings = Vec::new();
for tag in head.iter().chain(tail.iter()) {
findings.extend(tags::findings(tag, options));
}
let region = input
.get(audio_start..audio_end)
.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(audio_start)))?;
let padding = leading_padding(region)?;
let audio = region.get(padding..).unwrap_or_default();
if padding > 0 {
findings.push(Finding::new(
MetadataKind::Other,
"zero padding before the first frame",
as_u64(padding),
));
}
let Some(header) = frame_header(audio) else {
return Err(malformed(
MalformedDetail::MissingMarker,
as_offset(audio_start.saturating_add(padding)),
));
};
if header.layer != FrameHeader::LAYER_III {
return Err(StryptError::UnsupportedFormat {
format: UnsupportedKind::MpegAudioNotLayerThree,
});
}
let mut retained = Vec::new();
if let Some(marker) = vbr_header(audio, &header) {
retained.push(Retained {
location: format!("{marker} header frame, which names the encoder"),
reason: RetentionReason::RemovalWouldAlterPayload,
});
}
let notes = vec![Note::OutOfScopeContent {
location: "audio frames, which are copied without being decoded".to_owned(),
}];
Ok(Processed {
findings,
retained,
notes,
output: audio.to_vec(),
})
}
fn leading_padding(region: &[u8]) -> Result<usize> {
let run = region
.iter()
.take(MAX_LEADING_PADDING)
.take_while(|byte| **byte == 0)
.count();
if run == 0 || region.get(run).is_some_and(|byte| *byte == 0xFF) {
return Ok(run);
}
Err(malformed(MalformedDetail::UnexpectedMarker, as_offset(run)))
}
fn vbr_header(audio: &[u8], header: &FrameHeader) -> Option<&'static str> {
let at = header.side_info_bytes().saturating_add(4);
let marker = audio.get(at..at.saturating_add(4));
if marker == Some(b"Xing") {
return Some("Xing");
}
if marker == Some(b"Info") {
return Some("Info");
}
if audio.get(36..40) == Some(b"VBRI") {
return Some("VBRI");
}
None
}
fn convert(error: TagError) -> StryptError {
match error {
TagError::Malformed { detail, offset } => malformed(detail, as_offset(offset)),
TagError::Limit(limit) => StryptError::LimitExceeded {
format: Format::Mp3,
limit,
},
}
}
fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
StryptError::Malformed {
format: Format::Mp3,
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::panic,
clippy::indexing_slicing,
clippy::arithmetic_side_effects
)]
use super::*;
use crate::report::MetadataValue;
const FRAME_BYTES: usize = 417;
fn frame() -> Vec<u8> {
let mut out = vec![0xFF, 0xFB, 0x90, 0xC0];
out.resize(FRAME_BYTES, 0);
out
}
fn audio() -> Vec<u8> {
let mut out = Vec::new();
for _ in 0..4 {
out.extend_from_slice(&frame());
}
out
}
fn byte(n: usize) -> u8 {
u8::try_from(n & 0x7F).unwrap()
}
fn syncsafe(n: usize) -> [u8; 4] {
[byte(n >> 21), byte(n >> 14), byte(n >> 7), byte(n)]
}
fn id3v2(frames: &[(&[u8], &[u8])]) -> Vec<u8> {
let mut body = Vec::new();
for (id, text) in frames {
let mut payload = vec![0x03u8];
payload.extend_from_slice(text);
body.extend_from_slice(id);
body.extend_from_slice(&syncsafe(payload.len()));
body.extend_from_slice(&[0, 0]);
body.extend_from_slice(&payload);
}
let mut out = b"ID3\x04\x00\x00".to_vec();
out.extend_from_slice(&syncsafe(body.len()));
out.extend_from_slice(&body);
out
}
fn id3v1(artist: &[u8]) -> Vec<u8> {
let mut out = vec![0u8; 128];
out[0..3].copy_from_slice(b"TAG");
out[33..33 + artist.len()].copy_from_slice(artist);
out
}
fn strip_ok(data: &[u8]) -> Stripped {
Mp3Handler
.strip(data, &StripOptions::default())
.expect("strip failed")
}
fn findings(data: &[u8]) -> Vec<Finding> {
Mp3Handler
.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 a_file_with_no_tags_strips_to_a_byte_identical_copy() {
let input = audio();
let stripped = strip_ok(&input);
assert!(stripped.report.removed.is_empty());
assert_eq!(stripped.bytes, input);
}
#[test]
fn a_head_tag_is_removed_and_itemised() {
let mut input = id3v2(&[
(b"TPE1", b"SYNTHETIC-ARTIST-0001"),
(b"TSSE", b"SYNTHETIC-ENCODER-0002"),
]);
input.extend_from_slice(&audio());
let found = findings(&input);
let fields: Vec<&str> = found.iter().filter_map(|f| f.field.as_deref()).collect();
assert!(fields.contains(&"TPE1"));
assert!(fields.contains(&"TSSE"));
let stripped = strip_ok(&input);
assert!(!contains(&stripped.bytes, b"SYNTHETIC-ARTIST-0001"));
assert_eq!(stripped.bytes, audio(), "the frames did not cross intact");
}
#[test]
fn a_tail_tag_is_removed_and_itemised() {
let mut input = audio();
input.extend_from_slice(&id3v1(b"SYNTHETIC-ARTIST-0003"));
let stripped = strip_ok(&input);
assert_eq!(stripped.report.removed[0].field.as_deref(), Some("Artist"));
assert_eq!(stripped.bytes, audio());
}
#[test]
fn tags_at_both_ends_go_in_one_pass() {
let mut input = id3v2(&[(b"TIT2", b"SYNTHETIC-TITLE-0004")]);
input.extend_from_slice(&audio());
input.extend_from_slice(&id3v1(b"SYNTHETIC-ARTIST-0005"));
let stripped = strip_ok(&input);
assert_eq!(stripped.bytes, audio());
assert!(!contains(&stripped.bytes, b"SYNTHETIC"));
}
#[test]
fn a_value_is_reported_only_when_the_caller_asks() {
let mut input = id3v2(&[(b"TPE1", b"SYNTHETIC-ARTIST-0006")]);
input.extend_from_slice(&audio());
assert!(findings(&input).iter().all(|f| f.value.is_none()));
let report = Mp3Handler
.inspect(&input, &InspectOptions::with_values())
.unwrap();
assert!(
report
.findings
.iter()
.any(|f| f.value == Some(MetadataValue::Text("SYNTHETIC-ARTIST-0006".to_owned())))
);
}
#[test]
fn a_vbr_header_stays_and_is_declared() {
let mut first = frame();
first[4 + 17..4 + 17 + 4].copy_from_slice(b"Xing");
first[4 + 17 + 12..4 + 17 + 21].copy_from_slice(b"LAME3.100");
let mut input = first;
input.extend_from_slice(&audio());
let stripped = strip_ok(&input);
assert_eq!(
stripped.report.retained[0].reason,
RetentionReason::RemovalWouldAlterPayload
);
assert!(stripped.report.retained[0].location.starts_with("Xing"));
assert!(
contains(&stripped.bytes, b"LAME3.100"),
"the VBR frame was not copied through"
);
}
#[test]
fn every_file_says_the_frames_were_not_examined() {
let notes = Mp3Handler
.inspect(&audio(), &InspectOptions::names_only())
.unwrap()
.notes;
assert!(matches!(
notes.first(),
Some(Note::OutOfScopeContent { location }) if location.starts_with("audio frames")
));
}
#[test]
fn zero_padding_before_the_first_frame_is_dropped_and_reported() {
let mut input = id3v2(&[(b"TIT2", b"SYNTHETIC-0007")]);
input.extend_from_slice(&[0u8; 64]);
input.extend_from_slice(&audio());
let stripped = strip_ok(&input);
assert!(
stripped
.report
.removed
.iter()
.any(|f| f.location.starts_with("zero padding")),
"the size change went unaccounted for"
);
assert_eq!(stripped.bytes, audio());
}
#[test]
fn anything_other_than_zeros_in_front_of_the_audio_is_refused() {
let mut input = id3v2(&[(b"TIT2", b"x")]);
input.extend_from_slice(b"SYNTHETIC-HIDDEN-0008");
input.extend_from_slice(&audio());
assert!(matches!(
Mp3Handler.strip(&input, &StripOptions::default()),
Err(StryptError::Malformed { .. })
));
}
#[test]
fn a_file_that_is_nothing_but_tags_is_refused() {
let mut input = id3v2(&[(b"TPE1", b"SYNTHETIC-0009")]);
input.extend_from_slice(&id3v1(b"SYNTHETIC-0010"));
assert!(matches!(
Mp3Handler.strip(&input, &StripOptions::default()),
Err(StryptError::Malformed {
detail: MalformedDetail::MissingMarker,
..
})
));
}
#[test]
fn a_reserved_field_in_the_first_frame_header_is_refused() {
for (at, value) in [(1u8, 0xEBu8), (1, 0xF9), (2, 0xF0), (2, 0x9C)] {
let mut input = audio();
input[usize::from(at)] = value;
assert!(
Mp3Handler.strip(&input, &StripOptions::default()).is_err(),
"byte {at} = {value:#04x} was accepted"
);
}
}
#[test]
fn layer_one_and_layer_two_are_refused_by_name() {
for second in [0xFDu8, 0xFF] {
let mut input = audio();
input[1] = second;
match Mp3Handler.strip(&input, &StripOptions::default()) {
Err(StryptError::UnsupportedFormat { format }) => {
assert_eq!(format, UnsupportedKind::MpegAudioNotLayerThree);
}
other => panic!("byte 1 = {second:#04x} gave {other:?}"),
}
}
}
#[test]
fn stripping_twice_changes_nothing() {
let mut input = id3v2(&[(b"TPE1", b"SYNTHETIC-0011"), (b"APIC", b"SYNTHETIC-0012")]);
input.extend_from_slice(&audio());
input.extend_from_slice(&id3v1(b"SYNTHETIC-0013"));
let once = strip_ok(&input).bytes;
let twice = strip_ok(&once).bytes;
assert_eq!(once, twice, "strip is not idempotent");
}
#[test]
fn truncation_at_every_length_is_refused_or_survived_but_never_panics() {
let mut input = id3v2(&[(b"TPE1", b"SYNTHETIC-0014")]);
input.extend_from_slice(&audio());
input.extend_from_slice(&id3v1(b"SYNTHETIC-0015"));
for n in 0..=input.len() {
let prefix = &input[0..n];
let _ = Mp3Handler.inspect(prefix, &InspectOptions::names_only());
let _ = Mp3Handler.strip(prefix, &StripOptions::default());
}
}
}