Skip to main content

strypt_core/formats/
flac.rs

1//! FLAC.
2//!
3//! The first handler in Phase 2's fourth group (ADR-0037), and the format that makes the group
4//! look easier than it is: a four-byte magic, a list of typed metadata blocks, then audio frames
5//! to the end of the file. The identifying material is all in the block list — a Vorbis comment
6//! naming the artist, the ripping software and the machine that ripped it; cover art that is an
7//! ordinary image with its own Exif inside it; a cuesheet carrying the catalogue number of the
8//! disc it came from.
9//!
10//! # Block surgery, never re-encoding
11//!
12//! Blocks are dropped whole and everything else is copied through as raw bytes, so a clean file
13//! comes back byte-identical — the property GIF and JPEG XL have and TIFF and HEIF cannot
14//! (ADR-0033, ADR-0034). Nothing here decodes audio. **Removing a block moves no offset**: a seek
15//! point's offset is measured "from the first byte of the first frame header" (RFC 9639 §8.5), not
16//! from the start of the file, so the seek table stays valid however much metadata goes. That one
17//! sentence of the spec is why this tranche is cheap and MP4 is not (ADR-0037).
18//!
19//! The only field rewritten anywhere is the last-metadata-block flag in a block header, which says
20//! whether another block follows (§8.1) and therefore has to move when the block after it goes.
21//!
22//! # Padding is kept at its length and zeroed, rather than dropped
23//!
24//! §8.2 says a padding block is *n* zero bits. Real encoders leave several kilobytes of it so a
25//! later tagger can write in place, and real taggers leave whatever they were holding in it. So
26//! the bytes are replaced with the zeros the spec calls for and the block keeps its size: a
27//! compliant file is unchanged, a file hiding data in its padding is scrubbed and told about, and
28//! the room a tagger needs is still there.
29//!
30//! # Tags glued to the ends, which are not FLAC at all
31//!
32//! Taggers write an ID3v2 tag in front of the stream marker and an ID3v1, APE or Lyrics3 tag past
33//! the last frame. Neither is FLAC — §8 has no room for either — and a decoder skips them, so they
34//! survive every block this handler cleans. They were refused outright until the MP3 tranche put a
35//! reader in the tree; now they are peeled by [`super::tags`] and the FLAC in between is walked
36//! (ADR-0040 lifts ADR-0038 decision 7).
37//!
38//! # What is kept, and the one thing that is kept and declared
39//!
40//! `STREAMINFO` is mandatory and first (§8.2), and its last sixteen bytes are an MD5 of the
41//! *unencoded* audio. That is a fingerprint, and it is left alone: it is computed from the samples
42//! the file still carries, so anyone holding the file can recompute it and removing it hides
43//! nothing from them. It is declared in the report rather than passed over in silence, because
44//! it does link this file to any other copy of the same recording.
45
46use crate::bytes::Reader;
47use crate::detect::Format;
48use crate::error::{MalformedDetail, ResourceLimit, Result, StryptError};
49use crate::formats::tags::{self, TagError};
50use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, vorbis, xmp};
51use crate::report::{
52    Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, Retained,
53    RetentionReason, StripReport,
54};
55
56/// Removal of metadata from FLAC audio.
57#[derive(Debug, Clone, Copy, Default)]
58pub struct FlacHandler;
59
60impl MetadataHandler for FlacHandler {
61    fn name(&self) -> &'static str {
62        Format::Flac.id()
63    }
64
65    fn format(&self) -> Format {
66        Format::Flac
67    }
68
69    fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
70        // The identical pass stripping runs, with the output discarded, so that "everything
71        // `strip` removes is something `inspect` can see" holds by construction rather than by two
72        // code paths agreeing to stay in step (`docs/ARCHITECTURE.md` §3).
73        let processed = process(input, options, &ParseLimits::default())?;
74        Ok(MetadataReport {
75            format: Format::Flac,
76            findings: processed.findings,
77            notes: processed.notes,
78        })
79    }
80
81    fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
82        let processed = process(input, &options.inspect, &options.limits)?;
83        Ok(Stripped {
84            report: StripReport {
85                format: Format::Flac,
86                removed: processed.findings,
87                retained: processed.retained,
88                notes: processed.notes,
89                input_bytes: as_u64(input.len()),
90                output_bytes: as_u64(processed.output.len()),
91            },
92            bytes: processed.output,
93        })
94    }
95}
96
97/// The stream marker (§8).
98const MAGIC: &[u8; 4] = b"fLaC";
99
100/// Block types §8.2 defines. Everything from 7 to 126 is reserved and is removed unread.
101const STREAMINFO: u8 = 0;
102const PADDING: u8 = 1;
103const APPLICATION: u8 = 2;
104const SEEKTABLE: u8 = 3;
105const VORBIS_COMMENT: u8 = 4;
106const CUESHEET: u8 = 5;
107const PICTURE: u8 = 6;
108/// §8.1 forbids this type outright, so that a block header can never be mistaken for a frame sync.
109const FORBIDDEN: u8 = 127;
110
111/// `STREAMINFO` is a fixed 34 bytes (§8.2). A file declaring anything else is not one.
112const STREAMINFO_LEN: usize = 34;
113/// Where the MD5 of the unencoded audio starts within `STREAMINFO`.
114const STREAMINFO_MD5_AT: usize = 18;
115
116/// A registered application identifier is four bytes (§8.4).
117const APPLICATION_ID_LEN: usize = 4;
118/// The media catalogue number that opens a cuesheet (§8.6).
119const CUESHEET_CATALOGUE_LEN: usize = 128;
120
121/// One metadata block, and the payload span it occupied.
122struct Block<'a> {
123    kind: u8,
124    payload: &'a [u8],
125}
126
127/// What is written out for a kept block: its original bytes, or a run of zeros standing in for
128/// padding whose contents were not zeros.
129enum Payload<'a> {
130    Raw(&'a [u8]),
131    Zeros(usize),
132}
133
134impl Payload<'_> {
135    const fn len(&self) -> usize {
136        match self {
137            Self::Raw(bytes) => bytes.len(),
138            Self::Zeros(n) => *n,
139        }
140    }
141}
142
143/// The result of one pass over a file.
144struct Processed {
145    findings: Vec<Finding>,
146    retained: Vec<Retained>,
147    notes: Vec<Note>,
148    output: Vec<u8>,
149}
150
151/// Split `input` into its metadata blocks and its audio frames.
152///
153/// Every length here was chosen by whoever made the file, so every one is read through [`Reader`]
154/// and every failure is a typed error. A file that does not parse is refused whole: nothing
155/// returns a partial block list for a caller to strip and write out.
156fn walk<'a>(input: &'a [u8], limits: &ParseLimits) -> Result<(Vec<Block<'a>>, &'a [u8])> {
157    let mut r = Reader::new(input);
158    if r.take(MAGIC.len()) != Some(MAGIC.as_slice()) {
159        return Err(malformed(MalformedDetail::MissingMarker, Some(0)));
160    }
161
162    let mut blocks: Vec<Block<'a>> = Vec::new();
163    let mut budget = limits.max_items;
164    loop {
165        let at = r.position();
166        spend(&mut budget)?;
167        let header = r
168            .take(4)
169            .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(at)))?;
170        let first = header.first().copied().unwrap_or_default();
171        let kind = first & 0x7F;
172        let last = first & 0x80 != 0;
173        let length = block_length(header)
174            .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(at)))?;
175
176        if kind == FORBIDDEN {
177            // §8.1 reserves it precisely so this byte cannot look like a frame sync. A file using
178            // it is not a FLAC, and guessing at what it meant is how a walk ends up somewhere else.
179            return Err(malformed(MalformedDetail::UnexpectedMarker, as_offset(at)));
180        }
181        if blocks.is_empty() && kind != STREAMINFO {
182            return Err(malformed(MalformedDetail::MissingMarker, as_offset(at)));
183        }
184        if !blocks.is_empty() && kind == STREAMINFO {
185            return Err(malformed(MalformedDetail::UnexpectedMarker, as_offset(at)));
186        }
187
188        let payload = r
189            .take(length)
190            .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(at)))?;
191        if kind == STREAMINFO && payload.len() != STREAMINFO_LEN {
192            return Err(malformed(MalformedDetail::LengthOutOfRange, as_offset(at)));
193        }
194        blocks.push(Block { kind, payload });
195        if last {
196            break;
197        }
198    }
199
200    let audio = r.take_rest();
201    // A frame opens with the 14-bit sync code 0b11111111111110 (§9.1). Checking it is what turns a
202    // block length that lied into a refusal: without it, a length landing mid-audio produces a
203    // confident report about bytes that were never a metadata block.
204    let sync = matches!((audio.first(), audio.get(1)), (Some(0xFF), Some(second)) if second & 0xFE == 0xF8);
205    if !sync {
206        return Err(malformed(
207            MalformedDetail::MissingMarker,
208            as_offset(input.len().saturating_sub(audio.len())),
209        ));
210    }
211    Ok((blocks, audio))
212}
213
214/// The 24-bit big-endian length that follows a block header's type byte (§8.1).
215fn block_length(header: &[u8]) -> Option<usize> {
216    match header.get(1..4)? {
217        [high, middle, low] => usize::try_from(u32::from_be_bytes([0, *high, *middle, *low])).ok(),
218        _ => None,
219    }
220}
221
222/// Charge one structural item against the budget.
223fn spend(budget: &mut u32) -> Result<()> {
224    if *budget == 0 {
225        return Err(StryptError::LimitExceeded {
226            format: Format::Flac,
227            limit: ResourceLimit::ItemCount,
228        });
229    }
230    *budget = budget.saturating_sub(1);
231    Ok(())
232}
233
234/// Walk `input`, decide about every block, and build the sanitised file.
235fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
236    // Peeled before the stream marker is looked for, because a prepended ID3v2 tag is what stands
237    // in front of it (ADR-0040).
238    let (head_tags, start) = tags::head(input).map_err(convert)?;
239    let (tail_tags, end) = tags::tail(input, start).map_err(convert)?;
240    let body = input
241        .get(start..end)
242        .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(start)))?;
243
244    let (blocks, audio) = walk(body, limits)?;
245    let mut out = Processed {
246        findings: Vec::new(),
247        retained: Vec::new(),
248        notes: Vec::new(),
249        output: Vec::with_capacity(body.len()),
250    };
251    for tag in head_tags.iter().chain(tail_tags.iter()) {
252        out.findings.extend(tags::findings(tag, options));
253    }
254
255    let mut kept: Vec<(u8, Payload<'_>)> = Vec::new();
256    for block in &blocks {
257        let size = as_u64(block.payload.len());
258        match block.kind {
259            STREAMINFO => {
260                // Mandatory, and the only block a decoder cannot do without.
261                if !md5_is_absent(block.payload) {
262                    out.retained.push(Retained {
263                        location: "STREAMINFO (MD5 of the unencoded audio)".to_owned(),
264                        reason: RetentionReason::DerivedFromPayload,
265                    });
266                }
267                kept.push((block.kind, Payload::Raw(block.payload)));
268            }
269            SEEKTABLE => {
270                // Playback structure, and nothing else: sample numbers and offsets measured from
271                // the first frame header (§8.5), which no removal here can move.
272                kept.push((block.kind, Payload::Raw(block.payload)));
273            }
274            PADDING => {
275                if block.payload.iter().any(|byte| *byte != 0) {
276                    // §8.2 says this block is zero bits. Anything else was put there by something,
277                    // and a scrubber that leaves it because the block is "only padding" is not
278                    // scrubbing.
279                    out.findings.push(
280                        Finding::new(MetadataKind::Other, "PADDING", size)
281                            .with_field("Padding")
282                            .with_value(options, || MetadataValue::Opaque { bytes: size }),
283                    );
284                }
285                kept.push((block.kind, Payload::Zeros(block.payload.len())));
286            }
287            APPLICATION => out.findings.push(application(block.payload, size, options)),
288            VORBIS_COMMENT => {
289                vorbis::comments(
290                    block.payload,
291                    "VORBIS_COMMENT",
292                    size,
293                    options,
294                    &mut out.findings,
295                );
296            }
297            CUESHEET => {
298                out.findings.push(cuesheet(block.payload, size));
299                out.notes.push(Note::CapabilityRemoved {
300                    location: "CUESHEET".to_owned(),
301                    capability: "be split into the tracks of the disc it was ripped from"
302                        .to_owned(),
303                });
304            }
305            PICTURE => out.findings.push(picture(block.payload, size, options)),
306            other => out.findings.push(
307                // §8.2 defines seven types. A block under a reserved one was written by something
308                // whose intentions this code cannot know, and it goes for the same reason an
309                // unrecognised GIF extension does.
310                Finding::new(
311                    MetadataKind::Other,
312                    format!("Metadata block type {other}"),
313                    size,
314                ),
315            ),
316        }
317    }
318
319    out.output.extend_from_slice(MAGIC);
320    let last_index = kept.len().saturating_sub(1);
321    for (index, (kind, payload)) in kept.iter().enumerate() {
322        let last = if index == last_index { 0x80 } else { 0x00 };
323        out.output.push(kind | last);
324        // The payload length is unchanged by anything above — padding keeps its size — so this
325        // reproduces the header the file arrived with whenever nothing was removed.
326        let length = u32::try_from(payload.len())
327            .unwrap_or(u32::MAX)
328            .to_be_bytes();
329        out.output
330            .extend_from_slice(length.get(1..4).unwrap_or_default());
331        match payload {
332            Payload::Raw(bytes) => out.output.extend_from_slice(bytes),
333            Payload::Zeros(n) => out.output.resize(out.output.len().saturating_add(*n), 0),
334        }
335    }
336    out.output.extend_from_slice(audio);
337
338    // Said on every file, clean ones included. The frames are copied without being decoded, so
339    // anything hidden inside one — data in a frame's reserved bits, a payload in the last partial
340    // frame — is out of reach rather than absent. What is appended *past* them is now removed.
341    out.notes.push(Note::OutOfScopeContent {
342        location: "audio frames, which are copied without being decoded".to_owned(),
343    });
344    Ok(out)
345}
346
347/// Re-label a tag failure as this format's error, so a caller sees "a FLAC failed" rather than a
348/// module it has no reason to know about.
349fn convert(error: TagError) -> StryptError {
350    match error {
351        TagError::Malformed { detail, offset } => malformed(detail, as_offset(offset)),
352        TagError::Limit(limit) => StryptError::LimitExceeded {
353            format: Format::Flac,
354            limit,
355        },
356    }
357}
358
359/// True when `STREAMINFO`'s MD5 field is all zeros, which §8.2 defines as "unknown".
360fn md5_is_absent(payload: &[u8]) -> bool {
361    payload
362        .get(STREAMINFO_MD5_AT..)
363        .is_none_or(|md5| md5.iter().all(|byte| *byte == 0))
364}
365
366/// An application block: four bytes of registered identifier, then whatever that application put
367/// there (§8.4) — a whole RIFF or AIFF chunk, in the two cases the registry names.
368fn application(payload: &[u8], size: u64, options: &InspectOptions) -> Finding {
369    let id = payload.get(0..APPLICATION_ID_LEN).unwrap_or(payload);
370    Finding::new(MetadataKind::SoftwareFingerprint, "APPLICATION", size)
371        .with_field(xmp::name_of(id))
372        .with_value(options, || MetadataValue::Opaque { bytes: size })
373}
374
375/// A cuesheet (§8.6). It is track geometry, and it is also a 128-byte media catalogue number and
376/// an ISRC for every track — identifiers for the exact disc the audio was taken from.
377fn cuesheet(payload: &[u8], size: u64) -> Finding {
378    let catalogue = payload
379        .get(0..CUESHEET_CATALOGUE_LEN)
380        .unwrap_or(payload)
381        .iter()
382        .any(|byte| *byte != 0);
383    let finding = Finding::new(MetadataKind::DocumentIdentifier, "CUESHEET", size);
384    if catalogue {
385        finding.with_field("MediaCatalogNumber")
386    } else {
387        finding
388    }
389}
390
391/// A picture block (§8.7): cover art, which is an ordinary image file carrying whatever its own
392/// container carries, plus a description field that is free text.
393fn picture(payload: &[u8], size: u64, options: &InspectOptions) -> Finding {
394    let mut r = Reader::new(payload);
395    let kind = r.u32_be().unwrap_or_default();
396    let media_type = r
397        .u32_be()
398        .and_then(|n| usize::try_from(n).ok())
399        .and_then(|n| r.take(n))
400        .unwrap_or_default();
401    let description = r
402        .u32_be()
403        .and_then(|n| usize::try_from(n).ok())
404        .and_then(|n| r.take(n))
405        .unwrap_or_default();
406
407    let field = if media_type.is_empty() {
408        format!("PictureType {kind}")
409    } else {
410        format!("PictureType {kind} ({})", xmp::name_of(media_type))
411    };
412    Finding::new(MetadataKind::Thumbnail, "PICTURE", size)
413        .with_field(field)
414        .with_value(options, || {
415            if description.is_empty() {
416                MetadataValue::Opaque { bytes: size }
417            } else {
418                MetadataValue::Text(xmp::name_of(description))
419            }
420        })
421}
422
423/// A malformed-file error for this format.
424fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
425    StryptError::Malformed {
426        format: Format::Flac,
427        offset,
428        detail,
429    }
430}
431
432/// A byte position as a reportable offset.
433fn as_offset(position: usize) -> Option<u64> {
434    u64::try_from(position).ok()
435}
436
437/// Widen a length for reporting. Saturating: a report field is not worth failing a strip over.
438fn as_u64(value: usize) -> u64 {
439    u64::try_from(value).unwrap_or(u64::MAX)
440}
441
442#[cfg(test)]
443mod tests {
444    // Test code is never reachable from untrusted bytes, which is the boundary the panic-freedom
445    // lints exist to police (ADR-0006).
446    #![allow(
447        clippy::unwrap_used,
448        clippy::expect_used,
449        clippy::indexing_slicing,
450        clippy::arithmetic_side_effects
451    )]
452
453    use super::*;
454
455    /// A metadata block: type, length, payload. `last` sets the flag §8.1 defines.
456    fn block(kind: u8, payload: &[u8], last: bool) -> Vec<u8> {
457        let mut out = vec![kind | if last { 0x80 } else { 0 }];
458        let length = u32::try_from(payload.len()).unwrap().to_be_bytes();
459        out.extend_from_slice(&length[1..4]);
460        out.extend_from_slice(payload);
461        out
462    }
463
464    /// A 34-byte `STREAMINFO` whose MD5 field is `md5`.
465    fn streaminfo(md5: u8) -> Vec<u8> {
466        let mut payload = vec![0u8; STREAMINFO_LEN];
467        for byte in payload.iter_mut().skip(STREAMINFO_MD5_AT) {
468            *byte = md5;
469        }
470        payload
471    }
472
473    /// Two bytes that are a frame sync, standing in for the audio this handler never decodes.
474    fn frames() -> Vec<u8> {
475        let mut out = vec![0xFF, 0xF8];
476        out.extend_from_slice(b"SYNTHETIC-AUDIO-FRAMES");
477        out
478    }
479
480    /// A file: the marker, `STREAMINFO`, the given blocks, and the frames.
481    fn flac(blocks: &[Vec<u8>]) -> Vec<u8> {
482        let mut out = MAGIC.to_vec();
483        out.extend_from_slice(&block(STREAMINFO, &streaminfo(0xAB), blocks.is_empty()));
484        for (index, payload) in blocks.iter().enumerate() {
485            let mut copy = payload.clone();
486            if index == blocks.len() - 1 {
487                copy[0] |= 0x80;
488            }
489            out.extend_from_slice(&copy);
490        }
491        out.extend_from_slice(&frames());
492        out
493    }
494
495    fn le32(n: usize) -> [u8; 4] {
496        u32::try_from(n)
497            .expect("test lengths are small")
498            .to_le_bytes()
499    }
500
501    fn comment_block(vendor: &[u8], items: &[&[u8]]) -> Vec<u8> {
502        let mut payload = le32(vendor.len()).to_vec();
503        payload.extend_from_slice(vendor);
504        payload.extend_from_slice(&le32(items.len()));
505        for item in items {
506            payload.extend_from_slice(&le32(item.len()));
507            payload.extend_from_slice(item);
508        }
509        block(VORBIS_COMMENT, &payload, false)
510    }
511
512    fn strip_ok(data: &[u8]) -> Stripped {
513        FlacHandler
514            .strip(data, &StripOptions::default())
515            .expect("strip failed")
516    }
517
518    fn findings(data: &[u8]) -> Vec<Finding> {
519        FlacHandler
520            .inspect(data, &InspectOptions::names_only())
521            .expect("inspect failed")
522            .findings
523    }
524
525    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
526        haystack.windows(needle.len()).any(|w| w == needle)
527    }
528
529    #[test]
530    fn a_clean_file_strips_to_a_byte_identical_copy() {
531        // A block-list format edited by deletion can promise this, and so it must. TIFF and HEIF
532        // cannot (ADR-0033, ADR-0034).
533        let input = flac(&[]);
534        let stripped = strip_ok(&input);
535        assert!(stripped.report.removed.is_empty());
536        assert_eq!(stripped.bytes, input);
537    }
538
539    #[test]
540    fn the_audio_is_never_touched() {
541        let input = flac(&[comment_block(
542            b"SYNTHETIC-ENCODER",
543            &[b"ARTIST=SYNTHETIC-0001"],
544        )]);
545        let output = strip_ok(&input).bytes;
546        assert!(
547            contains(&output, b"SYNTHETIC-AUDIO-FRAMES"),
548            "the audio frames did not survive byte for byte"
549        );
550    }
551
552    #[test]
553    fn a_vorbis_comment_is_itemised_by_field_and_removed() {
554        let input = flac(&[comment_block(
555            b"reference libFLAC SYNTHETIC-VENDOR-0002",
556            &[
557                b"ARTIST=SYNTHETIC-ARTIST-0003",
558                b"DATE=2026-09-01",
559                b"MUSICBRAINZ_TRACKID=SYNTHETIC-0004",
560            ],
561        )]);
562        let found = findings(&input);
563        let fields: Vec<&str> = found.iter().filter_map(|f| f.field.as_deref()).collect();
564        assert!(fields.contains(&"vendor"));
565        assert!(fields.contains(&"ARTIST"));
566        assert_eq!(
567            found
568                .iter()
569                .find(|f| f.field.as_deref() == Some("ARTIST"))
570                .unwrap()
571                .kind,
572            MetadataKind::PersonalIdentity
573        );
574        assert_eq!(
575            found
576                .iter()
577                .find(|f| f.field.as_deref() == Some("DATE"))
578                .unwrap()
579                .kind,
580            MetadataKind::Timestamp
581        );
582        assert_eq!(
583            found
584                .iter()
585                .find(|f| f.field.as_deref() == Some("MUSICBRAINZ_TRACKID"))
586                .unwrap()
587                .kind,
588            MetadataKind::DocumentIdentifier
589        );
590        assert!(
591            found.iter().all(|f| f.value.is_none()),
592            "values are withheld by default"
593        );
594        assert!(!contains(&strip_ok(&input).bytes, b"SYNTHETIC-ARTIST-0003"));
595    }
596
597    #[test]
598    fn a_comment_value_is_reported_only_when_the_caller_asks() {
599        let input = flac(&[comment_block(b"v", &[b"ARTIST=SYNTHETIC-ARTIST-0005"])]);
600        let report = FlacHandler
601            .inspect(&input, &InspectOptions::with_values())
602            .unwrap();
603        assert!(
604            report
605                .findings
606                .iter()
607                .any(|f| f.value == Some(MetadataValue::Text("SYNTHETIC-ARTIST-0005".to_owned())))
608        );
609    }
610
611    #[test]
612    fn cover_art_is_removed_and_ranked_as_a_picture() {
613        // ADR-0037: cover art goes with its block. Deleting the block deletes the image and
614        // whatever Exif was inside it, so no descent into it is needed (ADR-0029).
615        let mut payload = 3u32.to_be_bytes().to_vec(); // front cover
616        payload.extend_from_slice(&(9u32).to_be_bytes());
617        payload.extend_from_slice(b"image/png");
618        payload.extend_from_slice(&(24u32).to_be_bytes());
619        payload.extend_from_slice(b"SYNTHETIC-DESCRIPTION-006");
620        let input = flac(&[block(PICTURE, &payload[0..payload.len()], false)]);
621
622        let found = findings(&input);
623        assert_eq!(found[0].kind, MetadataKind::Thumbnail);
624        assert_eq!(found[0].location, "PICTURE");
625        assert!(!contains(&strip_ok(&input).bytes, b"SYNTHETIC-DESCRIPTION"));
626    }
627
628    #[test]
629    fn a_cuesheet_is_removed_and_the_report_says_what_that_costs() {
630        let mut payload = vec![0u8; CUESHEET_CATALOGUE_LEN];
631        payload[0..9].copy_from_slice(b"012345678");
632        payload.extend_from_slice(&[0u8; 8]);
633        let input = flac(&[block(CUESHEET, &payload, false)]);
634
635        let stripped = strip_ok(&input);
636        assert_eq!(
637            stripped.report.removed[0].kind,
638            MetadataKind::DocumentIdentifier
639        );
640        assert_eq!(
641            stripped.report.removed[0].field.as_deref(),
642            Some("MediaCatalogNumber")
643        );
644        assert!(matches!(
645            stripped.report.notes.first(),
646            Some(Note::CapabilityRemoved { .. })
647        ));
648        assert!(!contains(&stripped.bytes, b"012345678"));
649    }
650
651    #[test]
652    fn padding_keeps_its_size_and_loses_its_contents() {
653        // §8.2 says a padding block is zero bits, so anything else in there was put there. Keeping
654        // the length means a compliant file is unchanged and a tagger still has its room.
655        let mut payload = vec![0u8; 64];
656        payload[8..31].copy_from_slice(b"SYNTHETIC-IN-PADDING-07");
657        let input = flac(&[block(PADDING, &payload, false)]);
658
659        let stripped = strip_ok(&input);
660        assert_eq!(stripped.report.removed[0].location, "PADDING");
661        assert!(!contains(&stripped.bytes, b"SYNTHETIC-IN-PADDING-07"));
662        assert_eq!(
663            stripped.bytes.len(),
664            input.len(),
665            "the padding block changed size"
666        );
667    }
668
669    #[test]
670    fn zero_padding_is_left_exactly_as_it_was() {
671        let input = flac(&[block(PADDING, &[0u8; 32], false)]);
672        let stripped = strip_ok(&input);
673        assert!(stripped.report.removed.is_empty());
674        assert_eq!(stripped.bytes, input);
675    }
676
677    #[test]
678    fn a_seek_table_survives_because_removal_moves_no_offset() {
679        // §8.5: a seek point's offset is measured from the first frame header, not from the start
680        // of the file, so it stays correct however many blocks in front of it go.
681        let seek = vec![0x11u8; 18];
682        let input = flac(&[
683            comment_block(b"v", &[b"ARTIST=SYNTHETIC-0008"]),
684            block(SEEKTABLE, &seek, false),
685        ]);
686        let output = strip_ok(&input).bytes;
687        assert!(contains(&output, &seek));
688        assert!(!contains(&output, b"SYNTHETIC-0008"));
689    }
690
691    #[test]
692    fn an_application_block_is_removed_and_named_by_its_registered_id() {
693        let mut payload = b"riff".to_vec();
694        payload.extend_from_slice(b"SYNTHETIC-APPLICATION-0009");
695        let input = flac(&[block(APPLICATION, &payload, false)]);
696        let found = findings(&input);
697        assert_eq!(found[0].field.as_deref(), Some("riff"));
698        assert!(!contains(
699            &strip_ok(&input).bytes,
700            b"SYNTHETIC-APPLICATION-0009"
701        ));
702    }
703
704    #[test]
705    fn a_reserved_block_type_does_not_survive_by_being_unknown() {
706        let input = flac(&[block(42, b"SYNTHETIC-RESERVED-0010", false)]);
707        let found = findings(&input);
708        assert_eq!(found[0].location, "Metadata block type 42");
709        assert!(!contains(
710            &strip_ok(&input).bytes,
711            b"SYNTHETIC-RESERVED-0010"
712        ));
713    }
714
715    #[test]
716    fn the_last_block_flag_moves_to_whatever_block_ends_up_last() {
717        // Removing the final block leaves the one before it last, and a file whose flag says
718        // otherwise sends a decoder into the audio looking for another header.
719        let input = flac(&[comment_block(b"v", &[b"ARTIST=SYNTHETIC-0011"])]);
720        let output = strip_ok(&input).bytes;
721        assert_eq!(
722            output[4] & 0x80,
723            0x80,
724            "STREAMINFO was not marked as the last metadata block"
725        );
726        assert_eq!(output[4] & 0x7F, STREAMINFO);
727    }
728
729    #[test]
730    fn the_audio_md5_is_kept_and_declared() {
731        // It is computed from the samples the file still carries, so removing it hides nothing
732        // from anyone holding the file — but it does link this copy to any other, so it is
733        // declared rather than passed over.
734        let stripped = strip_ok(&flac(&[]));
735        assert_eq!(
736            stripped.report.retained[0].reason,
737            RetentionReason::DerivedFromPayload
738        );
739    }
740
741    #[test]
742    fn a_file_whose_md5_is_already_absent_declares_nothing() {
743        let mut input = MAGIC.to_vec();
744        input.extend_from_slice(&block(STREAMINFO, &[0u8; STREAMINFO_LEN], true));
745        input.extend_from_slice(&frames());
746        assert!(strip_ok(&input).report.retained.is_empty());
747    }
748
749    #[test]
750    fn every_file_says_the_audio_was_not_examined() {
751        let notes = FlacHandler
752            .inspect(&flac(&[]), &InspectOptions::names_only())
753            .unwrap()
754            .notes;
755        assert!(matches!(
756            notes.last(),
757            Some(Note::OutOfScopeContent { location }) if location.starts_with("audio frames")
758        ));
759    }
760
761    #[test]
762    fn stripping_twice_changes_nothing() {
763        let input = flac(&[
764            comment_block(b"v", &[b"ARTIST=SYNTHETIC-0012"]),
765            block(PADDING, &[0x7Fu8; 16], false),
766            block(APPLICATION, b"riffSYNTHETIC-0013", false),
767        ]);
768        let once = strip_ok(&input).bytes;
769        let twice = strip_ok(&once).bytes;
770        assert_eq!(once, twice, "strip is not idempotent");
771    }
772
773    /// An ID3v2.4 tag carrying one text frame.
774    fn id3v2(id: &[u8], text: &[u8]) -> Vec<u8> {
775        let syncsafe = |n: usize| {
776            [
777                u8::try_from((n >> 21) & 0x7F).unwrap(),
778                u8::try_from((n >> 14) & 0x7F).unwrap(),
779                u8::try_from((n >> 7) & 0x7F).unwrap(),
780                u8::try_from(n & 0x7F).unwrap(),
781            ]
782        };
783        let mut payload = vec![0x03u8];
784        payload.extend_from_slice(text);
785        let mut body = id.to_vec();
786        body.extend_from_slice(&syncsafe(payload.len()));
787        body.extend_from_slice(&[0, 0]);
788        body.extend_from_slice(&payload);
789        let mut out = b"ID3\x04\x00\x00".to_vec();
790        out.extend_from_slice(&syncsafe(body.len()));
791        out.extend_from_slice(&body);
792        out
793    }
794
795    #[test]
796    fn a_prepended_id3v2_tag_is_read_and_removed_rather_than_refused() {
797        // ADR-0040 lifts ADR-0038 decision 7: the refusal stood only because there was no ID3
798        // reader in the tree.
799        let clean = flac(&[]);
800        let mut input = id3v2(b"TPE1", b"SYNTHETIC-ARTIST-0101");
801        input.extend_from_slice(&clean);
802        let result = strip_ok(&input);
803        assert_eq!(result.bytes, clean, "the FLAC behind the tag moved");
804        assert!(!contains(&result.bytes, b"SYNTHETIC-ARTIST-0101"));
805        assert!(
806            result
807                .report
808                .removed
809                .iter()
810                .any(|f| f.location == "ID3v2.4" && f.field.as_deref() == Some("TPE1"))
811        );
812    }
813
814    #[test]
815    fn tags_appended_past_the_last_frame_are_removed_too() {
816        let clean = flac(&[]);
817        let mut input = clean.clone();
818        let mut v1 = vec![0u8; 128];
819        v1[0..3].copy_from_slice(b"TAG");
820        v1[33..54].copy_from_slice(b"SYNTHETIC-ARTIST-0102");
821        input.extend_from_slice(&v1);
822        let result = strip_ok(&input);
823        assert_eq!(result.bytes, clean);
824        assert!(
825            result
826                .report
827                .removed
828                .iter()
829                .any(|f| f.location == "ID3v1" && f.field.as_deref() == Some("Artist"))
830        );
831    }
832
833    #[test]
834    fn a_tag_at_each_end_leaves_the_stream_between_them_untouched() {
835        let clean = flac(&[comment_block(b"SYNTHETIC-VENDOR-0103", &[])]);
836        let mut input = id3v2(b"TIT2", b"SYNTHETIC-TITLE-0104");
837        input.extend_from_slice(&clean);
838        input.extend_from_slice(b"LYRICSBEGINSYNTHETIC-LYRIC-0105");
839        input.extend_from_slice(b"LYRICSEND");
840        let result = strip_ok(&input);
841        assert_eq!(result.bytes, strip_ok(&clean).bytes);
842        for secret in [
843            &b"SYNTHETIC-TITLE-0104"[..],
844            &b"SYNTHETIC-LYRIC-0105"[..],
845            &b"SYNTHETIC-VENDOR-0103"[..],
846        ] {
847            assert!(!contains(&result.bytes, secret));
848        }
849    }
850
851    #[test]
852    fn a_file_that_is_not_flac_is_refused() {
853        assert!(matches!(
854            FlacHandler.inspect(b"fLaD\x00\x00\x00\x22", &InspectOptions::names_only()),
855            Err(StryptError::Malformed {
856                detail: MalformedDetail::MissingMarker,
857                ..
858            })
859        ));
860    }
861
862    #[test]
863    fn a_first_block_that_is_not_streaminfo_is_refused() {
864        let mut input = MAGIC.to_vec();
865        input.extend_from_slice(&block(PADDING, &[0u8; 4], true));
866        input.extend_from_slice(&frames());
867        assert!(matches!(
868            FlacHandler.inspect(&input, &InspectOptions::names_only()),
869            Err(StryptError::Malformed {
870                detail: MalformedDetail::MissingMarker,
871                ..
872            })
873        ));
874    }
875
876    #[test]
877    fn the_forbidden_block_type_is_refused() {
878        let mut input = MAGIC.to_vec();
879        input.extend_from_slice(&block(STREAMINFO, &streaminfo(1), false));
880        input.extend_from_slice(&block(FORBIDDEN, &[0u8; 2], true));
881        input.extend_from_slice(&frames());
882        assert!(matches!(
883            FlacHandler.inspect(&input, &InspectOptions::names_only()),
884            Err(StryptError::Malformed {
885                detail: MalformedDetail::UnexpectedMarker,
886                ..
887            })
888        ));
889    }
890
891    #[test]
892    fn a_block_length_running_past_the_end_of_the_file_is_refused() {
893        let mut input = flac(&[]);
894        input[5] = 0xFF;
895        assert!(matches!(
896            FlacHandler.inspect(&input, &InspectOptions::names_only()),
897            Err(StryptError::Malformed {
898                detail: MalformedDetail::LengthOutOfRange,
899                ..
900            })
901        ));
902    }
903
904    #[test]
905    fn a_file_with_no_frame_sync_after_its_blocks_is_refused() {
906        // The check that turns a lying block length into a refusal rather than a confident report
907        // about bytes that were never a metadata block.
908        let mut input = MAGIC.to_vec();
909        input.extend_from_slice(&block(STREAMINFO, &streaminfo(1), true));
910        input.extend_from_slice(b"NOT-A-FRAME");
911        assert!(matches!(
912            FlacHandler.strip(&input, &StripOptions::default()),
913            Err(StryptError::Malformed {
914                detail: MalformedDetail::MissingMarker,
915                ..
916            })
917        ));
918    }
919
920    #[test]
921    fn a_second_streaminfo_is_refused() {
922        let mut input = MAGIC.to_vec();
923        input.extend_from_slice(&block(STREAMINFO, &streaminfo(1), false));
924        input.extend_from_slice(&block(STREAMINFO, &streaminfo(2), true));
925        input.extend_from_slice(&frames());
926        assert!(matches!(
927            FlacHandler.inspect(&input, &InspectOptions::names_only()),
928            Err(StryptError::Malformed {
929                detail: MalformedDetail::UnexpectedMarker,
930                ..
931            })
932        ));
933    }
934
935    #[test]
936    fn a_streaminfo_of_the_wrong_length_is_refused() {
937        let mut input = MAGIC.to_vec();
938        input.extend_from_slice(&block(STREAMINFO, &[0u8; 20], true));
939        input.extend_from_slice(&frames());
940        assert!(matches!(
941            FlacHandler.inspect(&input, &InspectOptions::names_only()),
942            Err(StryptError::Malformed {
943                detail: MalformedDetail::LengthOutOfRange,
944                ..
945            })
946        ));
947    }
948
949    #[test]
950    fn a_comment_block_whose_lengths_do_not_add_up_is_reported_and_deleted() {
951        // A block strypt cannot read is still one it can delete, and deleting is the safe
952        // direction (the JXL handler takes the same line on an unreadable Exif block).
953        let mut payload = 0xFFFF_FFFFu32.to_le_bytes().to_vec();
954        payload.extend_from_slice(b"SYNTHETIC-UNREADABLE-0014");
955        let input = flac(&[block(VORBIS_COMMENT, &payload, false)]);
956
957        let stripped = strip_ok(&input);
958        assert_eq!(stripped.report.removed[0].location, "VORBIS_COMMENT");
959        assert!(!contains(&stripped.bytes, b"SYNTHETIC-UNREADABLE-0014"));
960    }
961
962    #[test]
963    fn a_block_count_beyond_the_limit_is_refused() {
964        let blocks: Vec<Vec<u8>> = (0..64).map(|_| block(PADDING, &[0u8; 1], false)).collect();
965        let input = flac(&blocks);
966        let options = StripOptions {
967            limits: ParseLimits {
968                max_items: 8,
969                ..ParseLimits::default()
970            },
971            ..StripOptions::default()
972        };
973        assert!(matches!(
974            FlacHandler.strip(&input, &options),
975            Err(StryptError::LimitExceeded { .. })
976        ));
977    }
978
979    #[test]
980    fn truncation_at_every_length_is_refused_or_survived_but_never_panics() {
981        let input = flac(&[
982            comment_block(b"vendor", &[b"ARTIST=SYNTHETIC-0015"]),
983            block(PICTURE, b"\0\0\0\x03\0\0\0\x09image/pngSYNTHETIC", false),
984            block(PADDING, &[0u8; 8], false),
985        ]);
986        for n in 0..=input.len() {
987            let prefix = &input[0..n];
988            let _ = FlacHandler.inspect(prefix, &InspectOptions::names_only());
989            let _ = FlacHandler.strip(prefix, &StripOptions::default());
990        }
991    }
992}