Skip to main content

strypt_core/formats/
mp4.rs

1//! MP4 and M4A: the ISO base media file format carrying tracks rather than a picture.
2//!
3//! # Why this is edited by deletion where HEIF is rebuilt
4//!
5//! `stco` holds absolute file offsets into `mdat` (§8.7.5), so removing a box in front of the media
6//! moves every chunk. ADR-0034 met that sentence in HEIF and rebuilt the file; here it reverses.
7//! HEIF's metadata is *inside* `mdat`, interleaved with the picture, so removals leave a hole and
8//! nothing translates uniformly. MP4's metadata is entirely outside `mdat` — `udta`, `meta`, a
9//! top-level `uuid` — so each `mdat` moves as one rigid block and the media never changes.
10//!
11//! The tree is therefore filtered and re-emitted: `ftyp` and every `mdat` cross verbatim, header
12//! form included, and only `moov` is written fresh. Every chunk offset is then remapped through a
13//! table of `mdat` extents, and **an offset that resolves inside none of them refuses the file**
14//! rather than being nudged by a delta nobody verified (ADR-0042).
15//!
16//! Fragmented files and encrypted files are refused by name: the first keeps sample offsets in
17//! structures this handler does not rewrite, the second is ciphertext no rule here matches.
18
19use crate::bytes::Reader;
20use crate::container::bmff::{self, Box as Bmff, BoxType, WalkError};
21use crate::detect::Format;
22use crate::error::{MalformedDetail, Result, StryptError, UnsupportedKind};
23use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, xmp};
24use crate::report::{
25    Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, Retained,
26    RetentionReason, StripReport,
27};
28
29pub(crate) mod boxes;
30
31/// The MP4 and M4A handler. One type, one instance per format, as [`super::heif`] does.
32#[derive(Debug, Clone, Copy)]
33#[non_exhaustive]
34pub struct Mp4Handler {
35    format: Format,
36}
37
38impl Mp4Handler {
39    /// The handler instance for MP4 — `.mp4`, `.m4v`.
40    pub const MP4: Self = Self {
41        format: Format::Mp4,
42    };
43    /// The handler instance for M4A — `.m4a`, `.m4b`.
44    pub const M4A: Self = Self {
45        format: Format::M4a,
46    };
47}
48
49impl MetadataHandler for Mp4Handler {
50    fn name(&self) -> &'static str {
51        self.format.id()
52    }
53
54    fn format(&self) -> Format {
55        self.format
56    }
57
58    fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
59        // The same pass strip runs, output discarded, so the two cannot drift (ARCHITECTURE §3).
60        let processed = process(self.format, input, options, &ParseLimits::default())?;
61        Ok(MetadataReport {
62            format: self.format,
63            findings: processed.findings,
64            notes: processed.notes,
65        })
66    }
67
68    fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
69        let processed = process(self.format, input, &options.inspect, &options.limits)?;
70        Ok(Stripped {
71            report: StripReport {
72                format: self.format,
73                removed: processed.findings,
74                retained: processed.retained,
75                notes: processed.notes,
76                input_bytes: as_u64(input.len()),
77                output_bytes: as_u64(processed.output.len()),
78            },
79            bytes: processed.output,
80        })
81    }
82}
83
84/// One run of the shared inspect/strip pass.
85struct Processed {
86    output: Vec<u8>,
87    findings: Vec<Finding>,
88    retained: Vec<Retained>,
89    notes: Vec<Note>,
90}
91
92/// One `mdat`'s payload, where it was and where it lands.
93#[derive(Debug, Clone, Copy)]
94struct Extent {
95    old_start: u64,
96    old_end: u64,
97    new_start: u64,
98}
99
100/// Resolve a chunk offset against the input's `mdat` extents.
101///
102/// [`None`] when it falls in none of them — into a box that was dropped, into `moov`, or past the
103/// end of the file. The caller turns that into a refusal (ADR-0042 decision 2).
104fn relocate(extents: &[Extent], offset: u64) -> Option<u64> {
105    extents
106        .iter()
107        .find(|e| offset >= e.old_start && offset < e.old_end)
108        .and_then(|e| offset.checked_sub(e.old_start)?.checked_add(e.new_start))
109}
110
111/// A box as it will be written.
112enum Node<'a> {
113    /// Copied byte for byte, header form included.
114    Copy(&'a [u8]),
115    /// A container whose children were filtered.
116    Container {
117        kind: BoxType,
118        children: Vec<Node<'a>>,
119    },
120    /// A full box whose fields were edited in place. `body` includes the version and flags.
121    Patched { kind: BoxType, body: Vec<u8> },
122    /// A chunk offset table, written once with placeholders to measure and once for real.
123    Offsets {
124        kind: BoxType,
125        head: [u8; 4],
126        values: Vec<u64>,
127    },
128}
129
130/// A top-level emission, in input order.
131enum Top<'a> {
132    Copy(&'a [u8]),
133    Mdat {
134        raw: &'a [u8],
135        header: u64,
136        old_start: u64,
137        len: u64,
138    },
139    Moov,
140}
141
142/// Read `input`, name what is being dropped, and write the edited file.
143fn process(
144    format: Format,
145    input: &[u8],
146    options: &InspectOptions,
147    limits: &ParseLimits,
148) -> Result<Processed> {
149    let mut budget = limits.max_items;
150    let (top, trailing) = bmff::top_level(input, &mut budget).map_err(|e| from_walk(format, e))?;
151
152    // Before anything else, so a refusal names what the file is rather than what it lacks.
153    for b in &top {
154        refuse_structural(b.kind)?;
155    }
156    let ftyp = bmff::find(&top, *b"ftyp")
157        .ok_or_else(|| malformed(format, MalformedDetail::MissingMarker))?;
158    refuse_brands(ftyp.payload)?;
159
160    // Exactly one movie box. None is a file with no index; more than one is a file whose index is
161    // ambiguous, and guessing which one a player picks is not a decision to make on a user's behalf.
162    match top.iter().filter(|b| b.is(*b"moov")).count() {
163        1 => {}
164        0 => return Err(malformed(format, MalformedDetail::MissingMarker)),
165        _ => return Err(malformed(format, MalformedDetail::UnexpectedMarker)),
166    }
167
168    let mut findings = Vec::new();
169    let mut tops = Vec::with_capacity(top.len());
170    let mut moov_node = None;
171
172    for b in &top {
173        let raw = bmff::raw(input, b)
174            .ok_or_else(|| malformed(format, MalformedDetail::LengthOutOfRange))?;
175        if b.is(*b"moov") {
176            moov_node = Some(container_node(
177                format,
178                input,
179                b,
180                b.payload,
181                limits.max_depth,
182                &mut budget,
183                &mut findings,
184                options,
185                None,
186            )?);
187            tops.push(Top::Moov);
188        } else if b.is(*b"mdat") {
189            let old_start = b.offset.saturating_add(b.header);
190            tops.push(Top::Mdat {
191                raw,
192                header: b.header,
193                old_start,
194                len: b.size.saturating_sub(b.header),
195            });
196        } else if boxes::TOP_LEVEL_COPIED.contains(&b.kind) {
197            tops.push(Top::Copy(raw));
198        } else {
199            findings.extend(report_dropped(
200                b,
201                "",
202                limits.max_depth,
203                &mut budget,
204                options,
205            ));
206        }
207    }
208
209    if !trailing.is_empty() {
210        // Appended past the last box, where nothing reads them — as JPEG after `EOI` (§7.2).
211        findings.push(Finding::new(
212            MetadataKind::Other,
213            "trailing data",
214            as_u64(trailing.len()),
215        ));
216    }
217
218    let moov = moov_node.ok_or_else(|| malformed(format, MalformedDetail::MissingMarker))?;
219    require_playable(format, &moov)?;
220
221    // Pass one measures `moov`; pass two writes it for real. The widths never change, so the two
222    // must agree — and if they do not, the layout the offsets were computed against is wrong and
223    // nothing is written (ADR-0042 decision 3).
224    let probe = write_moov(format, &moov, None)?;
225    let extents = layout(&tops, as_u64(probe.len()));
226    let written = write_moov(format, &moov, Some(&extents))?;
227    if written.len() != probe.len() {
228        return Err(malformed(format, MalformedDetail::NotRoundTrippable));
229    }
230
231    let mut output = Vec::with_capacity(input.len());
232    for t in &tops {
233        match t {
234            Top::Copy(raw) | Top::Mdat { raw, .. } => output.extend_from_slice(raw),
235            Top::Moov => output.extend_from_slice(&written),
236        }
237    }
238
239    Ok(Processed {
240        output,
241        findings,
242        retained: vec![Retained {
243            location: "moov/trak/mdia/minf/stbl/stsd (codec configuration)".to_owned(),
244            // Parameter sets and codec setup. Removing them leaves samples nothing can decode.
245            reason: RetentionReason::StructurallyRequired,
246        }],
247        notes: vec![Note::OutOfScopeContent {
248            location: "coded samples (SEI user data and in-band codec headers are not decoded)"
249                .to_owned(),
250        }],
251    })
252}
253
254/// Where each `mdat`'s payload lands once `moov` is `moov_len` bytes long.
255fn layout(tops: &[Top<'_>], moov_len: u64) -> Vec<Extent> {
256    let mut at = 0u64;
257    let mut extents = Vec::new();
258    for t in tops {
259        match t {
260            Top::Copy(raw) => at = at.saturating_add(as_u64(raw.len())),
261            Top::Mdat {
262                raw,
263                header,
264                old_start,
265                len,
266            } => {
267                let new_start = at.saturating_add(*header);
268                extents.push(Extent {
269                    old_start: *old_start,
270                    old_end: old_start.saturating_add(*len),
271                    new_start,
272                });
273                at = at.saturating_add(as_u64(raw.len()));
274            }
275            Top::Moov => at = at.saturating_add(moov_len),
276        }
277    }
278    extents
279}
280
281/// Refuse the two shapes this handler will not edit, wherever their marker boxes appear.
282fn refuse_structural(kind: BoxType) -> Result<()> {
283    if boxes::FRAGMENT_BOXES.contains(&kind) {
284        return Err(StryptError::UnsupportedFormat {
285            format: UnsupportedKind::FragmentedMp4,
286        });
287    }
288    if boxes::PROTECTION_BOXES.contains(&kind) {
289        return Err(StryptError::UnsupportedFormat {
290            format: UnsupportedKind::ProtectedMedia,
291        });
292    }
293    Ok(())
294}
295
296/// Refuse on what the file declares itself to be.
297///
298/// Re-checked here rather than trusted from detection: a caller may reach a handler directly, and
299/// fail-closed means the refusal does not depend on who dispatched (§5.4).
300fn refuse_brands(ftyp: &[u8]) -> Result<()> {
301    for brand in ftyp.chunks_exact(4) {
302        let Ok(b) = <[u8; 4]>::try_from(brand) else {
303            continue;
304        };
305        // The minor version sits between the major brand and the compatible list and is a number,
306        // not a brand. Matching it against these lists is harmless: none of them is a plausible
307        // version, and the check that matters is done on the same window detection uses.
308        if boxes::FRAGMENT_BRANDS.contains(&b) {
309            return Err(StryptError::UnsupportedFormat {
310                format: UnsupportedKind::FragmentedMp4,
311            });
312        }
313        if b == boxes::PROTECTED_BRAND {
314            return Err(StryptError::UnsupportedFormat {
315                format: UnsupportedKind::ProtectedMedia,
316            });
317        }
318        if b == boxes::QUICKTIME_BRAND {
319            return Err(StryptError::UnsupportedFormat {
320                format: UnsupportedKind::QuickTimeMovie,
321            });
322        }
323        if b.get(..3) == Some(b"3gp") || b.get(..3) == Some(b"3g2") {
324            return Err(StryptError::UnsupportedFormat {
325                format: UnsupportedKind::ThirdGenerationPartnership,
326            });
327        }
328    }
329    Ok(())
330}
331
332/// A `moov` with no `mvhd`, or with no surviving track, is not a file anybody can play.
333///
334/// Refused rather than written: output that opens as an empty container while the report says the
335/// strip succeeded is the failure in `docs/THREAT_MODEL.md` §5.4.
336fn require_playable(format: Format, moov: &Node<'_>) -> Result<()> {
337    let Node::Container { children, .. } = moov else {
338        return Err(malformed(format, MalformedDetail::MissingMarker));
339    };
340    let has = |kind: BoxType| {
341        children.iter().any(|c| match c {
342            Node::Container { kind: k, .. }
343            | Node::Patched { kind: k, .. }
344            | Node::Offsets { kind: k, .. } => *k == kind,
345            Node::Copy(_) => false,
346        })
347    };
348    if has(*b"mvhd") && has(*b"trak") {
349        Ok(())
350    } else {
351        Err(malformed(format, MalformedDetail::MissingMarker))
352    }
353}
354
355// ---------------------------------------------------------------------------------------------
356// Building the tree
357// ---------------------------------------------------------------------------------------------
358
359/// Filter one container's children against the allow-list for its type.
360#[allow(clippy::too_many_arguments)]
361fn container_node<'a>(
362    format: Format,
363    input: &'a [u8],
364    parent: &Bmff<'a>,
365    payload: &'a [u8],
366    depth: u32,
367    budget: &mut u32,
368    findings: &mut Vec<Finding>,
369    options: &InspectOptions,
370    handler: Option<BoxType>,
371) -> Result<Node<'a>> {
372    let kids =
373        bmff::children_at(parent, payload, depth, budget).map_err(|e| from_walk(format, e))?;
374    let mut children = Vec::with_capacity(kids.len());
375
376    // §8.5.1: which sample entry a `stsd` holds is decided by the track's handler type, so it is
377    // read at `mdia` and carried down to the `stbl` that needs it.
378    let handler = if parent.is(*b"mdia") {
379        bmff::find(&kids, *b"hdlr")
380            .and_then(Bmff::full)
381            .and_then(|(_, _, rest)| rest.get(4..8).and_then(|s| BoxType::try_from(s).ok()))
382    } else {
383        handler
384    };
385
386    for kid in &kids {
387        refuse_structural(kid.kind)?;
388        if !boxes::kept_in(parent.kind, kid.kind) {
389            findings.extend(report_dropped(
390                kid,
391                location_of(parent.kind),
392                depth,
393                budget,
394                options,
395            ));
396            continue;
397        }
398        let raw = bmff::raw(input, kid)
399            .ok_or_else(|| malformed(format, MalformedDetail::LengthOutOfRange))?;
400
401        if boxes::is_container(kid.kind) {
402            children.push(container_node(
403                format,
404                input,
405                kid,
406                kid.payload,
407                depth.saturating_sub(1),
408                budget,
409                findings,
410                options,
411                handler,
412            )?);
413            continue;
414        }
415
416        if kid.kind == boxes::STCO || kid.kind == boxes::CO64 {
417            children.push(offsets_node(format, kid)?);
418            continue;
419        }
420        match &kid.kind {
421            b"mvhd" | b"tkhd" | b"mdhd" => {
422                children.push(header_node(format, kid, raw, findings, options)?);
423            }
424            b"hdlr" => children.push(handler_node(kid, raw, findings, options)),
425            b"stsd" => {
426                children.push(sample_description_node(
427                    format,
428                    kid,
429                    raw,
430                    handler,
431                    depth.saturating_sub(1),
432                    budget,
433                    findings,
434                    options,
435                )?);
436            }
437            b"dinf" => {
438                check_data_reference(format, kid, depth.saturating_sub(1), budget)?;
439                children.push(Node::Copy(raw));
440            }
441            _ => children.push(Node::Copy(raw)),
442        }
443    }
444
445    Ok(Node::Container {
446        kind: parent.kind,
447        children,
448    })
449}
450
451/// The reporting path for a box dropped from inside `parent`.
452fn location_of(parent: BoxType) -> &'static str {
453    match &parent {
454        b"moov" => "moov",
455        b"trak" => "moov/trak",
456        b"mdia" => "moov/trak/mdia",
457        b"minf" => "moov/trak/mdia/minf",
458        b"stbl" => "moov/trak/mdia/minf/stbl",
459        _ => "",
460    }
461}
462
463/// Where one of the three patched header boxes sits in the tree, for reporting.
464fn path_of(kind: BoxType) -> &'static str {
465    match &kind {
466        b"mvhd" => "moov/mvhd",
467        b"tkhd" => "moov/trak/tkhd",
468        _ => "moov/trak/mdia/mdhd",
469    }
470}
471
472/// `und`, packed as three five-bit letters (§8.4.2). What a track declares when it is not in any
473/// particular language.
474const LANGUAGE_UNDETERMINED: [u8; 2] = [0x55, 0xC4];
475
476/// Zero the timestamps in `mvhd`, `tkhd` or `mdhd`, and the two fields that ride along with them.
477fn header_node<'a>(
478    format: Format,
479    b: &Bmff<'a>,
480    raw: &'a [u8],
481    findings: &mut Vec<Finding>,
482    options: &InspectOptions,
483) -> Result<Node<'a>> {
484    let (version, flags, rest) = b
485        .full()
486        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
487    let wide = version == 1;
488    let times = if wide { 16usize } else { 8 };
489    let Some(head) = rest.get(..times) else {
490        // Too short to hold the fields it declares. Copied rather than half-edited; the walker
491        // already proved the box tiles, so this is a producer quirk, not an attack surface.
492        return Ok(Node::Copy(raw));
493    };
494
495    let mut body = Vec::with_capacity(rest.len().saturating_add(4));
496    if head.iter().any(|byte| *byte != 0) {
497        findings.push(
498            Finding::new(MetadataKind::Timestamp, path_of(b.kind), as_u64(times))
499                .with_field("creation_time, modification_time"),
500        );
501    }
502    body.push(version);
503    let f = flags.to_be_bytes();
504    body.extend_from_slice(f.get(1..4).unwrap_or(&[0, 0, 0]));
505    body.resize(body.len().saturating_add(times), 0);
506    body.extend_from_slice(rest.get(times..).unwrap_or_default());
507
508    // `body` is version+flags followed by `rest`, so a field at `n` within `rest` is at `n + 4`.
509    if b.is(*b"mdhd") {
510        let at: usize = if wide { 32 } else { 20 };
511        if rest.get(at.saturating_sub(4)..at.saturating_sub(2)) != Some(&LANGUAGE_UNDETERMINED)
512            && set_bytes(&mut body, at, &LANGUAGE_UNDETERMINED)
513        {
514            findings.push(
515                Finding::new(MetadataKind::Other, "moov/trak/mdia/mdhd", 2).with_field("language"),
516            );
517        }
518    }
519    if b.is(*b"mvhd") {
520        // §8.2.2 says these 24 bytes are pre-defined and should already be zero. QuickTime writes
521        // poster time, preview and selection windows there, and ExifTool reports every one.
522        let at: usize = if wide { 84 } else { 72 };
523        let already_zero = body
524            .get(at..at.saturating_add(24))
525            .is_none_or(|s| s.iter().all(|byte| *byte == 0));
526        if !already_zero && set_bytes(&mut body, at, &[0u8; 24]) {
527            findings.push(
528                Finding::new(MetadataKind::Other, "moov/mvhd", 24)
529                    .with_field("pre_defined")
530                    .with_value(options, || {
531                        MetadataValue::Text("poster, preview and selection times".to_owned())
532                    }),
533            );
534        }
535    }
536
537    Ok(Node::Patched { kind: b.kind, body })
538}
539
540/// Overwrite `len` bytes of `body` at `at`. False when the range does not fit.
541fn set_bytes(body: &mut [u8], at: usize, value: &[u8]) -> bool {
542    let Some(end) = at.checked_add(value.len()) else {
543        return false;
544    };
545    match body.get_mut(at..end) {
546        Some(slot) => {
547            slot.copy_from_slice(value);
548            true
549        }
550        None => false,
551    }
552}
553
554/// How far into a `hdlr` box's payload the free-text name starts (§8.4.3).
555const HDLR_NAME_OFFSET: usize = 20;
556
557/// Empty `hdlr`'s trailing name, which is where encoders write their own product name.
558fn handler_node<'a>(
559    b: &Bmff<'a>,
560    raw: &'a [u8],
561    findings: &mut Vec<Finding>,
562    options: &InspectOptions,
563) -> Node<'a> {
564    let Some((version, flags, rest)) = b.full() else {
565        return Node::Copy(raw);
566    };
567    let Some(name) = rest.get(HDLR_NAME_OFFSET..) else {
568        return Node::Copy(raw);
569    };
570    // Already empty — an absent name or a bare terminator. Copied so that a clean file comes back
571    // byte-identical rather than gaining a byte (ADR-0042).
572    if name.is_empty() || name == b"\0" {
573        return Node::Copy(raw);
574    }
575
576    findings.push(
577        Finding::new(
578            MetadataKind::SoftwareFingerprint,
579            "moov/trak/mdia/hdlr",
580            as_u64(name.len()),
581        )
582        .with_field("name")
583        .with_value(options, || MetadataValue::Text(xmp::name_of(name))),
584    );
585
586    let mut body = Vec::with_capacity(HDLR_NAME_OFFSET.saturating_add(5));
587    body.push(version);
588    let f = flags.to_be_bytes();
589    body.extend_from_slice(f.get(1..4).unwrap_or(&[0, 0, 0]));
590    body.extend_from_slice(rest.get(..HDLR_NAME_OFFSET).unwrap_or_default());
591    body.push(0);
592    Node::Patched { kind: b.kind, body }
593}
594
595/// Read a `stco` or `co64` table. The entries are remapped when the tree is written.
596fn offsets_node<'a>(format: Format, b: &Bmff<'_>) -> Result<Node<'a>> {
597    let (version, flags, rest) = b
598        .full()
599        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
600    let wide = b.is(boxes::CO64);
601    let width = if wide { 8usize } else { 4 };
602
603    let mut r = Reader::new(rest);
604    let count = r
605        .u32_be()
606        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
607    let count =
608        usize::try_from(count).map_err(|_| malformed(format, MalformedDetail::BrokenIndex))?;
609    let declared = count
610        .checked_mul(width)
611        .ok_or_else(|| malformed(format, MalformedDetail::BrokenIndex))?;
612    // Exactly, not at least: a table shorter than it claims would be written back padded, and one
613    // longer hides bytes nothing accounted for.
614    if r.remaining() != declared {
615        return Err(malformed(format, MalformedDetail::BrokenIndex));
616    }
617
618    let mut values = Vec::with_capacity(count);
619    for _ in 0..count {
620        let value = if wide {
621            let hi = r
622                .u32_be()
623                .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
624            let lo = r
625                .u32_be()
626                .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
627            (u64::from(hi) << 32) | u64::from(lo)
628        } else {
629            u64::from(
630                r.u32_be()
631                    .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?,
632            )
633        };
634        values.push(value);
635    }
636
637    let f = flags.to_be_bytes();
638    let head = [
639        version,
640        f.get(1).copied().unwrap_or(0),
641        f.get(2).copied().unwrap_or(0),
642        f.get(3).copied().unwrap_or(0),
643    ];
644    Ok(Node::Offsets {
645        kind: b.kind,
646        head,
647        values,
648    })
649}
650
651/// Read the sample descriptions: refuse encrypted samples, and clear the encoder name.
652///
653/// The entry *type* is the encryption signal (§8.5.2 with ISO/IEC 23001-7). strypt does not descend
654/// into an entry to look for a `sinf`, because the fixed fields in front of an entry's child boxes
655/// differ per media type and it does not parse them — recorded in `docs/THREAT_MODEL.md` §7.17.
656///
657/// `compressorname` is the one exception, and only for a video track: §12.1.3 puts it at a fixed
658/// offset in `VisualSampleEntry`, it is a producer's own string (`Lavc libx264`, a camera's
659/// firmware), and nothing decodes it. It is zeroed in place, so the entry keeps its length and
660/// every offset behind it.
661#[allow(clippy::too_many_arguments)]
662fn sample_description_node<'a>(
663    format: Format,
664    b: &Bmff<'_>,
665    raw: &'a [u8],
666    handler: Option<BoxType>,
667    depth: u32,
668    budget: &mut u32,
669    findings: &mut Vec<Finding>,
670    options: &InspectOptions,
671) -> Result<Node<'a>> {
672    let (version, flags, rest) = b
673        .full()
674        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
675    let entries = rest.get(4..).unwrap_or_default();
676    // A sample description that does not tile is refused rather than skipped: an entry nobody read
677    // is an entry nobody ruled encryption out of.
678    let kids = bmff::children_at(b, entries, depth, budget).map_err(|e| from_walk(format, e))?;
679    for kid in &kids {
680        if boxes::PROTECTED_SAMPLE_ENTRIES.contains(&kid.kind) {
681            return Err(StryptError::UnsupportedFormat {
682                format: UnsupportedKind::ProtectedMedia,
683            });
684        }
685    }
686
687    if handler != Some(*b"vide") {
688        return Ok(Node::Copy(raw));
689    }
690
691    // Offsets are into `body`, which is version+flags then `rest`, matching how the box is written.
692    let mut body = Vec::with_capacity(rest.len().saturating_add(4));
693    body.push(version);
694    let f = flags.to_be_bytes();
695    body.extend_from_slice(f.get(1..4).unwrap_or(&[0, 0, 0]));
696    body.extend_from_slice(rest);
697
698    let mut touched = false;
699    for kid in &kids {
700        // The entry's payload starts `header` bytes into the box; `compressorname` is 32 bytes at
701        // offset 42 of the payload, itself a length-prefixed string.
702        let Some(at) = kid
703            .offset
704            .checked_add(kid.header)
705            .and_then(|start| start.checked_sub(b.offset.checked_add(b.header)?))
706            .and_then(|within| usize::try_from(within).ok())
707            .and_then(|within| within.checked_add(COMPRESSOR_NAME_OFFSET))
708        else {
709            continue;
710        };
711        let Some(end) = at.checked_add(COMPRESSOR_NAME_LEN) else {
712            continue;
713        };
714        let Some(field) = body.get(at..end) else {
715            continue;
716        };
717        if field.iter().all(|byte| *byte == 0) {
718            continue;
719        }
720        findings.push(
721            Finding::new(
722                MetadataKind::SoftwareFingerprint,
723                "moov/trak/mdia/minf/stbl/stsd",
724                as_u64(COMPRESSOR_NAME_LEN),
725            )
726            .with_field("compressorname")
727            .with_value(options, || {
728                MetadataValue::Text(compressor_name(field).unwrap_or_default())
729            }),
730        );
731        set_bytes(&mut body, at, &[0u8; COMPRESSOR_NAME_LEN]);
732        touched = true;
733    }
734
735    if touched {
736        Ok(Node::Patched { kind: b.kind, body })
737    } else {
738        Ok(Node::Copy(raw))
739    }
740}
741
742/// §12.1.3's `compressorname`: 32 bytes at this offset into a `VisualSampleEntry`'s payload.
743const COMPRESSOR_NAME_OFFSET: usize = 42;
744const COMPRESSOR_NAME_LEN: usize = 32;
745
746/// The encoder string, read as the length-prefixed name §12.1.3 specifies.
747fn compressor_name(field: &[u8]) -> Option<String> {
748    let len = usize::from(*field.first()?).min(COMPRESSOR_NAME_LEN.saturating_sub(1));
749    Some(xmp::name_of(field.get(1..1usize.checked_add(len)?)?))
750}
751
752/// Refuse a track whose media lives in another file.
753///
754/// §8.7.2: a `dref` entry with the self-contained flag clear names an external URL, so the `mdat`
755/// this handler relocates offsets into is not where the samples are.
756fn check_data_reference(
757    format: Format,
758    dinf: &Bmff<'_>,
759    depth: u32,
760    budget: &mut u32,
761) -> Result<()> {
762    let kids =
763        bmff::children_at(dinf, dinf.payload, depth, budget).map_err(|e| from_walk(format, e))?;
764    let Some(dref) = bmff::find(&kids, *b"dref") else {
765        return Ok(());
766    };
767    let Some((_, _, rest)) = dref.full() else {
768        return Err(malformed(format, MalformedDetail::Truncated));
769    };
770    let entries = rest.get(4..).unwrap_or_default();
771    let refs = bmff::children_at(dref, entries, depth.saturating_sub(1), budget)
772        .map_err(|e| from_walk(format, e))?;
773    for entry in &refs {
774        let self_contained = entry.full().is_some_and(|(_, flags, _)| flags & 1 != 0);
775        if !self_contained {
776            return Err(malformed(format, MalformedDetail::UnsupportedFeature));
777        }
778    }
779    Ok(())
780}
781
782// ---------------------------------------------------------------------------------------------
783// Writing
784// ---------------------------------------------------------------------------------------------
785
786/// Serialise the `moov` subtree. `extents` absent writes placeholder offsets, to measure only.
787fn write_moov(format: Format, moov: &Node<'_>, extents: Option<&[Extent]>) -> Result<Vec<u8>> {
788    let mut out = Vec::new();
789    write_node(&mut out, moov, extents).map_err(|detail| malformed(format, detail))?;
790    Ok(out)
791}
792
793fn write_node(
794    out: &mut Vec<u8>,
795    node: &Node<'_>,
796    extents: Option<&[Extent]>,
797) -> std::result::Result<(), MalformedDetail> {
798    match node {
799        Node::Copy(raw) => {
800            out.extend_from_slice(raw);
801            Ok(())
802        }
803        Node::Container { kind, children } => bmff::write_box(out, *kind, |o| {
804            for child in children {
805                write_node(o, child, extents)?;
806            }
807            Ok(())
808        }),
809        Node::Patched { kind, body } => bmff::write_box(out, *kind, |o| {
810            o.extend_from_slice(body);
811            Ok(())
812        }),
813        Node::Offsets { kind, head, values } => bmff::write_box(out, *kind, |o| {
814            o.extend_from_slice(head);
815            let count =
816                u32::try_from(values.len()).map_err(|_| MalformedDetail::LengthOutOfRange)?;
817            o.extend_from_slice(&count.to_be_bytes());
818            let wide = kind == &boxes::CO64;
819            for value in values {
820                let mapped = match extents {
821                    // The measuring pass. Widths are fixed, so the placeholder is the same size
822                    // as whatever the second pass writes here.
823                    None => 0,
824                    Some(extents) => {
825                        relocate(extents, *value).ok_or(MalformedDetail::BrokenIndex)?
826                    }
827                };
828                if wide {
829                    o.extend_from_slice(&mapped.to_be_bytes());
830                } else {
831                    let narrow =
832                        u32::try_from(mapped).map_err(|_| MalformedDetail::LengthOutOfRange)?;
833                    o.extend_from_slice(&narrow.to_be_bytes());
834                }
835            }
836            Ok(())
837        }),
838    }
839}
840
841// ---------------------------------------------------------------------------------------------
842// Reporting
843// ---------------------------------------------------------------------------------------------
844
845/// How many levels of a dropped box are walked to name what was in it.
846const REPORT_DESCENT: u32 = 3;
847
848/// Name what one dropped box held.
849fn report_dropped(
850    b: &Bmff<'_>,
851    parent: &str,
852    depth: u32,
853    budget: &mut u32,
854    options: &InspectOptions,
855) -> Vec<Finding> {
856    let at = if parent.is_empty() {
857        xmp::name_of(&b.kind)
858    } else {
859        format!("{parent}/{}", xmp::name_of(&b.kind))
860    };
861
862    if b.is(*b"uuid") {
863        if b.payload.get(..16) == Some(&boxes::XMP_UUID)
864            && let Some(packet) = b.payload.get(16..)
865        {
866            let scanned = xmp::scan(packet, &format!("{at} (XMP)"), options);
867            if !scanned.is_empty() {
868                return scanned;
869            }
870        }
871        return vec![Finding::new(MetadataKind::Other, at, b.size)];
872    }
873    if matches!(&b.kind, b"free" | b"skip" | b"wide") {
874        return vec![Finding::new(MetadataKind::Other, "free space", b.size).with_field(at)];
875    }
876
877    // `udta` and `meta` are where the tags live, so they are walked rather than named whole: a
878    // report that said "1.2 kB of udta" would not tell a user their video carries their address.
879    if matches!(&b.kind, b"udta" | b"meta" | b"ilst" | b"keys") {
880        let body = if b.is(*b"meta") {
881            b.full().map_or(b.payload, |(_, _, rest)| rest)
882        } else {
883            b.payload
884        };
885        if depth > 0
886            && let Ok(kids) = bmff::children_at(b, body, REPORT_DESCENT.min(depth), budget)
887            && !kids.is_empty()
888        {
889            let mut out = Vec::new();
890            for kid in &kids {
891                out.extend(report_dropped(
892                    kid,
893                    &at,
894                    depth.saturating_sub(1),
895                    budget,
896                    options,
897                ));
898            }
899            return out;
900        }
901    }
902
903    let kind = tag_kind(b.kind);
904    let mut finding = Finding::new(kind, at, b.size).with_field(xmp::name_of(&b.kind));
905    if let Some(text) = tag_text(b.payload) {
906        finding = finding.with_value(options, || MetadataValue::Text(text));
907    }
908    vec![finding]
909}
910
911/// What one metadata atom is, by its four-character name.
912///
913/// The names are Apple's `udta` vocabulary and the iTunes `ilst` keys built on it. `©xyz` is the
914/// one that matters most: it is an ISO-6709 coordinate, and every iPhone and most Android phones
915/// write it into every video they record.
916fn tag_kind(name: BoxType) -> MetadataKind {
917    match &name {
918        b"\xA9xyz" | b"loci" | b"gps " | b"\xA9gps" => MetadataKind::Location,
919        b"\xA9day" | b"date" | b"\xA9dtm" => MetadataKind::Timestamp,
920        b"\xA9too" | b"\xA9swr" | b"\xA9enc" | b"\xA9req" => MetadataKind::SoftwareFingerprint,
921        b"\xA9mak" | b"\xA9mod" | b"mak " | b"mod " | b"\xA9xmk" | b"\xA9xmd" => {
922            MetadataKind::DeviceIdentity
923        }
924        b"\xA9ART" | b"aART" | b"\xA9wrt" | b"\xA9alb" | b"\xA9cpy" | b"cprt" | b"auth"
925        | b"perf" | b"\xA9prf" | b"\xA9ope" => MetadataKind::PersonalIdentity,
926        b"covr" | b"\xA9art" => MetadataKind::Thumbnail,
927        b"\xA9cmt" | b"desc" | b"ldes" | b"\xA9des" | b"\xA9lyr" => MetadataKind::Comment,
928        _ => MetadataKind::Other,
929    }
930}
931
932/// A tag's text, where the payload is one of the two shapes these atoms use.
933///
934/// `udta` atoms carry a two-byte length and a two-byte language before the text; an `ilst` value
935/// sits in a `data` box behind four bytes of type and four of locale. Anything else returns
936/// [`None`] rather than being guessed at — a report field is not worth a wrong answer.
937fn tag_text(payload: &[u8]) -> Option<String> {
938    let text = payload.get(8..).filter(|_| payload.len() > 8)?;
939    let printable = text.iter().all(|b| *b >= 0x20 || *b == b'\n');
940    if printable && std::str::from_utf8(text).is_ok() {
941        return Some(xmp::name_of(text));
942    }
943    let short = payload.get(4..)?;
944    if short.iter().all(|b| *b >= 0x20) && std::str::from_utf8(short).is_ok() {
945        return Some(xmp::name_of(short));
946    }
947    None
948}
949
950// ---------------------------------------------------------------------------------------------
951
952fn from_walk(format: Format, e: WalkError) -> StryptError {
953    match e {
954        WalkError::Malformed(detail) => malformed(format, detail),
955        WalkError::Limit(limit) => StryptError::LimitExceeded { format, limit },
956    }
957}
958
959fn malformed(format: Format, detail: MalformedDetail) -> StryptError {
960    StryptError::Malformed {
961        format,
962        offset: None,
963        detail,
964    }
965}
966
967fn as_u64(value: usize) -> u64 {
968    u64::try_from(value).unwrap_or(u64::MAX)
969}
970
971#[cfg(test)]
972mod tests {
973    // Test code is never reachable from untrusted bytes, which is the boundary the panic-freedom
974    // lints police (ADR-0006).
975    #![allow(
976        clippy::unwrap_used,
977        clippy::indexing_slicing,
978        clippy::arithmetic_side_effects
979    )]
980
981    use super::*;
982
983    #[test]
984    fn an_offset_outside_every_mdat_does_not_resolve() {
985        // The whole safety argument (ADR-0042 decision 2): an offset into a box that was dropped
986        // must refuse the file rather than be nudged by a delta nobody verified.
987        let extents = [Extent {
988            old_start: 100,
989            old_end: 200,
990            new_start: 40,
991        }];
992        assert_eq!(relocate(&extents, 100), Some(40));
993        assert_eq!(relocate(&extents, 199), Some(139));
994        assert_eq!(relocate(&extents, 200), None);
995        assert_eq!(relocate(&extents, 99), None);
996    }
997
998    #[test]
999    fn several_mdats_each_translate_by_their_own_delta() {
1000        let extents = [
1001            Extent {
1002                old_start: 100,
1003                old_end: 200,
1004                new_start: 90,
1005            },
1006            Extent {
1007                old_start: 300,
1008                old_end: 400,
1009                new_start: 250,
1010            },
1011        ];
1012        assert_eq!(relocate(&extents, 150), Some(140));
1013        assert_eq!(relocate(&extents, 350), Some(300));
1014        assert_eq!(relocate(&extents, 250), None);
1015    }
1016
1017    #[test]
1018    fn the_gps_atom_is_named_as_a_location() {
1019        assert_eq!(tag_kind(*b"\xA9xyz"), MetadataKind::Location);
1020        assert_eq!(tag_kind(*b"\xA9too"), MetadataKind::SoftwareFingerprint);
1021        assert_eq!(tag_kind(*b"XPRV"), MetadataKind::Other);
1022    }
1023}