Skip to main content

strypt_core/formats/
mp3.rs

1//! MP3.
2//!
3//! The third tranche of Phase 2's fourth group (ADR-0037), and the one format in this project that
4//! is **not a container at all**. There is no header describing the file, no index, no chunk list:
5//! an MP3 is a run of self-describing MPEG audio frames, and every piece of metadata it carries was
6//! glued to one end or the other by a tagger. An ID3v2 tag at the head; ID3v1, APE, and Lyrics3 at
7//! the tail; frames in between (ADR-0040 — required reading before touching this handler).
8//!
9//! # Edited by deletion at both ends, never re-encoded
10//!
11//! The tags are dropped whole and the frames are copied verbatim, so a file with no tags comes back
12//! **byte-identical** — GIF's, JPEG XL's, FLAC's and WAV's property. Nothing here decodes audio and
13//! nothing rewrites a field: unlike every other format strypt handles, an MP3 has no length,
14//! offset, or flag anywhere that removal could invalidate, because nothing in it points at anything
15//! else.
16//!
17//! # The output is what is left, not what was kept
18//!
19//! There is no allow-list here because there is nothing to allow-list: the frames are the payload
20//! and everything that is not a frame goes. That makes the *boundary* the whole of the safety
21//! argument, which is why [`super::tags`] refuses a tag length rather than clamping it, and why
22//! this module demands a real frame header where the tags stop (§2.4.2.3 of ISO/IEC 11172-3). A
23//! tag that lied about its length would otherwise take audio with it, or leave metadata behind as
24//! "audio".
25//!
26//! # What stays, and it is measured rather than assumed
27//!
28//! A VBR header — `Xing`, `Info`, or `VBRI` — is a real MPEG frame that decoders decode as silence,
29//! and the LAME extension inside it names the encoder and its settings. It is a software
30//! fingerprint, it stays, and the report says so: it is *inside the encoded stream*, which
31//! ADR-0037 commits this group to never entering, and removing it would break VBR seeking and
32//! gapless playback. mat2 leaves it too (`docs/THREAT_MODEL.md` §7.15).
33
34use 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/// Removal of metadata from MP3 audio.
44#[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        // Inspection runs the identical pass that stripping does and throws the output away, so
58        // "everything `strip` removes is something `inspect` can see" holds by construction.
59        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
83/// How many zero bytes may sit between the head tags and the first frame.
84///
85/// Some taggers pad past the size their own header declares. The bytes are zeros, so they hide
86/// nothing, and they are dropped rather than copied. Anything that is *not* zero there refuses the
87/// file: a run of arbitrary bytes in front of the audio is exactly where something would be hidden
88/// from a tool that skipped ahead to the first sync.
89const MAX_LEADING_PADDING: usize = 4096;
90
91/// The result of one pass over a file.
92struct Processed {
93    findings: Vec<Finding>,
94    retained: Vec<Retained>,
95    notes: Vec<Note>,
96    output: Vec<u8>,
97}
98
99/// One MPEG audio frame header (ISO/IEC 11172-3 §2.4.2.3, ISO/IEC 13818-3 for MPEG-2).
100pub(crate) struct FrameHeader {
101    /// `3` for MPEG-1, `2` for MPEG-2, `0` for the unofficial MPEG-2.5.
102    version: u8,
103    /// `1` for Layer III, `2` for Layer II, `3` for Layer I.
104    layer: u8,
105    /// `3` for single channel; anything else has two.
106    channel_mode: u8,
107}
108
109impl FrameHeader {
110    /// Layer III, which is what "MP3" means. Layers I and II are a different format in the same
111    /// frame grammar, and Phase 2's scope is MP3 (ADR-0027).
112    const LAYER_III: u8 = 1;
113
114    /// How many bytes of side information follow the header (§2.4.1.7).
115    ///
116    /// It is the region a `Xing` or `Info` header sits behind, and its length depends on both the
117    /// MPEG version and the channel count.
118    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
128/// Read a frame header, or [`None`] when these four bytes are not one.
129///
130/// Reserved values in the version, layer, bitrate and sampling-frequency fields are all rejected,
131/// rather than only the eleven-bit sync being matched. `FF Ex` is a common byte pair in any binary
132/// file, so the weaker test would claim files that are not audio at all — and this same function is
133/// what [`crate::detect`] routes on, so a false positive there becomes a refusal the user has to
134/// read (ADR-0040).
135pub(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    // `01` is reserved in the version field and `00` in the layer field.
143    if version == 1 || layer == 0 {
144        return None;
145    }
146    // `1111` in the bitrate index and `11` in the sampling-frequency index are both forbidden.
147    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
157/// Walk `input`, decide about every tag, and build the sanitised file.
158///
159/// [`crate::formats::ParseLimits`] is not taken, and that is deliberate rather than an oversight:
160/// this format has
161/// no nesting, no item list, and nothing that decompresses. The only counts here are how many tags
162/// may be stacked at one end and how many items inside one are itemised, and both are structural
163/// constants of the tag formats rather than a caller's policy ([`super::tags`]).
164fn 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        // Zeros, so nothing identifying is in them — but the file changes size, and a report that
180        // did not account for that would be a report the output does not match.
181        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        // The only structural check this format has. Without it a tag that lied about its length
190        // would take audio with it, or leave metadata behind under the name "audio".
191        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        // Inside the encoded stream, which this group never enters (ADR-0037). Declared rather
205        // than passed over: the LAME extension behind that marker names the encoder and its
206        // settings, and a user is entitled to know it is still in the file.
207        retained.push(Retained {
208            location: format!("{marker} header frame, which names the encoder"),
209            reason: RetentionReason::RemovalWouldAlterPayload,
210        });
211    }
212
213    // Said on every file, clean ones included. The frames are copied without being decoded, so
214    // anything in a frame's ancillary data or between frames is out of reach rather than absent.
215    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
227/// How many zero bytes precede the first frame. Refuses anything else in front of the audio.
228fn 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
240/// The VBR header a variable-bitrate encoder writes into the first frame, if there is one.
241///
242/// `Xing` and `Info` sit behind the side information; `VBRI` is Fraunhofer's spelling and sits at a
243/// fixed offset of 32 bytes regardless of it.
244fn 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
259/// A tag-layer failure as this format's error.
260fn 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
270/// A malformed-file error for this format.
271fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
272    StryptError::Malformed {
273        format: Format::Mp3,
274        offset,
275        detail,
276    }
277}
278
279/// A byte position as a reportable offset.
280fn as_offset(position: usize) -> Option<u64> {
281    u64::try_from(position).ok()
282}
283
284/// Widen a length for reporting. Saturating: a report field is not worth failing a strip over.
285fn as_u64(value: usize) -> u64 {
286    u64::try_from(value).unwrap_or(u64::MAX)
287}
288
289#[cfg(test)]
290mod tests {
291    // Test code is never reachable from untrusted bytes, which is the boundary the panic-freedom
292    // lints exist to police (ADR-0006).
293    #![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    /// One MPEG-1 Layer III frame at 128 kbps, 44.1 kHz, mono: 417 bytes of which the first four
305    /// are the header and the rest is zeroed, which decodes as silence.
306    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        // Deletion at both ends can promise this, and so it must. TIFF and HEIF cannot.
373        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        // It is a real frame inside the encoded stream, which this group never enters (ADR-0037),
435        // and the LAME extension behind the marker names the encoder. Kept, and said out loud.
436        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        // A run of arbitrary bytes there is exactly where something would be hidden from a tool
486        // that skipped ahead to the first sync.
487        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        // It would otherwise strip to an empty file and be reported as a success, which is the
499        // fail-closed rule's worst case.
500        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        // Stricter than detection on purpose: this header decides where the audio begins.
514        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        // The same frame grammar, a different format, and Phase 2's scope is MP3 (ADR-0027).
527        // MPEG-1 Layer II and Layer I, which differ from `0xFB` only in the two layer bits.
528        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}