Skip to main content

strypt_core/formats/
ogg.rs

1//! Ogg: Vorbis, Opus, and FLAC-in-Ogg.
2//!
3//! The first format here that is a container for somebody else's format. The page layer knows
4//! nothing about codecs and lives in [`crate::container::ogg`]; this module is the codec half —
5//! which packet is the comment header, what an empty one looks like, and which mappings are
6//! refused (ADR-0041).
7//!
8//! # Rebuilt, because a page carries a CRC
9//!
10//! Emptying the comment packet changes its length, which changes its page's lacing table and
11//! therefore that page's CRC, and every page after it renumbers. So the file is rebuilt: packets
12//! are copied verbatim and the pages around them are new. Three fields do not survive the rebuild
13//! unchanged, and each is a decision rather than an accident:
14//!
15//! - **Granule positions are carried verbatim.** They are codec sample counts, not file offsets
16//!   (RFC 3533 §3), so removal moves nothing. They belong to a *page*, though, which is why the
17//!   rebuild keeps each input page's set of finished packets rather than repaginating freely.
18//! - **Page sequence numbers are renumbered from zero**, because removal can change the count.
19//! - **The serial number is rewritten to zero.** It is a 32-bit identifier nobody can recompute,
20//!   and libogg's own example seeds it from the clock. This is the one place a group-4 handler
21//!   gives up a byte-identical clean file, and it is why.
22//!
23//! # The comment header is emptied, never dropped
24//!
25//! All three mappings require the packet to be the second one in the stream, so unlike FLAC's
26//! metadata block it cannot go. What is written back is an empty vendor string and a zero count.
27//!
28//! # One logical bitstream
29//!
30//! A multiplexed or chained file is refused: a second stream is a second mapping with a second
31//! comment header this handler has not read.
32
33use crate::bytes::Reader;
34use crate::container::ogg::{self as page, Emit, Packet, WalkError};
35use crate::detect::Format;
36use crate::error::{MalformedDetail, Result, StryptError, UnsupportedKind};
37use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, vorbis, xmp};
38use crate::report::{
39    Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, Retained,
40    RetentionReason, StripReport,
41};
42
43/// Removal of metadata from an Ogg stream.
44#[derive(Debug, Clone, Copy)]
45pub struct OggHandler {
46    format: Format,
47}
48
49impl OggHandler {
50    /// The handler instance for Ogg Vorbis.
51    pub const VORBIS: Self = Self {
52        format: Format::Ogg,
53    };
54    /// The handler instance for Opus.
55    pub const OPUS: Self = Self {
56        format: Format::Opus,
57    };
58    /// The handler instance for FLAC-in-Ogg.
59    pub const FLAC: Self = Self {
60        format: Format::OggFlac,
61    };
62}
63
64impl MetadataHandler for OggHandler {
65    fn name(&self) -> &'static str {
66        self.format.id()
67    }
68
69    fn format(&self) -> Format {
70        self.format
71    }
72
73    fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
74        // The identical pass stripping runs, with the output discarded, so that "everything
75        // `strip` removes is something `inspect` can see" holds by construction
76        // (`docs/ARCHITECTURE.md` §3).
77        let processed = process(input, self.format, options, &ParseLimits::default())?;
78        Ok(MetadataReport {
79            format: self.format,
80            findings: processed.findings,
81            notes: processed.notes,
82        })
83    }
84
85    fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
86        let processed = process(input, self.format, &options.inspect, &options.limits)?;
87        Ok(Stripped {
88            report: StripReport {
89                format: self.format,
90                removed: processed.findings,
91                retained: processed.retained,
92                notes: processed.notes,
93                input_bytes: as_u64(input.len()),
94                output_bytes: as_u64(processed.output.len()),
95            },
96            bytes: processed.output,
97        })
98    }
99}
100
101/// The identification packets the three mappings open with.
102const VORBIS_ID: &[u8] = b"\x01vorbis";
103const VORBIS_COMMENT_ID: &[u8] = b"\x03vorbis";
104const VORBIS_SETUP_ID: &[u8] = b"\x05vorbis";
105const OPUS_HEAD: &[u8] = b"OpusHead";
106const OPUS_TAGS: &[u8] = b"OpusTags";
107const FLAC_MAPPING: &[u8] = b"\x7fFLAC";
108const THEORA_ID: &[u8] = b"\x80theora";
109const SPEEX_ID: &[u8] = b"Speex   ";
110const SKELETON_ID: &[u8] = b"fishead\0";
111
112/// Vorbis I §4.2.1 ends the comment header with a framing bit, which Opus and FLAC do not have.
113const VORBIS_FRAMING_BIT: u8 = 0x01;
114
115/// The Ogg FLAC mapping's own header: `\x7fFLAC`, a major and minor version, and a 16-bit count of
116/// the header packets that follow.
117const FLAC_MAPPING_MAJOR: u8 = 1;
118const FLAC_MAPPING_HEADER_LEN: usize = 9;
119
120/// FLAC metadata block types used here (RFC 9639 §8.2); the rest are removed unread.
121const FLAC_STREAMINFO: u8 = 0;
122const FLAC_PADDING: u8 = 1;
123const FLAC_SEEKTABLE: u8 = 3;
124const FLAC_VORBIS_COMMENT: u8 = 4;
125const FLAC_FORBIDDEN: u8 = 127;
126const FLAC_BLOCK_HEADER_LEN: usize = 4;
127const FLAC_STREAMINFO_LEN: usize = 34;
128const FLAC_STREAMINFO_MD5_AT: usize = 18;
129
130/// What [`sniff`] made of a file starting with a page.
131pub(crate) enum Sniff {
132    /// A mapping this release handles.
133    Supported(Format),
134    /// An Ogg worth naming in a refusal.
135    Refused(UnsupportedKind),
136}
137
138/// Identify an Ogg by the codec its first page declares.
139///
140/// Cheap and integrity-blind on purpose: it reads one page header and the first bytes of the first
141/// packet. A file whose CRC is wrong still detects as the codec it claims and is then refused by
142/// the handler, which is a far more useful message than "unrecognised".
143pub(crate) fn sniff(data: &[u8]) -> Option<Sniff> {
144    let first = page_at(data, 0)?;
145    if first.flags & page::BOS == 0 {
146        // A stream that does not begin at the beginning: either a fragment of one, or a chain
147        // joined mid-file. Neither is something to walk.
148        return Some(Sniff::Refused(UnsupportedKind::OtherOggCodec));
149    }
150    // A multiplexed file puts every stream's first page at the front, so the second page is the
151    // one that gives it away, and naming it here beats a generic refusal (ADR-0041 decision 6).
152    if let Some(second) = page_at(data, first.end)
153        && second.flags & page::BOS != 0
154    {
155        return Some(Sniff::Refused(UnsupportedKind::MultiplexedOgg));
156    }
157
158    let body = data.get(first.body..first.end).unwrap_or_default();
159    Some(match () {
160        () if body.starts_with(VORBIS_ID) => Sniff::Supported(Format::Ogg),
161        () if body.starts_with(OPUS_HEAD) => Sniff::Supported(Format::Opus),
162        () if body.starts_with(FLAC_MAPPING) => Sniff::Supported(Format::OggFlac),
163        () if body.starts_with(THEORA_ID) => Sniff::Refused(UnsupportedKind::OggTheora),
164        () if body.starts_with(SPEEX_ID) || body.starts_with(SKELETON_ID) => {
165            Sniff::Refused(UnsupportedKind::OtherOggCodec)
166        }
167        () => Sniff::Refused(UnsupportedKind::OtherOggCodec),
168    })
169}
170
171/// The little a sniff needs from a page header.
172struct Header {
173    flags: u8,
174    /// Where the page's body starts.
175    body: usize,
176    /// Where the page ends.
177    end: usize,
178}
179
180/// Read the page header at `at`, without checking its CRC.
181fn page_at(data: &[u8], at: usize) -> Option<Header> {
182    let rest = data.get(at..)?;
183    if !rest.starts_with(page::MAGIC) {
184        return None;
185    }
186    let segments = usize::from(*rest.get(26)?);
187    let body = at.checked_add(27)?.checked_add(segments)?;
188    let lacing = rest.get(27..27usize.checked_add(segments)?)?;
189    let length = lacing
190        .iter()
191        .fold(0usize, |sum, n| sum.saturating_add(usize::from(*n)));
192    Some(Header {
193        flags: *rest.get(5)?,
194        body,
195        end: body.checked_add(length)?,
196    })
197}
198
199/// The result of one pass over a file.
200struct Processed {
201    findings: Vec<Finding>,
202    retained: Vec<Retained>,
203    notes: Vec<Note>,
204    output: Vec<u8>,
205}
206
207/// Walk `input`, decide about every header packet, and rebuild the stream.
208fn process(
209    input: &[u8],
210    format: Format,
211    options: &InspectOptions,
212    limits: &ParseLimits,
213) -> Result<Processed> {
214    let mut budget = limits.max_items;
215    let pages = page::pages(input, &mut budget).map_err(|e| convert(e, format))?;
216    single_bitstream(&pages, format)?;
217    let packets = page::packets(&pages).map_err(|e| convert(e, format))?;
218
219    let mut out = Processed {
220        findings: Vec::new(),
221        retained: Vec::new(),
222        notes: Vec::new(),
223        output: Vec::new(),
224    };
225    // Built before any borrow of them, so a replacement can be handed to the writer by reference
226    // rather than copied a second time.
227    let mut replacements: Vec<Option<Vec<u8>>> = vec![None; packets.len()];
228    let headers = match format {
229        Format::Opus => opus(&packets, options, &mut replacements, &mut out)?,
230        Format::OggFlac => flac(&packets, options, &mut replacements, &mut out)?,
231        // `Format::Ogg` is Vorbis; nothing else reaches this handler.
232        _ => vorbis_stream(&packets, options, &mut replacements, &mut out)?,
233    };
234    if packets.len() <= headers {
235        // Headers and nothing else. Refused rather than rebuilt, on MP3's reasoning: a file with
236        // no payload is not one this tool should hand back reporting success (ADR-0040).
237        return Err(malformed(format, MalformedDetail::Truncated, None));
238    }
239
240    let emits: Vec<Emit<'_>> = packets
241        .iter()
242        .enumerate()
243        .map(
244            |(index, packet)| match replacements.get(index).and_then(|slot| slot.as_deref()) {
245                Some(bytes) => Emit::replacing(packet, bytes),
246                None => Emit::copied(packet),
247            },
248        )
249        .collect();
250    out.output = page::write(0, &emits).map_err(|detail| malformed(format, detail, None))?;
251
252    // Said on every file, clean ones included: the packets are copied without being decoded, so
253    // anything hidden inside one is out of reach rather than absent.
254    out.notes.push(Note::OutOfScopeContent {
255        location: "audio packets, which are copied without being decoded".to_owned(),
256    });
257    Ok(out)
258}
259
260/// Refuse anything that is not exactly one logical bitstream.
261fn single_bitstream(pages: &[page::Page<'_>], format: Format) -> Result<()> {
262    let Some(first) = pages.first() else {
263        return Err(malformed(format, MalformedDetail::MissingMarker, None));
264    };
265    if first.flags & page::BOS == 0 {
266        return Err(malformed(
267            format,
268            MalformedDetail::MissingMarker,
269            as_offset(first.offset),
270        ));
271    }
272    for (index, current) in pages.iter().enumerate() {
273        if current.serial != first.serial || (index > 0 && current.flags & page::BOS != 0) {
274            return Err(StryptError::UnsupportedFormat {
275                format: UnsupportedKind::MultiplexedOgg,
276            });
277        }
278        let last = index.saturating_add(1) == pages.len();
279        if (current.flags & page::EOS != 0) != last {
280            // An end-of-stream page in the middle is a chain; a stream that never ends is a
281            // fragment. Both are refused rather than guessed at.
282            return Err(StryptError::UnsupportedFormat {
283                format: UnsupportedKind::MultiplexedOgg,
284            });
285        }
286    }
287    Ok(())
288}
289
290/// Vorbis I §4.2: three header packets, the second of which is the comment.
291fn vorbis_stream(
292    packets: &[Packet<'_>],
293    options: &InspectOptions,
294    replacements: &mut [Option<Vec<u8>>],
295    out: &mut Processed,
296) -> Result<usize> {
297    let format = Format::Ogg;
298    expect(packets, 0, VORBIS_ID, format)?;
299    let comment = expect(packets, 1, VORBIS_COMMENT_ID, format)?;
300    expect(packets, 2, VORBIS_SETUP_ID, format)?;
301
302    let body = comment.get(VORBIS_COMMENT_ID.len()..).unwrap_or_default();
303    vorbis::comments(
304        body,
305        "VORBIS_COMMENT",
306        as_u64(body.len()),
307        options,
308        &mut out.findings,
309    );
310
311    let mut empty = VORBIS_COMMENT_ID.to_vec();
312    empty.extend_from_slice(&vorbis::EMPTY);
313    empty.push(VORBIS_FRAMING_BIT);
314    set(replacements, 1, empty);
315    Ok(3)
316}
317
318/// RFC 7845 §5: two header packets, the second of which is `OpusTags`.
319fn opus(
320    packets: &[Packet<'_>],
321    options: &InspectOptions,
322    replacements: &mut [Option<Vec<u8>>],
323    out: &mut Processed,
324) -> Result<usize> {
325    let format = Format::Opus;
326    expect(packets, 0, OPUS_HEAD, format)?;
327    let comment = expect(packets, 1, OPUS_TAGS, format)?;
328
329    let body = comment.get(OPUS_TAGS.len()..).unwrap_or_default();
330    vorbis::comments(
331        body,
332        "OpusTags",
333        as_u64(body.len()),
334        options,
335        &mut out.findings,
336    );
337
338    let mut empty = OPUS_TAGS.to_vec();
339    empty.extend_from_slice(&vorbis::EMPTY);
340    set(replacements, 1, empty);
341    Ok(2)
342}
343
344/// The Ogg FLAC mapping: a mapping header carrying `STREAMINFO`, then one FLAC metadata block per
345/// packet until one sets the last-block flag.
346///
347/// Blocks are replaced rather than dropped, so the packet count and the mapping header's declared
348/// count of them stay true without either being rewritten (ADR-0041 decision 9).
349fn flac(
350    packets: &[Packet<'_>],
351    options: &InspectOptions,
352    replacements: &mut [Option<Vec<u8>>],
353    out: &mut Processed,
354) -> Result<usize> {
355    let format = Format::OggFlac;
356    let mapping = expect(packets, 0, FLAC_MAPPING, format)?;
357    let mut r = Reader::new(&mapping);
358    r.skip(FLAC_MAPPING.len())
359        .ok_or_else(|| malformed(format, MalformedDetail::Truncated, None))?;
360    if r.u8() != Some(FLAC_MAPPING_MAJOR) {
361        // A mapping version whose layout this code has never read. Refused, not guessed at.
362        return Err(malformed(format, MalformedDetail::UnexpectedMarker, None));
363    }
364    let _minor = r.u8();
365    let declared = r
366        .u16_be()
367        .ok_or_else(|| malformed(format, MalformedDetail::Truncated, None))?;
368    if r.take(4) != Some(b"fLaC") {
369        return Err(malformed(
370            format,
371            MalformedDetail::MissingMarker,
372            as_offset(FLAC_MAPPING_HEADER_LEN),
373        ));
374    }
375    let (kind, last, payload) = block(r.take_rest(), format)?;
376    if kind != FLAC_STREAMINFO || payload.len() != FLAC_STREAMINFO_LEN {
377        return Err(malformed(format, MalformedDetail::MissingMarker, None));
378    }
379    if !md5_is_absent(payload) {
380        // Computed from the samples the file still carries, so its holder can recompute it and
381        // removing it hides nothing from them — but it does link this copy to another, so it is
382        // declared (ADR-0038 decision 4).
383        out.retained.push(Retained {
384            location: "STREAMINFO (MD5 of the unencoded audio)".to_owned(),
385            reason: RetentionReason::DerivedFromPayload,
386        });
387    }
388
389    let mut index = 1usize;
390    let mut done = last;
391    while !done {
392        let Some(packet) = packets.get(index) else {
393            return Err(malformed(format, MalformedDetail::Truncated, None));
394        };
395        let bytes = packet.bytes();
396        let (kind, last, payload) = block(&bytes, format)?;
397        done = last;
398        let flag = if last { 0x80 } else { 0x00 };
399        let size = as_u64(payload.len());
400        match kind {
401            FLAC_STREAMINFO | FLAC_FORBIDDEN => {
402                // A second `STREAMINFO` or the type §8.1 forbids outright: the walk is no longer
403                // where it thinks it is.
404                return Err(malformed(format, MalformedDetail::UnexpectedMarker, None));
405            }
406            FLAC_SEEKTABLE => {}
407            FLAC_VORBIS_COMMENT => {
408                vorbis::comments(payload, "VORBIS_COMMENT", size, options, &mut out.findings);
409                set(
410                    replacements,
411                    index,
412                    flac_block(FLAC_VORBIS_COMMENT, flag, &vorbis::EMPTY),
413                );
414            }
415            FLAC_PADDING => {
416                if payload.iter().any(|byte| *byte != 0) {
417                    // §8.2 says a padding block is zero bits. Anything else was put there.
418                    out.findings.push(
419                        Finding::new(MetadataKind::Other, "PADDING", size)
420                            .with_field("Padding")
421                            .with_value(options, || MetadataValue::Opaque { bytes: size }),
422                    );
423                    set(
424                        replacements,
425                        index,
426                        flac_block(FLAC_PADDING, flag, &vec![0u8; payload.len()]),
427                    );
428                }
429            }
430            other => {
431                out.findings
432                    .push(finding_for(other, payload, size, options));
433                set(replacements, index, flac_block(FLAC_PADDING, flag, &[]));
434            }
435        }
436        index = index.saturating_add(1);
437    }
438
439    let counted = u16::try_from(index.saturating_sub(1)).unwrap_or(u16::MAX);
440    if declared != 0 && declared != counted {
441        // The mapping header says how many header packets follow. A file where it disagrees with
442        // the last-block flag is one whose two answers cannot both be acted on.
443        return Err(malformed(format, MalformedDetail::LengthOutOfRange, None));
444    }
445    Ok(index)
446}
447
448/// A FLAC metadata block filling `data`: type, last-block flag, and payload.
449fn block(data: &[u8], format: Format) -> Result<(u8, bool, &[u8])> {
450    let header = data
451        .get(..FLAC_BLOCK_HEADER_LEN)
452        .ok_or_else(|| malformed(format, MalformedDetail::Truncated, None))?;
453    let first = header.first().copied().unwrap_or_default();
454    let length = match header.get(1..4) {
455        Some([high, middle, low]) => {
456            usize::try_from(u32::from_be_bytes([0, *high, *middle, *low])).ok()
457        }
458        _ => None,
459    }
460    .ok_or_else(|| malformed(format, MalformedDetail::LengthOutOfRange, None))?;
461    let payload = data
462        .get(FLAC_BLOCK_HEADER_LEN..)
463        .ok_or_else(|| malformed(format, MalformedDetail::Truncated, None))?;
464    if payload.len() != length {
465        // One block per packet, so the block must fill the packet exactly. A shorter one hides
466        // bytes behind it; a longer one is lying about its own size.
467        return Err(malformed(format, MalformedDetail::LengthOutOfRange, None));
468    }
469    Ok((first & 0x7F, first & 0x80 != 0, payload))
470}
471
472/// Build a FLAC metadata block packet.
473fn flac_block(kind: u8, last: u8, payload: &[u8]) -> Vec<u8> {
474    let mut out = vec![kind | last];
475    let length = u32::try_from(payload.len())
476        .unwrap_or(u32::MAX)
477        .to_be_bytes();
478    out.extend_from_slice(length.get(1..4).unwrap_or_default());
479    out.extend_from_slice(payload);
480    out
481}
482
483/// Report a FLAC metadata block that is going. Types are named where §8.2 names them, and a
484/// reserved one is named by its number rather than passed over for being unrecognised.
485fn finding_for(kind: u8, payload: &[u8], size: u64, options: &InspectOptions) -> Finding {
486    match kind {
487        2 => Finding::new(MetadataKind::SoftwareFingerprint, "APPLICATION", size)
488            .with_field(xmp::name_of(payload.get(0..4).unwrap_or(payload)))
489            .with_value(options, || MetadataValue::Opaque { bytes: size }),
490        5 => Finding::new(MetadataKind::DocumentIdentifier, "CUESHEET", size)
491            .with_field("MediaCatalogNumber"),
492        6 => Finding::new(MetadataKind::Thumbnail, "PICTURE", size)
493            .with_value(options, || MetadataValue::Opaque { bytes: size }),
494        other => Finding::new(
495            MetadataKind::Other,
496            format!("Metadata block type {other}"),
497            size,
498        ),
499    }
500}
501
502/// True when `STREAMINFO`'s MD5 field is all zeros, which §8.2 defines as "unknown".
503fn md5_is_absent(payload: &[u8]) -> bool {
504    payload
505        .get(FLAC_STREAMINFO_MD5_AT..)
506        .is_none_or(|md5| md5.iter().all(|byte| *byte == 0))
507}
508
509/// The packet at `index`, refusing the file unless it opens with `marker`.
510fn expect<'a>(
511    packets: &'a [Packet<'_>],
512    index: usize,
513    marker: &[u8],
514    format: Format,
515) -> Result<std::borrow::Cow<'a, [u8]>> {
516    let packet = packets
517        .get(index)
518        .ok_or_else(|| malformed(format, MalformedDetail::Truncated, None))?;
519    let bytes = packet.bytes();
520    if !bytes.starts_with(marker) {
521        return Err(malformed(format, MalformedDetail::MissingMarker, None));
522    }
523    Ok(bytes)
524}
525
526/// Record a replacement for the packet at `index`.
527fn set(replacements: &mut [Option<Vec<u8>>], index: usize, bytes: Vec<u8>) {
528    if let Some(slot) = replacements.get_mut(index) {
529        *slot = Some(bytes);
530    }
531}
532
533/// Re-label a page-layer failure as this format's error.
534fn convert(error: WalkError, format: Format) -> StryptError {
535    match error {
536        WalkError::Malformed { detail, offset } => malformed(format, detail, as_offset(offset)),
537        WalkError::Limit(limit) => StryptError::LimitExceeded { format, limit },
538    }
539}
540
541/// A malformed-file error for this format.
542fn malformed(format: Format, detail: MalformedDetail, offset: Option<u64>) -> StryptError {
543    StryptError::Malformed {
544        format,
545        offset,
546        detail,
547    }
548}
549
550/// A byte position as a reportable offset.
551fn as_offset(position: usize) -> Option<u64> {
552    u64::try_from(position).ok()
553}
554
555/// Widen a length for reporting. Saturating: a report field is not worth failing a strip over.
556fn as_u64(value: usize) -> u64 {
557    u64::try_from(value).unwrap_or(u64::MAX)
558}
559
560#[cfg(test)]
561mod tests {
562    // Test code is never reachable from untrusted bytes, which is the boundary the panic-freedom
563    // lints police (ADR-0006).
564    #![allow(
565        clippy::unwrap_used,
566        clippy::expect_used,
567        clippy::indexing_slicing,
568        clippy::arithmetic_side_effects
569    )]
570
571    use super::*;
572
573    fn le32(n: usize) -> [u8; 4] {
574        u32::try_from(n).unwrap().to_le_bytes()
575    }
576
577    /// A comment body: a vendor string, a count, then the items.
578    fn comment_body(vendor: &[u8], items: &[&[u8]]) -> Vec<u8> {
579        let mut out = le32(vendor.len()).to_vec();
580        out.extend_from_slice(vendor);
581        out.extend_from_slice(&le32(items.len()));
582        for item in items {
583            out.extend_from_slice(&le32(item.len()));
584            out.extend_from_slice(item);
585        }
586        out
587    }
588
589    /// One page carrying whole packets, with its CRC filled in.
590    fn build_page(
591        flags: u8,
592        granule: u64,
593        serial: u32,
594        sequence: u32,
595        packets: &[&[u8]],
596    ) -> Vec<u8> {
597        let mut lacing = Vec::new();
598        let mut body = Vec::new();
599        for packet in packets {
600            let mut written = 0;
601            loop {
602                let take = 255.min(packet.len() - written);
603                lacing.push(u8::try_from(take).unwrap());
604                body.extend_from_slice(&packet[written..written + take]);
605                written += take;
606                if take < 255 {
607                    break;
608                }
609            }
610        }
611        let mut out = page::MAGIC.to_vec();
612        out.push(0);
613        out.push(flags);
614        out.extend_from_slice(&granule.to_le_bytes());
615        out.extend_from_slice(&serial.to_le_bytes());
616        out.extend_from_slice(&sequence.to_le_bytes());
617        out.extend_from_slice(&[0u8; 4]);
618        out.push(u8::try_from(lacing.len()).unwrap());
619        out.extend_from_slice(&lacing);
620        out.extend_from_slice(&body);
621        let crc = page::crc(&out);
622        out[22..26].copy_from_slice(&crc.to_le_bytes());
623        out
624    }
625
626    /// A Vorbis stream: identification, comment, setup, and one audio packet.
627    fn vorbis_file(vendor: &[u8], items: &[&[u8]]) -> Vec<u8> {
628        let mut comment = VORBIS_COMMENT_ID.to_vec();
629        comment.extend_from_slice(&comment_body(vendor, items));
630        comment.push(VORBIS_FRAMING_BIT);
631        let mut id = VORBIS_ID.to_vec();
632        id.extend_from_slice(&[0u8; 23]);
633        let mut setup = VORBIS_SETUP_ID.to_vec();
634        setup.extend_from_slice(b"SETUP");
635
636        let mut out = build_page(page::BOS, 0, 0x1234_5678, 0, &[&id]);
637        out.extend_from_slice(&build_page(0, 0, 0x1234_5678, 1, &[&comment, &setup]));
638        out.extend_from_slice(&build_page(
639            page::EOS,
640            1024,
641            0x1234_5678,
642            2,
643            &[b"AUDIO-PACKET-PAYLOAD"],
644        ));
645        out
646    }
647
648    /// An Opus stream: `OpusHead`, `OpusTags`, and one audio packet.
649    fn opus_file(vendor: &[u8], items: &[&[u8]]) -> Vec<u8> {
650        let mut head = OPUS_HEAD.to_vec();
651        head.extend_from_slice(&[1, 1, 0x38, 1, 0x80, 0xBB, 0, 0, 0, 0, 0]);
652        let mut tags = OPUS_TAGS.to_vec();
653        tags.extend_from_slice(&comment_body(vendor, items));
654
655        let mut out = build_page(page::BOS, 0, 99, 0, &[&head]);
656        out.extend_from_slice(&build_page(0, 0, 99, 1, &[&tags]));
657        out.extend_from_slice(&build_page(page::EOS, 960, 99, 2, &[b"AUDIO-PAYLOAD"]));
658        out
659    }
660
661    fn flac_mapping(headers: u16, md5: u8) -> Vec<u8> {
662        let mut out = FLAC_MAPPING.to_vec();
663        out.extend_from_slice(&[FLAC_MAPPING_MAJOR, 0]);
664        out.extend_from_slice(&headers.to_be_bytes());
665        out.extend_from_slice(b"fLaC");
666        let mut streaminfo = vec![0u8; FLAC_STREAMINFO_LEN];
667        for byte in streaminfo.iter_mut().skip(FLAC_STREAMINFO_MD5_AT) {
668            *byte = md5;
669        }
670        out.extend_from_slice(&flac_block(FLAC_STREAMINFO, 0, &streaminfo));
671        out
672    }
673
674    /// An Ogg FLAC stream carrying the given metadata blocks after `STREAMINFO`.
675    fn flac_file(blocks: &[(u8, Vec<u8>)]) -> Vec<u8> {
676        let mapping = flac_mapping(u16::try_from(blocks.len()).unwrap(), 0xAB);
677        let mut packets: Vec<Vec<u8>> = vec![mapping];
678        for (index, (kind, payload)) in blocks.iter().enumerate() {
679            let last = if index + 1 == blocks.len() { 0x80 } else { 0 };
680            packets.push(flac_block(*kind, last, payload));
681        }
682        let refs: Vec<&[u8]> = packets.iter().skip(1).map(Vec::as_slice).collect();
683        let mut out = build_page(page::BOS, 0, 7, 0, &[&packets[0]]);
684        out.extend_from_slice(&build_page(0, 0, 7, 1, &refs));
685        out.extend_from_slice(&build_page(page::EOS, 4096, 7, 2, &[b"\xff\xf8AUDIO"]));
686        out
687    }
688
689    fn strip_ok(handler: OggHandler, data: &[u8]) -> Stripped {
690        handler
691            .strip(data, &StripOptions::default())
692            .expect("strip failed")
693    }
694
695    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
696        haystack.windows(needle.len()).any(|w| w == needle)
697    }
698
699    #[test]
700    fn a_vorbis_comment_is_itemised_and_emptied() {
701        let input = vorbis_file(
702            b"SYNTHETIC-VENDOR-0001",
703            &[b"ARTIST=SYNTHETIC-ARTIST-0002", b"DATE=2026-09-03"],
704        );
705        let stripped = strip_ok(OggHandler::VORBIS, &input);
706        let fields: Vec<&str> = stripped
707            .report
708            .removed
709            .iter()
710            .filter_map(|f| f.field.as_deref())
711            .collect();
712        assert!(fields.contains(&"vendor"));
713        assert!(fields.contains(&"ARTIST"));
714        assert!(!contains(&stripped.bytes, b"SYNTHETIC-"));
715    }
716
717    #[test]
718    fn the_audio_packets_cross_byte_for_byte() {
719        // The property the rebuild trades page structure for: nothing is re-encoded.
720        let input = vorbis_file(b"v", &[b"ARTIST=SYNTHETIC-0003"]);
721        let output = strip_ok(OggHandler::VORBIS, &input).bytes;
722        assert!(contains(&output, b"AUDIO-PACKET-PAYLOAD"));
723        assert!(contains(&output, b"SETUP"), "the setup header moved");
724    }
725
726    #[test]
727    fn the_comment_packet_is_emptied_rather_than_dropped() {
728        // All three mappings need it in place; unlike FLAC's block it cannot go (ADR-0041).
729        let input = opus_file(b"libopus SYNTHETIC-0004", &[b"TITLE=SYNTHETIC-0005"]);
730        let output = strip_ok(OggHandler::OPUS, &input).bytes;
731        assert!(contains(&output, OPUS_TAGS));
732        assert!(!contains(&output, b"SYNTHETIC-"));
733    }
734
735    #[test]
736    fn the_serial_number_is_rewritten() {
737        // It is a 32-bit identifier nobody can recompute, and libogg seeds it from the clock.
738        for (handler, input) in [
739            (OggHandler::VORBIS, vorbis_file(b"v", &[])),
740            (OggHandler::OPUS, opus_file(b"v", &[])),
741        ] {
742            let output = strip_ok(handler, &input).bytes;
743            assert_eq!(output.get(14..18), Some(&0u32.to_le_bytes()[..]));
744        }
745    }
746
747    #[test]
748    fn the_granule_positions_are_carried_verbatim() {
749        // They are sample counts, not offsets, so removal moves nothing (RFC 3533 §3).
750        let input = opus_file(b"v", &[b"ARTIST=SYNTHETIC-0006"]);
751        let output = strip_ok(OggHandler::OPUS, &input).bytes;
752        let mut budget = 4096;
753        let pages = page::pages(&output, &mut budget).unwrap();
754        let granules: Vec<u64> = pages.iter().map(|p| p.granule).collect();
755        assert_eq!(granules, vec![0, 0, 960]);
756    }
757
758    #[test]
759    fn stripping_twice_is_byte_exact() {
760        for (handler, input) in [
761            (
762                OggHandler::VORBIS,
763                vorbis_file(b"v", &[b"A=SYNTHETIC-0007"]),
764            ),
765            (OggHandler::OPUS, opus_file(b"v", &[b"A=SYNTHETIC-0008"])),
766            (
767                OggHandler::FLAC,
768                flac_file(&[(FLAC_VORBIS_COMMENT, comment_body(b"v", &[b"A=SYN-9"]))]),
769            ),
770        ] {
771            let once = strip_ok(handler, &input).bytes;
772            let twice = strip_ok(handler, &once).bytes;
773            assert_eq!(once, twice, "strip is not idempotent");
774        }
775    }
776
777    #[test]
778    fn a_stripped_stream_re_inspects_clean() {
779        for (handler, input) in [
780            (
781                OggHandler::VORBIS,
782                vorbis_file(b"v", &[b"A=SYNTHETIC-0010"]),
783            ),
784            (OggHandler::OPUS, opus_file(b"v", &[b"A=SYNTHETIC-0011"])),
785            (
786                OggHandler::FLAC,
787                flac_file(&[(FLAC_VORBIS_COMMENT, comment_body(b"v", &[b"A=SYN-12"]))]),
788            ),
789        ] {
790            let output = strip_ok(handler, &input).bytes;
791            let report = handler
792                .inspect(&output, &InspectOptions::names_only())
793                .unwrap();
794            assert!(report.findings.is_empty(), "{:?}", report.findings);
795        }
796    }
797
798    #[test]
799    fn an_ogg_flac_keeps_its_packet_count_when_a_block_goes() {
800        // A removed block becomes a zero-length PADDING packet, so neither the declared header
801        // count nor the last-block flag has to be rewritten (ADR-0041 decision 9).
802        let mut picture = 3u32.to_be_bytes().to_vec();
803        picture.extend_from_slice(&9u32.to_be_bytes());
804        picture.extend_from_slice(b"image/png");
805        picture.extend_from_slice(&21u32.to_be_bytes());
806        picture.extend_from_slice(b"SYNTHETIC-PICTURE-013");
807        let input = flac_file(&[
808            (
809                FLAC_VORBIS_COMMENT,
810                comment_body(b"v", &[b"A=SYNTHETIC-14"]),
811            ),
812            (6, picture),
813        ]);
814
815        let stripped = strip_ok(OggHandler::FLAC, &input);
816        assert!(!contains(&stripped.bytes, b"SYNTHETIC-"));
817        let mut budget = 4096;
818        let before = page::packets(&page::pages(&input, &mut budget).unwrap())
819            .unwrap()
820            .len();
821        let mut budget = 4096;
822        let after = page::packets(&page::pages(&stripped.bytes, &mut budget).unwrap())
823            .unwrap()
824            .len();
825        assert_eq!(before, after, "a header packet was dropped");
826    }
827
828    #[test]
829    fn an_ogg_flac_declares_the_audio_md5_it_keeps() {
830        let input = flac_file(&[(FLAC_PADDING, vec![0u8; 8])]);
831        let stripped = strip_ok(OggHandler::FLAC, &input);
832        assert_eq!(
833            stripped.report.retained.first().map(|r| r.reason),
834            Some(RetentionReason::DerivedFromPayload)
835        );
836    }
837
838    #[test]
839    fn ogg_flac_padding_keeps_its_size_and_loses_its_contents() {
840        let mut payload = vec![0u8; 32];
841        payload[4..27].copy_from_slice(b"SYNTHETIC-IN-PADDING-15");
842        let input = flac_file(&[(FLAC_PADDING, payload)]);
843        let stripped = strip_ok(OggHandler::FLAC, &input);
844        assert!(!contains(&stripped.bytes, b"SYNTHETIC-"));
845        assert_eq!(stripped.report.removed[0].location, "PADDING");
846    }
847
848    #[test]
849    fn a_second_bitstream_is_refused_by_name() {
850        let mut input = vorbis_file(b"v", &[]);
851        let mut second = build_page(page::BOS | page::EOS, 0, 4242, 0, &[b"\x80theora"]);
852        std::mem::swap(&mut input, &mut second);
853        input.extend_from_slice(&second);
854        assert!(matches!(
855            OggHandler::VORBIS.strip(&input, &StripOptions::default()),
856            Err(StryptError::UnsupportedFormat {
857                format: UnsupportedKind::MultiplexedOgg
858            })
859        ));
860    }
861
862    #[test]
863    fn a_sniff_names_what_it_will_not_handle() {
864        let theora = build_page(page::BOS | page::EOS, 0, 1, 0, &[b"\x80theora"]);
865        assert!(matches!(
866            sniff(&theora),
867            Some(Sniff::Refused(UnsupportedKind::OggTheora))
868        ));
869        let speex = build_page(page::BOS | page::EOS, 0, 1, 0, &[b"Speex   "]);
870        assert!(matches!(
871            sniff(&speex),
872            Some(Sniff::Refused(UnsupportedKind::OtherOggCodec))
873        ));
874        assert!(matches!(
875            sniff(&vorbis_file(b"v", &[])),
876            Some(Sniff::Supported(Format::Ogg))
877        ));
878        assert!(matches!(
879            sniff(&opus_file(b"v", &[])),
880            Some(Sniff::Supported(Format::Opus))
881        ));
882        assert!(matches!(
883            sniff(&flac_file(&[(FLAC_PADDING, vec![0u8; 4])])),
884            Some(Sniff::Supported(Format::OggFlac))
885        ));
886    }
887
888    #[test]
889    fn a_page_whose_crc_does_not_match_is_refused() {
890        let mut input = vorbis_file(b"v", &[]);
891        let at = input.len() - 1;
892        input[at] ^= 0xFF;
893        assert!(matches!(
894            OggHandler::VORBIS.strip(&input, &StripOptions::default()),
895            Err(StryptError::Malformed { .. })
896        ));
897    }
898
899    #[test]
900    fn a_stream_that_is_only_headers_is_refused() {
901        let mut comment = VORBIS_COMMENT_ID.to_vec();
902        comment.extend_from_slice(&comment_body(b"v", &[]));
903        comment.push(VORBIS_FRAMING_BIT);
904        let mut id = VORBIS_ID.to_vec();
905        id.extend_from_slice(&[0u8; 23]);
906        let mut setup = VORBIS_SETUP_ID.to_vec();
907        setup.extend_from_slice(b"SETUP");
908        let mut input = build_page(page::BOS, 0, 1, 0, &[&id]);
909        input.extend_from_slice(&build_page(page::EOS, 0, 1, 1, &[&comment, &setup]));
910        assert!(matches!(
911            OggHandler::VORBIS.strip(&input, &StripOptions::default()),
912            Err(StryptError::Malformed { .. })
913        ));
914    }
915
916    #[test]
917    fn a_missing_comment_header_is_refused() {
918        let mut id = VORBIS_ID.to_vec();
919        id.extend_from_slice(&[0u8; 23]);
920        let mut input = build_page(page::BOS, 0, 1, 0, &[&id]);
921        input.extend_from_slice(&build_page(
922            page::EOS,
923            1,
924            1,
925            1,
926            &[b"NOT-A-HEADER", b"AUDIO"],
927        ));
928        assert!(matches!(
929            OggHandler::VORBIS.strip(&input, &StripOptions::default()),
930            Err(StryptError::Malformed {
931                detail: MalformedDetail::MissingMarker,
932                ..
933            })
934        ));
935    }
936
937    #[test]
938    fn an_ogg_flac_block_that_does_not_fill_its_packet_is_refused() {
939        // One block per packet: a short one hides bytes behind it, a long one lies about its size.
940        let mut input = flac_file(&[(FLAC_PADDING, vec![0u8; 8])]);
941        // The block packet sits on the second page; corrupt its declared length and re-stamp.
942        let at = input
943            .windows(4)
944            .position(|w| w == b"OggS")
945            .and_then(|_| input.windows(2).position(|w| w == [0x81, 0x00]))
946            .unwrap();
947        input[at + 3] = 0x40;
948        let mut budget = 4096;
949        if page::pages(&input, &mut budget).is_ok() {
950            // The CRC guards the page, so a hand-edited byte usually fails there first; either
951            // refusal is the fail-closed answer this asserts.
952        }
953        assert!(
954            OggHandler::FLAC
955                .strip(&input, &StripOptions::default())
956                .is_err()
957        );
958    }
959
960    #[test]
961    fn truncation_at_every_length_is_refused_but_never_panics() {
962        let input = vorbis_file(b"vendor", &[b"ARTIST=SYNTHETIC-0016"]);
963        for n in 0..=input.len() {
964            let prefix = input.get(0..n).unwrap();
965            let _ = OggHandler::VORBIS.inspect(prefix, &InspectOptions::names_only());
966            let _ = OggHandler::VORBIS.strip(prefix, &StripOptions::default());
967        }
968    }
969}