Skip to content

Commit b64f972

Browse files
committed
IFF: Support invalid chunk padding
1 parent cb73828 commit b64f972

11 files changed

Lines changed: 489 additions & 291 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
when during generic conversions ([issue](https://github.com/Serial-ATA/lofty-rs/issues/621)) ([PR](https://github.com/Serial-ATA/lofty-rs/pull/623))
2020
- **Timestamp**: Only enforce valid year in strict mode ([issue](https://github.com/Serial-ATA/lofty-rs/issues/615)) ([PR](https://github.com/Serial-ATA/lofty-rs/pull/616))
2121
- **OGG Vorbis**: Fixed potential infinite loop while property reading ([issue](https://github.com/Serial-ATA/lofty-rs/issues/620)) ([PR](https://github.com/Serial-ATA/lofty-rs/pull/622))
22+
- **IFF**: Support chunks with invalid padding ([issue](https://github.com/Serial-ATA/lofty-rs/issues/619)) ([PR](https://github.com/Serial-ATA/lofty-rs/pull/627))
23+
- When RIFF/AIFF chunks have an odd length, they should be padded, and the padding is not counted in the chunk size.
24+
However, some encoders incorrectly include the padding in the size, which can cause the parser to go out of sync.
25+
We no longer assume padding is valid and have additional checks to stay in sync when possible.
2226

2327
## [0.23.2] - 2026-02-14
2428

lofty/src/id3/v2/write/chunk_file.rs

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
use crate::config::WriteOptions;
1+
use crate::config::{ParseOptions, WriteOptions};
22
use crate::error::{LoftyError, Result};
3-
use crate::iff::chunk::Chunks;
3+
use crate::iff::chunk::{Chunks, IFF_CHUNK_HEADER_SIZE};
44
use crate::macros::err;
55
use crate::util::io::{FileLike, Length, Truncate};
66

@@ -10,7 +10,6 @@ use byteorder::{ByteOrder, WriteBytesExt};
1010

1111
const CHUNK_NAME_UPPER: [u8; 4] = [b'I', b'D', b'3', b' '];
1212
const CHUNK_NAME_LOWER: [u8; 4] = [b'i', b'd', b'3', b' '];
13-
const RIFF_CHUNK_HEADER_SIZE: usize = 8;
1413

1514
pub(in crate::id3::v2) fn write_to_chunk_file<F, B>(
1615
file: &mut F,
@@ -23,54 +22,70 @@ where
2322
LoftyError: From<<F as Length>::Error>,
2423
B: ByteOrder,
2524
{
26-
// Only rely on the actual file for the first chunk read
25+
const FIRST_CHUNK_LEN: u64 = (IFF_CHUNK_HEADER_SIZE as u64) + 4;
26+
27+
// We only want to rely on the file size for the first chunk read.
28+
// Since a file can have trailing junk, but otherwise be valid, we actually want to use the
29+
// first chunk size, which (should) encompass the entire stream.
2730
let file_len = file.len()?;
2831

29-
let mut chunks = Chunks::<B>::new(file_len);
30-
chunks.next(file)?;
32+
let mut chunks = Chunks::<_, B>::new(file, file_len);
33+
34+
// TODO: Forcing the use of ParseOptions::default()
35+
let parse_options = ParseOptions::default();
36+
let Some(first_chunk) = chunks.next(parse_options.parsing_mode)? else {
37+
err!(UnknownFormat);
38+
};
3139

32-
let mut actual_stream_size = chunks.size;
40+
let mut actual_stream_size = first_chunk.size();
3341

42+
let file = chunks.into_inner();
3443
file.rewind()?;
3544

3645
let mut file_bytes = Cursor::new(Vec::with_capacity(actual_stream_size as usize));
3746
file.read_to_end(file_bytes.get_mut())?;
3847

39-
if file_bytes.get_ref().len() < (actual_stream_size as usize + RIFF_CHUNK_HEADER_SIZE) {
48+
if file_bytes.get_ref().len() < (actual_stream_size as usize + IFF_CHUNK_HEADER_SIZE as usize) {
4049
err!(SizeMismatch);
4150
}
4251

4352
// The first chunk format is RIFF....WAVE
44-
file_bytes.seek(SeekFrom::Start(12))?;
53+
file_bytes.seek(SeekFrom::Start(FIRST_CHUNK_LEN))?;
54+
55+
let mut existing_id3_tag = None;
56+
57+
let mut chunks = Chunks::<_, B>::new(file_bytes, u64::from(actual_stream_size));
58+
while let Some(chunk) = chunks.next(parse_options.parsing_mode)? {
59+
if chunk.fourcc == CHUNK_NAME_UPPER || chunk.fourcc == CHUNK_NAME_LOWER {
60+
// Need to add FIRST_CHUNK_LEN since we started the chunk reader at that offset
61+
let chunk_start = chunk.start() + FIRST_CHUNK_LEN;
4562

46-
let (mut exising_id3_start, mut existing_id3_size) = (None, None);
63+
// Can't trust the written chunk size, since some encoders don't handle padding
64+
// correctly, see Chunks::skip(). Since skip detects invalid padding, we just let it
65+
// do the work and figure out where we are in the file afterwards.
66+
chunks.skip()?;
4767

48-
let mut chunks = Chunks::<B>::new(u64::from(actual_stream_size));
49-
while let Ok(true) = chunks.next(&mut file_bytes) {
50-
if chunks.fourcc == CHUNK_NAME_UPPER || chunks.fourcc == CHUNK_NAME_LOWER {
51-
exising_id3_start = Some(file_bytes.stream_position()? - 8);
52-
existing_id3_size = Some(chunks.size);
68+
let chunk_end = chunks.stream_position() + FIRST_CHUNK_LEN;
69+
70+
log::debug!(
71+
"Found existing ID3v2 chunk, size: {} bytes",
72+
chunk_end - chunk_start
73+
);
74+
existing_id3_tag = Some(chunk_start..chunk_end);
5375
break;
5476
}
55-
56-
chunks.skip(&mut file_bytes)?;
5777
}
5878

59-
if let (Some(exising_id3_start), Some(mut existing_id3_size)) =
60-
(exising_id3_start, existing_id3_size)
61-
{
62-
// We need to remove the padding byte if it exists
63-
if existing_id3_size % 2 != 0 {
64-
existing_id3_size += 1;
65-
}
79+
let mut file_bytes = chunks.into_inner();
80+
81+
if let Some(existing_id3_tag) = existing_id3_tag {
82+
let tag_size = existing_id3_tag.end - existing_id3_tag.start;
6683

67-
let existing_tag_end =
68-
exising_id3_start as usize + RIFF_CHUNK_HEADER_SIZE + existing_id3_size as usize;
6984
let _ = file_bytes
7085
.get_mut()
71-
.drain(exising_id3_start as usize..existing_tag_end);
86+
.drain(existing_id3_tag.start as usize..existing_id3_tag.end as usize);
7287

73-
actual_stream_size -= existing_id3_size + RIFF_CHUNK_HEADER_SIZE as u32;
88+
actual_stream_size -= tag_size as u32;
7489
}
7590

7691
if !tag.is_empty() {
@@ -94,7 +109,7 @@ where
94109
err!(TooMuchData)
95110
};
96111

97-
let tag_position = actual_stream_size as usize + RIFF_CHUNK_HEADER_SIZE;
112+
let tag_position = actual_stream_size as usize + IFF_CHUNK_HEADER_SIZE as usize;
98113

99114
file_bytes.get_mut().splice(
100115
tag_position..tag_position,

lofty/src/iff/aiff/read.rs

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ where
5252
let compression_present = verify_aiff(data)?;
5353

5454
let current_pos = data.stream_position()?;
55-
let file_len = data.seek(SeekFrom::End(0))?;
55+
56+
// - 12 for the FORM chunk content we already read
57+
let file_len = data.seek(SeekFrom::End(0))?.saturating_sub(12);
5658

5759
data.seek(SeekFrom::Start(current_pos))?;
5860

@@ -65,12 +67,11 @@ where
6567

6668
let mut id3v2_tag: Option<Id3v2Tag> = None;
6769

68-
let mut chunks = Chunks::<BigEndian>::new(file_len);
69-
70-
while let Ok(true) = chunks.next(data) {
71-
match &chunks.fourcc {
70+
let mut chunks = Chunks::<_, BigEndian>::new(data, file_len);
71+
while let Some(mut chunk) = chunks.next(parse_options.parsing_mode)? {
72+
match &chunk.fourcc {
7273
b"ID3 " | b"id3 " if parse_options.read_tags => {
73-
let tag = chunks.id3_chunk(data, parse_options)?;
74+
let tag = chunk.id3_chunk(parse_options)?;
7475
if let Some(existing_tag) = id3v2_tag.as_mut() {
7576
log::warn!("Duplicate ID3v2 tag found, appending frames to previous tag");
7677

@@ -84,58 +85,56 @@ where
8485
id3v2_tag = Some(tag);
8586
},
8687
b"COMM" if parse_options.read_properties && comm.is_none() => {
87-
if chunks.size < 18 {
88+
if chunk.size() < 18 {
8889
decode_err!(@BAIL Aiff, "File has an invalid \"COMM\" chunk size (< 18)");
8990
}
9091

91-
comm = Some(chunks.content(data)?);
92-
chunks.correct_position(data)?;
92+
comm = Some(chunk.content()?);
9393
},
9494
b"SSND" if parse_options.read_properties => {
95-
stream_len = chunks.size;
96-
chunks.skip(data)?;
95+
stream_len = chunk.size();
9796
},
9897
b"ANNO" if parse_options.read_tags => {
99-
annotations.push(chunks.read_pstring(data, None)?);
98+
annotations.push(chunk.read_string(None)?);
10099
},
101100
// These four chunks are expected to appear at most once per file,
102101
// so there's no need to replace anything we already read
103102
b"COMT" if comments.is_empty() && parse_options.read_tags => {
104-
if chunks.size < 2 {
103+
if chunk.size() < 2 {
105104
continue;
106105
}
107106

108-
let num_comments = data.read_u16::<BigEndian>()?;
107+
let num_comments = chunk.read_u16::<BigEndian>()?;
109108

110109
for _ in 0..num_comments {
111-
let timestamp = data.read_u32::<BigEndian>()?;
112-
let marker_id = data.read_u16::<BigEndian>()?;
113-
let size = data.read_u16::<BigEndian>()?;
110+
let timestamp = chunk.read_u32::<BigEndian>()?;
111+
let marker_id = chunk.read_u16::<BigEndian>()?;
112+
let size = chunk.read_u16::<BigEndian>()?;
114113

115-
let text = chunks.read_pstring(data, Some(u32::from(size)))?;
114+
let text = chunk.read_string(Some(u32::from(size)))?;
116115

117116
comments.push(Comment {
118117
timestamp,
119118
marker_id,
120119
text,
121120
})
122121
}
123-
124-
chunks.correct_position(data)?;
125122
},
126123
b"NAME" if text_chunks.name.is_none() && parse_options.read_tags => {
127-
text_chunks.name = Some(chunks.read_pstring(data, None)?);
124+
text_chunks.name = Some(chunk.read_string(None)?);
128125
},
129126
b"AUTH" if text_chunks.author.is_none() && parse_options.read_tags => {
130-
text_chunks.author = Some(chunks.read_pstring(data, None)?);
127+
text_chunks.author = Some(chunk.read_string(None)?);
131128
},
132129
b"(c) " if text_chunks.copyright.is_none() && parse_options.read_tags => {
133-
text_chunks.copyright = Some(chunks.read_pstring(data, None)?);
130+
text_chunks.copyright = Some(chunk.read_string(None)?);
134131
},
135-
_ => chunks.skip(data)?,
132+
_ => {},
136133
}
137134
}
138135

136+
let data = chunks.into_inner();
137+
139138
if !annotations.is_empty() {
140139
text_chunks.annotations = Some(annotations);
141140
}

lofty/src/iff/aiff/tag.rs

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
use crate::config::WriteOptions;
1+
use crate::config::{ParseOptions, WriteOptions};
22
use crate::error::{LoftyError, Result};
3-
use crate::iff::chunk::Chunks;
4-
use crate::macros::err;
3+
use crate::iff::chunk::{Chunks, IFF_CHUNK_HEADER_SIZE};
4+
use crate::macros::{encode_err, err};
55
use crate::tag::{Accessor, ItemKey, ItemValue, MergeTag, SplitTag, Tag, TagExt, TagItem, TagType};
66
use crate::util::io::{FileLike, Length, Truncate};
77

88
use std::borrow::Cow;
99
use std::convert::TryFrom;
10-
use std::io::{SeekFrom, Write};
10+
use std::io::Write;
1111

1212
use byteorder::BigEndian;
1313
use lofty_attr::tag;
@@ -427,60 +427,69 @@ where
427427
LoftyError: From<<F as Truncate>::Error>,
428428
LoftyError: From<<F as Length>::Error>,
429429
{
430+
// FORM....AIFF
431+
const FIRST_CHUNK_LEN: u64 = (IFF_CHUNK_HEADER_SIZE as u64) + 4;
432+
430433
super::read::verify_aiff(file)?;
431-
let file_len = file.len()?.saturating_sub(12);
434+
let file_len = file.len()?.saturating_sub(FIRST_CHUNK_LEN);
432435

433436
let text_chunks = Self::create_text_chunks(&mut tag)?;
434437

435-
let mut chunks_remove = Vec::new();
436-
437-
let mut chunks = Chunks::<BigEndian>::new(file_len);
438+
let mut chunks_to_remove = Vec::new();
439+
let mut comm_end = None;
438440

439-
while let Ok(true) = chunks.next(file) {
440-
match &chunks.fourcc {
441+
// TODO: Forcing the use of ParseOptions::default()
442+
let parse_options = ParseOptions::default();
443+
let mut chunks = Chunks::<_, BigEndian>::new(file, file_len);
444+
while let Some(chunk) = chunks.next(parse_options.parsing_mode)? {
445+
match &chunk.fourcc {
441446
b"NAME" | b"AUTH" | b"(c) " | b"ANNO" | b"COMT" => {
442-
let start = (file.stream_position()? - 8) as usize;
443-
let mut end = start + 8 + chunks.size as usize;
447+
// Need to add FIRST_CHUNK_LEN since we started the chunk reader at that offset
448+
let start = chunk.start() + FIRST_CHUNK_LEN;
444449

445-
if chunks.size % 2 != 0 {
446-
end += 1
447-
}
450+
// Can't trust the written chunk size, since some encoders don't handle padding
451+
// correctly, see Chunks::skip(). Since skip detects invalid padding, we just let it
452+
// do the work and figure out where we are in the file afterwards.
453+
chunks.skip()?;
448454

449-
chunks_remove.push((start, end))
455+
let end = chunks.stream_position() + FIRST_CHUNK_LEN;
456+
457+
chunks_to_remove.push((start as usize, end as usize))
458+
},
459+
b"COMM" => {
460+
chunks.skip()?;
461+
comm_end = Some(chunks.stream_position() + FIRST_CHUNK_LEN);
450462
},
451463
_ => {},
452464
}
453-
454-
chunks.skip(file)?;
455465
}
456466

467+
let Some(comm_end) = comm_end else {
468+
encode_err!(@BAIL Aiff, "COMM chunk not found");
469+
};
470+
471+
let file = chunks.into_inner();
457472
file.rewind()?;
458473

459474
let mut file_bytes = Vec::new();
460475
file.read_to_end(&mut file_bytes)?;
461476

462-
if chunks_remove.is_empty() {
463-
file.seek(SeekFrom::Start(16))?;
464-
465-
let mut size = [0; 4];
466-
file.read_exact(&mut size)?;
467-
468-
let comm_end = (20 + u32::from_le_bytes(size)) as usize;
469-
file_bytes.splice(comm_end..comm_end, text_chunks);
477+
if chunks_to_remove.is_empty() {
478+
file_bytes.splice((comm_end as usize)..(comm_end as usize), text_chunks);
470479
} else {
471-
chunks_remove.sort_unstable();
472-
chunks_remove.reverse();
480+
chunks_to_remove.sort_unstable();
481+
chunks_to_remove.reverse();
473482

474-
let first = chunks_remove.pop().unwrap(); // Infallible
483+
let first = chunks_to_remove.pop().unwrap(); // Infallible
475484

476-
for (s, e) in &chunks_remove {
485+
for (s, e) in &chunks_to_remove {
477486
file_bytes.drain(*s..*e);
478487
}
479488

480489
file_bytes.splice(first.0..first.1, text_chunks);
481490
}
482491

483-
let total_size = ((file_bytes.len() - 8) as u32).to_be_bytes();
492+
let total_size = ((file_bytes.len() - IFF_CHUNK_HEADER_SIZE as usize) as u32).to_be_bytes();
484493
file_bytes.splice(4..8, total_size.to_vec());
485494

486495
file.rewind()?;

0 commit comments

Comments
 (0)