Skip to main content

musefs_format/
mp3.rs

1use crate::convert::usize_from;
2use crate::error::{FormatError, Result};
3use crate::input::{
4    ArtInput, BinaryTagInput, EmbeddedBinaryTag, EmbeddedPicture, PictureType, TagInput,
5};
6use crate::layout::{RegionLayout, Segment};
7use crate::probe::Extent;
8use crate::size;
9
10/// Where the MP3 audio frames begin and end (excluding any ID3v2 prefix and
11/// ID3v1 trailer). Unlike FLAC there is no preserved structural metadata: the
12/// ID3v2 tag is regenerated from the DB, and the Xing/LAME info frame lives
13/// inside the first audio frame, carried by the backing-audio segment.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Mp3Bounds {
16    pub audio_offset: u64,
17    pub audio_length: u64,
18}
19
20fn synchsafe_decode(b: &[u8]) -> u32 {
21    u32::from(b[0] & 0x7F) << 21
22        | u32::from(b[1] & 0x7F) << 14
23        | u32::from(b[2] & 0x7F) << 7
24        | u32::from(b[3] & 0x7F)
25}
26
27/// Decode an ID3v2 frame's 4-byte size field. ID3v2.4 sizes are syncsafe (a 28-bit
28/// value with the high bit of each byte cleared); v2.3 and earlier use a plain
29/// big-endian `u32`. `major_version` is the tag header's version byte (ID3v2
30/// offset 3). The write path emits v2.4 syncsafe sizes via [`syncsafe`]; keeping
31/// the version-dependent rule named here keeps read decode and write encode from
32/// silently drifting apart.
33fn decode_frame_size(major_version: u8, raw: &[u8]) -> u32 {
34    if major_version == 3 {
35        u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]])
36    } else {
37        synchsafe_decode(raw)
38    }
39}
40
41fn id3v2_header_len(data: &[u8]) -> Result<Option<usize>> {
42    if data.len() < 10 || &data[0..3] != b"ID3" {
43        return Ok(None);
44    }
45    if !matches!(data[3], 2..=4) {
46        return Err(FormatError::Malformed);
47    }
48    // A well-formed synchsafe size has the high bit clear in every byte; reject
49    // if any size byte has it set (the id3 crate may not mask those bits).
50    if data[6..10].iter().any(|&b| b & 0x80 != 0) {
51        return Err(FormatError::Malformed);
52    }
53    Ok(Some(10 + synchsafe_decode(&data[6..10]) as usize))
54}
55
56/// Locate the audio region: skip a leading ID3v2 tag (if present) and a trailing
57/// 128-byte ID3v1 tag (if present), then require an MPEG frame sync at the audio
58/// offset. The synthesized file re-prepends a fresh ID3v2 tag, so the original
59/// one is intentionally *not* preserved.
60pub fn locate_audio(data: &[u8]) -> Result<Mp3Bounds> {
61    let len = data.len();
62
63    let mut audio_offset = 0usize;
64    if let Some(base) = id3v2_header_len(data)? {
65        let flags = data[5];
66        let mut tag_len = base;
67        if flags & 0x10 != 0 {
68            tag_len += 10; // ID3v2.4 footer
69        }
70        if tag_len > len {
71            return Err(FormatError::Malformed);
72        }
73        audio_offset = tag_len;
74    }
75
76    let mut audio_end = len;
77    if audio_end >= audio_offset + 128 && &data[audio_end - 128..audio_end - 125] == b"TAG" {
78        audio_end -= 128; // strip ID3v1 trailer
79    }
80
81    // Require an MPEG audio frame sync (11 set bits) at the audio offset.
82    if audio_offset + 1 >= len
83        || data[audio_offset] != 0xFF
84        || (data[audio_offset + 1] & 0xE0) != 0xE0
85    {
86        return Err(FormatError::NotMp3);
87    }
88
89    Ok(Mp3Bounds {
90        audio_offset: audio_offset as u64,
91        audio_length: (audio_end - audio_offset) as u64,
92    })
93}
94
95/// Bounded twin of [`locate_audio`]. `prefix` is a front window; `file_len` is the
96/// true size; `tail` is the file's last 128 bytes (or `None` if the file is
97/// shorter than 128 bytes). The audio start is the end of any leading ID3v2 tag
98/// (declared in its 10-byte header); if that end is past the prefix, returns
99/// `NeedMore`. The audio end is `file_len` minus a 128-byte ID3v1 trailer when the
100/// `tail` begins with `TAG`.
101pub fn locate_audio_bounded(
102    prefix: &[u8],
103    file_len: u64,
104    tail: Option<&[u8; 128]>,
105) -> Result<Extent<Mp3Bounds>> {
106    let mut audio_offset = 0usize;
107    if prefix.len() < 10 && file_len >= 10 {
108        // Not enough bytes even to read the ID3v2 header.
109        return Ok(Extent::NeedMore { up_to: 10 });
110    }
111    if let Some(base) = id3v2_header_len(prefix)? {
112        let flags = prefix[5];
113        let mut tag_len = base;
114        if flags & 0x10 != 0 {
115            tag_len += 10; // ID3v2.4 footer
116        }
117        if tag_len as u64 > file_len {
118            return Err(FormatError::Malformed);
119        }
120        audio_offset = tag_len;
121    }
122
123    // The audio start (plus its 2-byte frame sync) must fit in the file. Mirrors
124    // the unbounded `locate_audio`'s `audio_offset + 1 >= len` reject: without
125    // this, a tag that claims audio begins at/after EOF would return `NeedMore`
126    // with `up_to > file_len`, and the caller would widen to the full file and
127    // get the same answer every retry instead of failing fast.
128    if audio_offset as u64 + 2 > file_len {
129        return Err(FormatError::NotMp3);
130    }
131
132    // Need the frame-sync pair at the audio offset to be inside the prefix.
133    if audio_offset + 2 > prefix.len() {
134        return Ok(Extent::NeedMore {
135            up_to: (audio_offset + 2) as u64,
136        });
137    }
138
139    if prefix[audio_offset] != 0xFF || (prefix[audio_offset + 1] & 0xE0) != 0xE0 {
140        return Err(FormatError::NotMp3);
141    }
142
143    let mut audio_end = file_len;
144    if let Some(tail) = tail
145        && file_len >= audio_offset as u64 + 128
146        && &tail[0..3] == b"TAG"
147    {
148        audio_end -= 128;
149    }
150
151    Ok(Extent::Complete(Mp3Bounds {
152        audio_offset: audio_offset as u64,
153        audio_length: audio_end - audio_offset as u64,
154    }))
155}
156
157const ENC_UTF8: u8 = 0x03;
158
159fn syncsafe(n: u32) -> [u8; 4] {
160    [
161        ((n >> 21) & 0x7F) as u8,
162        ((n >> 14) & 0x7F) as u8,
163        ((n >> 7) & 0x7F) as u8,
164        (n & 0x7F) as u8,
165    ]
166}
167
168/// Inclusive maximum of a 28-bit ID3v2.4 syncsafe size field.
169const SYNCHSAFE_MAX: u32 = 0x0FFF_FFFF;
170
171fn push_frame_header(out: &mut Vec<u8>, id: &[u8; 4], data_len: usize) -> Result<()> {
172    // ID3v2.4 frame sizes are a 28-bit syncsafe field; guard so an oversized frame
173    // is a hard error rather than a silently-truncated (corrupt) tag.
174    let data_len_u32 = u32::try_from(data_len)
175        .ok()
176        .filter(|&v| v <= SYNCHSAFE_MAX)
177        .ok_or(FormatError::TooLarge)?;
178    out.extend_from_slice(id);
179    out.extend_from_slice(&syncsafe(data_len_u32));
180    out.extend_from_slice(&[0x00, 0x00]); // frame flags
181    Ok(())
182}
183
184fn text_frame_data(values: &[String]) -> Vec<u8> {
185    let mut d = vec![ENC_UTF8];
186    d.extend_from_slice(values.join("\0").as_bytes());
187    d
188}
189
190fn txxx_frame_data(desc: &str, value: &str) -> Vec<u8> {
191    let mut d = vec![ENC_UTF8];
192    d.extend_from_slice(desc.as_bytes());
193    d.push(0x00);
194    d.extend_from_slice(value.as_bytes());
195    d
196}
197
198/// COMM/USLT share a body layout: `[enc][lang(3)][descriptor NUL][text]`. The
199/// language is written as exactly three bytes (padded with `X` when the supplied
200/// value is shorter); `lang`/`description` come from the tag key (see
201/// [`comm_like_key`]), defaulting to `XXX` / empty for the shared `comment` /
202/// `lyrics` key.
203fn comm_like_frame_data(lang: &str, description: &str, value: &str) -> Vec<u8> {
204    let l = lang.as_bytes();
205    let mut d = vec![ENC_UTF8];
206    d.extend_from_slice(&[
207        *l.first().unwrap_or(&b'X'),
208        *l.get(1).unwrap_or(&b'X'),
209        *l.get(2).unwrap_or(&b'X'),
210    ]);
211    d.extend_from_slice(description.as_bytes());
212    d.push(0x00); // content descriptor terminator
213    d.extend_from_slice(value.as_bytes());
214    d
215}
216
217/// ID3 languages treated as "no specific language": a COMM/USLT frame with one
218/// of these and an empty descriptor folds to the shared `comment`/`lyrics` key.
219fn is_placeholder_lang(lang: &str) -> bool {
220    matches!(lang.to_ascii_lowercase().as_str(), "" | "xxx" | "und")
221}
222
223/// Canonical key for a COMM/USLT frame. The common case (placeholder language,
224/// empty descriptor) folds to `default_key` (`comment`/`lyrics`), shared with
225/// MP4/Vorbis. A frame carrying a real language or descriptor keeps both under
226/// `id3:<FRAME>:<lang>:<descriptor>` so per-language / description-keyed frames
227/// stay distinct across a round-trip; only MP3/WAV re-emit that key.
228fn comm_like_key(frame: &str, lang: &str, description: &str, default_key: &str) -> String {
229    if description.is_empty() && is_placeholder_lang(lang) {
230        default_key.to_string()
231    } else {
232        format!("id3:{frame}:{lang}:{description}")
233    }
234}
235
236/// Inverse of [`comm_like_key`]: parse `id3:COMM:<lang>:<desc>` /
237/// `id3:USLT:<lang>:<desc>` into `(frame_id, lang, descriptor)`; the descriptor
238/// may itself contain `:`. None for any other key.
239fn parse_comm_like_key(key: &str) -> Option<(&'static [u8; 4], &str, &str)> {
240    let rest = key.strip_prefix("id3:")?;
241    let (frame, langdesc) = rest.split_once(':')?;
242    let frame_id: &'static [u8; 4] = match frame {
243        "COMM" => b"COMM",
244        "USLT" => b"USLT",
245        _ => return None,
246    };
247    let (lang, desc) = langdesc.split_once(':')?;
248    Some((frame_id, lang, desc))
249}
250
251/// True if `key` is shaped like an ID3v2 text frame id (`T` + 3 upper/digit),
252/// excluding `TXXX` itself. Used to round-trip unmapped standard text frames.
253fn is_id3_text_frame_id(key: &str) -> bool {
254    key.len() == 4
255        && key != "TXXX"
256        && key.starts_with('T')
257        && key
258            .bytes()
259            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
260}
261
262/// Reject a DB-sourced string with an embedded NUL before it is spliced into an
263/// ID3 frame. ID3 uses NUL as a field terminator/separator — the mime and
264/// description terminators in APIC, the value separator between multi-value text
265/// frames — so a NUL in the payload desyncs a downstream parser (#506).
266/// Length-prefixed formats (FLAC/Vorbis/MP4) are unaffected, so this guard is
267/// ID3-specific.
268fn reject_embedded_nul(field: &'static str, s: &str) -> Result<()> {
269    if s.as_bytes().contains(&0) {
270        return Err(FormatError::EmbeddedNul { field });
271    }
272    Ok(())
273}
274
275/// APIC frame data up to (but excluding) the image bytes:
276/// `[encoding][mime\0][picture type][description\0]`.
277fn apic_framing(art: &ArtInput) -> Vec<u8> {
278    let mut d = vec![ENC_UTF8];
279    d.extend_from_slice(art.mime.as_bytes());
280    d.push(0x00);
281    #[expect(
282        clippy::cast_possible_truncation,
283        reason = "ID3 APIC type is one byte; valid picture types are 0..=20"
284    )]
285    d.push(art.picture_type.get() as u8);
286    d.extend_from_slice(art.description.as_bytes());
287    d.push(0x00);
288    d
289}
290
291/// POPM body: `<owner>\0<rating:u8>[<counter: 4-byte big-endian>]`. Owner is empty
292/// by design (spec §5 — the original tagger identity is dropped). The counter is
293/// emitted as 4 bytes when `playcount > 0` and omitted when 0; values above
294/// `u32::MAX` are clamped (the typed read path caps at u64, the common case fits
295/// u32).
296fn popm_frame_data(rating: u8, playcount: u64) -> Vec<u8> {
297    let mut d = Vec::new();
298    d.push(0x00); // empty owner, NUL-terminated
299    d.push(rating);
300    if playcount > 0 {
301        let c = u32::try_from(playcount).unwrap_or(u32::MAX);
302        d.extend_from_slice(&c.to_be_bytes());
303    }
304    d
305}
306
307/// UFID body: `<owner>\0<identifier bytes>`.
308fn ufid_frame_data(owner: &str, identifier: &[u8]) -> Vec<u8> {
309    let mut d = Vec::new();
310    d.extend_from_slice(owner.as_bytes());
311    d.push(0x00);
312    d.extend_from_slice(identifier);
313    d
314}
315
316/// True for the canonical text keys that are rebuilt as POPM/UFID frames and must
317/// therefore be excluded from the generic text/TXXX emission (no double-store).
318fn is_promoted_key(key: &str) -> bool {
319    matches!(key, "rating" | "playcount" | "musicbrainz_trackid")
320}
321
322/// Build the ID3v2.4 tag region for `tags`/`arts`: an inline 10-byte header
323/// followed by text/`TXXX` frames and `APIC` frames whose image bytes are
324/// streamed as `ArtImage` segments. Returns the segments (no backing audio) and
325/// the total tag length (`10 + frames_len`). Shared by MP3 synthesis and the WAV
326/// `id3 ` chunk.
327pub fn build_id3v2_segments(
328    tags: &[TagInput],
329    binary_tags: &[BinaryTagInput],
330    arts: &[ArtInput],
331) -> Result<(Vec<Segment>, u64)> {
332    // ID3 splices these DB-sourced strings between NUL terminators/separators,
333    // so a NUL in any of them would corrupt the frame; reject up front (#506).
334    // The key is checked too: an unmapped key is serialized as a TXXX
335    // description (`txxx_frame_data(key, value)`), and the `tags.key` CHECK
336    // rejects control chars 1..=31 but not NUL.
337    for t in tags {
338        reject_embedded_nul("tag key", &t.key)?;
339        reject_embedded_nul("tag value", &t.value)?;
340    }
341    for art in arts {
342        reject_embedded_nul("art mime", &art.mime)?;
343        reject_embedded_nul("art description", &art.description)?;
344    }
345
346    // Pull the promoted scalar values out of `tags`: first `rating` /
347    // `musicbrainz_trackid` wins, `playcount` takes the last parseable value. A
348    // single POPM/UFID is the norm, so this only diverges from "first wins" for
349    // the rare multi-frame tag.
350    let mut popm_rating: Option<u8> = None;
351    let mut popm_playcount: u64 = 0;
352    let mut mbid: Option<String> = None;
353    for t in tags {
354        match t.key.as_str() {
355            "rating" if popm_rating.is_none() => popm_rating = t.value.parse().ok(),
356            "playcount" => popm_playcount = t.value.parse().unwrap_or(popm_playcount),
357            "musicbrainz_trackid" if mbid.is_none() => mbid = Some(t.value.clone()),
358            _ => {}
359        }
360    }
361
362    // Group consecutive same-key values (the DB returns tags ordered by key),
363    // skipping promoted keys so they never enter the generic text/TXXX path
364    // (no double-store).
365    let mut groups: Vec<(String, Vec<String>)> = Vec::new();
366    for t in tags {
367        if is_promoted_key(&t.key) {
368            continue;
369        }
370        match groups.last_mut() {
371            Some(g) if g.0 == t.key => g.1.push(t.value.clone()),
372            _ => groups.push((t.key.clone(), vec![t.value.clone()])),
373        }
374    }
375
376    let mut segments: Vec<Segment> = Vec::new();
377    let mut buf: Vec<u8> = Vec::new();
378    let mut frames_len: u64 = 0;
379
380    for (key, values) in &groups {
381        match crate::tagmap::key_to_id3(key) {
382            Some(crate::tagmap::Id3Slot::Text(id)) => {
383                let data = text_frame_data(values);
384                push_frame_header(&mut buf, id, data.len())?;
385                buf.extend_from_slice(&data);
386                frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
387            }
388            Some(crate::tagmap::Id3Slot::Txxx(desc)) => {
389                for value in values {
390                    let data = txxx_frame_data(desc, value);
391                    push_frame_header(&mut buf, b"TXXX", data.len())?;
392                    buf.extend_from_slice(&data);
393                    frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
394                }
395            }
396            Some(crate::tagmap::Id3Slot::Comment) => {
397                for value in values {
398                    let data = comm_like_frame_data("XXX", "", value);
399                    push_frame_header(&mut buf, b"COMM", data.len())?;
400                    buf.extend_from_slice(&data);
401                    frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
402                }
403            }
404            Some(crate::tagmap::Id3Slot::Lyrics) => {
405                for value in values {
406                    let data = comm_like_frame_data("XXX", "", value);
407                    push_frame_header(&mut buf, b"USLT", data.len())?;
408                    buf.extend_from_slice(&data);
409                    frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
410                }
411            }
412            None if is_id3_text_frame_id(key) => {
413                // safe: is_id3_text_frame_id guarantees key is exactly 4 bytes
414                let id: [u8; 4] = key.as_bytes().try_into().unwrap();
415                let data = text_frame_data(values);
416                push_frame_header(&mut buf, &id, data.len())?;
417                buf.extend_from_slice(&data);
418                frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
419            }
420            None => {
421                if let Some((frame_id, lang, desc)) = parse_comm_like_key(key) {
422                    for value in values {
423                        let data = comm_like_frame_data(lang, desc, value);
424                        push_frame_header(&mut buf, frame_id, data.len())?;
425                        buf.extend_from_slice(&data);
426                        frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
427                    }
428                } else {
429                    for value in values {
430                        let data = txxx_frame_data(key, value);
431                        push_frame_header(&mut buf, b"TXXX", data.len())?;
432                        buf.extend_from_slice(&data);
433                        frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
434                    }
435                }
436            }
437        }
438    }
439
440    // Rebuilt promoted frames (POPM from rating/playcount, UFID from MBID).
441    if let Some(rating) = popm_rating {
442        let data = popm_frame_data(rating, popm_playcount);
443        push_frame_header(&mut buf, b"POPM", data.len())?;
444        buf.extend_from_slice(&data);
445        frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
446    }
447    if let Some(id) = &mbid {
448        let data = ufid_frame_data(MUSICBRAINZ_UFID_OWNER, id.as_bytes());
449        push_frame_header(&mut buf, b"UFID", data.len())?;
450        buf.extend_from_slice(&data);
451        frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
452    }
453
454    // Opaque binary frames: header (inline) + streamed body (BinaryTag segment).
455    for bt in binary_tags {
456        // Defensive: ID3 opaque keys are 4-byte frame ids.
457        let Ok(id): std::result::Result<[u8; 4], _> = bt.key.as_bytes().try_into() else {
458            continue;
459        };
460        push_frame_header(&mut buf, &id, usize_from(bt.len.get()))?;
461        segments.push(Segment::Inline(std::mem::take(&mut buf)));
462        segments.push(Segment::BinaryTag {
463            payload_id: bt.payload_id,
464            len: bt.len,
465        });
466        frames_len = size::checked_add(frames_len, size::checked_add(10, bt.len.get())?)?;
467    }
468
469    for art in arts {
470        let framing = apic_framing(art);
471        let data_len = size::checked_add(framing.len() as u64, art.data_len.get())?;
472        push_frame_header(&mut buf, b"APIC", usize_from(data_len))?;
473        buf.extend_from_slice(&framing);
474        segments.push(Segment::Inline(std::mem::take(&mut buf)));
475        segments.push(Segment::ArtImage {
476            art_id: art.art_id,
477            len: art.data_len,
478        });
479        frames_len = size::checked_add(frames_len, size::checked_add(10, data_len)?)?;
480    }
481
482    if !buf.is_empty() {
483        segments.push(Segment::Inline(std::mem::take(&mut buf)));
484    }
485
486    // Prepend the 10-byte ID3v2.4 header now that the total frame length is known.
487    let mut header = Vec::with_capacity(10);
488    header.extend_from_slice(b"ID3");
489    header.extend_from_slice(&[0x04, 0x00]); // version 2.4.0
490    header.push(0x00); // flags: no unsync / extended header / footer
491
492    // The total tag size is a 28-bit syncsafe field. Ingestion caps each art well
493    // under this, but guard at the format boundary so an oversized tag (e.g. many
494    // large pictures summing past the limit) is a hard error, not a truncated file.
495    let frames_len_ss = u32::try_from(frames_len)
496        .ok()
497        .filter(|&v| v <= SYNCHSAFE_MAX)
498        .ok_or(FormatError::TooLarge)?;
499    header.extend_from_slice(&syncsafe(frames_len_ss));
500    segments.insert(0, Segment::Inline(header));
501
502    Ok((segments, size::checked_add(10, frames_len)?))
503}
504
505/// Build the synthesized region for an MP3: a fresh ID3v2.4 tag (text frames +
506/// APIC frames, with image bytes streamed as `ArtImage` segments) followed by the
507/// backing audio.
508pub fn synthesize_layout(
509    audio_offset: u64,
510    audio_length: u64,
511    tags: &[TagInput],
512    binary_tags: &[BinaryTagInput],
513    arts: &[ArtInput],
514) -> Result<RegionLayout> {
515    let (mut segments, _tag_len) = build_id3v2_segments(tags, binary_tags, arts)?;
516    segments.push(Segment::BackingAudio {
517        offset: audio_offset,
518        len: audio_length,
519    });
520    Ok(RegionLayout::validated(segments)?)
521}
522
523/// Returns false when `data` begins with an ID3v2 tag whose declared frame sizes
524/// could drive an unbounded allocation in the `id3` crate (which eagerly
525/// `with_capacity`s a frame's declared size — and ID3v2.3 frame sizes are plain
526/// 32-bit, up to 4 GiB). When false, callers skip ID3 parsing (yielding no tags
527/// for that file) rather than risk an OOM. Conservative: tags using an extended
528/// header or unsynchronisation, a malformed synchsafe body/frame-size field
529/// (any byte with high bit set), or an unrecognised major version are skipped
530/// (those files lose scan-time tag extraction, but cannot OOM the scanner).
531/// Files without an ID3v2 tag return true (the id3 crate handles them cheaply).
532fn id3v2_alloc_safe(data: &[u8]) -> bool {
533    // id3::Tag::read_from2 scans forward to locate a tag, so handing it any
534    // buffer that is not a validated ID3v2 tag at offset 0 risks the unbounded
535    // allocation we are guarding against. Only parse when an ID3v2 header is at
536    // offset 0 (and its frames validate, below). Trade-off: scan-time tag
537    // extraction for ID3v1-only files (no leading ID3v2 header) is skipped;
538    // ID3v1 is legacy/fixed-size and tags can be populated via the DB
539    // (beets/picard) regardless.
540    let Ok(Some(tag_end)) = id3v2_header_len(data) else {
541        // Not an ID3v2 tag at offset 0, or a malformed header: skip parsing.
542        return false;
543    };
544    let flags = data[5];
545    // Extended header (0x40) and unsynchronisation (0x80) complicate frame
546    // bounds; skip rather than risk mis-validating.
547    if flags & 0xC0 != 0 {
548        return false;
549    }
550    if tag_end > data.len() {
551        return false;
552    }
553    let major = data[3];
554    let header_len = if major == 2 { 6 } else { 10 };
555    // Walk frames over the entire remaining buffer (not just [10, tag_end)):
556    // the id3 crate does not consistently stop at the declared tag body and
557    // can walk and allocate from bytes beyond tag_end.  Any incomplete frame
558    // header visible in data (i.e. pos + header_len <= data.len()) is also
559    // validated.  We still reject if a frame's declared size exceeds tag_end.
560    let scan_end = data.len();
561    let mut pos = 10usize;
562    while pos + header_len <= scan_end {
563        // A zero first id byte marks the start of the padding region.
564        if data[pos] == 0 {
565            break;
566        }
567        // CHAP and CTOC frames contain embedded sub-frames; the id3 crate
568        // allocates based on those sub-frame sizes, creating a recursive OOM
569        // vector.  Reject tags containing either frame type (v2.3/v2.4 only;
570        // v2.2 uses 3-byte frame ids and never defines chapter frames).
571        if major != 2 && (&data[pos..pos + 4] == b"CHAP" || &data[pos..pos + 4] == b"CTOC") {
572            return false;
573        }
574        let size = if major == 2 {
575            u32::from_be_bytes([0, data[pos + 3], data[pos + 4], data[pos + 5]]) as usize
576        } else if major == 3 {
577            // ID3v2.3: plain 32-bit big-endian frame size.
578            // Frame flags at pos+8..pos+10: reject any non-zero flags.  The id3
579            // crate handles COMPRESSION (0x0080) by subtracting 4 from the size
580            // (panicking if size < 4), and ENCRYPTION/GROUPING_IDENTITY by
581            // returning errors; rejecting all non-zero frame flags avoids those
582            // paths entirely.
583            if data[pos + 8] != 0 || data[pos + 9] != 0 {
584                return false;
585            }
586            u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
587                as usize
588        } else {
589            // ID3v2.4: synchsafe frame size.  Reject if any byte has its high
590            // bit set (malformed synchsafe), for the same reason as the body.
591            // Also reject non-zero frame flags for the same reasons as v2.3.
592            if data[pos + 4] | data[pos + 5] | data[pos + 6] | data[pos + 7] >= 0x80 {
593                return false;
594            }
595            if data[pos + 8] != 0 || data[pos + 9] != 0 {
596                return false;
597            }
598            synchsafe_decode(&data[pos + 4..pos + 8]) as usize
599        };
600        let data_start = pos + header_len;
601        // Reject if the frame header itself extends past the declared tag body,
602        // or if the frame payload claims more bytes than the remaining body.
603        // The id3 crate would otherwise attempt to subtract or allocate with
604        // an invalid size, causing a panic or OOM.
605        if data_start > tag_end || size > tag_end - data_start {
606            return false;
607        }
608        pos = data_start + size;
609        // Stop once we have walked past the declared tag body: any subsequent
610        // bytes are audio or trailing tags, not ID3v2 frames.
611        if pos >= tag_end {
612            break;
613        }
614    }
615    true
616}
617
618/// Extract all APIC pictures from an MP3's ID3v2 tag as embedded pictures, for
619/// scan-time art ingestion. Returns empty if there is no tag or no pictures.
620pub fn read_pictures(data: &[u8]) -> Vec<EmbeddedPicture> {
621    if !id3v2_alloc_safe(data) {
622        return Vec::new();
623    }
624    let Ok(tag) = id3::Tag::read_from2(std::io::Cursor::new(data)) else {
625        return Vec::new();
626    };
627    tag.pictures()
628        .map(|p| EmbeddedPicture {
629            mime: p.mime_type.clone(),
630            // The id3 crate's PictureType has an `Undefined(u8)` variant that can
631            // exceed 20; clamp out-of-range to 0, matching the FLAC parser and
632            // scan's prior policy.
633            picture_type: PictureType::new(u8::from(p.picture_type).into())
634                .unwrap_or(PictureType::ZERO),
635            description: p.description.clone(),
636            width: 0,
637            height: 0,
638            data: p.data.clone(),
639        })
640        .collect()
641}
642
643/// Read an existing ID3v2 tag and fold it into canonical `(key, value)` pairs.
644/// Text frames map via the vocabulary (NUL-separated multi-value yields one pair
645/// per value); unmapped text frames pass through keyed by their frame id; `TXXX`
646/// frames key on their description (folded to canonical when known); `COMM`/`USLT`
647/// yield `comment`/`lyrics` (text only). Other/binary frames are skipped.
648/// Multiple `COMM` or `USLT` frames (e.g. one per language) each emit a separate
649/// pair; their language and description fields are not preserved.
650pub fn read_tags(data: &[u8]) -> Vec<(String, String)> {
651    if !id3v2_alloc_safe(data) {
652        return Vec::new();
653    }
654    let Ok(tag) = id3::Tag::read_from2(std::io::Cursor::new(data)) else {
655        return Vec::new();
656    };
657    let mut out = Vec::new();
658    for frame in tag.frames() {
659        let content = frame.content();
660        if let Some(et) = content.extended_text() {
661            let key = crate::tagmap::id3_txxx_to_key(&et.description)
662                .map_or_else(|| et.description.clone(), str::to_string);
663            out.push((key, et.value.clone()));
664        } else if let Some(c) = content.comment() {
665            out.push((
666                comm_like_key("COMM", &c.lang, &c.description, "comment"),
667                c.text.clone(),
668            ));
669        } else if let Some(l) = content.lyrics() {
670            out.push((
671                comm_like_key("USLT", &l.lang, &l.description, "lyrics"),
672                l.text.clone(),
673            ));
674        } else if let Some(text) = content.text() {
675            let id = frame.id();
676            let key =
677                crate::tagmap::id3_text_to_key(id).map_or_else(|| id.to_string(), str::to_string);
678            for value in text.split('\0').filter(|v| !v.is_empty()) {
679                out.push((key.clone(), value.to_string()));
680            }
681        }
682    }
683    out
684}
685
686pub(crate) const MUSICBRAINZ_UFID_OWNER: &str = "http://musicbrainz.org";
687
688/// Extract an ID3v2.3/2.4 tag's binary frames. Returns `(opaque, promoted)`:
689/// - `opaque`: frames preserved **byte-exact** — `(frame-id, raw post-header body)`.
690///   `PRIV`/`GEOB`/`SYLT`/`MCDI`/unknown frames and any non-MusicBrainz `UFID`.
691/// - `promoted`: `(key, value)` text pairs — `POPM` → `rating` (raw 0–255) + `playcount`
692///   (counter, omitted when 0); MusicBrainz `UFID` → `musicbrainz_trackid`. Promoted
693///   frames are NOT in `opaque`.
694///
695/// Text (`T***`), `COMM`, `USLT`, `APIC` are handled by `read_tags`/`read_pictures`
696/// and skipped. Gated by `id3v2_alloc_safe`, so the tag is well-formed, has no
697/// unsynchronisation/extended header/frame flags, and bodies are sliced verbatim.
698/// v2.2 (3-char ids) is not processed (rare; text/art still parse via the crate).
699pub fn read_binary_tags(data: &[u8]) -> (Vec<EmbeddedBinaryTag>, Vec<(String, String)>) {
700    let mut opaque = Vec::new();
701    let mut promoted = Vec::new();
702    if !id3v2_alloc_safe(data) || data[3] < 3 {
703        return (opaque, promoted);
704    }
705    let tag_end = 10 + synchsafe_decode(&data[6..10]) as usize;
706    let mut pos = 10usize;
707    while pos + 10 <= tag_end {
708        if data[pos] == 0 {
709            break;
710        }
711        let id = &data[pos..pos + 4];
712        let size = decode_frame_size(data[3], &data[pos + 4..pos + 8]) as usize;
713        let body_start = pos + 10;
714        if body_start + size > tag_end {
715            break;
716        }
717        classify_binary_frame(
718            id,
719            &data[body_start..body_start + size],
720            &mut opaque,
721            &mut promoted,
722        );
723        pos = body_start + size;
724    }
725    (opaque, promoted)
726}
727
728/// Classify one ID3v2 frame body into opaque-passthrough or promoted-text.
729fn classify_binary_frame(
730    id: &[u8],
731    body: &[u8],
732    opaque: &mut Vec<EmbeddedBinaryTag>,
733    promoted: &mut Vec<(String, String)>,
734) {
735    // Handled by read_tags/read_pictures: text frames (T***), COMM, USLT, APIC.
736    if id[0] == b'T' || id == b"COMM" || id == b"USLT" || id == b"APIC" {
737        return;
738    }
739    match id {
740        b"POPM" => {
741            // <owner>\0<rating:u8>[<counter: big-endian>]
742            if let Some(nul) = body.iter().position(|&b| b == 0)
743                && let Some((&rating, counter)) = body[nul + 1..].split_first()
744            {
745                promoted.push(("rating".to_string(), rating.to_string()));
746                let c = counter
747                    .iter()
748                    .take(8)
749                    .fold(0u64, |a, &b| (a << 8) | u64::from(b));
750                if c > 0 {
751                    promoted.push(("playcount".to_string(), c.to_string()));
752                }
753            }
754        }
755        b"UFID" => {
756            // <owner>\0<identifier>. MusicBrainz owner promotes; others opaque.
757            match body.iter().position(|&b| b == 0) {
758                Some(nul) if &body[..nul] == MUSICBRAINZ_UFID_OWNER.as_bytes() => {
759                    promoted.push((
760                        "musicbrainz_trackid".to_string(),
761                        String::from_utf8_lossy(&body[nul + 1..]).into_owned(),
762                    ));
763                }
764                _ => opaque.push(EmbeddedBinaryTag {
765                    key: "UFID".to_string(),
766                    payload: body.to_vec(),
767                }),
768            }
769        }
770        _ => {
771            // Opaque verbatim: PRIV, GEOB, SYLT, MCDI, W***, unknown, … (4-byte ids).
772            if id.iter().all(u8::is_ascii_graphic) {
773                opaque.push(EmbeddedBinaryTag {
774                    key: String::from_utf8_lossy(id).into_owned(),
775                    payload: body.to_vec(),
776                });
777            }
778        }
779    }
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785    use crate::input::{BlobLen, PictureType};
786
787    /// Build a minimal ID3v2.3 tag with a single frame whose declared size
788    /// overflows the tag bounds, and assert the guard rejects it.
789    #[test]
790    fn id3v2_guard_rejects_oversized_v23_frame() {
791        // Tag header: b"ID3" major=3 rev=0 flags=0
792        // Synchsafe body size encoding 10 (= one 10-byte frame header, no payload):
793        //   syncsafe(10) = [0, 0, 0, 0x0A]
794        // Frame: id=TIT2 (4 bytes), size=0xFFFF_FFFF (4 bytes, plain BE), flags=0x00 0x00
795        let mut bytes: Vec<u8> = Vec::new();
796        bytes.extend_from_slice(b"ID3");
797        bytes.push(0x03); // major version 2.3
798        bytes.push(0x00); // revision
799        bytes.push(0x00); // flags: no extended header, no unsync
800        // synchsafe body = 10 (covers exactly one 10-byte frame header)
801        bytes.extend_from_slice(&[0x00, 0x00, 0x00, 0x0A]);
802        // Frame header: id "TIT2", size 0xFFFF_FFFF (big-endian, plain 32-bit)
803        bytes.extend_from_slice(b"TIT2");
804        bytes.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
805        bytes.extend_from_slice(&[0x00, 0x00]); // frame flags
806
807        assert!(
808            !id3v2_alloc_safe(&bytes),
809            "guard should reject frame claiming more bytes than the tag holds"
810        );
811        // Must return quickly without OOM and produce no tags.
812        assert!(
813            read_tags(&bytes).is_empty(),
814            "read_tags must return empty for unsafe tag"
815        );
816    }
817
818    /// Minimal ID3v2.4 tag: header + one frame header whose 4-byte synchsafe size
819    /// is `frame_size`. The declared tag-body size is 10 (just the frame header),
820    /// so the frame walk reaches the size validation with the buffer ending right
821    /// after the 4 size bytes. All size bytes are 0x00/0x80, so the decoded size
822    /// (low 7 bits) is 0 — only the high-bit guard at line 514 can reject.
823    fn v24_tag_one_frame(frame_size: [u8; 4]) -> Vec<u8> {
824        let mut t = Vec::new();
825        t.extend_from_slice(b"ID3");
826        t.extend_from_slice(&[4, 0, 0]); // v2.4, no flags
827        t.extend_from_slice(&[0, 0, 0, 10]); // synchsafe tag-body size = 10
828        t.extend_from_slice(b"TIT2"); // frame id (non-zero, not CHAP/CTOC)
829        t.extend_from_slice(&frame_size); // the guarded size bytes
830        t.extend_from_slice(&[0, 0]); // frame flags = 0
831        t
832    }
833
834    #[test]
835    fn alloc_safe_rejects_v24_frame_with_nonsynchsafe_size() {
836        // The v2.4 guard, `b4 | b5 | b6 | b7 >= 0x80`, must reject a frame whose
837        // synchsafe size has the high bit set in ANY byte (a non-synchsafe size the
838        // id3 crate could OOM on). Replacing a `|` with `&`/`^` reshapes the chain
839        // (both bind tighter than `|`, so `b4 | b5 & b6 | b7` is `b4 | (b5 & b6) | b7`),
840        // letting an even/dominated high-bit pattern slip through. Each input below
841        // pins one such weakening at a distinct position.
842        for size in [
843            [0x80, 0x00, 0x00, 0x00], // b4 high
844            [0x00, 0x80, 0x00, 0x00], // b5 high
845            [0x00, 0x00, 0x80, 0x00], // b6 high
846            [0x00, 0x80, 0x80, 0x00], // b5,b6 high
847            [0x00, 0x00, 0x80, 0x80], // b6,b7 high
848        ] {
849            assert!(
850                !id3v2_alloc_safe(&v24_tag_one_frame(size)),
851                "v2.4 frame size {size:02x?} has a high bit set and must be rejected"
852            );
853        }
854    }
855
856    /// A buffer that does not start with "ID3" must be rejected by the guard.
857    /// id3::Tag::read_from2 scans forward to locate a tag, so any non-ID3-prefixed
858    /// buffer is unsafe regardless of what bytes appear later.
859    #[test]
860    fn id3v2_guard_rejects_non_id3_prefixed() {
861        // Plain non-ID3 bytes.
862        assert!(
863            !id3v2_alloc_safe(b"RIFF....just not an id3 tag...."),
864            "guard must reject buffer not starting with ID3"
865        );
866        assert!(
867            read_tags(b"RIFF....just not an id3 tag....").is_empty(),
868            "read_tags must return empty for non-ID3-prefixed buffer"
869        );
870
871        // The WAV crash vector: "RIFF..." body whose bytes do not start with "ID3"
872        // but contain a nested ID3v2.3 tag with a TDA frame declaring ~4 GiB.
873        // Extracted from fuzz/artifacts/wav/oom-4a21767820d5f05328f01d975fb6d3314f3fb902:
874        // the ID3 chunk body starts at offset 0x18 and begins with "RIFF".
875        const RIFF_BODY: &[u8] = &[
876            0x52, 0x49, 0x46, 0x46, 0x32, 0x00, 0x00, 0x00, // "RIFF2..."
877            0x57, 0x41, 0x56, 0x45, // "WAVE"
878            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4c, 0x00, 0x00, 0x00,
879            0x00, 0x00, 0x49, 0x44, 0x33, 0x20, // nested "ID3 " fourcc
880            0x15, 0x00, 0x00, 0x00, // chunk size = 21
881            0x49, 0x44, 0x33, // "ID3" — nested tag starts here
882            0x03, 0x00, 0x00, 0x00, 0xf7, 0x00, 0x00, 0x54, 0x44, 0x41, 0x03, 0xf6, 0x00, 0x00,
883            0x00, // TDA frame size = 0xF600_0000 (~4 GiB)
884        ];
885        assert!(
886            !id3v2_alloc_safe(RIFF_BODY),
887            "guard must reject RIFF-prefixed buffer (WAV crash vector)"
888        );
889        assert!(
890            read_tags(RIFF_BODY).is_empty(),
891            "read_tags must return empty for RIFF-prefixed buffer"
892        );
893    }
894
895    /// Write a real ID3v2.4 tag via the id3 crate and confirm the guard allows it
896    /// and that read_tags extracts the expected values.
897    #[test]
898    fn id3v2_guard_allows_valid_tag() {
899        use id3::{Tag, TagLike, Version};
900
901        let mut tag = Tag::new();
902        tag.set_text("TIT2", "Hello");
903        tag.set_text("TPE1", "Artist");
904        let mut buf = Vec::new();
905        tag.write_to(&mut buf, Version::Id3v24).unwrap();
906
907        assert!(
908            id3v2_alloc_safe(&buf),
909            "guard should allow a well-formed tag written by the id3 crate"
910        );
911        let tags = read_tags(&buf);
912        assert!(
913            tags.contains(&("title".to_string(), "Hello".to_string())),
914            "missing title in {tags:?}"
915        );
916        assert!(
917            tags.contains(&("artist".to_string(), "Artist".to_string())),
918            "missing artist in {tags:?}"
919        );
920    }
921
922    /// Replay fuzz-discovered crash artifacts: tags that would OOM the id3 crate.
923    /// The guard must reject all of them and return empty without allocating.
924    #[test]
925    fn read_tags_handles_oom_crash_input_safely() {
926        // Artifact 1 (oom-a9b766b...): 30-byte ID3v2.3 tag with flags=0xf0
927        // (extended header + unsync bits set).  Guard rejects via flags & 0xC0.
928        // xxd fuzz/artifacts/mp3/oom-a9b766b841c2a964e72b01f31c174f25bf11b2d2
929        const CRASH1: &[u8] = &[
930            0x49, 0x44, 0x33, // "ID3"
931            0x03, 0xf0, // major=3, flags=0xf0 (extended header + unsync)
932            0x00, 0x00, 0xf9, 0x2d, // synchsafe body size
933            0x49, 0x50, 0x4c, 0x53, // frame id "IPLS"
934            0x00, 0xf9, 0x3d, 0x02, // frame size (big-endian)
935            0x00, 0x2d, 0x01, 0x00, // frame flags + data
936            0x00, 0x03, 0x00, 0x49, 0x07, 0x10, 0xff, 0x07, 0xfe,
937        ];
938        // Artifact 2 (oom-54f1f5e1...): 26-byte ID3v2.3 tag with a malformed
939        // synchsafe body field (data[9]=0x80, high bit set).  The id3 crate
940        // treated the raw value as 128, walked the oversized IPLS frame, and
941        // OOMed.  Guard rejects via the high-bit check on body bytes.
942        // xxd fuzz/artifacts/mp3/oom-54f1f5e197c4aa191f4aac77bc263939a4e4ee83
943        const CRASH2: &[u8] = &[
944            0x49, 0x44, 0x33, // "ID3"
945            0x03, 0x00, // major=3, flags=0 (no extended header / unsync)
946            0x00, 0x00, 0x00, 0x80, // body bytes: data[9]=0x80 — malformed synchsafe
947            0x0a, 0x27, 0x2f, 0x00, // frame id (partial)
948            0xff, 0xee, 0x01, 0x00, // frame size declares ~4 GB
949            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x2f,
950        ];
951        for (i, crash) in [CRASH1, CRASH2].iter().enumerate() {
952            assert!(
953                read_tags(crash).is_empty(),
954                "read_tags must be safe on crash artifact {i}"
955            );
956        }
957    }
958
959    #[test]
960    fn read_tags_captures_txxx_comm_uslt_and_unmapped_text() {
961        use id3::frame::{Comment, ExtendedText, Lyrics};
962        use id3::{Tag, TagLike, Version}; // TagLike brings set_text/add_frame into scope
963
964        let mut tag = Tag::new();
965        tag.set_text("TIT2", "Song");
966        tag.set_text("TKEY", "120"); // standard frame, not in vocabulary
967        tag.add_frame(ExtendedText {
968            description: "MOOD".into(),
969            value: "happy".into(),
970        });
971        tag.add_frame(ExtendedText {
972            description: "REPLAYGAIN_TRACK_GAIN".into(),
973            value: "-6.5 dB".into(),
974        });
975        tag.add_frame(Comment {
976            lang: "XXX".into(),
977            description: String::new(),
978            text: "nice".into(),
979        });
980        tag.add_frame(Lyrics {
981            lang: "XXX".into(),
982            description: String::new(),
983            text: "la la".into(),
984        });
985
986        let mut buf = Vec::new();
987        tag.write_to(&mut buf, Version::Id3v24).unwrap();
988
989        let tags = read_tags(&buf);
990        assert!(tags.contains(&("title".to_string(), "Song".to_string())));
991        assert!(tags.contains(&("TKEY".to_string(), "120".to_string())));
992        assert!(tags.contains(&("MOOD".to_string(), "happy".to_string())));
993        assert!(tags.contains(&("replaygain_track_gain".to_string(), "-6.5 dB".to_string())));
994        assert!(tags.contains(&("comment".to_string(), "nice".to_string())));
995        assert!(tags.contains(&("lyrics".to_string(), "la la".to_string())));
996    }
997
998    #[test]
999    fn synthesize_round_trips_arbitrary_id3_tags() {
1000        let tags = vec![
1001            TagInput::new("title", "Song"),
1002            TagInput::new("TKEY", "120"),     // unmapped standard frame
1003            TagInput::new("MyRating", "5"),   // user-defined -> TXXX
1004            TagInput::new("comment", "nice"), // -> COMM
1005            TagInput::new("lyrics", "la la"), // -> USLT
1006            TagInput::new("replaygain_track_gain", "-3.21 dB"), // -> TXXX (fixed desc)
1007        ];
1008        let (segments, _len) = build_id3v2_segments(&tags, &[], &[]).unwrap();
1009        let mut buf = Vec::new();
1010        for seg in &segments {
1011            if let Segment::Inline(bytes) = seg {
1012                buf.extend_from_slice(bytes);
1013            }
1014        }
1015        let read = read_tags(&buf);
1016        for expected in [
1017            ("title", "Song"),
1018            ("TKEY", "120"),
1019            ("MyRating", "5"),
1020            ("comment", "nice"),
1021            ("lyrics", "la la"),
1022            ("replaygain_track_gain", "-3.21 dB"),
1023        ] {
1024            assert!(
1025                read.contains(&(expected.0.to_string(), expected.1.to_string())),
1026                "missing {expected:?} in {read:?}"
1027            );
1028        }
1029    }
1030
1031    #[test]
1032    fn read_tags_preserves_comm_uslt_language_and_descriptor() {
1033        use id3::frame::{Comment, Lyrics};
1034        use id3::{Tag, TagLike, Version};
1035
1036        let mut tag = Tag::new();
1037        // Placeholder language + empty descriptor folds to the shared key.
1038        tag.add_frame(Comment {
1039            lang: "XXX".into(),
1040            description: String::new(),
1041            text: "plain".into(),
1042        });
1043        // A real language is preserved under a format-specific key.
1044        tag.add_frame(Comment {
1045            lang: "deu".into(),
1046            description: String::new(),
1047            text: "hallo".into(),
1048        });
1049        // A descriptor is preserved too (e.g. an iTunes-keyed comment).
1050        tag.add_frame(Comment {
1051            lang: "eng".into(),
1052            description: "note".into(),
1053            text: "see liner".into(),
1054        });
1055        // Per-language lyrics stay distinct rather than colliding under `lyrics`.
1056        tag.add_frame(Lyrics {
1057            lang: "eng".into(),
1058            description: String::new(),
1059            text: "verse".into(),
1060        });
1061        tag.add_frame(Lyrics {
1062            lang: "deu".into(),
1063            description: String::new(),
1064            text: "strophe".into(),
1065        });
1066
1067        let mut buf = Vec::new();
1068        tag.write_to(&mut buf, Version::Id3v24).unwrap();
1069        let tags = read_tags(&buf);
1070
1071        assert!(
1072            tags.contains(&("comment".into(), "plain".into())),
1073            "got {tags:?}"
1074        );
1075        assert!(
1076            tags.contains(&("id3:COMM:deu:".into(), "hallo".into())),
1077            "got {tags:?}"
1078        );
1079        assert!(
1080            tags.contains(&("id3:COMM:eng:note".into(), "see liner".into())),
1081            "got {tags:?}"
1082        );
1083        assert!(
1084            tags.contains(&("id3:USLT:eng:".into(), "verse".into())),
1085            "got {tags:?}"
1086        );
1087        assert!(
1088            tags.contains(&("id3:USLT:deu:".into(), "strophe".into())),
1089            "got {tags:?}"
1090        );
1091    }
1092
1093    #[test]
1094    fn synthesize_round_trips_comm_uslt_language_and_descriptor() {
1095        let tags = vec![
1096            TagInput::new("comment", "plain"),
1097            TagInput::new("id3:COMM:deu:", "hallo"),
1098            TagInput::new("id3:COMM:eng:note", "see liner"),
1099            TagInput::new("id3:USLT:eng:Chorus", "la la"),
1100        ];
1101        let (segments, len) = build_id3v2_segments(&tags, &[], &[]).unwrap();
1102        let mut buf = Vec::new();
1103        for seg in &segments {
1104            if let Segment::Inline(bytes) = seg {
1105                buf.extend_from_slice(bytes);
1106            }
1107        }
1108        // No streamed segments here, so the reported length is the whole tag:
1109        // pins each frame's `10 + data.len()` accounting (kills `+` -> `*`).
1110        assert_eq!(len, buf.len() as u64);
1111        // Assert real COMM/USLT frames (with the right lang/descriptor) are emitted,
1112        // not generic TXXX frames that would happen to round-trip the key/value.
1113        let tag = id3::Tag::read_from2(std::io::Cursor::new(&buf)).unwrap();
1114        assert!(
1115            tag.comments()
1116                .any(|c| c.text == "plain" && c.description.is_empty()),
1117            "plain COMM missing"
1118        );
1119        assert!(
1120            tag.comments()
1121                .any(|c| c.lang == "deu" && c.description.is_empty() && c.text == "hallo"),
1122            "deu COMM missing"
1123        );
1124        assert!(
1125            tag.comments()
1126                .any(|c| c.lang == "eng" && c.description == "note" && c.text == "see liner"),
1127            "descriptor-keyed COMM missing"
1128        );
1129        assert!(
1130            tag.lyrics()
1131                .any(|l| l.lang == "eng" && l.description == "Chorus" && l.text == "la la"),
1132            "USLT missing"
1133        );
1134    }
1135
1136    #[test]
1137    fn synchsafe_decode_assembles_7bit_groups() {
1138        // (1<<21)|(2<<14)|(3<<7)|4
1139        assert_eq!(synchsafe_decode(&[0x01, 0x02, 0x03, 0x04]), 0x0020_8184);
1140        // high bit of each byte masked (& 0x7F): 0xFF -> 0x7F per group.
1141        assert_eq!(synchsafe_decode(&[0xFF, 0xFF, 0xFF, 0xFF]), 0x0FFF_FFFF);
1142        // only the top group set -> pins the `<<21` (kills `<<21 -> >>21`).
1143        assert_eq!(synchsafe_decode(&[0x7F, 0x00, 0x00, 0x00]), 0x0FE0_0000);
1144        // only the second group set -> pins the `<<14` (kills `<<14 -> >>14`).
1145        assert_eq!(synchsafe_decode(&[0x00, 0x7F, 0x00, 0x00]), 0x001F_C000);
1146    }
1147
1148    #[test]
1149    fn syncsafe_encodes_and_round_trips() {
1150        // pins the `>>21` and `>>14` group extraction.
1151        assert_eq!(syncsafe(0x0FE0_0000), [0x7F, 0x00, 0x00, 0x00]);
1152        assert_eq!(syncsafe(0x001F_C000), [0x00, 0x7F, 0x00, 0x00]);
1153        // round-trip over the full 28-bit range pins every group boundary.
1154        for n in [0u32, 1, 127, 128, 0x0123_4567, 0x0FFF_FFFF] {
1155            assert_eq!(synchsafe_decode(&syncsafe(n)), n);
1156        }
1157    }
1158
1159    #[test]
1160    fn locate_audio_no_id3_starts_at_zero() {
1161        // >=10 bytes, not "ID3": original skips the ID3 block (audio at 0). The
1162        // `&& -> ||` mutant enters the block, decodes garbage, and returns Err — so
1163        // this unwrap kills it. Frame sync 0xFF 0xFB at offset 0.
1164        let data = [0xFF, 0xFB, 0x90, 0x00, 0, 0, 0, 0, 0, 0];
1165        let b = locate_audio(&data).unwrap();
1166        assert_eq!(b.audio_offset, 0);
1167        assert_eq!(b.audio_length, 10);
1168    }
1169
1170    #[test]
1171    fn locate_audio_skips_id3v2_then_finds_sync() {
1172        // "ID3" v2.4, flags=0, synchsafe body=4 -> tag_len=14. Sync at offset 14.
1173        let mut data = Vec::new();
1174        data.extend_from_slice(b"ID3");
1175        data.extend_from_slice(&[0x04, 0x00, 0x00]); // major, rev, flags
1176        data.extend_from_slice(&[0x00, 0x00, 0x00, 0x04]); // synchsafe body=4
1177        data.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); // 4 body bytes
1178        data.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // audio sync at 14
1179        let b = locate_audio(&data).unwrap();
1180        assert_eq!(b.audio_offset, 14);
1181        assert_eq!(b.audio_length, 4);
1182    }
1183
1184    #[test]
1185    fn locate_audio_honors_footer_flag() {
1186        // footer flag (0x10) adds 10 to tag_len. body=0 -> tag_len = 10+0+10 = 20.
1187        // Sync at offset 20. The `+= -> -=`/`*=` mutant computes the wrong tag_len
1188        // and the sync check lands on the wrong byte -> Err (kills the `+=`).
1189        let mut data = Vec::new();
1190        data.extend_from_slice(b"ID3");
1191        data.extend_from_slice(&[0x04, 0x00, 0x10]); // flags: footer present
1192        data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // synchsafe body=0
1193        data.extend_from_slice(&[0u8; 10]); // 10-byte footer region
1194        data.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // sync at offset 20
1195        let b = locate_audio(&data).unwrap();
1196        assert_eq!(b.audio_offset, 20);
1197    }
1198
1199    #[test]
1200    fn locate_audio_requires_frame_sync() {
1201        // data[0]=0xFF but data[1] lacks the 0xE0 sync bits: original rejects
1202        // (NotMp3). The `|| -> &&` mutant accepts (only rejects if ALL conditions
1203        // hold). The `+ -> *` on data[audio_offset+1] would read data[0] instead of
1204        // data[1]; with distinct bytes the sync decision flips.
1205        let data = [0xFF, 0x00, 0x00, 0x00, 0, 0, 0, 0, 0, 0];
1206        assert_eq!(locate_audio(&data), Err(FormatError::NotMp3));
1207        // 1-byte buffer: original NotMp3 (audio_offset+1 >= len). The `+ -> *`
1208        // mutant computes 0*1=0 >= 1 = false, falls through, and panics on data[1].
1209        assert_eq!(locate_audio(&[0xFF]), Err(FormatError::NotMp3));
1210    }
1211
1212    #[test]
1213    fn push_frame_header_size_boundary_is_inclusive() {
1214        // ID3v2.4 frame size is a 28-bit syncsafe field; the guard rejects
1215        // data_len > 0x0FFF_FFFF. 0x0FFF_FFFF is the inclusive max (Ok); +1 errors.
1216        let mut out = Vec::new();
1217        assert!(push_frame_header(&mut out, b"TIT2", 0x0FFF_FFFF).is_ok());
1218        let mut over = Vec::new();
1219        assert_eq!(
1220            push_frame_header(&mut over, b"TIT2", 0x1000_0000),
1221            Err(FormatError::TooLarge)
1222        );
1223    }
1224
1225    #[test]
1226    fn is_id3_text_frame_id_classifies_text_frames() {
1227        assert!(is_id3_text_frame_id("TPE1")); // T + 3 upper/digit, not TXXX
1228        assert!(is_id3_text_frame_id("TIT2"));
1229        assert!(!is_id3_text_frame_id("TXXX")); // excluded (kills `!= -> ==`)
1230        assert!(!is_id3_text_frame_id("COMM")); // not T-prefixed
1231        assert!(!is_id3_text_frame_id("TPE")); // wrong length
1232        assert!(!is_id3_text_frame_id("Txx1")); // lowercase -> false
1233    }
1234
1235    #[test]
1236    fn build_id3v2_segments_emits_standard_text_frame_as_itself() {
1237        // A 4-char T-frame key (TPE1) must round-trip as a TPE1 frame, not TXXX.
1238        // The `is_id3_text_frame_id` match-guard `-> false` mutant would route it to
1239        // the TXXX branch, so read_tags would surface it under a different key.
1240        let tags = vec![TagInput::new("TPE1", "Band")];
1241        let (segments, _len) = build_id3v2_segments(&tags, &[], &[]).unwrap();
1242        let mut buf = Vec::new();
1243        for seg in &segments {
1244            if let Segment::Inline(b) = seg {
1245                buf.extend_from_slice(b);
1246            }
1247        }
1248        // The literal frame id "TPE1" must appear in the emitted tag bytes.
1249        assert!(
1250            buf.windows(4).any(|w| w == b"TPE1"),
1251            "TPE1 frame not emitted: routed elsewhere"
1252        );
1253        // And it round-trips to the mapped key (artist), not a TXXX user field.
1254        let read = read_tags(&buf);
1255        assert!(
1256            read.contains(&("artist".to_string(), "Band".to_string())),
1257            "got {read:?}"
1258        );
1259    }
1260
1261    #[test]
1262    fn build_id3v2_segments_rejects_oversized_total_tag() {
1263        // The total-tag guard rejects frames_len > 0x0FFF_FFFF. An APIC art whose
1264        // data_len (a count, not allocated) pushes the total just over the limit
1265        // must error; one byte under must succeed.
1266        let mk = |data_len: u64| ArtInput {
1267            art_id: 1,
1268            mime: "image/png".to_string(),
1269            description: String::new(),
1270            picture_type: PictureType::new(3).unwrap(),
1271            width: 0,
1272            height: 0,
1273            data_len: BlobLen::new(data_len).unwrap(),
1274        };
1275        assert_eq!(
1276            build_id3v2_segments(&[], &[], &[mk(0x1000_0000)]).err(),
1277            Some(FormatError::TooLarge)
1278        );
1279        assert!(build_id3v2_segments(&[], &[], &[mk(16)]).is_ok());
1280        // Exact boundary: compute the APIC framing overhead, then place
1281        // frames_len exactly on 0x0FFF_FFFF (one byte under must succeed) and
1282        // 0x1_0000_0000 (must error). This pins the `> -> >=` mutation. The
1283        // baseline art uses data_len=1 (not 0) because zero-byte art is skipped.
1284        let (_, total_at_one) = build_id3v2_segments(&[], &[], &[mk(1)]).unwrap();
1285        let overhead = total_at_one - 10 - 1; // frames_len = overhead + data_len
1286        let boundary_data_len = 0x0FFF_FFFF - overhead;
1287        assert!(
1288            build_id3v2_segments(&[], &[], &[mk(boundary_data_len)]).is_ok(),
1289            "exact boundary (frames_len == 0x0FFF_FFFF) should be accepted"
1290        );
1291        assert_eq!(
1292            build_id3v2_segments(&[], &[], &[mk(boundary_data_len + 1)]).err(),
1293            Some(FormatError::TooLarge),
1294            "one byte past boundary must be rejected"
1295        );
1296    }
1297
1298    #[test]
1299    fn build_id3v2_segments_rejects_embedded_nul() {
1300        // Regression for #506: a NUL in any DB-sourced text field would desync an
1301        // ID3 frame's terminators/separators, so synthesis must reject it.
1302        let nul_key = build_id3v2_segments(&[TagInput::new("bad\0key", "ok")], &[], &[]);
1303        assert_eq!(
1304            nul_key.err(),
1305            Some(FormatError::EmbeddedNul { field: "tag key" })
1306        );
1307
1308        let nul_value = build_id3v2_segments(&[TagInput::new("TIT2", "a\0b")], &[], &[]);
1309        assert_eq!(
1310            nul_value.err(),
1311            Some(FormatError::EmbeddedNul { field: "tag value" })
1312        );
1313
1314        let art = |mime: &str, desc: &str| ArtInput {
1315            art_id: 1,
1316            mime: mime.to_string(),
1317            description: desc.to_string(),
1318            picture_type: PictureType::new(3).unwrap(),
1319            width: 0,
1320            height: 0,
1321            data_len: BlobLen::new(16).unwrap(),
1322        };
1323        assert_eq!(
1324            build_id3v2_segments(&[], &[], &[art("image/png\0junk", "")]).err(),
1325            Some(FormatError::EmbeddedNul { field: "art mime" })
1326        );
1327        assert_eq!(
1328            build_id3v2_segments(&[], &[], &[art("image/png", "front\0cover")]).err(),
1329            Some(FormatError::EmbeddedNul {
1330                field: "art description"
1331            })
1332        );
1333
1334        // A clean tag/art with no NUL still synthesizes.
1335        assert!(
1336            build_id3v2_segments(
1337                &[TagInput::new("TIT2", "ok")],
1338                &[],
1339                &[art("image/png", "front")]
1340            )
1341            .is_ok()
1342        );
1343    }
1344
1345    #[test]
1346    fn build_id3v2_segments_emits_art_segment_with_correct_id_and_len() {
1347        // Feed a single art entry and verify the emitted ArtImage segment carries
1348        // the correct art_id and data length.
1349        let mk = |art_id: i64, data_len: u64| ArtInput {
1350            art_id,
1351            mime: "image/png".to_string(),
1352            description: String::new(),
1353            picture_type: PictureType::new(3).unwrap(),
1354            width: 0,
1355            height: 0,
1356            data_len: BlobLen::new(data_len).unwrap(),
1357        };
1358        let (segments, _len) = build_id3v2_segments(&[], &[], &[mk(2, 16)]).unwrap();
1359        let art_segs: Vec<_> = segments
1360            .iter()
1361            .filter_map(|s| match s {
1362                Segment::ArtImage { art_id, len } => Some((*art_id, len.get())),
1363                _ => None,
1364            })
1365            .collect();
1366        assert_eq!(
1367            art_segs,
1368            vec![(2_i64, 16_u64)],
1369            "only the non-empty art should be emitted"
1370        );
1371    }
1372
1373    /// Independent synchsafe encoder for fixtures (does NOT call `syncsafe`, so a
1374    /// mutation there cannot mask a fixture).
1375    fn ss(n: u32) -> [u8; 4] {
1376        [
1377            ((n >> 21) & 0x7F) as u8,
1378            ((n >> 14) & 0x7F) as u8,
1379            ((n >> 7) & 0x7F) as u8,
1380            (n & 0x7F) as u8,
1381        ]
1382    }
1383
1384    /// Build an ID3v2 tag: "ID3", `major`, rev=0, `flags`, synchsafe `body` size,
1385    /// then the raw `frames` bytes.
1386    fn id3v2(major: u8, flags: u8, body: u32, frames: &[u8]) -> Vec<u8> {
1387        let mut v = Vec::new();
1388        v.extend_from_slice(b"ID3");
1389        v.push(major);
1390        v.push(0x00);
1391        v.push(flags);
1392        v.extend_from_slice(&ss(body));
1393        v.extend_from_slice(frames);
1394        v
1395    }
1396
1397    #[test]
1398    fn alloc_safe_accepts_minimal_valid_header() {
1399        // 10-byte v2.4 header, body=0, no frames -> safe. This is exactly the
1400        // len==10 boundary, so the `< -> <=` mutant (10<=10 -> reject) flips it.
1401        let tag = id3v2(0x04, 0x00, 0, &[]);
1402        assert_eq!(tag.len(), 10);
1403        assert!(id3v2_alloc_safe(&tag));
1404    }
1405
1406    #[test]
1407    fn alloc_safe_rejects_short_and_non_id3() {
1408        // "ID3" + 2 bytes (len 5, marker correct): original returns false (len<10).
1409        // `< -> ==` (5==10 false) and `|| -> &&` (true && false) both fall through
1410        // and panic reading data[5]. Asserting `!safe` kills them.
1411        assert!(!id3v2_alloc_safe(b"ID3xx"));
1412        // Right length, wrong marker -> false.
1413        assert!(!id3v2_alloc_safe(b"XXX\x04\x00\x00\x00\x00\x00\x00"));
1414    }
1415
1416    #[test]
1417    fn alloc_safe_rejects_bad_version_and_header_flags() {
1418        // major outside 2..=4 -> false (kills the `matches!(major, 2..=4)` mutations).
1419        assert!(!id3v2_alloc_safe(&id3v2(0x05, 0x00, 0, &[])));
1420        assert!(!id3v2_alloc_safe(&id3v2(0x01, 0x00, 0, &[])));
1421        // extended-header (0x40) or unsync (0x80) -> false (kills `& 0xC0` mutations).
1422        assert!(!id3v2_alloc_safe(&id3v2(0x04, 0x40, 0, &[])));
1423        assert!(!id3v2_alloc_safe(&id3v2(0x04, 0x80, 0, &[])));
1424    }
1425
1426    #[test]
1427    fn alloc_safe_rejects_high_bit_in_body_size() {
1428        // Two body-size bytes with the high bit set: OR = 0x80 (reject). The
1429        // `| -> ^` mutant gives 0x80^0x80 = 0 (accept); `| -> &` gives 0x80&0x80&0&0
1430        // = 0 (accept). Built by hand because `ss()` would clear the high bits.
1431        let tag = vec![b'I', b'D', b'3', 0x04, 0x00, 0x00, 0x80, 0x80, 0x00, 0x00];
1432        assert!(!id3v2_alloc_safe(&tag));
1433        // Single high-bit byte still rejected (pins the `>= 0x80` comparison).
1434        let tag1 = vec![b'I', b'D', b'3', 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80];
1435        assert!(!id3v2_alloc_safe(&tag1));
1436    }
1437
1438    #[test]
1439    fn alloc_safe_rejects_high_bit_in_v24_frame_size() {
1440        // v2.4 frame size is synchsafe; two size bytes with the high bit set must be
1441        // rejected (whole-byte OR check on data[pos+4..pos+8]). The frame is 10 bytes
1442        // (4 id + 4 size + 2 flags), so body=10 makes tag_end == len (20): the walk
1443        // is entered (NOT short-circuited by `tag_end > data.len()`) and the high-bit
1444        // check fires.
1445        let mut frame = b"TIT2".to_vec();
1446        frame.extend_from_slice(&[0x80, 0x80, 0x00, 0x00]); // size bytes, two high bits
1447        frame.extend_from_slice(&[0x00, 0x00]); // frame flags
1448        let tag = id3v2(0x04, 0x00, 10, &frame);
1449        assert!(!id3v2_alloc_safe(&tag));
1450    }
1451
1452    /// A valid ID3v2.3 frame: 4-byte id, 4-byte plain big-endian size, 2 flag bytes,
1453    /// then `payload`.
1454    fn v23_frame(id: &[u8; 4], size: u32, payload: &[u8]) -> Vec<u8> {
1455        let mut v = id.to_vec();
1456        v.extend_from_slice(&size.to_be_bytes());
1457        v.extend_from_slice(&[0x00, 0x00]);
1458        v.extend_from_slice(payload);
1459        v
1460    }
1461
1462    #[test]
1463    fn alloc_safe_v22_24bit_size_decode() {
1464        // v2.2 frame header is 6 bytes: 3-byte id + 3-byte 24-bit big-endian size.
1465        // Declare a size that the *correct* decode puts out of bounds (reject), so a
1466        // decode that drops a size byte would wrongly accept.
1467        // size bytes [0x00,0x01,0x00] = 256, body = 6 (header only, no room) -> reject.
1468        let mut f_mid = b"TT2".to_vec();
1469        f_mid.extend_from_slice(&[0x00, 0x01, 0x00]); // 24-bit size = 256
1470        assert!(!id3v2_alloc_safe(&id3v2(0x02, 0x00, 6, &f_mid))); // pins the mid byte
1471        // size bytes [0x01,0x00,0x00] = 65536 -> reject; pins the high byte.
1472        let mut f_hi = b"TT2".to_vec();
1473        f_hi.extend_from_slice(&[0x01, 0x00, 0x00]);
1474        assert!(!id3v2_alloc_safe(&id3v2(0x02, 0x00, 6, &f_hi)));
1475        // size bytes [0x00,0x00,0x10] = 16, body = 6, tag_end = 16,
1476        // data_start = 16. 16 > 16 - 16 = 0 -> reject. Pins the low byte:
1477        // reading the flags byte (0x00) instead gives size 0 -> wrongly accept.
1478        let mut f_lo = b"TT2".to_vec();
1479        f_lo.extend_from_slice(&[0x00, 0x00, 0x10]); // 24-bit size = 16
1480        assert!(!id3v2_alloc_safe(&id3v2(0x02, 0x00, 6, &f_lo)));
1481        // A valid in-bounds v2.2 frame is accepted: size 4, body = 6+4 = 10.
1482        let mut f_ok = b"TT2".to_vec();
1483        f_ok.extend_from_slice(&[0x00, 0x00, 0x04]);
1484        f_ok.extend_from_slice(&[1, 2, 3, 4]);
1485        assert!(id3v2_alloc_safe(&id3v2(0x02, 0x00, 10, &f_ok)));
1486    }
1487
1488    #[test]
1489    fn alloc_safe_rejects_nonzero_frame_flags() {
1490        // v2.3: non-zero frame flags -> reject (the v2.3 flag check).
1491        let mut f3 = b"TIT2".to_vec();
1492        f3.extend_from_slice(&4u32.to_be_bytes()); // plain size 4
1493        f3.extend_from_slice(&[0x00, 0x01]); // non-zero frame flags
1494        f3.extend_from_slice(&[1, 2, 3, 4]);
1495        assert!(!id3v2_alloc_safe(&id3v2(0x03, 0x00, 14, &f3)));
1496
1497        // v2.4: non-zero frame flags -> reject. This is a SEPARATE code path (the
1498        // v2.4 `else` branch) from the v2.3 check, so it needs its own fixture.
1499        let mut f4 = b"TIT2".to_vec();
1500        f4.extend_from_slice(&ss(4)); // valid synchsafe size 4
1501        f4.extend_from_slice(&[0x00, 0x01]); // non-zero frame flags
1502        f4.extend_from_slice(&[1, 2, 3, 4]);
1503        assert!(!id3v2_alloc_safe(&id3v2(0x04, 0x00, 14, &f4)));
1504    }
1505
1506    #[test]
1507    fn alloc_safe_rejects_chap_and_ctoc() {
1508        // CHAP/CTOC carry sub-frames -> recursive OOM vector -> reject (v2.3/2.4).
1509        let chap = v23_frame(b"CHAP", 4, &[1, 2, 3, 4]);
1510        assert!(!id3v2_alloc_safe(&id3v2(0x03, 0x00, 14, &chap)));
1511        let ctoc = v23_frame(b"CTOC", 4, &[1, 2, 3, 4]);
1512        assert!(!id3v2_alloc_safe(&id3v2(0x03, 0x00, 14, &ctoc)));
1513    }
1514
1515    #[test]
1516    fn alloc_safe_frame_size_bounds() {
1517        // Frame exactly filling the body -> accept (size 4, body = 10+4 = 14).
1518        // data_start = 10+10 = 20, tag_end = 24, rem = 4, size 4 -> 4 > 4 is false.
1519        // Kills A `+ -> *` (data_start=100 -> 100>24 -> reject) and C `> -> >=`
1520        // (4 >= 4 -> reject).
1521        let ok = v23_frame(b"TIT2", 4, &[1, 2, 3, 4]);
1522        assert!(id3v2_alloc_safe(&id3v2(0x03, 0x00, 14, &ok)));
1523        // size one byte past the remainder -> reject (size 5: 5 > 24-20=4). Kills C
1524        // `> -> ==` (5==4 false -> accept), C `- -> +` (rem=44 -> 5>44 false ->
1525        // accept), D `|| -> &&` (false && true -> accept), and A `+ -> -`
1526        // (data_start=0 -> 5 > 24-0=24 false -> accept).
1527        let over = v23_frame(b"TIT2", 5, &[1, 2, 3, 4]);
1528        assert!(!id3v2_alloc_safe(&id3v2(0x03, 0x00, 14, &over)));
1529    }
1530
1531    #[test]
1532    fn alloc_safe_data_start_equal_to_tag_end_is_ok() {
1533        // A size-0 frame: data_start (20) == tag_end (20). Original: `20 > 20` is
1534        // false -> accept. Kills B `> -> ==` (20==20 -> reject) and `> -> >=`.
1535        let zero = v23_frame(b"TIT2", 0, &[]);
1536        assert!(id3v2_alloc_safe(&id3v2(0x03, 0x00, 10, &zero)));
1537    }
1538
1539    #[test]
1540    fn alloc_safe_rejects_bad_second_frame_in_body() {
1541        // Valid frame1 (size 2) then an out-of-bounds frame2 (size 100), both inside
1542        // the declared body (body=26, tag_end=36). Original walks to frame2 and
1543        // rejects. Kills E `+ -> *` (pos = 20*2 = 40 >= 36 -> break -> accept,
1544        // skipping frame2) and E `+ -> -` (pos = 20-2 = 18 -> data[18]==0 padding
1545        // break -> accept).
1546        let mut frames = v23_frame(b"TIT2", 2, &[0xAA, 0xBB]); // 12 bytes, 10..22
1547        frames.extend_from_slice(&v23_frame(b"TPE1", 100, &[1, 2, 3, 4])); // 14, 22..36
1548        assert!(!id3v2_alloc_safe(&id3v2(0x03, 0x00, 26, &frames)));
1549    }
1550
1551    #[test]
1552    fn alloc_safe_stops_at_tag_body_end() {
1553        // A size-0 frame fills the body (tag_end=20), then a bad trailing frame
1554        // beyond tag_end but within the buffer. Original breaks at `pos >= tag_end`
1555        // (20 >= 20) and accepts without walking the trailing garbage. Kills F
1556        // `>= -> <` (20 < 20 false -> no break -> walks the bad frame -> reject).
1557        let mut frames = v23_frame(b"TIT2", 0, &[]); // 10 bytes, 10..20
1558        frames.extend_from_slice(&v23_frame(b"TPE1", 100, &[1, 2, 3, 4])); // 14, 20..34
1559        assert!(id3v2_alloc_safe(&id3v2(0x03, 0x00, 10, &frames)));
1560    }
1561
1562    #[test]
1563    fn alloc_safe_walks_two_frames_and_stops_at_padding() {
1564        // Two valid frames (24 bytes, 10..34) then 10 padding zero bytes (34..44).
1565        // body=25 -> tag_end=35, so after frame2 (pos=34) `34 >= 35` is false (no
1566        // tag-end break); the next iteration enters (`34+10=44 <= 44`) and
1567        // `data[34] == 0` triggers the PADDING break. Kills I `== -> !=` (no break ->
1568        // walks zero bytes -> data_start past tag_end -> reject) and exercises the
1569        // multi-frame walk (E) and the while guard (G).
1570        let mut frames = v23_frame(b"TIT2", 2, &[0xAA, 0xBB]);
1571        frames.extend_from_slice(&v23_frame(b"TPE1", 2, &[0xCC, 0xDD]));
1572        frames.extend_from_slice(&[0u8; 10]); // >= header_len of padding so the walk re-enters
1573        assert!(id3v2_alloc_safe(&id3v2(0x03, 0x00, 25, &frames)));
1574    }
1575
1576    #[test]
1577    fn alloc_safe_rejects_frame_size_exceeding_tag_end() {
1578        // Single frame claiming size 100 in a 14-byte body -> reject before any
1579        // allocation. Reinforces C.
1580        let huge = v23_frame(b"TIT2", 100, &[1, 2, 3, 4]);
1581        assert!(!id3v2_alloc_safe(&id3v2(0x03, 0x00, 14, &huge)));
1582    }
1583
1584    /// ID3v2 header declaring `body` bytes of tag, then a frame-sync byte pair,
1585    /// then `audio`. Returns (full, audio_offset).
1586    fn mp3_with_id3v2(body_len: usize, audio: &[u8]) -> (Vec<u8>, u64) {
1587        let mut v = b"ID3\x04\x00\x00".to_vec(); // version 2.4, no flags
1588        v.extend_from_slice(&syncsafe(u32::try_from(body_len).unwrap()));
1589        v.extend(std::iter::repeat_n(0u8, body_len)); // tag body
1590        let audio_offset = v.len() as u64;
1591        v.extend_from_slice(&[0xFF, 0xFB]); // MPEG frame sync
1592        v.extend_from_slice(audio);
1593        (v, audio_offset)
1594    }
1595
1596    #[test]
1597    fn locate_audio_bounded_complete_with_no_id3v1() {
1598        let (full, audio_offset) = mp3_with_id3v2(8, b"frames");
1599        let prefix = &full[..usize_from(audio_offset) + 2]; // covers tag + sync
1600        let file_len = full.len() as u64;
1601        match locate_audio_bounded(prefix, file_len, None).unwrap() {
1602            Extent::Complete(b) => {
1603                assert_eq!(b.audio_offset, audio_offset);
1604                assert_eq!(b.audio_length, file_len - audio_offset);
1605            }
1606            other @ Extent::NeedMore { .. } => panic!("expected Complete, got {other:?}"),
1607        }
1608    }
1609
1610    #[test]
1611    fn locate_audio_bounded_needmore_when_tag_exceeds_prefix() {
1612        let (full, _audio_offset) = mp3_with_id3v2(4096, b"frames");
1613        let prefix = &full[..32]; // only the 10-byte header is present
1614        let file_len = full.len() as u64;
1615        match locate_audio_bounded(prefix, file_len, None).unwrap() {
1616            Extent::NeedMore { up_to } => assert_eq!(up_to, 10 + 4096 + 2),
1617            other @ Extent::Complete(_) => panic!("expected NeedMore, got {other:?}"),
1618        }
1619    }
1620
1621    #[test]
1622    fn locate_audio_bounded_strips_id3v1_tail() {
1623        let (mut full, audio_offset) = mp3_with_id3v2(8, b"frames");
1624        let body_end = full.len();
1625        full.extend_from_slice(b"TAG"); // ID3v1 marker
1626        full.extend(std::iter::repeat_n(0u8, 125)); // 128-byte tag total
1627        let file_len = full.len() as u64;
1628        let tail: [u8; 128] = full[full.len() - 128..].try_into().unwrap();
1629        let prefix = &full[..usize_from(audio_offset) + 2];
1630        match locate_audio_bounded(prefix, file_len, Some(&tail)).unwrap() {
1631            Extent::Complete(b) => {
1632                assert_eq!(b.audio_offset, audio_offset);
1633                assert_eq!(b.audio_length, body_end as u64 - audio_offset);
1634            }
1635            other @ Extent::NeedMore { .. } => panic!("expected Complete, got {other:?}"),
1636        }
1637    }
1638
1639    #[test]
1640    fn locate_audio_bounded_rejects_audio_start_past_eof() {
1641        // An ID3v2 tag whose declared length leaves no room for the frame sync
1642        // (audio_offset == file_len). The bounded prober must fail fast with
1643        // `NotMp3` rather than loop on `NeedMore { up_to > file_len }`.
1644        let mut full = b"ID3\x04\x00\x00".to_vec();
1645        full.extend_from_slice(&syncsafe(8));
1646        full.extend(std::iter::repeat_n(0u8, 8)); // tag body; file ends here
1647        let file_len = full.len() as u64; // == tag end == audio_offset
1648        match locate_audio_bounded(&full, file_len, None) {
1649            Err(FormatError::NotMp3) => {}
1650            other => panic!("expected Err(NotMp3), got {other:?}"),
1651        }
1652    }
1653
1654    // kills mp3 L75 (`prefix.len() >= 10 && &prefix[0..3] == b"ID3"`: `&&`->`||`).
1655    // A long (>=10) prefix that is NOT "ID3" and starts with a valid frame sync.
1656    // Correct (`&&`): the ID3 branch is skipped -> audio_offset stays 0 -> Complete
1657    // at offset 0. Under `||`: `len>=10 || "ID3"==..` is true, so it parses an ID3
1658    // header out of the non-ID3 bytes, computing a bogus tag_len and a wrong
1659    // audio_offset (or Malformed). Asserting audio_offset==0 kills it.
1660    #[test]
1661    fn locate_audio_bounded_plain_mp3_no_id3_starts_at_zero() {
1662        // 0xFF 0xFB frame sync at offset 0, then payload. len 12 (>= 10).
1663        let data = [0xFF, 0xFB, 0x90, 0x00, 1, 2, 3, 4, 5, 6, 7, 8];
1664        let file_len = data.len() as u64;
1665        match locate_audio_bounded(&data, file_len, None).unwrap() {
1666            Extent::Complete(b) => {
1667                assert_eq!(b.audio_offset, 0);
1668                assert_eq!(b.audio_length, file_len);
1669            }
1670            other @ Extent::NeedMore { .. } => {
1671                panic!("expected Complete at offset 0, got {other:?}")
1672            }
1673        }
1674    }
1675
1676    // Reinforces L75 with a short non-ID3 prefix below the ID3-header length.
1677    // A 5-byte prefix that is not "ID3", file_len < 10. Correct (`&&`): the ID3
1678    // branch is false (len 5 < 10) AND the else-if at L86 is `len<10 && file_len>=10`
1679    // = `true && false` = false, so it proceeds; the frame sync at offset 0 is in
1680    // the prefix -> Complete. Under L75 `||`: `5>=10 || "ID3"==prefix[0..3]` ->
1681    // false || false is still false here, BUT the point is the `&&`->`||` mutant on
1682    // a len>=10 non-ID3 prefix (covered above). This case pins that a short non-ID3
1683    // prefix with a valid sync resolves to Complete (no panic indexing prefix[5..]).
1684    #[test]
1685    fn locate_audio_bounded_short_non_id3_with_small_file() {
1686        // 0xFF 0xFB sync at offset 0; file_len 5 (< 10).
1687        let data = [0xFF, 0xFB, 0x90, 0x00, 0x00];
1688        let file_len = data.len() as u64; // 5
1689        match locate_audio_bounded(&data, file_len, None).unwrap() {
1690            Extent::Complete(b) => {
1691                assert_eq!(b.audio_offset, 0);
1692                assert_eq!(b.audio_length, 5);
1693            }
1694            other @ Extent::NeedMore { .. } => panic!("expected Complete, got {other:?}"),
1695        }
1696    }
1697
1698    // kills mp3 L80 (footer `tag_len += 10`: `+=`->`-=`,`*=`).
1699    // ID3v2.4 tag WITH the footer flag (0x10) and a known body. tag_len must be
1700    // 10 (header) + body + 10 (footer). With body=6, audio_offset must be 26.
1701    // `-=` gives 10+6-10 = 6; `*=` gives (10+6)*10 = 160 (> file_len -> Malformed).
1702    // Frame sync is placed at offset 26 so the correct path returns Complete.
1703    #[test]
1704    fn locate_audio_bounded_footer_flag_adds_ten() {
1705        let body = 6usize;
1706        let mut full = b"ID3\x04\x00".to_vec();
1707        full.push(0x10); // flags: footer present
1708        full.extend_from_slice(&syncsafe(u32::try_from(body).unwrap()));
1709        full.extend(std::iter::repeat_n(0u8, body)); // tag body
1710        full.extend(std::iter::repeat_n(0u8, 10)); // footer region
1711        let expected_offset = full.len() as u64; // 10 + 6 + 10 = 26
1712        full.extend_from_slice(&[0xFF, 0xFB]); // frame sync at offset 26
1713        full.extend_from_slice(b"audio");
1714        let file_len = full.len() as u64;
1715        match locate_audio_bounded(&full, file_len, None).unwrap() {
1716            Extent::Complete(b) => {
1717                assert_eq!(b.audio_offset, 26);
1718                assert_eq!(b.audio_offset, expected_offset);
1719                assert_eq!(b.audio_length, file_len - 26);
1720            }
1721            other @ Extent::NeedMore { .. } => {
1722                panic!("expected Complete at offset 26, got {other:?}")
1723            }
1724        }
1725    }
1726
1727    // kills mp3 L82 (`tag_len as u64 > file_len`: `>`->`==`,`>=`).
1728    // Construct a tag where tag_len == file_len EXACTLY (no room for audio).
1729    // Correct (`>`): `tag_len > file_len` is false -> proceeds; then the L96
1730    // `audio_offset + 2 > file_len` check fires (audio_offset == file_len) ->
1731    // Err(NotMp3). Under `==`/`>=`: `tag_len == file_len` true -> early
1732    // Err(Malformed). Asserting NotMp3 (not Malformed) kills both.
1733    #[test]
1734    fn locate_audio_bounded_tag_len_equals_file_len_is_notmp3_not_malformed() {
1735        let body = 8usize;
1736        let mut full = b"ID3\x04\x00\x00".to_vec();
1737        full.extend_from_slice(&syncsafe(u32::try_from(body).unwrap()));
1738        full.extend(std::iter::repeat_n(0u8, body)); // file ends exactly at tag end
1739        let file_len = full.len() as u64; // == tag_len == audio_offset (18)
1740        match locate_audio_bounded(&full, file_len, None) {
1741            Err(FormatError::NotMp3) => {}
1742            other => panic!("expected Err(NotMp3) for tag_len==file_len, got {other:?}"),
1743        }
1744    }
1745
1746    // kills mp3 L82 true branch (`>`): a tag declaring more than the file holds
1747    // must be Malformed. Pins that the `>` branch is reachable and returns
1748    // Malformed (so the `>`->`==`/`>=` mutants, which change WHICH side is taken,
1749    // are distinguished from the equals case above).
1750    #[test]
1751    fn locate_audio_bounded_tag_len_exceeds_file_len_is_malformed() {
1752        // Declare body=100 but provide a tiny file. tag_len = 110 > file_len.
1753        let mut full = b"ID3\x04\x00\x00".to_vec();
1754        full.extend_from_slice(&syncsafe(100));
1755        full.extend_from_slice(&[0xFF, 0xFB]); // some bytes, but file is short
1756        let file_len = full.len() as u64; // 12, << 110
1757        match locate_audio_bounded(&full, file_len, None) {
1758            Err(FormatError::Malformed) => {}
1759            other => panic!("expected Err(Malformed), got {other:?}"),
1760        }
1761    }
1762
1763    // kills mp3 L86 (`prefix.len() < 10 && file_len >= 10`: the NeedMore{up_to:10}
1764    // else-if). Short non-ID3 prefix (len 5) with file_len >= 10. Correct: `5 < 10
1765    // && 10 >= 10` = true -> NeedMore{up_to:10} (we cannot even read the ID3 header).
1766    // `&&`->`||` keeps it true here; the distinguishing variants are below.
1767    #[test]
1768    fn locate_audio_bounded_short_prefix_large_file_needs_header() {
1769        let prefix = [0x00, 0x00, 0x00, 0x00, 0x00]; // 5 bytes, not "ID3"
1770        let file_len = 64u64; // >= 10
1771        match locate_audio_bounded(&prefix, file_len, None).unwrap() {
1772            Extent::NeedMore { up_to } => assert_eq!(up_to, 10),
1773            other @ Extent::Complete(_) => panic!("expected NeedMore{{up_to:10}}, got {other:?}"),
1774        }
1775    }
1776
1777    // kills mp3 L86 `<`->`<=` (and `<`->`==`): boundary prefix.len()==10.
1778    // A 10-byte non-ID3 prefix with file_len >= 10. Correct (`<`): `10 < 10` is
1779    // false -> does NOT take the NeedMore-header branch -> proceeds. The first two
1780    // prefix bytes are a valid frame sync, so audio at offset 0 resolves Complete.
1781    // Under `<=`: `10 <= 10` true -> wrongly NeedMore{up_to:10}. Under `==`:
1782    // `10 == 10` true -> wrongly NeedMore. Asserting Complete kills both.
1783    #[test]
1784    fn locate_audio_bounded_prefix_len_exactly_ten_proceeds() {
1785        // 10 bytes, not "ID3", frame sync at offset 0.
1786        let prefix = [0xFF, 0xFB, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
1787        let file_len = 64u64; // >= 10, audio extends to file_len
1788        match locate_audio_bounded(&prefix, file_len, None).unwrap() {
1789            Extent::Complete(b) => {
1790                assert_eq!(b.audio_offset, 0);
1791                assert_eq!(b.audio_length, file_len);
1792            }
1793            other @ Extent::NeedMore { .. } => {
1794                panic!("expected Complete (10<10 false), got {other:?}")
1795            }
1796        }
1797    }
1798
1799    // kills mp3 L86 `>=`->`<` on file_len (and helps `&&`->`||`). Short non-ID3
1800    // prefix (len 5) with file_len < 10 (file_len=8). Correct (`>=`): `5 < 10 &&
1801    // 8 >= 10` = `true && false` = false -> does NOT NeedMore -> proceeds; sync at
1802    // offset 0 is in the prefix -> Complete with audio_length 8. Under `>=`->`<`:
1803    // `8 < 10` true -> `true && true` -> wrongly NeedMore{up_to:10}. Under `&&`->
1804    // `||`: `true || false` -> true -> wrongly NeedMore. Asserting Complete kills
1805    // both the `>=`->`<` and the `&&`->`||` mutants.
1806    #[test]
1807    fn locate_audio_bounded_short_prefix_small_file_proceeds() {
1808        let data = [0xFF, 0xFB, 0x90, 0x00, 0x00]; // len 5, file_len 8 -> but prefix==file here
1809        // Make file_len 8 with the same 5-byte prefix window; the sync pair (2 bytes)
1810        // is inside the prefix, so it resolves without needing more.
1811        let file_len = 8u64;
1812        match locate_audio_bounded(&data, file_len, None).unwrap() {
1813            Extent::Complete(b) => {
1814                assert_eq!(b.audio_offset, 0);
1815                assert_eq!(b.audio_length, 8);
1816            }
1817            other @ Extent::NeedMore { .. } => {
1818                panic!("expected Complete (file_len<10), got {other:?}")
1819            }
1820        }
1821    }
1822
1823    // kills mp3 L96 (`audio_offset as u64 + 2 > file_len`: `+`->`-`).
1824    // Build a real ID3v2 tag so audio_offset > 0, with the audio start placed
1825    // JUST past EOF: audio_offset + 2 == file_len + 1 (i.e. audio_offset ==
1826    // file_len - 1). Correct (`+`): `audio_offset + 2 > file_len` -> true ->
1827    // Err(NotMp3). Under `-`: `audio_offset - 2 > file_len` -> false (since
1828    // audio_offset < file_len) -> proceeds -> would read past EOF / wrong answer.
1829    #[test]
1830    fn locate_audio_bounded_sync_one_byte_past_eof_is_notmp3() {
1831        let body = 4usize;
1832        let mut full = b"ID3\x04\x00\x00".to_vec();
1833        full.extend_from_slice(&syncsafe(u32::try_from(body).unwrap()));
1834        full.extend(std::iter::repeat_n(0u8, body)); // tag end at offset 14
1835        let audio_offset = full.len() as u64; // 14
1836        full.push(0xFF); // a single sync byte present (so prefix has audio_offset+1)
1837        // file_len = audio_offset + 1, so audio_offset + 2 == file_len + 1 (just past).
1838        let file_len = audio_offset + 1; // 15
1839        match locate_audio_bounded(&full, file_len, None) {
1840            Err(FormatError::NotMp3) => {}
1841            other => panic!("expected Err(NotMp3) (sync past EOF), got {other:?}"),
1842        }
1843    }
1844
1845    // Complement to L96: audio_offset + 2 <= file_len must proceed (not reject).
1846    // Pins that the `>` comparison's false branch is reachable; with `+`->`-` the
1847    // earlier case flips, so this guards the true semantics of "+2 fits".
1848    #[test]
1849    fn locate_audio_bounded_sync_fits_in_file_proceeds() {
1850        let (full, audio_offset) = mp3_with_id3v2(4, b"frames");
1851        let file_len = full.len() as u64; // audio_offset + 2 + 6
1852        match locate_audio_bounded(&full, file_len, None).unwrap() {
1853            Extent::Complete(b) => assert_eq!(b.audio_offset, audio_offset),
1854            other @ Extent::NeedMore { .. } => panic!("expected Complete, got {other:?}"),
1855        }
1856    }
1857
1858    #[test]
1859    fn locate_audio_bounded_sync_exactly_at_eof_proceeds() {
1860        // Boundary: audio_offset + 2 == file_len exactly (audio is just the 2-byte
1861        // frame sync). `audio_offset + 2 > file_len` is false -> Complete. The
1862        // `>`->`>=` mutant makes `16 >= 16` true -> wrongly Err(NotMp3). Mirrors the
1863        // unbounded reject `audio_offset + 1 >= len` (accepts when +2 <= len).
1864        let body = 4usize;
1865        let mut full = b"ID3\x04\x00\x00".to_vec();
1866        full.extend_from_slice(&syncsafe(u32::try_from(body).unwrap()));
1867        full.extend(std::iter::repeat_n(0u8, body)); // tag end at offset 14
1868        let audio_offset = full.len() as u64; // 14
1869        full.push(0xFF); // frame sync pair, and nothing after
1870        full.push(0xFB);
1871        let file_len = full.len() as u64; // 16 == audio_offset + 2
1872        // kills mp3 L96 `>`->`>=`: equal-fit audio must be accepted, not rejected.
1873        match locate_audio_bounded(&full, file_len, None).unwrap() {
1874            Extent::Complete(b) => {
1875                assert_eq!(b.audio_offset, audio_offset);
1876                assert_eq!(b.audio_length, 2);
1877            }
1878            other @ Extent::NeedMore { .. } => {
1879                panic!("expected Complete (exact fit), got {other:?}")
1880            }
1881        }
1882    }
1883
1884    // kills mp3 L107 (`prefix[audio_offset] != 0xFF || (prefix[audio_offset+1] &
1885    // 0xE0) != 0xE0`): `||`->`&&` and `+`->`*`.
1886    // Frame-sync byte 0 is 0xFF but byte 1 lacks the 0xE0 sync bits. Correct
1887    // (`||`): first operand false, second true -> reject NotMp3. Under `&&`:
1888    // `false && true` -> accept (wrong) -> would return Complete. The `+`->`*` on
1889    // `audio_offset + 1`: with audio_offset==0, `0*1 == 0` reads byte 0 (0xFF)
1890    // instead of byte 1, so `(0xFF & 0xE0) != 0xE0` is false -> with `||` short of
1891    // first-operand-false the decision changes; pairing distinct bytes makes the
1892    // sync verdict observable.
1893    #[test]
1894    fn locate_audio_bounded_rejects_bad_second_sync_byte() {
1895        // byte0 = 0xFF (passes first half), byte1 = 0x00 (fails the 0xE0 check).
1896        let data = [
1897            0xFF, 0x00, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1898        ];
1899        let file_len = data.len() as u64;
1900        match locate_audio_bounded(&data, file_len, None) {
1901            Err(FormatError::NotMp3) => {}
1902            other => panic!("expected Err(NotMp3) (bad sync byte 1), got {other:?}"),
1903        }
1904    }
1905
1906    // Reinforces L107 `+`->`*` at a NON-zero audio_offset so `audio_offset + 1`
1907    // and `audio_offset * 1` differ. With an ID3 tag pushing audio_offset to 14:
1908    // byte[14] = 0xFF (good), byte[15] = 0x00 (bad second byte). Correct reads
1909    // byte[15] -> reject NotMp3. Under `+`->`*`: `14 * 1 == 14` reads byte[14]
1910    // (0xFF) again -> `(0xFF & 0xE0)==0xE0` so the second test passes -> accept
1911    // (wrong). Asserting NotMp3 kills `+`->`*`.
1912    #[test]
1913    fn locate_audio_bounded_rejects_bad_second_sync_byte_after_id3() {
1914        let body = 4usize;
1915        let mut full = b"ID3\x04\x00\x00".to_vec();
1916        full.extend_from_slice(&syncsafe(u32::try_from(body).unwrap()));
1917        full.extend(std::iter::repeat_n(0u8, body)); // audio_offset = 14
1918        full.extend_from_slice(&[0xFF, 0x00]); // byte14=0xFF good, byte15=0x00 bad
1919        full.extend_from_slice(b"tail");
1920        let file_len = full.len() as u64;
1921        match locate_audio_bounded(&full, file_len, None) {
1922            Err(FormatError::NotMp3) => {}
1923            other => panic!("expected Err(NotMp3) (bad sync at 15), got {other:?}"),
1924        }
1925    }
1926
1927    // kills mp3 L101 frame-sync NeedMore (`audio_offset + 2 > prefix.len()`).
1928    // A tag whose audio_offset is inside file_len, but the prefix is shorter than
1929    // audio_offset + 2 (the sync pair is past the prefix window). Correct: returns
1930    // NeedMore{up_to: audio_offset + 2}. A `+`->`*` (audio_offset*2) or a flipped
1931    // comparison changes up_to. Here audio_offset=14, so up_to must be 16; prefix
1932    // is only 15 bytes (one short of the sync pair).
1933    #[test]
1934    fn locate_audio_bounded_needmore_for_sync_past_prefix() {
1935        let body = 4usize;
1936        let mut full = b"ID3\x04\x00\x00".to_vec();
1937        full.extend_from_slice(&syncsafe(u32::try_from(body).unwrap()));
1938        full.extend(std::iter::repeat_n(0u8, body)); // audio_offset = 14
1939        full.extend_from_slice(&[0xFF, 0xFB]); // sync at 14..16
1940        full.extend_from_slice(b"more audio bytes here");
1941        let file_len = full.len() as u64; // plenty of room
1942        let prefix = &full[..15]; // 14-byte tag + only 1 of the 2 sync bytes
1943        match locate_audio_bounded(prefix, file_len, None).unwrap() {
1944            Extent::NeedMore { up_to } => assert_eq!(up_to, 16), // audio_offset(14) + 2
1945            other @ Extent::Complete(_) => panic!("expected NeedMore{{up_to:16}}, got {other:?}"),
1946        }
1947    }
1948
1949    // kills mp3 L113 (`file_len >= audio_offset + 128 && &tail[0..3] == b"TAG"`:
1950    // `&&`->`||`) — the TRIM case. A valid MP3 with a "TAG"-prefixed tail and
1951    // file_len >= audio_offset + 128. Correct: trim -> audio_length = file_len -
1952    // audio_offset - 128. (The complement no-trim case is below; together they pin
1953    // the `&&`.)
1954    #[test]
1955    fn locate_audio_bounded_trims_id3v1_when_tag_and_room() {
1956        let (mut full, audio_offset) = mp3_with_id3v2(8, b"frames");
1957        let body_end = full.len();
1958        full.extend_from_slice(b"TAG");
1959        full.extend(std::iter::repeat_n(0u8, 125)); // 128-byte ID3v1 trailer
1960        let file_len = full.len() as u64;
1961        assert!(file_len >= audio_offset + 128); // both conditions true
1962        let tail: [u8; 128] = full[full.len() - 128..].try_into().unwrap();
1963        let prefix = &full[..usize_from(audio_offset) + 2];
1964        match locate_audio_bounded(prefix, file_len, Some(&tail)).unwrap() {
1965            Extent::Complete(b) => {
1966                assert_eq!(b.audio_offset, audio_offset);
1967                // kills mp3 L113: trimmed length excludes the 128-byte ID3v1 tail.
1968                assert_eq!(b.audio_length, file_len - audio_offset - 128);
1969                assert_eq!(b.audio_length, body_end as u64 - audio_offset);
1970            }
1971            other @ Extent::NeedMore { .. } => panic!("expected Complete (trimmed), got {other:?}"),
1972        }
1973    }
1974
1975    // kills mp3 L113 (`&&`->`||`) — the NO-TRIM case. file_len >= audio_offset+128
1976    // is TRUE, but the tail does NOT start with "TAG". Correct (`&&`): second
1977    // operand false -> no trim -> audio_length == file_len - audio_offset. Under
1978    // `||`: first operand true -> trims 128 wrongly -> shorter length. Asserting
1979    // the un-trimmed length kills the `||` mutant.
1980    #[test]
1981    fn locate_audio_bounded_no_trim_when_tail_not_tag() {
1982        let (mut full, audio_offset) = mp3_with_id3v2(8, b"frames");
1983        // Pad with enough non-"TAG" trailing bytes so file_len >= audio_offset+128.
1984        full.extend(std::iter::repeat_n(0u8, 200));
1985        let file_len = full.len() as u64;
1986        assert!(file_len >= audio_offset + 128); // first operand TRUE
1987        let tail: [u8; 128] = full[full.len() - 128..].try_into().unwrap();
1988        assert_ne!(&tail[0..3], b"TAG"); // second operand FALSE
1989        let prefix = &full[..usize_from(audio_offset) + 2];
1990        match locate_audio_bounded(prefix, file_len, Some(&tail)).unwrap() {
1991            Extent::Complete(b) => {
1992                assert_eq!(b.audio_offset, audio_offset);
1993                // No trim: full audio length from offset to EOF.
1994                assert_eq!(b.audio_length, file_len - audio_offset);
1995            }
1996            other @ Extent::NeedMore { .. } => panic!("expected Complete (no trim), got {other:?}"),
1997        }
1998    }
1999
2000    // Complement to L113 first-operand: tail starts with "TAG" but file_len <
2001    // audio_offset + 128 (no room for a real ID3v1). Correct (`&&`): first operand
2002    // false -> no trim. Under `||`: second operand true -> trims 128 even though
2003    // file_len < audio_offset + 128, which would underflow / shorten wrongly.
2004    // Asserting the un-trimmed length pins the first operand of the `&&`.
2005    #[test]
2006    fn locate_audio_bounded_no_trim_when_no_room_even_with_tag_tail() {
2007        let (mut full, audio_offset) = mp3_with_id3v2(8, b"frames");
2008        // Short file: append a "TAG"-prefixed tail but keep file_len < offset+128.
2009        full.extend_from_slice(b"TAGxx"); // tail-ish marker, but file stays short
2010        let file_len = full.len() as u64;
2011        assert!(file_len < audio_offset + 128); // first operand FALSE
2012        // Build a 128-byte tail buffer that starts with "TAG" (the function only
2013        // looks at tail[0..3]); file_len is the real gate here.
2014        let mut tail = [0u8; 128];
2015        tail[0..3].copy_from_slice(b"TAG");
2016        let prefix = &full[..usize_from(audio_offset) + 2];
2017        match locate_audio_bounded(prefix, file_len, Some(&tail)).unwrap() {
2018            Extent::Complete(b) => {
2019                assert_eq!(b.audio_offset, audio_offset);
2020                assert_eq!(b.audio_length, file_len - audio_offset); // no trim
2021            }
2022            other @ Extent::NeedMore { .. } => {
2023                panic!("expected Complete (no room, no trim), got {other:?}")
2024            }
2025        }
2026    }
2027
2028    /// Build a minimal ID3v2.4 tag containing the given frames, with header
2029    /// flags=0 (no unsync, no extended header) and per-frame flags=0 so
2030    /// `id3v2_alloc_safe` accepts it. Used by `read_binary_tags` tests that
2031    /// need a tag without going through the `id3` crate's encoder (which would
2032    /// re-encode `Unknown` bodies and defeat the byte-exact property).
2033    fn build_v24_tag(frames: &[(&[u8; 4], &[u8])]) -> Vec<u8> {
2034        let total_body: usize = frames.iter().map(|(_, b)| 10 + b.len()).sum();
2035        let mut out = Vec::new();
2036        out.extend_from_slice(b"ID3");
2037        out.extend_from_slice(&[0x04, 0x00, 0x00]); // v2.4.0, no flags
2038        out.extend_from_slice(&ss(u32::try_from(total_body).unwrap()));
2039        for (id, body) in frames {
2040            out.extend_from_slice(*id);
2041            out.extend_from_slice(&ss(u32::try_from(body.len()).unwrap()));
2042            out.extend_from_slice(&[0x00, 0x00]); // frame flags
2043            out.extend_from_slice(body);
2044        }
2045        out
2046    }
2047
2048    /// Like `build_v24_tag` but emits an ID3v2.3 tag: frame sizes are plain
2049    /// 32-bit big-endian (not synchsafe). Exercises the `data[3] == 3` size-decode
2050    /// branch of `read_binary_tags`, which no v2.4 fixture can reach.
2051    fn build_v23_tag(frames: &[(&[u8; 4], &[u8])]) -> Vec<u8> {
2052        let total_body: usize = frames.iter().map(|(_, b)| 10 + b.len()).sum();
2053        let mut out = Vec::new();
2054        out.extend_from_slice(b"ID3");
2055        out.extend_from_slice(&[0x03, 0x00, 0x00]); // v2.3.0, no flags
2056        out.extend_from_slice(&ss(u32::try_from(total_body).unwrap())); // tag size is synchsafe in every version
2057        for (id, body) in frames {
2058            out.extend_from_slice(*id);
2059            out.extend_from_slice(&(u32::try_from(body.len()).unwrap()).to_be_bytes()); // v2.3: plain u32 frame size
2060            out.extend_from_slice(&[0x00, 0x00]); // frame flags
2061            out.extend_from_slice(body);
2062        }
2063        out
2064    }
2065
2066    // Documented EQUIVALENT mutant (no test can kill it):
2067    //  * classify_binary_frame counter fold `(a << 8) | b` -> `(a << 8) ^ b`.
2068    //    The accumulator is left-shifted by 8 before each combine, so its low
2069    //    byte is always zero where `b` lands; OR and XOR are bit-for-bit identical
2070    //    for every input. Confirmed by hand; left as-is.
2071
2072    #[test]
2073    fn read_binary_tags_v23_plain_u32_frame_size() {
2074        // Two v2.3 frames: a non-zero filler followed by a >=128-byte PRIV. The
2075        // filler's trailing bytes sit just before the PRIV header, so every byte of
2076        // the plain-u32 size field (data[pos+4..pos+8], the `data[3] == 3` branch)
2077        // reads a distinct non-zero value — a wrong size-byte offset (e.g. `pos + 4`
2078        // -> `pos - 4`) then decodes a bogus size and drops/corrupts the PRIV, and a
2079        // synchsafe misdecode (the `== 3` branch flipped) truncates it. Both frames
2080        // must survive byte-exact.
2081        let filler = vec![0xAAu8; 8];
2082        let body: Vec<u8> = (0..200u32)
2083            .map(|i| u8::try_from(i % 250 + 1).unwrap())
2084            .collect();
2085        let tag = build_v23_tag(&[(b"GEOB", &filler), (b"PRIV", &body)]);
2086        let (opaque, _promoted) = super::read_binary_tags(&tag);
2087        let geob = opaque
2088            .iter()
2089            .find(|e| e.key == "GEOB")
2090            .expect("v2.3 GEOB preserved");
2091        assert_eq!(
2092            geob.payload, filler,
2093            "v2.3 first frame must survive byte-exact"
2094        );
2095        let priv_frame = opaque
2096            .iter()
2097            .find(|e| e.key == "PRIV")
2098            .expect("v2.3 PRIV preserved");
2099        assert_eq!(
2100            priv_frame.payload, body,
2101            "v2.3 plain-u32 frame body must survive byte-exact"
2102        );
2103    }
2104
2105    #[test]
2106    fn read_binary_tags_skips_unsafe_tag() {
2107        // A well-formed v2.4 PRIV tag with the unsynchronisation flag forced on:
2108        // id3v2_alloc_safe rejects it, so read_binary_tags must yield nothing. The
2109        // major version stays >= 3, so the `!alloc_safe || major<3` guard hinges on
2110        // the `||` (an `&&` mutant would parse the rejected tag).
2111        let mut tag = build_v24_tag(&[(b"PRIV", &[1, 2, 3])]);
2112        tag[5] = 0x80; // unsynchronisation flag
2113        let (opaque, promoted) = super::read_binary_tags(&tag);
2114        assert!(
2115            opaque.is_empty() && promoted.is_empty(),
2116            "an alloc-unsafe tag must yield no binary frames"
2117        );
2118    }
2119
2120    #[test]
2121    fn read_binary_tags_skips_text_comm_uslt_apic() {
2122        // T***/COMM/USLT/APIC are handled by read_tags/read_pictures and must NOT
2123        // be captured as opaque binary frames; only PRIV is.
2124        let tag = build_v24_tag(&[
2125            (b"TIT2", &[0x00, b'x']),
2126            (b"COMM", &[0x00]),
2127            (b"USLT", &[0x00]),
2128            (b"APIC", &[0x00]),
2129            (b"PRIV", &[9, 9, 9]),
2130        ]);
2131        let (opaque, _promoted) = super::read_binary_tags(&tag);
2132        let keys: Vec<&str> = opaque.iter().map(|e| e.key.as_str()).collect();
2133        assert_eq!(
2134            keys,
2135            vec!["PRIV"],
2136            "only PRIV is opaque; T***/COMM/USLT/APIC are handled elsewhere: {keys:?}"
2137        );
2138    }
2139
2140    #[test]
2141    fn read_binary_tags_decodes_popm_counter_big_endian_and_zero() {
2142        // Multi-byte counter must decode big-endian (0x0102 == 258), pinning the
2143        // `<< 8` shift in the fold.
2144        let tag = build_v24_tag(&[(b"POPM", &[0x00, 200, 0x01, 0x02])]);
2145        let (_opaque, promoted) = super::read_binary_tags(&tag);
2146        assert!(
2147            promoted.contains(&("rating".to_string(), "200".to_string())),
2148            "rating: {promoted:?}"
2149        );
2150        assert!(
2151            promoted.contains(&("playcount".to_string(), "258".to_string())),
2152            "counter must decode big-endian: {promoted:?}"
2153        );
2154
2155        // A zero counter must NOT promote a playcount (pins `c > 0`).
2156        let tag0 = build_v24_tag(&[(b"POPM", &[0x00, 128, 0x00])]);
2157        let (_o0, promoted0) = super::read_binary_tags(&tag0);
2158        assert!(
2159            promoted0.contains(&("rating".to_string(), "128".to_string())),
2160            "rating: {promoted0:?}"
2161        );
2162        assert!(
2163            !promoted0.iter().any(|(k, _)| k == "playcount"),
2164            "a zero POPM counter must not promote playcount: {promoted0:?}"
2165        );
2166    }
2167
2168    #[test]
2169    fn popm_frame_data_emits_counter_only_when_positive() {
2170        // playcount == 0: owner-nul + rating, no counter.
2171        assert_eq!(
2172            super::popm_frame_data(200, 0),
2173            vec![0x00, 200],
2174            "playcount 0 must omit the counter"
2175        );
2176        // playcount > 0: 4-byte big-endian counter appended.
2177        assert_eq!(
2178            super::popm_frame_data(200, 5),
2179            vec![0x00, 200, 0x00, 0x00, 0x00, 0x05],
2180            "playcount > 0 must append a 4-byte counter"
2181        );
2182    }
2183
2184    #[test]
2185    fn build_id3v2_segments_accounts_playcount_and_opaque_len() {
2186        use crate::{BinaryTagInput, TagInput};
2187
2188        // playcount text tag must rebuild into the POPM counter (pins the
2189        // `"playcount"` match arm).
2190        let tags = vec![
2191            TagInput::new("rating", "100"),
2192            TagInput::new("playcount", "42"),
2193        ];
2194        let (segments, _len) = build_id3v2_segments(&tags, &[], &[]).unwrap();
2195        let inline: Vec<u8> = segments
2196            .iter()
2197            .flat_map(|s| match s {
2198                Segment::Inline(b) => b.clone(),
2199                _ => Vec::new(),
2200            })
2201            .collect();
2202        let (_opaque, promoted) = super::read_binary_tags(&inline);
2203        assert!(
2204            promoted.contains(&("playcount".to_string(), "42".to_string())),
2205            "playcount must rebuild into the POPM counter: {promoted:?}"
2206        );
2207
2208        // Opaque-frame length accounting: total == ID3 header (10) + frame header
2209        // (10) + body. Pins `frames_len += 10 + bt.len`.
2210        let bin = vec![BinaryTagInput {
2211            key: "PRIV".into(),
2212            payload_id: 1,
2213            len: BlobLen::new(7).unwrap(),
2214        }];
2215        let (_segs, total) = build_id3v2_segments(&[], &bin, &[]).unwrap();
2216        assert_eq!(total, 10 + 10 + 7, "opaque binary frame length accounting");
2217    }
2218
2219    #[test]
2220    fn read_binary_tags_promotes_popm_and_mbid_and_passes_through_priv() {
2221        use id3::frame::{Content, Popularimeter, UniqueFileIdentifier, Unknown};
2222        use id3::{Encoder, Frame, Tag, TagLike, Version};
2223
2224        let mut tag = Tag::new();
2225        tag.add_frame(Popularimeter {
2226            user: "a@b.c".into(),
2227            rating: 200,
2228            counter: 7,
2229        });
2230        tag.add_frame(UniqueFileIdentifier {
2231            owner_identifier: "http://musicbrainz.org".into(),
2232            identifier: b"mbid-123".to_vec(),
2233        });
2234        tag.add_frame(UniqueFileIdentifier {
2235            owner_identifier: "http://other.example".into(),
2236            identifier: b"other".to_vec(),
2237        });
2238        tag.add_frame(Frame::with_content(
2239            "PRIV",
2240            Content::Unknown(Unknown {
2241                data: vec![9, 8, 7],
2242                version: Version::Id3v24,
2243            }),
2244        ));
2245        let mut buf = Vec::new();
2246        Encoder::new()
2247            .version(Version::Id3v24)
2248            .encode(&tag, &mut buf)
2249            .unwrap();
2250
2251        let (opaque, promoted) = super::read_binary_tags(&buf);
2252        assert!(promoted.contains(&("rating".to_string(), "200".to_string())));
2253        assert!(promoted.contains(&("playcount".to_string(), "7".to_string())));
2254        assert!(promoted.contains(&("musicbrainz_trackid".to_string(), "mbid-123".to_string())));
2255        let keys: Vec<&str> = opaque.iter().map(|e| e.key.as_str()).collect();
2256        assert!(keys.contains(&"PRIV"));
2257        // Non-MusicBrainz UFID is opaque (raw body, owner + identifier); exactly one UFID.
2258        assert_eq!(keys.iter().filter(|k| **k == "UFID").count(), 1);
2259        assert_eq!(
2260            opaque.iter().find(|e| e.key == "PRIV").unwrap().payload,
2261            vec![9, 8, 7]
2262        );
2263    }
2264
2265    #[test]
2266    fn read_binary_tags_preserves_geob_body_byte_exact() {
2267        // A GEOB body with a Latin1 (encoding 0x00) description — the exact case
2268        // the crate's to_unknown() would re-encode to UTF-8. Build a minimal v2.4
2269        // tag by hand so the bytes on the wire are guaranteed to match the
2270        // asserted body.
2271        let geob_body: Vec<u8> = {
2272            let mut b = vec![0x00]; // text encoding: ISO-8859-1
2273            b.extend_from_slice(b"application/octet-stream\0"); // mime
2274            b.extend_from_slice(b"Serato Overview\0"); // filename (latin1)
2275            b.extend_from_slice(b"\0"); // description (empty, terminator only)
2276            b.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); // object data
2277            b
2278        };
2279        let tag = build_v24_tag(&[(b"GEOB", &geob_body)]);
2280
2281        let (opaque, _promoted) = super::read_binary_tags(&tag);
2282        let geob = opaque
2283            .iter()
2284            .find(|e| e.key == "GEOB")
2285            .expect("GEOB preserved");
2286        assert_eq!(
2287            geob.payload, geob_body,
2288            "GEOB body must survive byte-identical"
2289        );
2290    }
2291
2292    #[test]
2293    fn build_id3v2_segments_rebuilds_popm_ufid_and_streams_opaque() {
2294        use crate::BinaryTagInput;
2295        let tags = vec![
2296            TagInput::new("artist", "A"),
2297            TagInput::new("rating", "200"),
2298            TagInput::new("playcount", "7"),
2299            TagInput::new("musicbrainz_trackid", "mbid-123"),
2300        ];
2301        let bin = vec![BinaryTagInput {
2302            key: "PRIV".into(),
2303            payload_id: 42,
2304            len: BlobLen::new(3).unwrap(),
2305        }];
2306        let (segments, _len) = super::build_id3v2_segments(&tags, &bin, &[]).unwrap();
2307
2308        assert!(
2309            segments.iter().any(|s| matches!(
2310                s,
2311                Segment::BinaryTag {
2312                    payload_id: 42,
2313                    len,
2314                    ..
2315                } if len.get() == 3
2316            )),
2317            "opaque PRIV must stream as Segment::BinaryTag"
2318        );
2319
2320        let inline: Vec<u8> = segments
2321            .iter()
2322            .flat_map(|s| match s {
2323                Segment::Inline(b) => b.clone(),
2324                _ => Vec::new(),
2325            })
2326            .collect();
2327        assert!(find_sub(&inline, b"POPM"), "POPM not rebuilt");
2328        assert!(find_sub(&inline, b"UFID"), "UFID not rebuilt");
2329        assert!(
2330            find_sub(&inline, b"http://musicbrainz.org"),
2331            "UFID owner missing"
2332        );
2333        assert!(!find_sub(&inline, b"rating"), "promoted key leaked as TXXX");
2334        assert!(
2335            !find_sub(&inline, b"musicbrainz_trackid"),
2336            "promoted key leaked as TXXX"
2337        );
2338    }
2339
2340    #[test]
2341    fn build_id3v2_segments_first_promoted_scalar_wins() {
2342        // Duplicate `rating`/`musicbrainz_trackid` rows (e.g. an over-tagged DB):
2343        // the first value is rebuilt into the POPM/UFID frame, later ones dropped.
2344        // Pins the `popm_rating.is_none()` / `mbid.is_none()` guards.
2345        let tags = vec![
2346            TagInput::new("rating", "10"),
2347            TagInput::new("rating", "20"),
2348            TagInput::new("musicbrainz_trackid", "mbid-first"),
2349            TagInput::new("musicbrainz_trackid", "mbid-second"),
2350        ];
2351        let (segments, _len) = build_id3v2_segments(&tags, &[], &[]).unwrap();
2352        let inline: Vec<u8> = segments
2353            .iter()
2354            .flat_map(|s| match s {
2355                Segment::Inline(b) => b.clone(),
2356                _ => Vec::new(),
2357            })
2358            .collect();
2359
2360        // First MusicBrainz id wins, later one dropped.
2361        assert!(find_sub(&inline, b"mbid-first"), "first mbid must win");
2362        assert!(
2363            !find_sub(&inline, b"mbid-second"),
2364            "later mbid must be dropped"
2365        );
2366
2367        // First rating wins: re-parse the synthesized tag and read the promoted value.
2368        let (_opaque, promoted) = super::read_binary_tags(&inline);
2369        assert!(
2370            promoted.contains(&("rating".to_string(), "10".to_string())),
2371            "first rating must win: {promoted:?}"
2372        );
2373        assert!(
2374            !promoted.iter().any(|(k, v)| k == "rating" && v == "20"),
2375            "later rating must be dropped: {promoted:?}"
2376        );
2377    }
2378
2379    #[test]
2380    fn build_id3v2_segments_checked_art_len_rejects_overflow() {
2381        // A hostile art data_len near u64::MAX must fail closed with TooLarge at
2382        // the checked add, not panic (debug) / wrap (release).
2383        let mk = |data_len: u64| ArtInput {
2384            art_id: 1,
2385            mime: "image/png".to_string(),
2386            description: String::new(),
2387            picture_type: PictureType::new(3).unwrap(),
2388            width: 0,
2389            height: 0,
2390            data_len: BlobLen::new(data_len).unwrap(),
2391        };
2392        assert_eq!(
2393            build_id3v2_segments(&[], &[], &[mk(u64::MAX)]).err(),
2394            Some(FormatError::TooLarge)
2395        );
2396    }
2397
2398    fn find_sub(hay: &[u8], needle: &[u8]) -> bool {
2399        hay.windows(needle.len()).any(|w| w == needle)
2400    }
2401
2402    /// On a whole buffer with the production tail (`Some(last 128 bytes)` when
2403    /// the file is at least 128 bytes), `locate_audio_bounded` must agree with
2404    /// `locate_audio`: same accept/reject, same `Mp3Bounds`. This pins the
2405    /// equivalence the #212 fuzz oracle relies on.
2406    fn assert_mp3_bounded_matches_full(data: &[u8]) {
2407        let len = data.len() as u64;
2408        let tail: Option<&[u8; 128]> = if data.len() >= 128 {
2409            data[data.len() - 128..].try_into().ok()
2410        } else {
2411            None
2412        };
2413        match (locate_audio(data), locate_audio_bounded(data, len, tail)) {
2414            (Ok(full), Ok(Extent::Complete(bounded))) => assert_eq!(full, bounded),
2415            (Err(_), Err(_)) => {}
2416            (full, bounded) => {
2417                panic!("mp3 bounded/full divergence: full={full:?} bounded={bounded:?}")
2418            }
2419        }
2420    }
2421
2422    #[test]
2423    fn locate_audio_rejects_high_bit_size_byte() {
2424        // Malformed synchsafe size (last byte 0x80) that masks to body=0, with a valid
2425        // frame sync at offset 10. Must reject rather than serve audio from offset 10.
2426        let mut data = Vec::new();
2427        data.extend_from_slice(b"ID3");
2428        data.extend_from_slice(&[0x04, 0x00, 0x00]); // major, rev, flags
2429        data.extend_from_slice(&[0x00, 0x00, 0x00, 0x80]); // high bit set -> malformed
2430        data.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // valid sync at offset 10
2431        assert_eq!(locate_audio(&data), Err(FormatError::Malformed));
2432    }
2433
2434    #[test]
2435    fn locate_audio_rejects_unsupported_major_version() {
2436        let mut data = Vec::new();
2437        data.extend_from_slice(b"ID3");
2438        data.extend_from_slice(&[0x05, 0x00, 0x00]); // major 5 (unsupported)
2439        data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
2440        data.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
2441        assert_eq!(locate_audio(&data), Err(FormatError::Malformed));
2442    }
2443
2444    #[test]
2445    fn locate_audio_bounded_rejects_high_bit_size_byte() {
2446        let mut data = Vec::new();
2447        data.extend_from_slice(b"ID3");
2448        data.extend_from_slice(&[0x04, 0x00, 0x00]);
2449        data.extend_from_slice(&[0x00, 0x00, 0x00, 0x80]);
2450        data.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
2451        let file_len = data.len() as u64;
2452        assert_eq!(
2453            locate_audio_bounded(&data, file_len, None),
2454            Err(FormatError::Malformed)
2455        );
2456    }
2457
2458    #[test]
2459    fn mp3_bounded_matches_full_on_whole_buffer() {
2460        // Plain ID3v2.4 + frame sync (no trailer, < 128 bytes -> tail None).
2461        assert_mp3_bounded_matches_full(&crate::fuzz_check::fixtures::mp3());
2462        // Carries a GEOB frame; longer file.
2463        assert_mp3_bounded_matches_full(&crate::fuzz_check::fixtures::mp3_with_binary_frame());
2464
2465        // A >=128-byte MP3 with a trailing ID3v1 "TAG" block, so the tail-strip
2466        // path is exercised and the tail argument is Some.
2467        let mut with_trailer = crate::fuzz_check::fixtures::mp3();
2468        with_trailer.resize(200, 0x00);
2469        with_trailer.extend_from_slice(b"TAG");
2470        with_trailer.resize(with_trailer.len() + 125, 0x00); // pad ID3v1 to 128 bytes
2471        assert_mp3_bounded_matches_full(&with_trailer);
2472    }
2473}