Skip to main content

dsf/
tag.rs

1use std::fs::{File, OpenOptions};
2use std::io::{BufReader, Read, Seek, SeekFrom};
3use std::path::Path;
4
5use id3::{ErrorKind, Tag, Version};
6
7use crate::edit::{TagEdit, WriteOptions};
8use crate::header::DsfHeader;
9use crate::storage::Storage;
10use crate::{Error, Result};
11
12/// Parses the header and, if present, the ID3v2 tag. Validates that the
13/// declared audio fits in the file and the metadata pointer is sane.
14fn load<R: Read + Seek>(r: &mut R) -> Result<(DsfHeader, Option<Tag>)> {
15    r.seek(SeekFrom::Start(0))?;
16    let header = DsfHeader::read(&mut *r)?;
17    let file_len = r.seek(SeekFrom::End(0))?;
18
19    if header.audio_end() > file_len {
20        return Err(Error::Malformed(
21            "file is shorter than its declared audio data",
22        ));
23    }
24    let offset = header.metadata_offset;
25    if offset == 0 || offset == file_len {
26        return Ok((header, None));
27    }
28    if offset < header.audio_end() || offset > file_len {
29        return Err(Error::Malformed("metadata pointer outside the file tail"));
30    }
31
32    r.seek(SeekFrom::Start(offset))?;
33    match Tag::read_from2(&mut *r) {
34        Ok(tag) => Ok((header, Some(tag))),
35        Err(e) if matches!(e.kind, ErrorKind::NoTag) => Ok((header, None)),
36        // A corrupt tag is an error, never silently replaced.
37        Err(e) => Err(e.into()),
38    }
39}
40
41/// Writes `tag_bytes` (or removes the tag when `None`) and fixes the header.
42fn commit<S: Storage>(s: &mut S, header: &DsfHeader, tag_bytes: Option<&[u8]>) -> Result<()> {
43    let audio_end = header.audio_end();
44    let (offset, new_len) = match tag_bytes {
45        Some(bytes) => {
46            let len = u64::try_from(bytes.len()).map_err(|_| Error::Malformed("tag too large"))?;
47            let end = audio_end
48                .checked_add(len)
49                .ok_or(Error::Malformed("tag too large"))?;
50            s.seek(SeekFrom::Start(audio_end))?;
51            s.write_all(bytes)?;
52            (audio_end, end)
53        }
54        None => (0, audio_end),
55    };
56    s.set_len(new_len)?;
57
58    // total_file_size (bytes 12..20) and metadata pointer (20..28) in one write.
59    let mut fields = [0u8; 16];
60    fields[..8].copy_from_slice(&new_len.to_le_bytes());
61    fields[8..].copy_from_slice(&offset.to_le_bytes());
62    s.seek(SeekFrom::Start(12))?;
63    s.write_all(&fields)?;
64    s.sync()?;
65    Ok(())
66}
67
68fn encode(tag: &Tag, version: Version) -> Result<Vec<u8>> {
69    let mut buf = Vec::new();
70    tag.write_to(&mut buf, version)?;
71    Ok(buf)
72}
73
74/// Reads the ID3v2 tag, or `None` if the file has none.
75pub fn read_tag<R: Read + Seek>(r: &mut R) -> Result<Option<Tag>> {
76    Ok(load(r)?.1)
77}
78
79/// Replaces the whole tag.
80pub fn write_tag<S: Storage>(s: &mut S, tag: &Tag, version: Version) -> Result<()> {
81    let (header, _) = load(s)?;
82    commit(s, &header, Some(&encode(tag, version)?))
83}
84
85/// Applies `edit` to the existing tag (creating one if absent), preserving all other frames.
86pub fn update_tag<S: Storage>(s: &mut S, edit: &TagEdit, opts: &WriteOptions) -> Result<()> {
87    let (header, existing) = load(s)?;
88    let mut tag = existing.unwrap_or_else(Tag::new);
89    edit.apply(&mut tag);
90    let version = opts.version.unwrap_or_else(|| tag.version());
91    commit(s, &header, Some(&encode(&tag, version)?))
92}
93
94/// Removes the tag and zeroes the metadata pointer.
95pub fn remove_tag<S: Storage>(s: &mut S) -> Result<()> {
96    let (header, _) = load(s)?;
97    commit(s, &header, None)
98}
99
100/// Convenience: read the tag from a path.
101pub fn read_path(path: impl AsRef<Path>) -> Result<Option<Tag>> {
102    read_tag(&mut BufReader::new(File::open(path)?))
103}
104
105/// Convenience: apply `edit` to the file at `path`.
106pub fn update_path(path: impl AsRef<Path>, edit: &TagEdit, opts: &WriteOptions) -> Result<()> {
107    let mut file = OpenOptions::new().read(true).write(true).open(path)?;
108    update_tag(&mut file, edit, opts)
109}
110
111#[cfg(test)]
112mod tests {
113    use std::io::Cursor;
114
115    use id3::TagLike;
116    use id3::frame::Comment;
117
118    use super::*;
119    use crate::edit::FrameId;
120
121    type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;
122    const AUDIO: usize = 1024;
123    const AUDIO_END: usize = 92 + AUDIO;
124
125    /// Minimal valid DSF with no metadata and an 0xA5-filled payload.
126    fn synth() -> std::result::Result<Vec<u8>, Box<dyn std::error::Error>> {
127        let header = DsfHeader {
128            total_file_size: AUDIO_END as u64,
129            metadata_offset: 0,
130            channel_type: 2,
131            channel_count: 2,
132            sample_rate: 2_822_400,
133            bits_per_sample: 1,
134            sample_count: 4096,
135            block_size: 4096,
136            data_chunk_size: 12 + AUDIO as u64,
137        };
138        let mut v = Vec::new();
139        header.write(&mut v)?;
140        v.extend(std::iter::repeat_n(0xA5u8, AUDIO));
141        Ok(v)
142    }
143
144    #[test]
145    fn creates_tag_and_keeps_header_consistent() -> TestResult {
146        let mut f = Cursor::new(synth()?);
147        let edit = TagEdit::new()
148            .set(FrameId::TITLE, "Allemande")
149            .set(FrameId::COMPOSER, "J.S. Bach");
150        update_tag(&mut f, &edit, &WriteOptions::default())?;
151
152        let tag = read_tag(&mut f)?.ok_or("tag missing")?;
153        assert_eq!(tag.title(), Some("Allemande"));
154
155        let bytes = f.into_inner();
156        let header = DsfHeader::read(&bytes[..])?;
157        assert_eq!(header.metadata_offset, AUDIO_END as u64);
158        assert_eq!(header.total_file_size, bytes.len() as u64);
159        assert!(
160            bytes[92..AUDIO_END].iter().all(|b| *b == 0xA5),
161            "audio must be untouched"
162        );
163        Ok(())
164    }
165
166    #[test]
167    fn shrinking_a_tag_truncates_the_file() -> TestResult {
168        let mut f = Cursor::new(synth()?);
169        let long = "x".repeat(2000);
170        update_tag(
171            &mut f,
172            &TagEdit::new().set(FrameId::TITLE, long),
173            &WriteOptions::default(),
174        )?;
175        let big = f.get_ref().len();
176
177        update_tag(
178            &mut f,
179            &TagEdit::new().set(FrameId::TITLE, "short"),
180            &WriteOptions::default(),
181        )?;
182        let bytes = f.into_inner();
183        assert!(bytes.len() < big);
184        let header = DsfHeader::read(&bytes[..])?;
185        assert_eq!(header.total_file_size, bytes.len() as u64);
186        Ok(())
187    }
188
189    #[test]
190    fn unrelated_frames_survive_an_edit() -> TestResult {
191        let mut f = Cursor::new(synth()?);
192        let mut tag = Tag::new();
193        tag.add_frame(Comment {
194            lang: "eng".into(),
195            description: String::new(),
196            text: "keep me".into(),
197        });
198        write_tag(&mut f, &tag, Version::Id3v24)?;
199
200        update_tag(
201            &mut f,
202            &TagEdit::new().set(FrameId::TITLE, "T"),
203            &WriteOptions::default(),
204        )?;
205        let tag = read_tag(&mut f)?.ok_or("tag missing")?;
206        assert_eq!(tag.comments().count(), 1);
207        assert_eq!(tag.title(), Some("T"));
208        Ok(())
209    }
210
211    #[test]
212    fn user_text_comment_and_cover_round_trip() -> TestResult {
213        let mut f = Cursor::new(synth()?);
214        let cover = crate::edit::Picture {
215            mime_type: "image/jpeg".to_owned(),
216            data: vec![0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3],
217        };
218        let edit = TagEdit::new()
219            .set_user_text("MOOD", "calm")
220            .set_user_text("LABEL", "Test Records")
221            .set_comment("remastered")
222            .set_cover(cover.clone());
223        update_tag(&mut f, &edit, &WriteOptions::default())?;
224
225        let tag = read_tag(&mut f)?.ok_or("tag missing")?;
226        let mut texts: Vec<(&str, &str)> = tag
227            .extended_texts()
228            .map(|t| (t.description.as_str(), t.value.as_str()))
229            .collect();
230        texts.sort_unstable();
231        assert_eq!(texts, [("LABEL", "Test Records"), ("MOOD", "calm")]);
232        assert_eq!(
233            tag.comments().map(|c| c.text.as_str()).collect::<Vec<_>>(),
234            ["remastered"]
235        );
236        let picture = tag.pictures().next().ok_or("cover missing")?;
237        assert_eq!(picture.data, cover.data);
238        assert_eq!(picture.mime_type, cover.mime_type);
239        assert_eq!(picture.picture_type, id3::frame::PictureType::CoverFront);
240        Ok(())
241    }
242
243    #[test]
244    fn replacing_and_clearing_extras_leaves_no_duplicates() -> TestResult {
245        let mut f = Cursor::new(synth()?);
246        let first = TagEdit::new()
247            .set_user_text("MOOD", "calm")
248            .set_comment("one")
249            .set_cover(crate::edit::Picture {
250                mime_type: "image/png".to_owned(),
251                data: vec![1],
252            });
253        update_tag(&mut f, &first, &WriteOptions::default())?;
254        let second = TagEdit::new()
255            .set_user_text("MOOD", "tense")
256            .set_comment("two")
257            .set_cover(crate::edit::Picture {
258                mime_type: "image/png".to_owned(),
259                data: vec![2],
260            });
261        update_tag(&mut f, &second, &WriteOptions::default())?;
262
263        let tag = read_tag(&mut f)?.ok_or("tag missing")?;
264        assert_eq!(tag.extended_texts().count(), 1);
265        assert_eq!(tag.comments().count(), 1);
266        assert_eq!(
267            tag.pictures().map(|p| p.data.clone()).collect::<Vec<_>>(),
268            [vec![2]]
269        );
270
271        let clear = TagEdit::new()
272            .clear_user_text("MOOD")
273            .set_comment("  ")
274            .clear_cover();
275        update_tag(&mut f, &clear, &WriteOptions::default())?;
276        let tag = read_tag(&mut f)?.ok_or("tag missing")?;
277        assert_eq!(tag.extended_texts().count(), 0);
278        assert_eq!(tag.comments().count(), 0);
279        assert_eq!(tag.pictures().count(), 0);
280        Ok(())
281    }
282
283    #[test]
284    fn other_user_text_and_comments_survive() -> TestResult {
285        let mut f = Cursor::new(synth()?);
286        let mut tag = Tag::new();
287        tag.add_frame(id3::frame::ExtendedText {
288            description: "KEEP".into(),
289            value: "me".into(),
290        });
291        tag.add_frame(Comment {
292            lang: "eng".into(),
293            description: "other".into(),
294            text: "described comment".into(),
295        });
296        write_tag(&mut f, &tag, Version::Id3v24)?;
297
298        let edit = TagEdit::new()
299            .set_user_text("MOOD", "x")
300            .set_comment("plain");
301        update_tag(&mut f, &edit, &WriteOptions::default())?;
302        let tag = read_tag(&mut f)?.ok_or("tag missing")?;
303        assert_eq!(tag.extended_texts().count(), 2);
304        assert_eq!(tag.comments().count(), 2);
305        Ok(())
306    }
307
308    #[test]
309    fn extras_survive_id3v23() -> TestResult {
310        let mut f = Cursor::new(synth()?);
311        let edit = TagEdit::new()
312            .set_user_text("MOOD", "calm")
313            .set_comment("hello")
314            .set_cover(crate::edit::Picture {
315                mime_type: "image/jpeg".to_owned(),
316                data: vec![9, 9],
317            });
318        let opts = WriteOptions::default().with_version(Version::Id3v23);
319        update_tag(&mut f, &edit, &opts)?;
320        let tag = read_tag(&mut f)?.ok_or("tag missing")?;
321        assert_eq!(tag.version(), Version::Id3v23);
322        assert_eq!(tag.extended_texts().count(), 1);
323        assert_eq!(tag.comments().count(), 1);
324        assert_eq!(tag.pictures().count(), 1);
325        Ok(())
326    }
327
328    #[test]
329    fn remove_tag_restores_a_tagless_file() -> TestResult {
330        let original = synth()?;
331        let mut f = Cursor::new(original.clone());
332        update_tag(
333            &mut f,
334            &TagEdit::new().set(FrameId::TITLE, "T"),
335            &WriteOptions::default(),
336        )?;
337        remove_tag(&mut f)?;
338        assert_eq!(f.into_inner(), original);
339        Ok(())
340    }
341
342    #[test]
343    fn rejects_foreign_and_truncated_input() {
344        let riff = read_tag(&mut Cursor::new(b"RIFF....".to_vec()));
345        assert!(matches!(riff, Err(Error::BadSignature(_))));
346        let short = read_tag(&mut Cursor::new(b"DSD ".to_vec()));
347        assert!(matches!(short, Err(Error::Malformed(_))));
348    }
349
350    #[test]
351    fn frame_id_validation() {
352        assert!(FrameId::new("TIT2").is_ok());
353        assert!(FrameId::new("tit2").is_err());
354        assert!(FrameId::new("TXXX").is_err());
355        assert!(FrameId::new("APIC").is_err());
356        assert!(FrameId::new("TI").is_err());
357    }
358}