use crate::convert::usize_from;
use crate::error::{FormatError, Result};
use crate::input::{
ArtInput, BinaryTagInput, EmbeddedBinaryTag, EmbeddedPicture, PictureType, TagInput,
};
use crate::layout::{RegionLayout, Segment};
use crate::probe::Extent;
use crate::size;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mp3Bounds {
pub audio_offset: u64,
pub audio_length: u64,
}
fn synchsafe_decode(b: &[u8]) -> u32 {
u32::from(b[0] & 0x7F) << 21
| u32::from(b[1] & 0x7F) << 14
| u32::from(b[2] & 0x7F) << 7
| u32::from(b[3] & 0x7F)
}
fn decode_frame_size(major_version: u8, raw: &[u8]) -> u32 {
if major_version == 3 {
u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]])
} else {
synchsafe_decode(raw)
}
}
fn id3v2_header_len(data: &[u8]) -> Result<Option<usize>> {
if data.len() < 10 || &data[0..3] != b"ID3" {
return Ok(None);
}
if !matches!(data[3], 2..=4) {
return Err(FormatError::Malformed);
}
if data[6..10].iter().any(|&b| b & 0x80 != 0) {
return Err(FormatError::Malformed);
}
Ok(Some(10 + synchsafe_decode(&data[6..10]) as usize))
}
pub fn locate_audio(data: &[u8]) -> Result<Mp3Bounds> {
let len = data.len();
let mut audio_offset = 0usize;
if let Some(base) = id3v2_header_len(data)? {
let flags = data[5];
let mut tag_len = base;
if flags & 0x10 != 0 {
tag_len += 10; }
if tag_len > len {
return Err(FormatError::Malformed);
}
audio_offset = tag_len;
}
let mut audio_end = len;
if audio_end >= audio_offset + 128 && &data[audio_end - 128..audio_end - 125] == b"TAG" {
audio_end -= 128; }
if audio_offset + 1 >= len
|| data[audio_offset] != 0xFF
|| (data[audio_offset + 1] & 0xE0) != 0xE0
{
return Err(FormatError::NotMp3);
}
Ok(Mp3Bounds {
audio_offset: audio_offset as u64,
audio_length: (audio_end - audio_offset) as u64,
})
}
pub fn locate_audio_bounded(
prefix: &[u8],
file_len: u64,
tail: Option<&[u8; 128]>,
) -> Result<Extent<Mp3Bounds>> {
let mut audio_offset = 0usize;
if prefix.len() < 10 && file_len >= 10 {
return Ok(Extent::NeedMore { up_to: 10 });
}
if let Some(base) = id3v2_header_len(prefix)? {
let flags = prefix[5];
let mut tag_len = base;
if flags & 0x10 != 0 {
tag_len += 10; }
if tag_len as u64 > file_len {
return Err(FormatError::Malformed);
}
audio_offset = tag_len;
}
if audio_offset as u64 + 2 > file_len {
return Err(FormatError::NotMp3);
}
if audio_offset + 2 > prefix.len() {
return Ok(Extent::NeedMore {
up_to: (audio_offset + 2) as u64,
});
}
if prefix[audio_offset] != 0xFF || (prefix[audio_offset + 1] & 0xE0) != 0xE0 {
return Err(FormatError::NotMp3);
}
let mut audio_end = file_len;
if let Some(tail) = tail
&& file_len >= audio_offset as u64 + 128
&& &tail[0..3] == b"TAG"
{
audio_end -= 128;
}
Ok(Extent::Complete(Mp3Bounds {
audio_offset: audio_offset as u64,
audio_length: audio_end - audio_offset as u64,
}))
}
const ENC_UTF8: u8 = 0x03;
fn syncsafe(n: u32) -> [u8; 4] {
[
((n >> 21) & 0x7F) as u8,
((n >> 14) & 0x7F) as u8,
((n >> 7) & 0x7F) as u8,
(n & 0x7F) as u8,
]
}
const SYNCHSAFE_MAX: u32 = 0x0FFF_FFFF;
fn push_frame_header(out: &mut Vec<u8>, id: &[u8; 4], data_len: usize) -> Result<()> {
let data_len_u32 = u32::try_from(data_len)
.ok()
.filter(|&v| v <= SYNCHSAFE_MAX)
.ok_or(FormatError::TooLarge)?;
out.extend_from_slice(id);
out.extend_from_slice(&syncsafe(data_len_u32));
out.extend_from_slice(&[0x00, 0x00]); Ok(())
}
fn text_frame_data(values: &[String]) -> Vec<u8> {
let mut d = vec![ENC_UTF8];
d.extend_from_slice(values.join("\0").as_bytes());
d
}
fn txxx_frame_data(desc: &str, value: &str) -> Vec<u8> {
let mut d = vec![ENC_UTF8];
d.extend_from_slice(desc.as_bytes());
d.push(0x00);
d.extend_from_slice(value.as_bytes());
d
}
fn comm_like_frame_data(lang: &str, description: &str, value: &str) -> Vec<u8> {
let l = lang.as_bytes();
let mut d = vec![ENC_UTF8];
d.extend_from_slice(&[
*l.first().unwrap_or(&b'X'),
*l.get(1).unwrap_or(&b'X'),
*l.get(2).unwrap_or(&b'X'),
]);
d.extend_from_slice(description.as_bytes());
d.push(0x00); d.extend_from_slice(value.as_bytes());
d
}
fn is_placeholder_lang(lang: &str) -> bool {
matches!(lang.to_ascii_lowercase().as_str(), "" | "xxx" | "und")
}
fn comm_like_key(frame: &str, lang: &str, description: &str, default_key: &str) -> String {
if description.is_empty() && is_placeholder_lang(lang) {
default_key.to_string()
} else {
format!("id3:{frame}:{lang}:{description}")
}
}
fn parse_comm_like_key(key: &str) -> Option<(&'static [u8; 4], &str, &str)> {
let rest = key.strip_prefix("id3:")?;
let (frame, langdesc) = rest.split_once(':')?;
let frame_id: &'static [u8; 4] = match frame {
"COMM" => b"COMM",
"USLT" => b"USLT",
_ => return None,
};
let (lang, desc) = langdesc.split_once(':')?;
Some((frame_id, lang, desc))
}
fn is_id3_text_frame_id(key: &str) -> bool {
key.len() == 4
&& key != "TXXX"
&& key.starts_with('T')
&& key
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
}
fn reject_embedded_nul(field: &'static str, s: &str) -> Result<()> {
if s.as_bytes().contains(&0) {
return Err(FormatError::EmbeddedNul { field });
}
Ok(())
}
fn apic_framing(art: &ArtInput) -> Vec<u8> {
let mut d = vec![ENC_UTF8];
d.extend_from_slice(art.mime.as_bytes());
d.push(0x00);
#[expect(
clippy::cast_possible_truncation,
reason = "ID3 APIC type is one byte; valid picture types are 0..=20"
)]
d.push(art.picture_type.get() as u8);
d.extend_from_slice(art.description.as_bytes());
d.push(0x00);
d
}
fn popm_frame_data(rating: u8, playcount: u64) -> Vec<u8> {
let mut d = Vec::new();
d.push(0x00); d.push(rating);
if playcount > 0 {
let c = u32::try_from(playcount).unwrap_or(u32::MAX);
d.extend_from_slice(&c.to_be_bytes());
}
d
}
fn ufid_frame_data(owner: &str, identifier: &[u8]) -> Vec<u8> {
let mut d = Vec::new();
d.extend_from_slice(owner.as_bytes());
d.push(0x00);
d.extend_from_slice(identifier);
d
}
fn is_promoted_key(key: &str) -> bool {
matches!(key, "rating" | "playcount" | "musicbrainz_trackid")
}
pub fn build_id3v2_segments(
tags: &[TagInput],
binary_tags: &[BinaryTagInput],
arts: &[ArtInput],
) -> Result<(Vec<Segment>, u64)> {
for t in tags {
reject_embedded_nul("tag key", &t.key)?;
reject_embedded_nul("tag value", &t.value)?;
}
for art in arts {
reject_embedded_nul("art mime", &art.mime)?;
reject_embedded_nul("art description", &art.description)?;
}
let mut popm_rating: Option<u8> = None;
let mut popm_playcount: u64 = 0;
let mut mbid: Option<String> = None;
for t in tags {
match t.key.as_str() {
"rating" if popm_rating.is_none() => popm_rating = t.value.parse().ok(),
"playcount" => popm_playcount = t.value.parse().unwrap_or(popm_playcount),
"musicbrainz_trackid" if mbid.is_none() => mbid = Some(t.value.clone()),
_ => {}
}
}
let mut groups: Vec<(String, Vec<String>)> = Vec::new();
for t in tags {
if is_promoted_key(&t.key) {
continue;
}
match groups.last_mut() {
Some(g) if g.0 == t.key => g.1.push(t.value.clone()),
_ => groups.push((t.key.clone(), vec![t.value.clone()])),
}
}
let mut segments: Vec<Segment> = Vec::new();
let mut buf: Vec<u8> = Vec::new();
let mut frames_len: u64 = 0;
for (key, values) in &groups {
match crate::tagmap::key_to_id3(key) {
Some(crate::tagmap::Id3Slot::Text(id)) => {
let data = text_frame_data(values);
push_frame_header(&mut buf, id, data.len())?;
buf.extend_from_slice(&data);
frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
}
Some(crate::tagmap::Id3Slot::Txxx(desc)) => {
for value in values {
let data = txxx_frame_data(desc, value);
push_frame_header(&mut buf, b"TXXX", data.len())?;
buf.extend_from_slice(&data);
frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
}
}
Some(crate::tagmap::Id3Slot::Comment) => {
for value in values {
let data = comm_like_frame_data("XXX", "", value);
push_frame_header(&mut buf, b"COMM", data.len())?;
buf.extend_from_slice(&data);
frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
}
}
Some(crate::tagmap::Id3Slot::Lyrics) => {
for value in values {
let data = comm_like_frame_data("XXX", "", value);
push_frame_header(&mut buf, b"USLT", data.len())?;
buf.extend_from_slice(&data);
frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
}
}
None if is_id3_text_frame_id(key) => {
let id: [u8; 4] = key.as_bytes().try_into().unwrap();
let data = text_frame_data(values);
push_frame_header(&mut buf, &id, data.len())?;
buf.extend_from_slice(&data);
frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
}
None => {
if let Some((frame_id, lang, desc)) = parse_comm_like_key(key) {
for value in values {
let data = comm_like_frame_data(lang, desc, value);
push_frame_header(&mut buf, frame_id, data.len())?;
buf.extend_from_slice(&data);
frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
}
} else {
for value in values {
let data = txxx_frame_data(key, value);
push_frame_header(&mut buf, b"TXXX", data.len())?;
buf.extend_from_slice(&data);
frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
}
}
}
}
}
if let Some(rating) = popm_rating {
let data = popm_frame_data(rating, popm_playcount);
push_frame_header(&mut buf, b"POPM", data.len())?;
buf.extend_from_slice(&data);
frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
}
if let Some(id) = &mbid {
let data = ufid_frame_data(MUSICBRAINZ_UFID_OWNER, id.as_bytes());
push_frame_header(&mut buf, b"UFID", data.len())?;
buf.extend_from_slice(&data);
frames_len = size::checked_add(frames_len, 10 + data.len() as u64)?;
}
for bt in binary_tags {
let Ok(id): std::result::Result<[u8; 4], _> = bt.key.as_bytes().try_into() else {
continue;
};
push_frame_header(&mut buf, &id, usize_from(bt.len.get()))?;
segments.push(Segment::Inline(std::mem::take(&mut buf)));
segments.push(Segment::BinaryTag {
payload_id: bt.payload_id,
len: bt.len,
});
frames_len = size::checked_add(frames_len, size::checked_add(10, bt.len.get())?)?;
}
for art in arts {
let framing = apic_framing(art);
let data_len = size::checked_add(framing.len() as u64, art.data_len.get())?;
push_frame_header(&mut buf, b"APIC", usize_from(data_len))?;
buf.extend_from_slice(&framing);
segments.push(Segment::Inline(std::mem::take(&mut buf)));
segments.push(Segment::ArtImage {
art_id: art.art_id,
len: art.data_len,
});
frames_len = size::checked_add(frames_len, size::checked_add(10, data_len)?)?;
}
if !buf.is_empty() {
segments.push(Segment::Inline(std::mem::take(&mut buf)));
}
let mut header = Vec::with_capacity(10);
header.extend_from_slice(b"ID3");
header.extend_from_slice(&[0x04, 0x00]); header.push(0x00);
let frames_len_ss = u32::try_from(frames_len)
.ok()
.filter(|&v| v <= SYNCHSAFE_MAX)
.ok_or(FormatError::TooLarge)?;
header.extend_from_slice(&syncsafe(frames_len_ss));
segments.insert(0, Segment::Inline(header));
Ok((segments, size::checked_add(10, frames_len)?))
}
pub fn synthesize_layout(
audio_offset: u64,
audio_length: u64,
tags: &[TagInput],
binary_tags: &[BinaryTagInput],
arts: &[ArtInput],
) -> Result<RegionLayout> {
let (mut segments, _tag_len) = build_id3v2_segments(tags, binary_tags, arts)?;
segments.push(Segment::BackingAudio {
offset: audio_offset,
len: audio_length,
});
Ok(RegionLayout::validated(segments)?)
}
fn id3v2_alloc_safe(data: &[u8]) -> bool {
let Ok(Some(tag_end)) = id3v2_header_len(data) else {
return false;
};
let flags = data[5];
if flags & 0xC0 != 0 {
return false;
}
if tag_end > data.len() {
return false;
}
let major = data[3];
let header_len = if major == 2 { 6 } else { 10 };
let scan_end = data.len();
let mut pos = 10usize;
while pos + header_len <= scan_end {
if data[pos] == 0 {
break;
}
if major != 2 && (&data[pos..pos + 4] == b"CHAP" || &data[pos..pos + 4] == b"CTOC") {
return false;
}
let size = if major == 2 {
u32::from_be_bytes([0, data[pos + 3], data[pos + 4], data[pos + 5]]) as usize
} else if major == 3 {
if data[pos + 8] != 0 || data[pos + 9] != 0 {
return false;
}
u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
as usize
} else {
if data[pos + 4] | data[pos + 5] | data[pos + 6] | data[pos + 7] >= 0x80 {
return false;
}
if data[pos + 8] != 0 || data[pos + 9] != 0 {
return false;
}
synchsafe_decode(&data[pos + 4..pos + 8]) as usize
};
let data_start = pos + header_len;
if data_start > tag_end || size > tag_end - data_start {
return false;
}
pos = data_start + size;
if pos >= tag_end {
break;
}
}
true
}
pub fn read_pictures(data: &[u8]) -> Vec<EmbeddedPicture> {
if !id3v2_alloc_safe(data) {
return Vec::new();
}
let Ok(tag) = id3::Tag::read_from2(std::io::Cursor::new(data)) else {
return Vec::new();
};
tag.pictures()
.map(|p| EmbeddedPicture {
mime: p.mime_type.clone(),
picture_type: PictureType::new(u8::from(p.picture_type).into())
.unwrap_or(PictureType::ZERO),
description: p.description.clone(),
width: 0,
height: 0,
data: p.data.clone(),
})
.collect()
}
pub fn read_tags(data: &[u8]) -> Vec<(String, String)> {
if !id3v2_alloc_safe(data) {
return Vec::new();
}
let Ok(tag) = id3::Tag::read_from2(std::io::Cursor::new(data)) else {
return Vec::new();
};
let mut out = Vec::new();
for frame in tag.frames() {
let content = frame.content();
if let Some(et) = content.extended_text() {
let key = crate::tagmap::id3_txxx_to_key(&et.description)
.map_or_else(|| et.description.clone(), str::to_string);
out.push((key, et.value.clone()));
} else if let Some(c) = content.comment() {
out.push((
comm_like_key("COMM", &c.lang, &c.description, "comment"),
c.text.clone(),
));
} else if let Some(l) = content.lyrics() {
out.push((
comm_like_key("USLT", &l.lang, &l.description, "lyrics"),
l.text.clone(),
));
} else if let Some(text) = content.text() {
let id = frame.id();
let key =
crate::tagmap::id3_text_to_key(id).map_or_else(|| id.to_string(), str::to_string);
for value in text.split('\0').filter(|v| !v.is_empty()) {
out.push((key.clone(), value.to_string()));
}
}
}
out
}
pub(crate) const MUSICBRAINZ_UFID_OWNER: &str = "http://musicbrainz.org";
pub fn read_binary_tags(data: &[u8]) -> (Vec<EmbeddedBinaryTag>, Vec<(String, String)>) {
let mut opaque = Vec::new();
let mut promoted = Vec::new();
if !id3v2_alloc_safe(data) || data[3] < 3 {
return (opaque, promoted);
}
let tag_end = 10 + synchsafe_decode(&data[6..10]) as usize;
let mut pos = 10usize;
while pos + 10 <= tag_end {
if data[pos] == 0 {
break;
}
let id = &data[pos..pos + 4];
let size = decode_frame_size(data[3], &data[pos + 4..pos + 8]) as usize;
let body_start = pos + 10;
if body_start + size > tag_end {
break;
}
classify_binary_frame(
id,
&data[body_start..body_start + size],
&mut opaque,
&mut promoted,
);
pos = body_start + size;
}
(opaque, promoted)
}
fn classify_binary_frame(
id: &[u8],
body: &[u8],
opaque: &mut Vec<EmbeddedBinaryTag>,
promoted: &mut Vec<(String, String)>,
) {
if id[0] == b'T' || id == b"COMM" || id == b"USLT" || id == b"APIC" {
return;
}
match id {
b"POPM" => {
if let Some(nul) = body.iter().position(|&b| b == 0)
&& let Some((&rating, counter)) = body[nul + 1..].split_first()
{
promoted.push(("rating".to_string(), rating.to_string()));
let c = counter
.iter()
.take(8)
.fold(0u64, |a, &b| (a << 8) | u64::from(b));
if c > 0 {
promoted.push(("playcount".to_string(), c.to_string()));
}
}
}
b"UFID" => {
match body.iter().position(|&b| b == 0) {
Some(nul) if &body[..nul] == MUSICBRAINZ_UFID_OWNER.as_bytes() => {
promoted.push((
"musicbrainz_trackid".to_string(),
String::from_utf8_lossy(&body[nul + 1..]).into_owned(),
));
}
_ => opaque.push(EmbeddedBinaryTag {
key: "UFID".to_string(),
payload: body.to_vec(),
}),
}
}
_ => {
if id.iter().all(u8::is_ascii_graphic) {
opaque.push(EmbeddedBinaryTag {
key: String::from_utf8_lossy(id).into_owned(),
payload: body.to_vec(),
});
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::{BlobLen, PictureType};
#[test]
fn id3v2_guard_rejects_oversized_v23_frame() {
let mut bytes: Vec<u8> = Vec::new();
bytes.extend_from_slice(b"ID3");
bytes.push(0x03); bytes.push(0x00); bytes.push(0x00); bytes.extend_from_slice(&[0x00, 0x00, 0x00, 0x0A]);
bytes.extend_from_slice(b"TIT2");
bytes.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
bytes.extend_from_slice(&[0x00, 0x00]);
assert!(
!id3v2_alloc_safe(&bytes),
"guard should reject frame claiming more bytes than the tag holds"
);
assert!(
read_tags(&bytes).is_empty(),
"read_tags must return empty for unsafe tag"
);
}
fn v24_tag_one_frame(frame_size: [u8; 4]) -> Vec<u8> {
let mut t = Vec::new();
t.extend_from_slice(b"ID3");
t.extend_from_slice(&[4, 0, 0]); t.extend_from_slice(&[0, 0, 0, 10]); t.extend_from_slice(b"TIT2"); t.extend_from_slice(&frame_size); t.extend_from_slice(&[0, 0]); t
}
#[test]
fn alloc_safe_rejects_v24_frame_with_nonsynchsafe_size() {
for size in [
[0x80, 0x00, 0x00, 0x00], [0x00, 0x80, 0x00, 0x00], [0x00, 0x00, 0x80, 0x00], [0x00, 0x80, 0x80, 0x00], [0x00, 0x00, 0x80, 0x80], ] {
assert!(
!id3v2_alloc_safe(&v24_tag_one_frame(size)),
"v2.4 frame size {size:02x?} has a high bit set and must be rejected"
);
}
}
#[test]
fn id3v2_guard_rejects_non_id3_prefixed() {
assert!(
!id3v2_alloc_safe(b"RIFF....just not an id3 tag...."),
"guard must reject buffer not starting with ID3"
);
assert!(
read_tags(b"RIFF....just not an id3 tag....").is_empty(),
"read_tags must return empty for non-ID3-prefixed buffer"
);
const RIFF_BODY: &[u8] = &[
0x52, 0x49, 0x46, 0x46, 0x32, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4c, 0x00, 0x00, 0x00,
0x00, 0x00, 0x49, 0x44, 0x33, 0x20, 0x15, 0x00, 0x00, 0x00, 0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0xf7, 0x00, 0x00, 0x54, 0x44, 0x41, 0x03, 0xf6, 0x00, 0x00,
0x00, ];
assert!(
!id3v2_alloc_safe(RIFF_BODY),
"guard must reject RIFF-prefixed buffer (WAV crash vector)"
);
assert!(
read_tags(RIFF_BODY).is_empty(),
"read_tags must return empty for RIFF-prefixed buffer"
);
}
#[test]
fn id3v2_guard_allows_valid_tag() {
use id3::{Tag, TagLike, Version};
let mut tag = Tag::new();
tag.set_text("TIT2", "Hello");
tag.set_text("TPE1", "Artist");
let mut buf = Vec::new();
tag.write_to(&mut buf, Version::Id3v24).unwrap();
assert!(
id3v2_alloc_safe(&buf),
"guard should allow a well-formed tag written by the id3 crate"
);
let tags = read_tags(&buf);
assert!(
tags.contains(&("title".to_string(), "Hello".to_string())),
"missing title in {tags:?}"
);
assert!(
tags.contains(&("artist".to_string(), "Artist".to_string())),
"missing artist in {tags:?}"
);
}
#[test]
fn read_tags_handles_oom_crash_input_safely() {
const CRASH1: &[u8] = &[
0x49, 0x44, 0x33, 0x03, 0xf0, 0x00, 0x00, 0xf9, 0x2d, 0x49, 0x50, 0x4c, 0x53, 0x00, 0xf9, 0x3d, 0x02, 0x00, 0x2d, 0x01, 0x00, 0x00, 0x03, 0x00, 0x49, 0x07, 0x10, 0xff, 0x07, 0xfe,
];
const CRASH2: &[u8] = &[
0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x80, 0x0a, 0x27, 0x2f, 0x00, 0xff, 0xee, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x2f,
];
for (i, crash) in [CRASH1, CRASH2].iter().enumerate() {
assert!(
read_tags(crash).is_empty(),
"read_tags must be safe on crash artifact {i}"
);
}
}
#[test]
fn read_tags_captures_txxx_comm_uslt_and_unmapped_text() {
use id3::frame::{Comment, ExtendedText, Lyrics};
use id3::{Tag, TagLike, Version};
let mut tag = Tag::new();
tag.set_text("TIT2", "Song");
tag.set_text("TKEY", "120"); tag.add_frame(ExtendedText {
description: "MOOD".into(),
value: "happy".into(),
});
tag.add_frame(ExtendedText {
description: "REPLAYGAIN_TRACK_GAIN".into(),
value: "-6.5 dB".into(),
});
tag.add_frame(Comment {
lang: "XXX".into(),
description: String::new(),
text: "nice".into(),
});
tag.add_frame(Lyrics {
lang: "XXX".into(),
description: String::new(),
text: "la la".into(),
});
let mut buf = Vec::new();
tag.write_to(&mut buf, Version::Id3v24).unwrap();
let tags = read_tags(&buf);
assert!(tags.contains(&("title".to_string(), "Song".to_string())));
assert!(tags.contains(&("TKEY".to_string(), "120".to_string())));
assert!(tags.contains(&("MOOD".to_string(), "happy".to_string())));
assert!(tags.contains(&("replaygain_track_gain".to_string(), "-6.5 dB".to_string())));
assert!(tags.contains(&("comment".to_string(), "nice".to_string())));
assert!(tags.contains(&("lyrics".to_string(), "la la".to_string())));
}
#[test]
fn synthesize_round_trips_arbitrary_id3_tags() {
let tags = vec![
TagInput::new("title", "Song"),
TagInput::new("TKEY", "120"), TagInput::new("MyRating", "5"), TagInput::new("comment", "nice"), TagInput::new("lyrics", "la la"), TagInput::new("replaygain_track_gain", "-3.21 dB"), ];
let (segments, _len) = build_id3v2_segments(&tags, &[], &[]).unwrap();
let mut buf = Vec::new();
for seg in &segments {
if let Segment::Inline(bytes) = seg {
buf.extend_from_slice(bytes);
}
}
let read = read_tags(&buf);
for expected in [
("title", "Song"),
("TKEY", "120"),
("MyRating", "5"),
("comment", "nice"),
("lyrics", "la la"),
("replaygain_track_gain", "-3.21 dB"),
] {
assert!(
read.contains(&(expected.0.to_string(), expected.1.to_string())),
"missing {expected:?} in {read:?}"
);
}
}
#[test]
fn read_tags_preserves_comm_uslt_language_and_descriptor() {
use id3::frame::{Comment, Lyrics};
use id3::{Tag, TagLike, Version};
let mut tag = Tag::new();
tag.add_frame(Comment {
lang: "XXX".into(),
description: String::new(),
text: "plain".into(),
});
tag.add_frame(Comment {
lang: "deu".into(),
description: String::new(),
text: "hallo".into(),
});
tag.add_frame(Comment {
lang: "eng".into(),
description: "note".into(),
text: "see liner".into(),
});
tag.add_frame(Lyrics {
lang: "eng".into(),
description: String::new(),
text: "verse".into(),
});
tag.add_frame(Lyrics {
lang: "deu".into(),
description: String::new(),
text: "strophe".into(),
});
let mut buf = Vec::new();
tag.write_to(&mut buf, Version::Id3v24).unwrap();
let tags = read_tags(&buf);
assert!(
tags.contains(&("comment".into(), "plain".into())),
"got {tags:?}"
);
assert!(
tags.contains(&("id3:COMM:deu:".into(), "hallo".into())),
"got {tags:?}"
);
assert!(
tags.contains(&("id3:COMM:eng:note".into(), "see liner".into())),
"got {tags:?}"
);
assert!(
tags.contains(&("id3:USLT:eng:".into(), "verse".into())),
"got {tags:?}"
);
assert!(
tags.contains(&("id3:USLT:deu:".into(), "strophe".into())),
"got {tags:?}"
);
}
#[test]
fn synthesize_round_trips_comm_uslt_language_and_descriptor() {
let tags = vec![
TagInput::new("comment", "plain"),
TagInput::new("id3:COMM:deu:", "hallo"),
TagInput::new("id3:COMM:eng:note", "see liner"),
TagInput::new("id3:USLT:eng:Chorus", "la la"),
];
let (segments, len) = build_id3v2_segments(&tags, &[], &[]).unwrap();
let mut buf = Vec::new();
for seg in &segments {
if let Segment::Inline(bytes) = seg {
buf.extend_from_slice(bytes);
}
}
assert_eq!(len, buf.len() as u64);
let tag = id3::Tag::read_from2(std::io::Cursor::new(&buf)).unwrap();
assert!(
tag.comments()
.any(|c| c.text == "plain" && c.description.is_empty()),
"plain COMM missing"
);
assert!(
tag.comments()
.any(|c| c.lang == "deu" && c.description.is_empty() && c.text == "hallo"),
"deu COMM missing"
);
assert!(
tag.comments()
.any(|c| c.lang == "eng" && c.description == "note" && c.text == "see liner"),
"descriptor-keyed COMM missing"
);
assert!(
tag.lyrics()
.any(|l| l.lang == "eng" && l.description == "Chorus" && l.text == "la la"),
"USLT missing"
);
}
#[test]
fn synchsafe_decode_assembles_7bit_groups() {
assert_eq!(synchsafe_decode(&[0x01, 0x02, 0x03, 0x04]), 0x0020_8184);
assert_eq!(synchsafe_decode(&[0xFF, 0xFF, 0xFF, 0xFF]), 0x0FFF_FFFF);
assert_eq!(synchsafe_decode(&[0x7F, 0x00, 0x00, 0x00]), 0x0FE0_0000);
assert_eq!(synchsafe_decode(&[0x00, 0x7F, 0x00, 0x00]), 0x001F_C000);
}
#[test]
fn syncsafe_encodes_and_round_trips() {
assert_eq!(syncsafe(0x0FE0_0000), [0x7F, 0x00, 0x00, 0x00]);
assert_eq!(syncsafe(0x001F_C000), [0x00, 0x7F, 0x00, 0x00]);
for n in [0u32, 1, 127, 128, 0x0123_4567, 0x0FFF_FFFF] {
assert_eq!(synchsafe_decode(&syncsafe(n)), n);
}
}
#[test]
fn locate_audio_no_id3_starts_at_zero() {
let data = [0xFF, 0xFB, 0x90, 0x00, 0, 0, 0, 0, 0, 0];
let b = locate_audio(&data).unwrap();
assert_eq!(b.audio_offset, 0);
assert_eq!(b.audio_length, 10);
}
#[test]
fn locate_audio_skips_id3v2_then_finds_sync() {
let mut data = Vec::new();
data.extend_from_slice(b"ID3");
data.extend_from_slice(&[0x04, 0x00, 0x00]); data.extend_from_slice(&[0x00, 0x00, 0x00, 0x04]); data.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); data.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); let b = locate_audio(&data).unwrap();
assert_eq!(b.audio_offset, 14);
assert_eq!(b.audio_length, 4);
}
#[test]
fn locate_audio_honors_footer_flag() {
let mut data = Vec::new();
data.extend_from_slice(b"ID3");
data.extend_from_slice(&[0x04, 0x00, 0x10]); data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); data.extend_from_slice(&[0u8; 10]); data.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); let b = locate_audio(&data).unwrap();
assert_eq!(b.audio_offset, 20);
}
#[test]
fn locate_audio_requires_frame_sync() {
let data = [0xFF, 0x00, 0x00, 0x00, 0, 0, 0, 0, 0, 0];
assert_eq!(locate_audio(&data), Err(FormatError::NotMp3));
assert_eq!(locate_audio(&[0xFF]), Err(FormatError::NotMp3));
}
#[test]
fn push_frame_header_size_boundary_is_inclusive() {
let mut out = Vec::new();
assert!(push_frame_header(&mut out, b"TIT2", 0x0FFF_FFFF).is_ok());
let mut over = Vec::new();
assert_eq!(
push_frame_header(&mut over, b"TIT2", 0x1000_0000),
Err(FormatError::TooLarge)
);
}
#[test]
fn is_id3_text_frame_id_classifies_text_frames() {
assert!(is_id3_text_frame_id("TPE1")); assert!(is_id3_text_frame_id("TIT2"));
assert!(!is_id3_text_frame_id("TXXX")); assert!(!is_id3_text_frame_id("COMM")); assert!(!is_id3_text_frame_id("TPE")); assert!(!is_id3_text_frame_id("Txx1")); }
#[test]
fn build_id3v2_segments_emits_standard_text_frame_as_itself() {
let tags = vec![TagInput::new("TPE1", "Band")];
let (segments, _len) = build_id3v2_segments(&tags, &[], &[]).unwrap();
let mut buf = Vec::new();
for seg in &segments {
if let Segment::Inline(b) = seg {
buf.extend_from_slice(b);
}
}
assert!(
buf.windows(4).any(|w| w == b"TPE1"),
"TPE1 frame not emitted: routed elsewhere"
);
let read = read_tags(&buf);
assert!(
read.contains(&("artist".to_string(), "Band".to_string())),
"got {read:?}"
);
}
#[test]
fn build_id3v2_segments_rejects_oversized_total_tag() {
let mk = |data_len: u64| ArtInput {
art_id: 1,
mime: "image/png".to_string(),
description: String::new(),
picture_type: PictureType::new(3).unwrap(),
width: 0,
height: 0,
data_len: BlobLen::new(data_len).unwrap(),
};
assert_eq!(
build_id3v2_segments(&[], &[], &[mk(0x1000_0000)]).err(),
Some(FormatError::TooLarge)
);
assert!(build_id3v2_segments(&[], &[], &[mk(16)]).is_ok());
let (_, total_at_one) = build_id3v2_segments(&[], &[], &[mk(1)]).unwrap();
let overhead = total_at_one - 10 - 1; let boundary_data_len = 0x0FFF_FFFF - overhead;
assert!(
build_id3v2_segments(&[], &[], &[mk(boundary_data_len)]).is_ok(),
"exact boundary (frames_len == 0x0FFF_FFFF) should be accepted"
);
assert_eq!(
build_id3v2_segments(&[], &[], &[mk(boundary_data_len + 1)]).err(),
Some(FormatError::TooLarge),
"one byte past boundary must be rejected"
);
}
#[test]
fn build_id3v2_segments_rejects_embedded_nul() {
let nul_key = build_id3v2_segments(&[TagInput::new("bad\0key", "ok")], &[], &[]);
assert_eq!(
nul_key.err(),
Some(FormatError::EmbeddedNul { field: "tag key" })
);
let nul_value = build_id3v2_segments(&[TagInput::new("TIT2", "a\0b")], &[], &[]);
assert_eq!(
nul_value.err(),
Some(FormatError::EmbeddedNul { field: "tag value" })
);
let art = |mime: &str, desc: &str| ArtInput {
art_id: 1,
mime: mime.to_string(),
description: desc.to_string(),
picture_type: PictureType::new(3).unwrap(),
width: 0,
height: 0,
data_len: BlobLen::new(16).unwrap(),
};
assert_eq!(
build_id3v2_segments(&[], &[], &[art("image/png\0junk", "")]).err(),
Some(FormatError::EmbeddedNul { field: "art mime" })
);
assert_eq!(
build_id3v2_segments(&[], &[], &[art("image/png", "front\0cover")]).err(),
Some(FormatError::EmbeddedNul {
field: "art description"
})
);
assert!(
build_id3v2_segments(
&[TagInput::new("TIT2", "ok")],
&[],
&[art("image/png", "front")]
)
.is_ok()
);
}
#[test]
fn build_id3v2_segments_emits_art_segment_with_correct_id_and_len() {
let mk = |art_id: i64, data_len: u64| ArtInput {
art_id,
mime: "image/png".to_string(),
description: String::new(),
picture_type: PictureType::new(3).unwrap(),
width: 0,
height: 0,
data_len: BlobLen::new(data_len).unwrap(),
};
let (segments, _len) = build_id3v2_segments(&[], &[], &[mk(2, 16)]).unwrap();
let art_segs: Vec<_> = segments
.iter()
.filter_map(|s| match s {
Segment::ArtImage { art_id, len } => Some((*art_id, len.get())),
_ => None,
})
.collect();
assert_eq!(
art_segs,
vec![(2_i64, 16_u64)],
"only the non-empty art should be emitted"
);
}
fn ss(n: u32) -> [u8; 4] {
[
((n >> 21) & 0x7F) as u8,
((n >> 14) & 0x7F) as u8,
((n >> 7) & 0x7F) as u8,
(n & 0x7F) as u8,
]
}
fn id3v2(major: u8, flags: u8, body: u32, frames: &[u8]) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(b"ID3");
v.push(major);
v.push(0x00);
v.push(flags);
v.extend_from_slice(&ss(body));
v.extend_from_slice(frames);
v
}
#[test]
fn alloc_safe_accepts_minimal_valid_header() {
let tag = id3v2(0x04, 0x00, 0, &[]);
assert_eq!(tag.len(), 10);
assert!(id3v2_alloc_safe(&tag));
}
#[test]
fn alloc_safe_rejects_short_and_non_id3() {
assert!(!id3v2_alloc_safe(b"ID3xx"));
assert!(!id3v2_alloc_safe(b"XXX\x04\x00\x00\x00\x00\x00\x00"));
}
#[test]
fn alloc_safe_rejects_bad_version_and_header_flags() {
assert!(!id3v2_alloc_safe(&id3v2(0x05, 0x00, 0, &[])));
assert!(!id3v2_alloc_safe(&id3v2(0x01, 0x00, 0, &[])));
assert!(!id3v2_alloc_safe(&id3v2(0x04, 0x40, 0, &[])));
assert!(!id3v2_alloc_safe(&id3v2(0x04, 0x80, 0, &[])));
}
#[test]
fn alloc_safe_rejects_high_bit_in_body_size() {
let tag = vec![b'I', b'D', b'3', 0x04, 0x00, 0x00, 0x80, 0x80, 0x00, 0x00];
assert!(!id3v2_alloc_safe(&tag));
let tag1 = vec![b'I', b'D', b'3', 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80];
assert!(!id3v2_alloc_safe(&tag1));
}
#[test]
fn alloc_safe_rejects_high_bit_in_v24_frame_size() {
let mut frame = b"TIT2".to_vec();
frame.extend_from_slice(&[0x80, 0x80, 0x00, 0x00]); frame.extend_from_slice(&[0x00, 0x00]); let tag = id3v2(0x04, 0x00, 10, &frame);
assert!(!id3v2_alloc_safe(&tag));
}
fn v23_frame(id: &[u8; 4], size: u32, payload: &[u8]) -> Vec<u8> {
let mut v = id.to_vec();
v.extend_from_slice(&size.to_be_bytes());
v.extend_from_slice(&[0x00, 0x00]);
v.extend_from_slice(payload);
v
}
#[test]
fn alloc_safe_v22_24bit_size_decode() {
let mut f_mid = b"TT2".to_vec();
f_mid.extend_from_slice(&[0x00, 0x01, 0x00]); assert!(!id3v2_alloc_safe(&id3v2(0x02, 0x00, 6, &f_mid))); let mut f_hi = b"TT2".to_vec();
f_hi.extend_from_slice(&[0x01, 0x00, 0x00]);
assert!(!id3v2_alloc_safe(&id3v2(0x02, 0x00, 6, &f_hi)));
let mut f_lo = b"TT2".to_vec();
f_lo.extend_from_slice(&[0x00, 0x00, 0x10]); assert!(!id3v2_alloc_safe(&id3v2(0x02, 0x00, 6, &f_lo)));
let mut f_ok = b"TT2".to_vec();
f_ok.extend_from_slice(&[0x00, 0x00, 0x04]);
f_ok.extend_from_slice(&[1, 2, 3, 4]);
assert!(id3v2_alloc_safe(&id3v2(0x02, 0x00, 10, &f_ok)));
}
#[test]
fn alloc_safe_rejects_nonzero_frame_flags() {
let mut f3 = b"TIT2".to_vec();
f3.extend_from_slice(&4u32.to_be_bytes()); f3.extend_from_slice(&[0x00, 0x01]); f3.extend_from_slice(&[1, 2, 3, 4]);
assert!(!id3v2_alloc_safe(&id3v2(0x03, 0x00, 14, &f3)));
let mut f4 = b"TIT2".to_vec();
f4.extend_from_slice(&ss(4)); f4.extend_from_slice(&[0x00, 0x01]); f4.extend_from_slice(&[1, 2, 3, 4]);
assert!(!id3v2_alloc_safe(&id3v2(0x04, 0x00, 14, &f4)));
}
#[test]
fn alloc_safe_rejects_chap_and_ctoc() {
let chap = v23_frame(b"CHAP", 4, &[1, 2, 3, 4]);
assert!(!id3v2_alloc_safe(&id3v2(0x03, 0x00, 14, &chap)));
let ctoc = v23_frame(b"CTOC", 4, &[1, 2, 3, 4]);
assert!(!id3v2_alloc_safe(&id3v2(0x03, 0x00, 14, &ctoc)));
}
#[test]
fn alloc_safe_frame_size_bounds() {
let ok = v23_frame(b"TIT2", 4, &[1, 2, 3, 4]);
assert!(id3v2_alloc_safe(&id3v2(0x03, 0x00, 14, &ok)));
let over = v23_frame(b"TIT2", 5, &[1, 2, 3, 4]);
assert!(!id3v2_alloc_safe(&id3v2(0x03, 0x00, 14, &over)));
}
#[test]
fn alloc_safe_data_start_equal_to_tag_end_is_ok() {
let zero = v23_frame(b"TIT2", 0, &[]);
assert!(id3v2_alloc_safe(&id3v2(0x03, 0x00, 10, &zero)));
}
#[test]
fn alloc_safe_rejects_bad_second_frame_in_body() {
let mut frames = v23_frame(b"TIT2", 2, &[0xAA, 0xBB]); frames.extend_from_slice(&v23_frame(b"TPE1", 100, &[1, 2, 3, 4])); assert!(!id3v2_alloc_safe(&id3v2(0x03, 0x00, 26, &frames)));
}
#[test]
fn alloc_safe_stops_at_tag_body_end() {
let mut frames = v23_frame(b"TIT2", 0, &[]); frames.extend_from_slice(&v23_frame(b"TPE1", 100, &[1, 2, 3, 4])); assert!(id3v2_alloc_safe(&id3v2(0x03, 0x00, 10, &frames)));
}
#[test]
fn alloc_safe_walks_two_frames_and_stops_at_padding() {
let mut frames = v23_frame(b"TIT2", 2, &[0xAA, 0xBB]);
frames.extend_from_slice(&v23_frame(b"TPE1", 2, &[0xCC, 0xDD]));
frames.extend_from_slice(&[0u8; 10]); assert!(id3v2_alloc_safe(&id3v2(0x03, 0x00, 25, &frames)));
}
#[test]
fn alloc_safe_rejects_frame_size_exceeding_tag_end() {
let huge = v23_frame(b"TIT2", 100, &[1, 2, 3, 4]);
assert!(!id3v2_alloc_safe(&id3v2(0x03, 0x00, 14, &huge)));
}
fn mp3_with_id3v2(body_len: usize, audio: &[u8]) -> (Vec<u8>, u64) {
let mut v = b"ID3\x04\x00\x00".to_vec(); v.extend_from_slice(&syncsafe(u32::try_from(body_len).unwrap()));
v.extend(std::iter::repeat_n(0u8, body_len)); let audio_offset = v.len() as u64;
v.extend_from_slice(&[0xFF, 0xFB]); v.extend_from_slice(audio);
(v, audio_offset)
}
#[test]
fn locate_audio_bounded_complete_with_no_id3v1() {
let (full, audio_offset) = mp3_with_id3v2(8, b"frames");
let prefix = &full[..usize_from(audio_offset) + 2]; let file_len = full.len() as u64;
match locate_audio_bounded(prefix, file_len, None).unwrap() {
Extent::Complete(b) => {
assert_eq!(b.audio_offset, audio_offset);
assert_eq!(b.audio_length, file_len - audio_offset);
}
other @ Extent::NeedMore { .. } => panic!("expected Complete, got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_needmore_when_tag_exceeds_prefix() {
let (full, _audio_offset) = mp3_with_id3v2(4096, b"frames");
let prefix = &full[..32]; let file_len = full.len() as u64;
match locate_audio_bounded(prefix, file_len, None).unwrap() {
Extent::NeedMore { up_to } => assert_eq!(up_to, 10 + 4096 + 2),
other @ Extent::Complete(_) => panic!("expected NeedMore, got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_strips_id3v1_tail() {
let (mut full, audio_offset) = mp3_with_id3v2(8, b"frames");
let body_end = full.len();
full.extend_from_slice(b"TAG"); full.extend(std::iter::repeat_n(0u8, 125)); let file_len = full.len() as u64;
let tail: [u8; 128] = full[full.len() - 128..].try_into().unwrap();
let prefix = &full[..usize_from(audio_offset) + 2];
match locate_audio_bounded(prefix, file_len, Some(&tail)).unwrap() {
Extent::Complete(b) => {
assert_eq!(b.audio_offset, audio_offset);
assert_eq!(b.audio_length, body_end as u64 - audio_offset);
}
other @ Extent::NeedMore { .. } => panic!("expected Complete, got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_rejects_audio_start_past_eof() {
let mut full = b"ID3\x04\x00\x00".to_vec();
full.extend_from_slice(&syncsafe(8));
full.extend(std::iter::repeat_n(0u8, 8)); let file_len = full.len() as u64; match locate_audio_bounded(&full, file_len, None) {
Err(FormatError::NotMp3) => {}
other => panic!("expected Err(NotMp3), got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_plain_mp3_no_id3_starts_at_zero() {
let data = [0xFF, 0xFB, 0x90, 0x00, 1, 2, 3, 4, 5, 6, 7, 8];
let file_len = data.len() as u64;
match locate_audio_bounded(&data, file_len, None).unwrap() {
Extent::Complete(b) => {
assert_eq!(b.audio_offset, 0);
assert_eq!(b.audio_length, file_len);
}
other @ Extent::NeedMore { .. } => {
panic!("expected Complete at offset 0, got {other:?}")
}
}
}
#[test]
fn locate_audio_bounded_short_non_id3_with_small_file() {
let data = [0xFF, 0xFB, 0x90, 0x00, 0x00];
let file_len = data.len() as u64; match locate_audio_bounded(&data, file_len, None).unwrap() {
Extent::Complete(b) => {
assert_eq!(b.audio_offset, 0);
assert_eq!(b.audio_length, 5);
}
other @ Extent::NeedMore { .. } => panic!("expected Complete, got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_footer_flag_adds_ten() {
let body = 6usize;
let mut full = b"ID3\x04\x00".to_vec();
full.push(0x10); full.extend_from_slice(&syncsafe(u32::try_from(body).unwrap()));
full.extend(std::iter::repeat_n(0u8, body)); full.extend(std::iter::repeat_n(0u8, 10)); let expected_offset = full.len() as u64; full.extend_from_slice(&[0xFF, 0xFB]); full.extend_from_slice(b"audio");
let file_len = full.len() as u64;
match locate_audio_bounded(&full, file_len, None).unwrap() {
Extent::Complete(b) => {
assert_eq!(b.audio_offset, 26);
assert_eq!(b.audio_offset, expected_offset);
assert_eq!(b.audio_length, file_len - 26);
}
other @ Extent::NeedMore { .. } => {
panic!("expected Complete at offset 26, got {other:?}")
}
}
}
#[test]
fn locate_audio_bounded_tag_len_equals_file_len_is_notmp3_not_malformed() {
let body = 8usize;
let mut full = b"ID3\x04\x00\x00".to_vec();
full.extend_from_slice(&syncsafe(u32::try_from(body).unwrap()));
full.extend(std::iter::repeat_n(0u8, body)); let file_len = full.len() as u64; match locate_audio_bounded(&full, file_len, None) {
Err(FormatError::NotMp3) => {}
other => panic!("expected Err(NotMp3) for tag_len==file_len, got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_tag_len_exceeds_file_len_is_malformed() {
let mut full = b"ID3\x04\x00\x00".to_vec();
full.extend_from_slice(&syncsafe(100));
full.extend_from_slice(&[0xFF, 0xFB]); let file_len = full.len() as u64; match locate_audio_bounded(&full, file_len, None) {
Err(FormatError::Malformed) => {}
other => panic!("expected Err(Malformed), got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_short_prefix_large_file_needs_header() {
let prefix = [0x00, 0x00, 0x00, 0x00, 0x00]; let file_len = 64u64; match locate_audio_bounded(&prefix, file_len, None).unwrap() {
Extent::NeedMore { up_to } => assert_eq!(up_to, 10),
other @ Extent::Complete(_) => panic!("expected NeedMore{{up_to:10}}, got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_prefix_len_exactly_ten_proceeds() {
let prefix = [0xFF, 0xFB, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let file_len = 64u64; match locate_audio_bounded(&prefix, file_len, None).unwrap() {
Extent::Complete(b) => {
assert_eq!(b.audio_offset, 0);
assert_eq!(b.audio_length, file_len);
}
other @ Extent::NeedMore { .. } => {
panic!("expected Complete (10<10 false), got {other:?}")
}
}
}
#[test]
fn locate_audio_bounded_short_prefix_small_file_proceeds() {
let data = [0xFF, 0xFB, 0x90, 0x00, 0x00]; let file_len = 8u64;
match locate_audio_bounded(&data, file_len, None).unwrap() {
Extent::Complete(b) => {
assert_eq!(b.audio_offset, 0);
assert_eq!(b.audio_length, 8);
}
other @ Extent::NeedMore { .. } => {
panic!("expected Complete (file_len<10), got {other:?}")
}
}
}
#[test]
fn locate_audio_bounded_sync_one_byte_past_eof_is_notmp3() {
let body = 4usize;
let mut full = b"ID3\x04\x00\x00".to_vec();
full.extend_from_slice(&syncsafe(u32::try_from(body).unwrap()));
full.extend(std::iter::repeat_n(0u8, body)); let audio_offset = full.len() as u64; full.push(0xFF); let file_len = audio_offset + 1; match locate_audio_bounded(&full, file_len, None) {
Err(FormatError::NotMp3) => {}
other => panic!("expected Err(NotMp3) (sync past EOF), got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_sync_fits_in_file_proceeds() {
let (full, audio_offset) = mp3_with_id3v2(4, b"frames");
let file_len = full.len() as u64; match locate_audio_bounded(&full, file_len, None).unwrap() {
Extent::Complete(b) => assert_eq!(b.audio_offset, audio_offset),
other @ Extent::NeedMore { .. } => panic!("expected Complete, got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_sync_exactly_at_eof_proceeds() {
let body = 4usize;
let mut full = b"ID3\x04\x00\x00".to_vec();
full.extend_from_slice(&syncsafe(u32::try_from(body).unwrap()));
full.extend(std::iter::repeat_n(0u8, body)); let audio_offset = full.len() as u64; full.push(0xFF); full.push(0xFB);
let file_len = full.len() as u64; match locate_audio_bounded(&full, file_len, None).unwrap() {
Extent::Complete(b) => {
assert_eq!(b.audio_offset, audio_offset);
assert_eq!(b.audio_length, 2);
}
other @ Extent::NeedMore { .. } => {
panic!("expected Complete (exact fit), got {other:?}")
}
}
}
#[test]
fn locate_audio_bounded_rejects_bad_second_sync_byte() {
let data = [
0xFF, 0x00, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
let file_len = data.len() as u64;
match locate_audio_bounded(&data, file_len, None) {
Err(FormatError::NotMp3) => {}
other => panic!("expected Err(NotMp3) (bad sync byte 1), got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_rejects_bad_second_sync_byte_after_id3() {
let body = 4usize;
let mut full = b"ID3\x04\x00\x00".to_vec();
full.extend_from_slice(&syncsafe(u32::try_from(body).unwrap()));
full.extend(std::iter::repeat_n(0u8, body)); full.extend_from_slice(&[0xFF, 0x00]); full.extend_from_slice(b"tail");
let file_len = full.len() as u64;
match locate_audio_bounded(&full, file_len, None) {
Err(FormatError::NotMp3) => {}
other => panic!("expected Err(NotMp3) (bad sync at 15), got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_needmore_for_sync_past_prefix() {
let body = 4usize;
let mut full = b"ID3\x04\x00\x00".to_vec();
full.extend_from_slice(&syncsafe(u32::try_from(body).unwrap()));
full.extend(std::iter::repeat_n(0u8, body)); full.extend_from_slice(&[0xFF, 0xFB]); full.extend_from_slice(b"more audio bytes here");
let file_len = full.len() as u64; let prefix = &full[..15]; match locate_audio_bounded(prefix, file_len, None).unwrap() {
Extent::NeedMore { up_to } => assert_eq!(up_to, 16), other @ Extent::Complete(_) => panic!("expected NeedMore{{up_to:16}}, got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_trims_id3v1_when_tag_and_room() {
let (mut full, audio_offset) = mp3_with_id3v2(8, b"frames");
let body_end = full.len();
full.extend_from_slice(b"TAG");
full.extend(std::iter::repeat_n(0u8, 125)); let file_len = full.len() as u64;
assert!(file_len >= audio_offset + 128); let tail: [u8; 128] = full[full.len() - 128..].try_into().unwrap();
let prefix = &full[..usize_from(audio_offset) + 2];
match locate_audio_bounded(prefix, file_len, Some(&tail)).unwrap() {
Extent::Complete(b) => {
assert_eq!(b.audio_offset, audio_offset);
assert_eq!(b.audio_length, file_len - audio_offset - 128);
assert_eq!(b.audio_length, body_end as u64 - audio_offset);
}
other @ Extent::NeedMore { .. } => panic!("expected Complete (trimmed), got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_no_trim_when_tail_not_tag() {
let (mut full, audio_offset) = mp3_with_id3v2(8, b"frames");
full.extend(std::iter::repeat_n(0u8, 200));
let file_len = full.len() as u64;
assert!(file_len >= audio_offset + 128); let tail: [u8; 128] = full[full.len() - 128..].try_into().unwrap();
assert_ne!(&tail[0..3], b"TAG"); let prefix = &full[..usize_from(audio_offset) + 2];
match locate_audio_bounded(prefix, file_len, Some(&tail)).unwrap() {
Extent::Complete(b) => {
assert_eq!(b.audio_offset, audio_offset);
assert_eq!(b.audio_length, file_len - audio_offset);
}
other @ Extent::NeedMore { .. } => panic!("expected Complete (no trim), got {other:?}"),
}
}
#[test]
fn locate_audio_bounded_no_trim_when_no_room_even_with_tag_tail() {
let (mut full, audio_offset) = mp3_with_id3v2(8, b"frames");
full.extend_from_slice(b"TAGxx"); let file_len = full.len() as u64;
assert!(file_len < audio_offset + 128); let mut tail = [0u8; 128];
tail[0..3].copy_from_slice(b"TAG");
let prefix = &full[..usize_from(audio_offset) + 2];
match locate_audio_bounded(prefix, file_len, Some(&tail)).unwrap() {
Extent::Complete(b) => {
assert_eq!(b.audio_offset, audio_offset);
assert_eq!(b.audio_length, file_len - audio_offset); }
other @ Extent::NeedMore { .. } => {
panic!("expected Complete (no room, no trim), got {other:?}")
}
}
}
fn build_v24_tag(frames: &[(&[u8; 4], &[u8])]) -> Vec<u8> {
let total_body: usize = frames.iter().map(|(_, b)| 10 + b.len()).sum();
let mut out = Vec::new();
out.extend_from_slice(b"ID3");
out.extend_from_slice(&[0x04, 0x00, 0x00]); out.extend_from_slice(&ss(u32::try_from(total_body).unwrap()));
for (id, body) in frames {
out.extend_from_slice(*id);
out.extend_from_slice(&ss(u32::try_from(body.len()).unwrap()));
out.extend_from_slice(&[0x00, 0x00]); out.extend_from_slice(body);
}
out
}
fn build_v23_tag(frames: &[(&[u8; 4], &[u8])]) -> Vec<u8> {
let total_body: usize = frames.iter().map(|(_, b)| 10 + b.len()).sum();
let mut out = Vec::new();
out.extend_from_slice(b"ID3");
out.extend_from_slice(&[0x03, 0x00, 0x00]); out.extend_from_slice(&ss(u32::try_from(total_body).unwrap())); for (id, body) in frames {
out.extend_from_slice(*id);
out.extend_from_slice(&(u32::try_from(body.len()).unwrap()).to_be_bytes()); out.extend_from_slice(&[0x00, 0x00]); out.extend_from_slice(body);
}
out
}
#[test]
fn read_binary_tags_v23_plain_u32_frame_size() {
let filler = vec![0xAAu8; 8];
let body: Vec<u8> = (0..200u32)
.map(|i| u8::try_from(i % 250 + 1).unwrap())
.collect();
let tag = build_v23_tag(&[(b"GEOB", &filler), (b"PRIV", &body)]);
let (opaque, _promoted) = super::read_binary_tags(&tag);
let geob = opaque
.iter()
.find(|e| e.key == "GEOB")
.expect("v2.3 GEOB preserved");
assert_eq!(
geob.payload, filler,
"v2.3 first frame must survive byte-exact"
);
let priv_frame = opaque
.iter()
.find(|e| e.key == "PRIV")
.expect("v2.3 PRIV preserved");
assert_eq!(
priv_frame.payload, body,
"v2.3 plain-u32 frame body must survive byte-exact"
);
}
#[test]
fn read_binary_tags_skips_unsafe_tag() {
let mut tag = build_v24_tag(&[(b"PRIV", &[1, 2, 3])]);
tag[5] = 0x80; let (opaque, promoted) = super::read_binary_tags(&tag);
assert!(
opaque.is_empty() && promoted.is_empty(),
"an alloc-unsafe tag must yield no binary frames"
);
}
#[test]
fn read_binary_tags_skips_text_comm_uslt_apic() {
let tag = build_v24_tag(&[
(b"TIT2", &[0x00, b'x']),
(b"COMM", &[0x00]),
(b"USLT", &[0x00]),
(b"APIC", &[0x00]),
(b"PRIV", &[9, 9, 9]),
]);
let (opaque, _promoted) = super::read_binary_tags(&tag);
let keys: Vec<&str> = opaque.iter().map(|e| e.key.as_str()).collect();
assert_eq!(
keys,
vec!["PRIV"],
"only PRIV is opaque; T***/COMM/USLT/APIC are handled elsewhere: {keys:?}"
);
}
#[test]
fn read_binary_tags_decodes_popm_counter_big_endian_and_zero() {
let tag = build_v24_tag(&[(b"POPM", &[0x00, 200, 0x01, 0x02])]);
let (_opaque, promoted) = super::read_binary_tags(&tag);
assert!(
promoted.contains(&("rating".to_string(), "200".to_string())),
"rating: {promoted:?}"
);
assert!(
promoted.contains(&("playcount".to_string(), "258".to_string())),
"counter must decode big-endian: {promoted:?}"
);
let tag0 = build_v24_tag(&[(b"POPM", &[0x00, 128, 0x00])]);
let (_o0, promoted0) = super::read_binary_tags(&tag0);
assert!(
promoted0.contains(&("rating".to_string(), "128".to_string())),
"rating: {promoted0:?}"
);
assert!(
!promoted0.iter().any(|(k, _)| k == "playcount"),
"a zero POPM counter must not promote playcount: {promoted0:?}"
);
}
#[test]
fn popm_frame_data_emits_counter_only_when_positive() {
assert_eq!(
super::popm_frame_data(200, 0),
vec![0x00, 200],
"playcount 0 must omit the counter"
);
assert_eq!(
super::popm_frame_data(200, 5),
vec![0x00, 200, 0x00, 0x00, 0x00, 0x05],
"playcount > 0 must append a 4-byte counter"
);
}
#[test]
fn build_id3v2_segments_accounts_playcount_and_opaque_len() {
use crate::{BinaryTagInput, TagInput};
let tags = vec![
TagInput::new("rating", "100"),
TagInput::new("playcount", "42"),
];
let (segments, _len) = build_id3v2_segments(&tags, &[], &[]).unwrap();
let inline: Vec<u8> = segments
.iter()
.flat_map(|s| match s {
Segment::Inline(b) => b.clone(),
_ => Vec::new(),
})
.collect();
let (_opaque, promoted) = super::read_binary_tags(&inline);
assert!(
promoted.contains(&("playcount".to_string(), "42".to_string())),
"playcount must rebuild into the POPM counter: {promoted:?}"
);
let bin = vec![BinaryTagInput {
key: "PRIV".into(),
payload_id: 1,
len: BlobLen::new(7).unwrap(),
}];
let (_segs, total) = build_id3v2_segments(&[], &bin, &[]).unwrap();
assert_eq!(total, 10 + 10 + 7, "opaque binary frame length accounting");
}
#[test]
fn read_binary_tags_promotes_popm_and_mbid_and_passes_through_priv() {
use id3::frame::{Content, Popularimeter, UniqueFileIdentifier, Unknown};
use id3::{Encoder, Frame, Tag, TagLike, Version};
let mut tag = Tag::new();
tag.add_frame(Popularimeter {
user: "a@b.c".into(),
rating: 200,
counter: 7,
});
tag.add_frame(UniqueFileIdentifier {
owner_identifier: "http://musicbrainz.org".into(),
identifier: b"mbid-123".to_vec(),
});
tag.add_frame(UniqueFileIdentifier {
owner_identifier: "http://other.example".into(),
identifier: b"other".to_vec(),
});
tag.add_frame(Frame::with_content(
"PRIV",
Content::Unknown(Unknown {
data: vec![9, 8, 7],
version: Version::Id3v24,
}),
));
let mut buf = Vec::new();
Encoder::new()
.version(Version::Id3v24)
.encode(&tag, &mut buf)
.unwrap();
let (opaque, promoted) = super::read_binary_tags(&buf);
assert!(promoted.contains(&("rating".to_string(), "200".to_string())));
assert!(promoted.contains(&("playcount".to_string(), "7".to_string())));
assert!(promoted.contains(&("musicbrainz_trackid".to_string(), "mbid-123".to_string())));
let keys: Vec<&str> = opaque.iter().map(|e| e.key.as_str()).collect();
assert!(keys.contains(&"PRIV"));
assert_eq!(keys.iter().filter(|k| **k == "UFID").count(), 1);
assert_eq!(
opaque.iter().find(|e| e.key == "PRIV").unwrap().payload,
vec![9, 8, 7]
);
}
#[test]
fn read_binary_tags_preserves_geob_body_byte_exact() {
let geob_body: Vec<u8> = {
let mut b = vec![0x00]; b.extend_from_slice(b"application/octet-stream\0"); b.extend_from_slice(b"Serato Overview\0"); b.extend_from_slice(b"\0"); b.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); b
};
let tag = build_v24_tag(&[(b"GEOB", &geob_body)]);
let (opaque, _promoted) = super::read_binary_tags(&tag);
let geob = opaque
.iter()
.find(|e| e.key == "GEOB")
.expect("GEOB preserved");
assert_eq!(
geob.payload, geob_body,
"GEOB body must survive byte-identical"
);
}
#[test]
fn build_id3v2_segments_rebuilds_popm_ufid_and_streams_opaque() {
use crate::BinaryTagInput;
let tags = vec![
TagInput::new("artist", "A"),
TagInput::new("rating", "200"),
TagInput::new("playcount", "7"),
TagInput::new("musicbrainz_trackid", "mbid-123"),
];
let bin = vec![BinaryTagInput {
key: "PRIV".into(),
payload_id: 42,
len: BlobLen::new(3).unwrap(),
}];
let (segments, _len) = super::build_id3v2_segments(&tags, &bin, &[]).unwrap();
assert!(
segments.iter().any(|s| matches!(
s,
Segment::BinaryTag {
payload_id: 42,
len,
..
} if len.get() == 3
)),
"opaque PRIV must stream as Segment::BinaryTag"
);
let inline: Vec<u8> = segments
.iter()
.flat_map(|s| match s {
Segment::Inline(b) => b.clone(),
_ => Vec::new(),
})
.collect();
assert!(find_sub(&inline, b"POPM"), "POPM not rebuilt");
assert!(find_sub(&inline, b"UFID"), "UFID not rebuilt");
assert!(
find_sub(&inline, b"http://musicbrainz.org"),
"UFID owner missing"
);
assert!(!find_sub(&inline, b"rating"), "promoted key leaked as TXXX");
assert!(
!find_sub(&inline, b"musicbrainz_trackid"),
"promoted key leaked as TXXX"
);
}
#[test]
fn build_id3v2_segments_first_promoted_scalar_wins() {
let tags = vec![
TagInput::new("rating", "10"),
TagInput::new("rating", "20"),
TagInput::new("musicbrainz_trackid", "mbid-first"),
TagInput::new("musicbrainz_trackid", "mbid-second"),
];
let (segments, _len) = build_id3v2_segments(&tags, &[], &[]).unwrap();
let inline: Vec<u8> = segments
.iter()
.flat_map(|s| match s {
Segment::Inline(b) => b.clone(),
_ => Vec::new(),
})
.collect();
assert!(find_sub(&inline, b"mbid-first"), "first mbid must win");
assert!(
!find_sub(&inline, b"mbid-second"),
"later mbid must be dropped"
);
let (_opaque, promoted) = super::read_binary_tags(&inline);
assert!(
promoted.contains(&("rating".to_string(), "10".to_string())),
"first rating must win: {promoted:?}"
);
assert!(
!promoted.iter().any(|(k, v)| k == "rating" && v == "20"),
"later rating must be dropped: {promoted:?}"
);
}
#[test]
fn build_id3v2_segments_checked_art_len_rejects_overflow() {
let mk = |data_len: u64| ArtInput {
art_id: 1,
mime: "image/png".to_string(),
description: String::new(),
picture_type: PictureType::new(3).unwrap(),
width: 0,
height: 0,
data_len: BlobLen::new(data_len).unwrap(),
};
assert_eq!(
build_id3v2_segments(&[], &[], &[mk(u64::MAX)]).err(),
Some(FormatError::TooLarge)
);
}
fn find_sub(hay: &[u8], needle: &[u8]) -> bool {
hay.windows(needle.len()).any(|w| w == needle)
}
fn assert_mp3_bounded_matches_full(data: &[u8]) {
let len = data.len() as u64;
let tail: Option<&[u8; 128]> = if data.len() >= 128 {
data[data.len() - 128..].try_into().ok()
} else {
None
};
match (locate_audio(data), locate_audio_bounded(data, len, tail)) {
(Ok(full), Ok(Extent::Complete(bounded))) => assert_eq!(full, bounded),
(Err(_), Err(_)) => {}
(full, bounded) => {
panic!("mp3 bounded/full divergence: full={full:?} bounded={bounded:?}")
}
}
}
#[test]
fn locate_audio_rejects_high_bit_size_byte() {
let mut data = Vec::new();
data.extend_from_slice(b"ID3");
data.extend_from_slice(&[0x04, 0x00, 0x00]); data.extend_from_slice(&[0x00, 0x00, 0x00, 0x80]); data.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); assert_eq!(locate_audio(&data), Err(FormatError::Malformed));
}
#[test]
fn locate_audio_rejects_unsupported_major_version() {
let mut data = Vec::new();
data.extend_from_slice(b"ID3");
data.extend_from_slice(&[0x05, 0x00, 0x00]); data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
data.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
assert_eq!(locate_audio(&data), Err(FormatError::Malformed));
}
#[test]
fn locate_audio_bounded_rejects_high_bit_size_byte() {
let mut data = Vec::new();
data.extend_from_slice(b"ID3");
data.extend_from_slice(&[0x04, 0x00, 0x00]);
data.extend_from_slice(&[0x00, 0x00, 0x00, 0x80]);
data.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
let file_len = data.len() as u64;
assert_eq!(
locate_audio_bounded(&data, file_len, None),
Err(FormatError::Malformed)
);
}
#[test]
fn mp3_bounded_matches_full_on_whole_buffer() {
assert_mp3_bounded_matches_full(&crate::fuzz_check::fixtures::mp3());
assert_mp3_bounded_matches_full(&crate::fuzz_check::fixtures::mp3_with_binary_frame());
let mut with_trailer = crate::fuzz_check::fixtures::mp3();
with_trailer.resize(200, 0x00);
with_trailer.extend_from_slice(b"TAG");
with_trailer.resize(with_trailer.len() + 125, 0x00); assert_mp3_bounded_matches_full(&with_trailer);
}
}