aede_core/audit/
integrity.rs

1//! Is the file still what it was when it was written?
2//!
3//! Every other check in this module describes what a file *is*. This one asks
4//! whether it is still intact — the question that matters on a library that has
5//! been moved from disk to disk for ten years.
6//!
7//! The answer comes from the checksums the containers already carry, so nothing
8//! has to be decoded and no reference copy is needed:
9//!
10//! - a FLAC frame ends with a **CRC-16** over its own bytes, and its header
11//!   carries a **CRC-8**;
12//! - an Ogg page carries a **CRC-32** over the whole page.
13//!
14//! Both catch what actually happens to stored files: a flipped bit, a bad
15//! sector, a truncated copy. What they do not catch is a stream re-encoded
16//! consistently — for that, FLAC also stores an MD5 of the *decoded* audio in
17//! STREAMINFO, and checking it means decoding. That verdict will come with the
18//! decoder; what is stored here is shaped to accommodate it without changing.
19//!
20//! This verifier does not support checksums for MP3, MP4, WAV or AIFF. Their
21//! answer is "nothing to check", which is distinct from "not checked yet" and
22//! does not establish that the file is healthy.
23
24use std::path::Path;
25
26use super::bits::BitReader;
27use super::crc;
28use crate::tags::TagError;
29use crate::tags::bytes::read_at_most;
30
31/// Sanity bound on how much is read from a single file.
32///
33/// Unlike the resolution audit, this check is meaningless on a sample: the one
34/// frame left out is exactly where the damage will be. It reads the file whole,
35/// and this limit only guards against a pathological input.
36const MAX_BYTES: usize = 2 * 1024 * 1024 * 1024;
37
38/// What is known about the integrity of a file.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum Verdict {
41    /// This verifier has no supported checksum for the container. Repeating
42    /// the same check gives no integrity evidence; other checks may exist.
43    NothingToCheck,
44    /// Every checksum matched.
45    Intact,
46    /// At least one did not: the file has been damaged since it was written.
47    Damaged {
48        /// Where it went wrong, in plain words, so a report can name it.
49        detail: String,
50    },
51}
52
53impl Verdict {
54    /// Short lowercase name, for a report line.
55    pub fn label(&self) -> &'static str {
56        match self {
57            Verdict::NothingToCheck => "nothing to check",
58            Verdict::Intact => "intact",
59            Verdict::Damaged { .. } => "damaged",
60        }
61    }
62
63    /// Key used in the catalog file and in the SQL schema.
64    pub fn key(&self) -> &'static str {
65        match self {
66            Verdict::NothingToCheck => "nothing_to_check",
67            Verdict::Intact => "intact",
68            Verdict::Damaged { .. } => "damaged",
69        }
70    }
71}
72
73/// The outcome of one verification.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Report {
76    /// What was concluded.
77    pub verdict: Verdict,
78    /// How it was reached, kept so a stored verdict can be read later knowing
79    /// what it was worth: a frame checksum is not an MD5 of the decoded audio.
80    pub method: &'static str,
81    /// Number of frames or pages verified.
82    pub units: usize,
83}
84
85impl Report {
86    fn nothing() -> Report {
87        Report {
88            verdict: Verdict::NothingToCheck,
89            method: "none",
90            units: 0,
91        }
92    }
93}
94
95/// Verifies the checksums a file carries.
96///
97/// An unreadable file is an error; a readable but damaged one is a verdict, not
98/// an error — being damaged is the answer to the question, not a failure to
99/// answer it.
100/// Supported streams exceeding the 2 GiB read budget are refused before
101/// loading their audio, rather than returning a verdict for a prefix.
102pub fn check(path: &Path) -> Result<Report, TagError> {
103    check_with_limit(path, MAX_BYTES)
104}
105
106fn check_with_limit(path: &Path, max_bytes: usize) -> Result<Report, TagError> {
107    let mut file = crate::tags::bytes::open_regular(path)?;
108    let head = read_at_most(&mut file, 0, 4)?;
109    if head.len() < 4 {
110        return Err(TagError::UnrecognizedFormat);
111    }
112    if &head[..4] == b"OggS" {
113        return check_ogg(&mut file, max_bytes);
114    }
115    // A FLAC stream may sit behind an ID3v2 tag, which is where `fLaC` lands.
116    let start = crate::tags::id3::skip_id3v2(&mut file)?;
117    let signature = read_at_most(&mut file, start, 4)?;
118    if signature.len() == 4 && &signature[..] == b"fLaC" {
119        return check_flac(&mut file, max_bytes);
120    }
121    Ok(Report::nothing())
122}
123
124/// Walks every frame of a FLAC stream and compares the two checksums it carries
125/// with the ones computed from its bytes.
126fn check_flac(file: &mut std::fs::File, max_bytes: usize) -> Result<Report, TagError> {
127    let (stream_info, audio_start) = super::flac::read_stream_info(file)?;
128    let data = read_complete(file, audio_start, max_bytes)?;
129    let mut reader = BitReader::new(&data);
130    let mut frames = 0usize;
131    let mut verified_to = 0usize;
132
133    while let Some(frame) = super::flac::read_frame(&mut reader, &stream_info) {
134        let span = &frame.span;
135        if span.end > data.len() || span.end < 2 {
136            return Ok(damaged(
137                frames,
138                format!("frame {} runs past the end of the file", frames + 1),
139            ));
140        }
141        if crc::crc8(&data[span.start..span.header_end]) != span.stored_crc8 {
142            return Ok(damaged(
143                frames,
144                format!("frame {}: header checksum mismatch", frames + 1),
145            ));
146        }
147        if crc::crc16(&data[span.start..span.end - 2]) != span.stored_crc16 {
148            return Ok(damaged(
149                frames,
150                format!("frame {}: audio checksum mismatch", frames + 1),
151            ));
152        }
153
154        frames += 1;
155        verified_to = span.end;
156        reader.align_to_byte();
157        if reader.is_exhausted() {
158            break;
159        }
160    }
161
162    // The walk stops on anything it cannot parse. What proves the file whole is
163    // not where the reader gave up — a failed parse consumes bits on its way —
164    // but where the last *verified* frame ended: it has to be the end of the
165    // file. This is what catches a truncated copy, whose surviving frames are
166    // all perfectly valid.
167    if !is_end_of_stream(&data, verified_to) {
168        return Ok(damaged(
169            frames,
170            format!("stream unreadable after frame {frames}"),
171        ));
172    }
173    if frames == 0 {
174        return Ok(damaged(0, "no readable audio frame".to_string()));
175    }
176
177    Ok(Report {
178        verdict: Verdict::Intact,
179        method: FLAC_METHOD,
180        units: frames,
181    })
182}
183
184/// Walks every Ogg page and compares its declared CRC-32 with the one computed
185/// over the page, its own checksum field read as zero.
186fn check_ogg(file: &mut std::fs::File, max_bytes: usize) -> Result<Report, TagError> {
187    let data = read_complete(file, 0, max_bytes)?;
188    let mut offset = 0usize;
189    let mut pages = 0usize;
190
191    while offset + 27 <= data.len() {
192        if &data[offset..offset + 4] != b"OggS" {
193            return Ok(damaged_ogg(
194                pages,
195                format!("no page header after page {pages}"),
196            ));
197        }
198        let segment_count = data[offset + 26] as usize;
199        let table_end = offset + 27 + segment_count;
200        if table_end > data.len() {
201            return Ok(damaged_ogg(
202                pages,
203                format!("page {} is truncated", pages + 1),
204            ));
205        }
206        let payload: usize = data[offset + 27..table_end]
207            .iter()
208            .map(|&length| length as usize)
209            .sum();
210        let page_end = table_end + payload;
211        if page_end > data.len() {
212            return Ok(damaged_ogg(
213                pages,
214                format!("page {} runs past the end of the file", pages + 1),
215            ));
216        }
217
218        let stored = u32::from_le_bytes([
219            data[offset + 22],
220            data[offset + 23],
221            data[offset + 24],
222            data[offset + 25],
223        ]);
224        // The checksum covers the page including its own field, which is read
225        // as zero for the computation.
226        let mut page = data[offset..page_end].to_vec();
227        page[22..26].fill(0);
228        if crc::crc32_ogg(&page) != stored {
229            return Ok(damaged_ogg(
230                pages,
231                format!("page {}: checksum mismatch", pages + 1),
232            ));
233        }
234
235        pages += 1;
236        offset = page_end;
237    }
238
239    if !is_end_of_stream(&data, offset) {
240        return Ok(damaged_ogg(
241            pages,
242            format!("trailing bytes after page {pages}"),
243        ));
244    }
245    if pages == 0 {
246        return Ok(damaged_ogg(0, "no readable page".to_string()));
247    }
248
249    Ok(Report {
250        verdict: Verdict::Intact,
251        method: OGG_METHOD,
252        units: pages,
253    })
254}
255
256/// A checksum verdict covers the complete stream, never just the part that
257/// happened to fit in memory and end at a valid frame or page boundary.
258fn read_complete(
259    file: &mut std::fs::File,
260    start: u64,
261    max_bytes: usize,
262) -> Result<Vec<u8>, TagError> {
263    let remaining = file.metadata()?.len().saturating_sub(start);
264    if remaining > max_bytes as u64 {
265        return Err(std::io::Error::new(
266            std::io::ErrorKind::InvalidInput,
267            "stream exceeds the checksum verification read limit",
268        )
269        .into());
270    }
271    // The budget is a bound, not an allocation target: a small file needs only
272    // its measured remaining bytes, even when the caller allows 2 GiB.
273    let data = read_at_most(file, start, remaining as usize)?;
274    if data.len() as u64 != remaining || file.metadata()?.len().saturating_sub(start) != remaining {
275        return Err(std::io::Error::new(
276            std::io::ErrorKind::UnexpectedEof,
277            "stream size changed during checksum verification",
278        )
279        .into());
280    }
281    Ok(data)
282}
283
284/// `true` when nothing but a legitimate trailer follows `offset`.
285///
286/// An ID3v1 block is 128 bytes at the very end of the file and is not part of
287/// the audio stream; a few taggers leave one behind on FLAC and Ogg files.
288/// Anything else after the last frame means the file is not what it was.
289fn is_end_of_stream(data: &[u8], offset: usize) -> bool {
290    match data.len().checked_sub(offset) {
291        Some(0) => true,
292        Some(128) => &data[offset..offset + 3] == b"TAG",
293        _ => false,
294    }
295}
296
297/// Name of the FLAC method, as stored in the catalog.
298pub const FLAC_METHOD: &str = "flac-frame-crc";
299/// Name of the Ogg method, as stored in the catalog.
300pub const OGG_METHOD: &str = "ogg-page-crc";
301
302fn damaged(units: usize, detail: String) -> Report {
303    Report {
304        verdict: Verdict::Damaged { detail },
305        method: FLAC_METHOD,
306        units,
307    }
308}
309
310fn damaged_ogg(units: usize, detail: String) -> Report {
311    Report {
312        verdict: Verdict::Damaged { detail },
313        method: OGG_METHOD,
314        units,
315    }
316}
317
318#[cfg(test)]
319#[path = "integrity_tests.rs"]
320mod tests;