Skip to main content

strypt_core/formats/
wav.rs

1//! WAV.
2//!
3//! A RIFF file of form type `WAVE`, so the chunk walk is the shared one in
4//! [`crate::container::riff`] and this module is only the part that is WAVE rather than RIFF
5//! (ADR-0039). Specified by "Multimedia Programming Interface and Data Specifications 1.0"
6//! (IBM/Microsoft, 1991); the metadata chunks come from later specifications named at each one.
7//!
8//! # Chunk surgery, never re-encoding
9//!
10//! Kept chunks are copied through **as raw bytes** — code, length, payload, and pad byte
11//! verbatim — and the only field recomputed anywhere is the RIFF size. The samples are never
12//! decoded, so a WAV comes out of strypt as the same recording it went in as, bit for bit, and a
13//! file with nothing to remove strips to a byte-identical copy of itself.
14//!
15//! mat2's `WAVParser` rebuilds the file through ffmpeg instead. That looked like it would reach
16//! data hidden in the samples where this cannot; measured 2026-09-01, it does not — the re-encode
17//! reproduces 16-bit PCM exactly. See `docs/THREAT_MODEL.md` §7.14.
18//!
19//! # Nothing here moves an offset
20//!
21//! Group 4's hazard is a format whose index is a table of absolute file offsets, so that removing
22//! a chunk silently invalidates it. WAV does not have one. `cue `'s `dwChunkStart` and
23//! `dwBlockStart` are byte offsets **into the data section of a `wavl` list**, not into the file,
24//! so chunk removal moves nothing (verified 2026-09-01). The one shape where that reasoning would
25//! not hold — a `LIST` of form `wavl` — is refused by name rather than edited.
26//!
27//! # Output is an allow-list
28//!
29//! Only `fmt `, `data`, `fact`, and `cue ` reach the output; `JUNK` and `PAD ` are kept at their
30//! length with every byte zeroed (ADR-0038 decision 3, carried across). Everything else goes,
31//! including every chunk this handler has no name for — a producer's private chunk is where the
32//! thing they did not want looked at is kept.
33
34use crate::container::riff::{self, Chunk, FourCc, WalkError, name_of};
35use crate::detect::Format;
36use crate::error::{MalformedDetail, Result, StryptError, UnsupportedKind};
37use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, xmp};
38use crate::report::{
39    Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, Retained,
40    RetentionReason, StripReport,
41};
42
43/// Removal of metadata from WAV audio.
44#[derive(Debug, Clone, Copy, Default)]
45pub struct WavHandler;
46
47impl MetadataHandler for WavHandler {
48    fn name(&self) -> &'static str {
49        Format::Wav.id()
50    }
51
52    fn format(&self) -> Format {
53        Format::Wav
54    }
55
56    fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
57        // Inspection runs the identical pass that stripping does and throws the output away, so
58        // "everything `strip` removes is something `inspect` can see" holds by construction.
59        let processed = process(input, options, &ParseLimits::default())?;
60        Ok(MetadataReport {
61            format: Format::Wav,
62            findings: processed.findings,
63            notes: processed.notes,
64        })
65    }
66
67    fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
68        let processed = process(input, &options.inspect, &options.limits)?;
69        Ok(Stripped {
70            report: StripReport {
71                format: Format::Wav,
72                removed: processed.findings,
73                retained: processed.retained,
74                notes: processed.notes,
75                input_bytes: as_u64(input.len()),
76                output_bytes: as_u64(processed.output.len()),
77            },
78            bytes: processed.output,
79        })
80    }
81}
82
83/// The form type that makes a RIFF file a WAV file.
84const WAVE: FourCc = *b"WAVE";
85
86/// The shortest `fmt ` any WAV has: the PCM form, before the extension size field.
87const FMT_MINIMUM: usize = 16;
88
89/// Chunks copied through byte for byte.
90///
91/// `fmt ` says what the audio is, `data` is the audio, `fact` is the decoded sample count that
92/// every non-PCM encoding requires, and `cue ` is playback structure whose offsets removal cannot
93/// move. None of the four has a field that names anybody.
94const KEPT: [&FourCc; 4] = [b"fmt ", b"data", b"fact", b"cue "];
95
96/// Chunks kept at their length with every byte zeroed.
97///
98/// `FLLR` is Pro Tools' spelling of the same idea. Real writers leave kilobytes of this for
99/// sector alignment or to reserve room for a `bext` that may be written later, and a producer
100/// that reserved room by writing out an old buffer left whatever was in it (ADR-0038 decision 3).
101const ZEROED: [&FourCc; 3] = [b"JUNK", b"PAD ", b"FLLR"];
102
103/// `LIST` form types this handler knows. Any other is removed whole.
104const LIST_INFO: FourCc = *b"INFO";
105const LIST_ADTL: FourCc = *b"adtl";
106/// A wave list: `data` and `slnt` chunks interleaved, and the thing `cue ` offsets index into.
107const LIST_WAVL: FourCc = *b"wavl";
108
109/// `LIST`/`INFO` tags worth naming individually in a report (RIFFMCI, "Information chunks").
110const INFO_TAGS: &[(&[u8; 4], &str, MetadataKind)] = &[
111    (b"IART", "Artist", MetadataKind::PersonalIdentity),
112    (b"IENG", "Engineer", MetadataKind::PersonalIdentity),
113    (b"ITCH", "Technician", MetadataKind::PersonalIdentity),
114    (b"ICMS", "Commissioned", MetadataKind::PersonalIdentity),
115    (b"ICOP", "Copyright", MetadataKind::PersonalIdentity),
116    (b"IARL", "ArchivalLocation", MetadataKind::Location),
117    (b"ICRD", "CreationDate", MetadataKind::Timestamp),
118    (b"IDIT", "DigitisationTime", MetadataKind::Timestamp),
119    (b"ISFT", "Software", MetadataKind::SoftwareFingerprint),
120    (b"ITOC", "TableOfContents", MetadataKind::DocumentIdentifier),
121    (b"ISRC", "Source", MetadataKind::DocumentIdentifier),
122    (b"ICMT", "Comment", MetadataKind::Comment),
123    (b"INAM", "Title", MetadataKind::Comment),
124    (b"ISBJ", "Subject", MetadataKind::Comment),
125    (b"IKEY", "Keywords", MetadataKind::Comment),
126    (b"IPRD", "Product", MetadataKind::Comment),
127    (b"IGNR", "Genre", MetadataKind::Comment),
128    (b"IMED", "Medium", MetadataKind::Comment),
129];
130
131/// `bext`'s fixed-width text fields, by offset (EBU Tech 3285, the Broadcast Wave extension).
132///
133/// The chunk goes whole either way; this table only decides how the report reads. `TimeReference`
134/// and the loudness fields are numeric and are covered by the whole-chunk finding.
135const BEXT_FIELDS: &[(usize, usize, &str, MetadataKind)] = &[
136    (0, 256, "Description", MetadataKind::Comment),
137    (256, 32, "Originator", MetadataKind::PersonalIdentity),
138    (288, 32, "OriginatorReference", MetadataKind::DeviceIdentity),
139    (320, 10, "OriginationDate", MetadataKind::Timestamp),
140    (330, 8, "OriginationTime", MetadataKind::Timestamp),
141    // A UMID embeds the recorder's own number, which links every take it ever made.
142    (348, 64, "UMID", MetadataKind::DocumentIdentifier),
143    // Unbounded, and a log of every process the audio has been through.
144    (
145        602,
146        usize::MAX,
147        "CodingHistory",
148        MetadataKind::EditingHistory,
149    ),
150];
151
152/// `cart`'s fixed-width text fields, by offset (AES46-2002, the radio traffic chunk).
153const CART_FIELDS: &[(usize, usize, &str, MetadataKind)] = &[
154    (4, 64, "Title", MetadataKind::Comment),
155    (68, 64, "Artist", MetadataKind::PersonalIdentity),
156    (132, 64, "CutID", MetadataKind::DocumentIdentifier),
157    (196, 64, "ClientID", MetadataKind::PersonalIdentity),
158    (388, 10, "StartDate", MetadataKind::Timestamp),
159    (420, 64, "ProducerAppID", MetadataKind::SoftwareFingerprint),
160    (
161        484,
162        64,
163        "ProducerAppVersion",
164        MetadataKind::SoftwareFingerprint,
165    ),
166    (1024, 1024, "URL", MetadataKind::Location),
167];
168
169/// The result of one pass over a file: what was found, and what the sanitised file looks like.
170struct Processed {
171    findings: Vec<Finding>,
172    retained: Vec<Retained>,
173    notes: Vec<Note>,
174    output: Vec<u8>,
175}
176
177/// What to do with one chunk.
178enum Outcome {
179    /// Copy it through unchanged.
180    Keep,
181    /// Copy it through with the given bytes in its place.
182    Replace(Vec<u8>),
183    /// Remove it entirely.
184    Drop,
185}
186
187/// A decision about one chunk, with what to tell the user about it.
188struct Decision {
189    outcome: Outcome,
190    findings: Vec<Finding>,
191    retained: Vec<Retained>,
192    notes: Vec<Note>,
193}
194
195impl Decision {
196    const fn keep() -> Self {
197        Self {
198            outcome: Outcome::Keep,
199            findings: Vec::new(),
200            retained: Vec::new(),
201            notes: Vec::new(),
202        }
203    }
204
205    fn drop_with(findings: Vec<Finding>) -> Self {
206        Self {
207            outcome: Outcome::Drop,
208            findings,
209            retained: Vec::new(),
210            notes: Vec::new(),
211        }
212    }
213
214    fn drop_one(kind: MetadataKind, location: impl Into<String>, bytes: u64) -> Self {
215        Self::drop_with(vec![Finding::new(kind, location, bytes)])
216    }
217}
218
219/// Walk `input`, decide about every chunk, and build the sanitised file.
220fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
221    let mut budget = limits.max_items;
222    let (chunks, trailing) = riff::read(input, WAVE, &mut budget).map_err(convert)?;
223    validate_shape(&chunks)?;
224
225    let mut findings = Vec::new();
226    let mut retained = Vec::new();
227    let mut notes = Vec::new();
228
229    let mut body: Vec<u8> = Vec::with_capacity(input.len());
230    for chunk in &chunks {
231        let decision = decide(chunk, options, limits);
232        notes.extend(decision.notes);
233        retained.extend(decision.retained);
234        findings.extend(decision.findings);
235        match decision.outcome {
236            Outcome::Keep => body.extend_from_slice(chunk.raw),
237            Outcome::Replace(bytes) => body.extend_from_slice(&bytes),
238            Outcome::Drop => {}
239        }
240    }
241
242    if !trailing.is_empty() {
243        // Nothing reads past the length the RIFF header declares, and few users know anything
244        // can be there.
245        findings.push(Finding::new(
246            MetadataKind::Other,
247            "trailing data after the RIFF chunk",
248            as_u64(trailing.len()),
249        ));
250    }
251
252    // Said once per file, because the whole design rests on it: the samples are copied without
253    // being looked at, so anything hidden *in the audio* is still there.
254    notes.push(Note::OutOfScopeContent {
255        location: "audio samples, which are copied without being decoded".to_owned(),
256    });
257
258    // Unreachable in practice: the output body is never larger than the input's declared RIFF
259    // size, which was itself read as a `u32`.
260    let output = riff::write(WAVE, &body).map_err(|d| malformed(d, None))?;
261
262    Ok(Processed {
263        findings,
264        retained,
265        notes,
266        output,
267    })
268}
269
270/// A container-layer walk failure as this format's error.
271fn convert(error: WalkError) -> StryptError {
272    match error {
273        WalkError::Malformed { detail, offset } => malformed(detail, as_offset(offset)),
274        WalkError::Limit(limit) => StryptError::LimitExceeded {
275            format: Format::Wav,
276            limit,
277        },
278    }
279}
280
281/// Refuse a chunk list that is not a shape this handler has understood.
282///
283/// The first two checks stop the handler emitting something that passes for a WAV and is not
284/// one: a file consisting of a `bext` and nothing else would otherwise strip to an empty
285/// container and be reported as a success, which is the fail-closed rule's worst case.
286fn validate_shape(chunks: &[Chunk<'_>]) -> Result<()> {
287    for chunk in chunks {
288        if &chunk.kind == b"LIST" && matches!(riff::list_form(chunk.data), Some((LIST_WAVL, _))) {
289            return Err(StryptError::UnsupportedFormat {
290                format: UnsupportedKind::WaveList,
291            });
292        }
293        // A `fmt ` shorter than the PCM form is not a `fmt `, and every field after it in the
294        // file is being read relative to something this handler cannot check.
295        if &chunk.kind == b"fmt " && chunk.data.len() < FMT_MINIMUM {
296            return Err(malformed(
297                MalformedDetail::LengthOutOfRange,
298                as_offset(chunk.offset),
299            ));
300        }
301    }
302
303    for required in [b"fmt ", b"data"] {
304        if !chunks.iter().any(|c| &c.kind == required) {
305            return Err(malformed(
306                MalformedDetail::MissingMarker,
307                as_offset(riff::HEADER_BYTES),
308            ));
309        }
310    }
311    Ok(())
312}
313
314/// Decide about one chunk.
315fn decide(chunk: &Chunk<'_>, options: &InspectOptions, limits: &ParseLimits) -> Decision {
316    let size = as_u64(chunk.data.len());
317    let kind = &chunk.kind;
318
319    if KEPT.contains(&kind) {
320        return Decision::keep();
321    }
322    if ZEROED.contains(&kind) {
323        return zeroed(chunk);
324    }
325
326    match kind {
327        b"LIST" => list(chunk, options, limits),
328        // EBU Tech 3285. The chunk this tranche exists for: an originator, a globally unique
329        // material identifier, and an unbounded log of every process the audio has been through.
330        b"bext" => Decision::drop_with(fields(
331            chunk.data,
332            BEXT_FIELDS,
333            "bext",
334            size,
335            MetadataKind::Other,
336            options,
337        )),
338        // AES46-2002: the broadcast traffic chunk, which names a client and a producing station.
339        b"cart" => Decision::drop_with(fields(
340            chunk.data,
341            CART_FIELDS,
342            "cart",
343            size,
344            MetadataKind::Other,
345            options,
346        )),
347        // XMP in RIFF, as Adobe's applications write it.
348        b"_PMX" => {
349            let found = xmp::scan(chunk.data, "_PMX", options);
350            if found.is_empty() {
351                Decision::drop_one(MetadataKind::Other, "_PMX", size)
352            } else {
353                Decision::drop_with(found)
354            }
355        }
356        // Field-recorder XML: project, scene, take, note, and the recorder's serial number.
357        b"iXML" => Decision::drop_one(MetadataKind::DeviceIdentity, "iXML", size),
358        // Dropped whole rather than parsed. An ID3 reader belongs to the MP3 tranche, and this
359        // handler does not need one to remove the tag (ADR-0037).
360        b"id3 " | b"ID3 " => Decision::drop_one(MetadataKind::Other, "id3", size),
361        // A clipboard rendering of the file — often its title, sometimes a bitmap.
362        b"DISP" => Decision::drop_one(MetadataKind::Comment, "DISP", size),
363        // The code page the text chunks were written in, which narrows down where they came from.
364        b"CSET" => Decision::drop_one(MetadataKind::Other, "CSET", size),
365        // Sampler metadata: MIDI manufacturer and product numbers, and an SMPTE offset. Removing
366        // it costs the file its loop points, which is worth saying out loud.
367        b"smpl" => Decision {
368            outcome: Outcome::Drop,
369            findings: vec![Finding::new(MetadataKind::DeviceIdentity, "smpl", size)],
370            retained: Vec::new(),
371            notes: vec![Note::CapabilityRemoved {
372                location: "smpl".to_owned(),
373                capability: "be looped by a sampler at the points it recorded".to_owned(),
374            }],
375        },
376        b"inst" => Decision::drop_one(MetadataKind::Other, "inst", size),
377        b"plst" => Decision::drop_one(MetadataKind::Other, "plst", size),
378        // Everything else, `aXML` — EBU Tech 3285's arbitrary XML document — included. An unknown
379        // chunk is precisely where a producer puts something they do not want a metadata tool to
380        // look at, and RIFF readers are required to skip what they do not know, so dropping one
381        // cannot break a player.
382        _ => Decision::drop_one(MetadataKind::Other, name_of(kind), size),
383    }
384}
385
386/// `JUNK`, `PAD `, `FLLR`: kept at their length, every byte zeroed.
387///
388/// Dropping them instead would be simpler and would lose the alignment the file was written with.
389/// Zeroing scrubs whatever was in the buffer without costing the user the room it exists for.
390fn zeroed(chunk: &Chunk<'_>) -> Decision {
391    if chunk.data.iter().all(|b| *b == 0) {
392        // Already clean, so it is copied rather than rebuilt — which keeps a padded file
393        // byte-identical through a strip.
394        return Decision::keep();
395    }
396    let mut out = Vec::with_capacity(chunk.raw.len());
397    if riff::write_chunk(&mut out, chunk.kind, &vec![0u8; chunk.data.len()]).is_err() {
398        // Unreachable: the length came from a chunk that was already read.
399        return Decision::keep();
400    }
401    Decision {
402        outcome: Outcome::Replace(out),
403        findings: vec![Finding::new(
404            MetadataKind::Other,
405            name_of(&chunk.kind),
406            as_u64(chunk.data.len()),
407        )],
408        retained: vec![Retained {
409            location: name_of(&chunk.kind),
410            reason: RetentionReason::StructurallyRequired,
411        }],
412        notes: Vec::new(),
413    }
414}
415
416/// `LIST`: a form type and a nested chunk sequence.
417fn list(chunk: &Chunk<'_>, options: &InspectOptions, limits: &ParseLimits) -> Decision {
418    let size = as_u64(chunk.data.len());
419    let Some((form, rest)) = riff::list_form(chunk.data) else {
420        return Decision::drop_one(MetadataKind::Other, "LIST", size);
421    };
422    // `wavl` never reaches here: `validate_shape` refuses the file.
423    if form != LIST_INFO && form != LIST_ADTL {
424        return Decision::drop_one(
425            MetadataKind::Other,
426            format!("LIST {}", name_of(&form)),
427            size,
428        );
429    }
430
431    let mut budget = limits.max_items;
432    let Ok(items) = riff::chunks(rest, 0, &mut budget) else {
433        // The list does not parse, so it is removed whole and reported as one item rather than
434        // half-read. Nothing is kept, so there is nothing to be wrong about.
435        return Decision::drop_one(
436            MetadataKind::Other,
437            format!("LIST {}", name_of(&form)),
438            size,
439        );
440    };
441
442    let mut findings = Vec::new();
443    for item in &items {
444        let bytes = as_u64(item.data.len());
445        let (field, kind) = if form == LIST_INFO {
446            INFO_TAGS
447                .iter()
448                .find(|(tag, _, _)| *tag == &item.kind)
449                .map_or_else(
450                    || (name_of(&item.kind), MetadataKind::Other),
451                    |(_, name, kind)| ((*name).to_owned(), *kind),
452                )
453        } else {
454            // `adtl`: `labl` and `note` are free text attached to a cue point, `ltxt` is a
455            // labelled text region.
456            (name_of(&item.kind), MetadataKind::Comment)
457        };
458        findings.push(
459            Finding::new(kind, format!("LIST {}", name_of(&form)), bytes)
460                .with_field(field)
461                .with_value(options, || MetadataValue::Text(text_of(item.data))),
462        );
463    }
464    if findings.is_empty() {
465        findings.push(Finding::new(
466            MetadataKind::Other,
467            format!("LIST {}", name_of(&form)),
468            size,
469        ));
470    }
471    Decision::drop_with(findings)
472}
473
474/// Report the non-empty fixed-width text fields of a chunk that is going whole.
475///
476/// A chunk too short for the table still yields one finding under `fallback`: something is there,
477/// it is going, and silence would read as "no metadata here".
478fn fields(
479    data: &[u8],
480    table: &[(usize, usize, &str, MetadataKind)],
481    location: &str,
482    size: u64,
483    fallback: MetadataKind,
484    options: &InspectOptions,
485) -> Vec<Finding> {
486    let mut out = Vec::new();
487    for (at, len, name, kind) in table {
488        let Some(tail) = data.get(*at..) else {
489            continue;
490        };
491        let raw = tail.get(..*len).unwrap_or(tail);
492        let value = text_of(raw);
493        if value.is_empty() {
494            continue;
495        }
496        out.push(
497            Finding::new(*kind, location.to_owned(), as_u64(raw.len()))
498                .with_field((*name).to_owned())
499                .with_value(options, || MetadataValue::Text(value.clone())),
500        );
501    }
502    if out.is_empty() {
503        out.push(Finding::new(fallback, location.to_owned(), size));
504    }
505    out
506}
507
508/// A fixed-width or NUL-terminated RIFF string as reportable text.
509///
510/// Padding here is written as NULs by some producers and as spaces by others, so both are
511/// trimmed. Control characters are dropped rather than rendered, which is also what keeps a
512/// hostile field from writing escape sequences into a terminal.
513fn text_of(raw: &[u8]) -> String {
514    let end = raw.iter().position(|b| *b == 0).unwrap_or(raw.len());
515    let head = raw.get(..end).unwrap_or_default();
516    xmp::name_of(head).trim().to_owned()
517}
518
519/// A malformed-file error for this format.
520fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
521    StryptError::Malformed {
522        format: Format::Wav,
523        offset,
524        detail,
525    }
526}
527
528/// A byte position as a reportable offset.
529fn as_offset(position: usize) -> Option<u64> {
530    u64::try_from(position).ok()
531}
532
533/// Widen a length for reporting. Saturating: a report field is not worth failing a strip over.
534fn as_u64(value: usize) -> u64 {
535    u64::try_from(value).unwrap_or(u64::MAX)
536}
537
538#[cfg(test)]
539mod tests {
540    // Test code is never reachable from untrusted bytes, which is the boundary the
541    // panic-freedom lints exist to police (ADR-0006).
542    #![allow(
543        clippy::unwrap_used,
544        clippy::expect_used,
545        clippy::indexing_slicing,
546        clippy::arithmetic_side_effects
547    )]
548
549    use super::*;
550
551    fn chunk(kind: FourCc, payload: &[u8]) -> Vec<u8> {
552        let mut out = Vec::new();
553        riff::write_chunk(&mut out, kind, payload).unwrap();
554        out
555    }
556
557    /// A 16-bit mono PCM format chunk at 8 kHz.
558    fn fmt() -> Vec<u8> {
559        let mut p = Vec::new();
560        p.extend_from_slice(&1u16.to_le_bytes()); // PCM
561        p.extend_from_slice(&1u16.to_le_bytes()); // channels
562        p.extend_from_slice(&8000u32.to_le_bytes());
563        p.extend_from_slice(&16000u32.to_le_bytes());
564        p.extend_from_slice(&2u16.to_le_bytes());
565        p.extend_from_slice(&16u16.to_le_bytes());
566        chunk(*b"fmt ", &p)
567    }
568
569    fn data() -> Vec<u8> {
570        chunk(*b"data", b"SYNTHETIC-SAMPLES-0001\0\0")
571    }
572
573    fn wav(parts: &[Vec<u8>]) -> Vec<u8> {
574        riff::write(WAVE, &parts.concat()).unwrap()
575    }
576
577    fn strip_ok(input: &[u8]) -> Stripped {
578        WavHandler
579            .strip(input, &StripOptions::default())
580            .expect("strip failed")
581    }
582
583    fn findings(input: &[u8]) -> Vec<Finding> {
584        WavHandler
585            .inspect(input, &InspectOptions::names_only())
586            .expect("inspect failed")
587            .findings
588    }
589
590    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
591        haystack.windows(needle.len()).any(|w| w == needle)
592    }
593
594    /// A `LIST`/`INFO` carrying the given tags.
595    fn info(tags: &[(FourCc, &[u8])]) -> Vec<u8> {
596        let mut body = LIST_INFO.to_vec();
597        for (tag, value) in tags {
598            let mut v = value.to_vec();
599            v.push(0);
600            body.extend_from_slice(&chunk(*tag, &v));
601        }
602        chunk(*b"LIST", &body)
603    }
604
605    #[test]
606    fn a_clean_file_comes_back_byte_identical() {
607        let input = wav(&[fmt(), data()]);
608        let stripped = strip_ok(&input);
609        assert!(stripped.report.removed.is_empty());
610        assert_eq!(stripped.bytes, input, "a clean WAV was rewritten");
611    }
612
613    #[test]
614    fn the_audio_crosses_byte_for_byte() {
615        let input = wav(&[fmt(), info(&[(*b"IART", b"SYNTHETIC-ARTIST-0002")]), data()]);
616        let output = strip_ok(&input).bytes;
617        assert!(contains(&output, b"SYNTHETIC-SAMPLES-0001"));
618        assert!(!contains(&output, b"SYNTHETIC-ARTIST-0002"));
619    }
620
621    #[test]
622    fn an_info_list_is_itemised_by_tag() {
623        let input = wav(&[
624            fmt(),
625            info(&[
626                (*b"IART", b"SYNTHETIC-ARTIST-0002"),
627                (*b"ISFT", b"SYNTHETIC-RECORDER-0003"),
628                (*b"ICRD", b"2026-09-01"),
629            ]),
630            data(),
631        ]);
632        let found = findings(&input);
633        let fields: Vec<&str> = found.iter().filter_map(|f| f.field.as_deref()).collect();
634        assert!(fields.contains(&"Artist"), "{fields:?}");
635        assert!(fields.contains(&"Software"), "{fields:?}");
636        assert!(fields.contains(&"CreationDate"), "{fields:?}");
637        let kinds: Vec<MetadataKind> = found.iter().map(|f| f.kind).collect();
638        assert!(kinds.contains(&MetadataKind::PersonalIdentity));
639        assert!(kinds.contains(&MetadataKind::SoftwareFingerprint));
640        assert!(kinds.contains(&MetadataKind::Timestamp));
641    }
642
643    #[test]
644    fn an_unrecognised_info_tag_is_still_removed_and_reported() {
645        let input = wav(&[
646            fmt(),
647            info(&[(*b"IZZZ", b"SYNTHETIC-PRIVATE-0004")]),
648            data(),
649        ]);
650        let stripped = strip_ok(&input);
651        assert!(!contains(&stripped.bytes, b"SYNTHETIC-PRIVATE-0004"));
652        assert_eq!(stripped.report.removed[0].field.as_deref(), Some("IZZZ"));
653    }
654
655    #[test]
656    fn a_broadcast_extension_is_itemised_down_to_its_coding_history() {
657        let mut p = vec![0u8; 602];
658        p[0..9].copy_from_slice(b"SYNTHETIC");
659        p[256..265].copy_from_slice(b"ORIG-0005");
660        p[288..297].copy_from_slice(b"REF--0006");
661        p[320..330].copy_from_slice(b"2026-09-01");
662        p[348..357].copy_from_slice(b"UMID-0007");
663        p.extend_from_slice(b"A=PCM,F=8000,W=16,M=mono,T=SYNTHETIC-DECK-0008");
664        let input = wav(&[fmt(), chunk(*b"bext", &p), data()]);
665
666        let found = findings(&input);
667        let fields: Vec<&str> = found.iter().filter_map(|f| f.field.as_deref()).collect();
668        for want in [
669            "Description",
670            "Originator",
671            "OriginatorReference",
672            "OriginationDate",
673            "UMID",
674            "CodingHistory",
675        ] {
676            assert!(fields.contains(&want), "{want} missing from {fields:?}");
677        }
678
679        let stripped = strip_ok(&input);
680        assert!(!contains(&stripped.bytes, b"SYNTHETIC-DECK-0008"));
681        assert!(!contains(&stripped.bytes, b"UMID-0007"));
682    }
683
684    #[test]
685    fn an_empty_broadcast_extension_still_yields_one_finding() {
686        // Silence would read as "no metadata here".
687        let input = wav(&[fmt(), chunk(*b"bext", &[0u8; 602]), data()]);
688        let found = findings(&input);
689        assert_eq!(found.len(), 1);
690        assert_eq!(found[0].location, "bext");
691    }
692
693    #[test]
694    fn an_id3_tag_is_dropped_whole_without_being_parsed() {
695        let mut tag = b"ID3\x04\x00\x00\x00\x00\x00\x20".to_vec();
696        tag.extend_from_slice(b"TPE1SYNTHETIC-ID3-ARTIST-0009");
697        let input = wav(&[fmt(), data(), chunk(*b"id3 ", &tag)]);
698        let stripped = strip_ok(&input);
699        assert!(!contains(&stripped.bytes, b"SYNTHETIC-ID3-ARTIST-0009"));
700        assert_eq!(stripped.report.removed[0].location, "id3");
701    }
702
703    #[test]
704    fn an_xmp_packet_is_itemised_by_property() {
705        let input = wav(&[
706            fmt(),
707            data(),
708            chunk(
709                *b"_PMX",
710                br#"<x:xmpmeta xmpMM:DocumentID="uuid:1" xmp:CreatorTool="SYNTHETIC"/>"#,
711            ),
712        ]);
713        let fields: Vec<String> = findings(&input)
714            .iter()
715            .filter_map(|f| f.field.clone())
716            .collect();
717        assert!(fields.iter().any(|f| f == "xmpMM:DocumentID"), "{fields:?}");
718    }
719
720    #[test]
721    fn a_sampler_chunk_says_what_the_file_can_no_longer_do() {
722        let input = wav(&[fmt(), data(), chunk(*b"smpl", &[0u8; 36])]);
723        let stripped = strip_ok(&input);
724        assert!(matches!(
725            stripped.report.notes.first(),
726            Some(Note::CapabilityRemoved { location, .. }) if location == "smpl"
727        ));
728    }
729
730    #[test]
731    fn padding_is_zeroed_at_its_length_rather_than_dropped() {
732        let input = wav(&[fmt(), chunk(*b"JUNK", b"SYNTHETIC-LEFTOVER-0010!"), data()]);
733        let stripped = strip_ok(&input);
734        assert!(!contains(&stripped.bytes, b"SYNTHETIC-LEFTOVER-0010"));
735        assert_eq!(
736            stripped.bytes.len(),
737            input.len(),
738            "zeroing changed the file's length"
739        );
740        assert_eq!(
741            stripped.report.retained[0].reason,
742            RetentionReason::StructurallyRequired
743        );
744    }
745
746    #[test]
747    fn padding_that_is_already_zero_is_copied_rather_than_rebuilt() {
748        let input = wav(&[fmt(), chunk(*b"JUNK", &[0u8; 32]), data()]);
749        let stripped = strip_ok(&input);
750        assert!(stripped.report.removed.is_empty());
751        assert_eq!(stripped.bytes, input);
752    }
753
754    #[test]
755    fn a_cue_chunk_survives_because_removing_metadata_cannot_move_its_offsets() {
756        let mut cue = 1u32.to_le_bytes().to_vec();
757        cue.extend_from_slice(&[0u8; 24]);
758        let input = wav(&[fmt(), data(), chunk(*b"cue ", &cue)]);
759        assert_eq!(strip_ok(&input).bytes, input);
760    }
761
762    #[test]
763    fn an_unknown_chunk_is_removed_rather_than_preserved() {
764        let input = wav(&[fmt(), data(), chunk(*b"PrVw", b"SYNTHETIC-PRIVATE-0011")]);
765        let stripped = strip_ok(&input);
766        assert!(!contains(&stripped.bytes, b"SYNTHETIC-PRIVATE-0011"));
767        assert_eq!(stripped.report.removed[0].location, "PrVw");
768    }
769
770    #[test]
771    fn data_after_the_riff_chunk_is_removed() {
772        let mut input = wav(&[fmt(), data()]);
773        input.extend_from_slice(b"SYNTHETIC-APPENDED-0012");
774        let stripped = strip_ok(&input);
775        assert!(!contains(&stripped.bytes, b"SYNTHETIC-APPENDED-0012"));
776        assert_eq!(
777            stripped.report.removed[0].location,
778            "trailing data after the RIFF chunk"
779        );
780    }
781
782    #[test]
783    fn a_file_with_no_format_or_no_audio_is_refused_rather_than_emptied() {
784        // Otherwise this strips to a valid-looking container with no audio in it, and the user is
785        // told it succeeded.
786        for parts in [
787            vec![fmt()],
788            vec![data()],
789            vec![chunk(*b"bext", &[0u8; 602])],
790        ] {
791            assert!(
792                matches!(
793                    WavHandler.strip(&wav(&parts), &StripOptions::default()),
794                    Err(StryptError::Malformed {
795                        detail: MalformedDetail::MissingMarker,
796                        ..
797                    })
798                ),
799                "a WAV missing fmt or data was accepted"
800            );
801        }
802    }
803
804    #[test]
805    fn a_wave_list_is_refused_by_name_rather_than_edited() {
806        // The one shape where a cue offset indexes into something removal could move.
807        let mut body = LIST_WAVL.to_vec();
808        body.extend_from_slice(&chunk(*b"data", b"AB"));
809        let input = wav(&[fmt(), chunk(*b"LIST", &body), data()]);
810        assert!(matches!(
811            WavHandler.inspect(&input, &InspectOptions::names_only()),
812            Err(StryptError::UnsupportedFormat {
813                format: UnsupportedKind::WaveList,
814            })
815        ));
816    }
817
818    #[test]
819    fn a_format_chunk_shorter_than_the_pcm_form_is_refused() {
820        let input = wav(&[chunk(*b"fmt ", &[0u8; 8]), data()]);
821        assert!(matches!(
822            WavHandler.inspect(&input, &InspectOptions::names_only()),
823            Err(StryptError::Malformed {
824                detail: MalformedDetail::LengthOutOfRange,
825                ..
826            })
827        ));
828    }
829
830    #[test]
831    fn a_riff_size_beyond_the_end_of_the_file_is_refused_rather_than_clamped() {
832        let mut input = wav(&[fmt(), data()]);
833        input[4..8].copy_from_slice(&0x00FF_FFFFu32.to_le_bytes());
834        assert!(matches!(
835            WavHandler.inspect(&input, &InspectOptions::names_only()),
836            Err(StryptError::Malformed {
837                detail: MalformedDetail::LengthOutOfRange,
838                ..
839            })
840        ));
841    }
842
843    #[test]
844    fn a_chunk_count_beyond_the_limit_is_refused() {
845        let mut parts = vec![fmt(), data()];
846        parts.extend((0..64).map(|_| chunk(*b"PrVw", b"xx")));
847        let options = StripOptions {
848            limits: ParseLimits {
849                max_items: 8,
850                ..ParseLimits::default()
851            },
852            ..StripOptions::default()
853        };
854        assert!(matches!(
855            WavHandler.strip(&wav(&parts), &options),
856            Err(StryptError::LimitExceeded { .. })
857        ));
858    }
859
860    #[test]
861    fn values_are_withheld_from_a_default_inspection() {
862        let input = wav(&[fmt(), info(&[(*b"IART", b"SYNTHETIC-ARTIST-0002")]), data()]);
863        assert_eq!(findings(&input)[0].value, None);
864        let with_values = WavHandler
865            .inspect(&input, &InspectOptions::with_values())
866            .unwrap();
867        assert_eq!(
868            with_values.findings[0].value,
869            Some(MetadataValue::Text("SYNTHETIC-ARTIST-0002".to_owned()))
870        );
871    }
872
873    #[test]
874    fn the_uninspected_audio_is_declared_on_every_file() {
875        let stripped = strip_ok(&wav(&[fmt(), data()]));
876        assert!(
877            stripped
878                .report
879                .notes
880                .iter()
881                .any(|n| matches!(n, Note::OutOfScopeContent { location } if location.starts_with("audio samples")))
882        );
883    }
884
885    #[test]
886    fn stripping_twice_changes_nothing() {
887        let input = wav(&[
888            fmt(),
889            info(&[(*b"IART", b"SYNTHETIC-ARTIST-0002")]),
890            chunk(*b"JUNK", b"SYNTHETIC-LEFTOVER-0010!"),
891            data(),
892            chunk(*b"bext", &[0u8; 602]),
893        ]);
894        let once = strip_ok(&input).bytes;
895        let twice = strip_ok(&once).bytes;
896        assert_eq!(once, twice, "strip is not idempotent");
897        assert!(findings(&once).is_empty(), "a strip left something behind");
898    }
899
900    #[test]
901    fn an_odd_length_chunk_keeps_its_padding_byte() {
902        let input = wav(&[fmt(), chunk(*b"data", b"ODD")]);
903        let stripped = strip_ok(&input);
904        assert_eq!(stripped.bytes, input);
905        assert_eq!(stripped.bytes.len() % 2, 0);
906    }
907
908    #[test]
909    fn truncation_at_every_length_is_refused_or_survived_but_never_panics() {
910        let input = wav(&[
911            fmt(),
912            info(&[(*b"IART", b"SYNTHETIC-ARTIST-0002")]),
913            chunk(*b"bext", &[7u8; 700]),
914            chunk(*b"JUNK", b"x"),
915            data(),
916            chunk(*b"smpl", &[0u8; 36]),
917        ]);
918        for n in 0..=input.len() {
919            let prefix = &input[0..n];
920            let _ = WavHandler.inspect(prefix, &InspectOptions::names_only());
921            let _ = WavHandler.strip(prefix, &StripOptions::default());
922        }
923    }
924}