Skip to main content

strypt_core/formats/
heif.rs

1//! HEIF and AVIF: one ISO-BMFF container, two codecs (HEVC and AV1). One handler for both, which
2//! is why ADR-0032 makes them one tranche.
3//!
4//! # Why the file is rebuilt rather than edited
5//!
6//! Exif and XMP are *items* here — declared in `iinf`, located by `iloc` as absolute file offsets
7//! into `mdat`, beside the coded picture. There is no delimited region to excise, and dropping an
8//! item's bytes moves every surviving one. So the file is written fresh from allow-lists in
9//! [`boxes`], with every offset computed against the buffer being built and the coded picture
10//! copied byte for byte (ADR-0034; ADR-0033 for the same reasoning applied to TIFF).
11//!
12//! Two free-text fields leak without being metadata boxes: `hdlr`'s name (libheif writes its own
13//! there) and `infe`'s item name. Both are written empty and reported.
14//!
15//! A `moov` box or a sequence brand means a motion file — an Apple Live Photo — which is refused
16//! by name rather than half-cleaned.
17
18use crate::bytes::Reader;
19use crate::container::bmff::{self, Box as Bmff, BoxType, WalkError};
20use crate::detect::Format;
21use crate::error::{MalformedDetail, Result, StryptError};
22use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, exif, xmp};
23use crate::report::{
24    Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, Retained,
25    RetentionReason, StripReport,
26};
27
28pub(crate) mod boxes;
29
30/// The HEIF and AVIF handler. One type, one instance per format, as [`super::ooxml`] does.
31#[derive(Debug, Clone, Copy)]
32#[non_exhaustive]
33pub struct HeifHandler {
34    format: Format,
35}
36
37impl HeifHandler {
38    /// The handler instance for HEIF — `.heic` and `.heif`.
39    pub const HEIF: Self = Self {
40        format: Format::Heif,
41    };
42    /// The handler instance for AVIF.
43    pub const AVIF: Self = Self {
44        format: Format::Avif,
45    };
46}
47
48impl MetadataHandler for HeifHandler {
49    fn name(&self) -> &'static str {
50        self.format.id()
51    }
52
53    fn format(&self) -> Format {
54        self.format
55    }
56
57    fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
58        // The same pass strip runs, output discarded, so the two cannot drift (ARCHITECTURE §3).
59        let processed = process(self.format, input, options, &ParseLimits::default())?;
60        Ok(MetadataReport {
61            format: self.format,
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(self.format, input, &options.inspect, &options.limits)?;
69        Ok(Stripped {
70            report: StripReport {
71                format: self.format,
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/// One run of the shared inspect/strip pass.
84struct Processed {
85    output: Vec<u8>,
86    findings: Vec<Finding>,
87    retained: Vec<Retained>,
88    notes: Vec<Note>,
89}
90
91/// An item as the input declared it.
92struct Item<'a> {
93    /// The input's identifier. Never written out: items are renumbered from 1.
94    id: u32,
95    kind: BoxType,
96    /// `infe`'s free-text name. Written empty.
97    name: &'a [u8],
98    /// A `mime` item's declared content type, used to tell XMP from anything else.
99    content_type: &'a [u8],
100    /// The payload, resolved from its extents and bounds-checked.
101    data: Vec<&'a [u8]>,
102    bytes: u64,
103}
104
105/// One entry of `iref`.
106struct Reference {
107    kind: BoxType,
108    from: u32,
109    to: Vec<u32>,
110}
111
112/// A property, as it will be written.
113struct Property<'a> {
114    kind: BoxType,
115    payload: &'a [u8],
116}
117
118/// One item's association with a property, preserving the essential bit.
119#[derive(Clone, Copy)]
120struct Association {
121    /// One-based index into `ipco`.
122    index: u16,
123    /// Whether a reader that cannot understand the property must refuse the image.
124    essential: bool,
125}
126
127/// Everything read out of `meta`, before anything is decided.
128struct Parsed<'a> {
129    ftyp: &'a [u8],
130    primary: u32,
131    items: Vec<Item<'a>>,
132    references: Vec<Reference>,
133    properties: Vec<Property<'a>>,
134    associations: Vec<(u32, Vec<Association>)>,
135    /// `hdlr`'s free-text name, reported when non-empty.
136    handler_name: &'a [u8],
137    /// Boxes inside `meta` the allow-list does not keep. §8.11.2 puts `xml ` and `bxml` here, and
138    /// a top-level-only rule would drop them silently.
139    stray_meta: Vec<(BoxType, u64)>,
140}
141
142/// Read `input`, name what is being dropped, and write the rebuilt 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    let mut findings = Vec::new();
153    let mut notes = Vec::new();
154
155    // Before anything else, so the refusal names what the file is.
156    for b in &top {
157        if boxes::MOTION_BOXES.contains(&b.kind) {
158            return Err(StryptError::UnsupportedFormat {
159                format: crate::error::UnsupportedKind::MotionHeif,
160            });
161        }
162    }
163
164    let parsed = parse(format, input, &top, &mut budget, limits)?;
165
166    for b in &top {
167        if boxes::top_level_kept(b.kind) {
168            continue;
169        }
170        // Not kept, but not a finding either: its bytes are accounted for by the items pointing
171        // into it, and reporting it would make `show` announce a finding on a clean file.
172        if b.is(*b"mdat") {
173            continue;
174        }
175        findings.extend(report_stray_box(b, options));
176    }
177
178    for (kind, size) in &parsed.stray_meta {
179        let location = match kind {
180            b"xml " | b"bxml" => "meta/xml",
181            b"uuid" => "meta/uuid",
182            _ => "meta",
183        };
184        findings.push(
185            Finding::new(MetadataKind::Other, location, *size).with_field(xmp::name_of(kind)),
186        );
187    }
188
189    if !trailing.is_empty() {
190        // Appended past the last box, where nothing reads them — as JPEG after `EOI` (§7.2).
191        findings.push(Finding::new(
192            MetadataKind::Other,
193            "trailing data",
194            as_u64(trailing.len()),
195        ));
196    }
197
198    // A property that is not kept is removed, so it is reported. `colr` carrying an ICC profile
199    // is the one that names a device; `udes` is free text describing the image.
200    for property in &parsed.properties {
201        if boxes::property_kept(property.kind, property.payload) {
202            continue;
203        }
204        let kind = if property.kind == *b"colr" {
205            MetadataKind::ColourProfile
206        } else {
207            MetadataKind::Other
208        };
209        findings.push(
210            Finding::new(kind, "iprp/ipco", as_u64(property.payload.len()))
211                .with_field(xmp::name_of(&property.kind)),
212        );
213    }
214
215    let removed_ids = decide(&parsed, &mut findings, options);
216
217    // Without it the output would be a valid container with no picture, reported as success —
218    // §5.4, and the refusal WebP makes for a file with no bitstream (§7.4).
219    let primary_kept = parsed
220        .items
221        .iter()
222        .any(|i| i.id == parsed.primary && !removed_ids.contains(&i.id));
223    if !primary_kept {
224        return Err(malformed(format, MalformedDetail::MissingMarker));
225    }
226
227    if !parsed.handler_name.is_empty() {
228        findings.push(
229            Finding::new(
230                MetadataKind::SoftwareFingerprint,
231                "meta/hdlr",
232                as_u64(parsed.handler_name.len()),
233            )
234            .with_field("name")
235            .with_value(options, || {
236                MetadataValue::Text(xmp::name_of(parsed.handler_name))
237            }),
238        );
239    }
240
241    let output = write(&parsed, &removed_ids, format)?;
242
243    let retained = if parsed
244        .properties
245        .iter()
246        .any(|p| p.kind == *b"colr" && boxes::property_kept(p.kind, p.payload))
247    {
248        vec![Retained {
249            location: "iprp/ipco (colr nclx)".to_owned(),
250            // Numeric colour signalling: dropping it would change how the decoder interprets the
251            // pixels, which is a visible change to the picture rather than a metadata removal.
252            reason: RetentionReason::RemovalWouldAlterPayload,
253        }]
254    } else {
255        Vec::new()
256    };
257
258    notes.extend(parsed_notes(&parsed));
259    Ok(Processed {
260        output,
261        findings,
262        retained,
263        notes,
264    })
265}
266
267/// Notes describing what the structure leaves out of reach.
268fn parsed_notes(parsed: &Parsed<'_>) -> Vec<Note> {
269    let mut notes = Vec::new();
270    if parsed.items.iter().any(|i| i.kind == *b"grid") {
271        notes.push(Note::OutOfScopeContent {
272            location: "grid (tiled image; tiles are moved, never decoded)".to_owned(),
273        });
274    }
275    notes
276}
277
278/// The 16-byte extended type the XMP specification fixes for a `uuid` box (§4.2).
279const XMP_UUID: [u8; 16] = [
280    0xBE, 0x7A, 0xCF, 0xCB, 0x97, 0xA9, 0x42, 0xE8, 0x9C, 0x71, 0x99, 0x94, 0x91, 0xE3, 0xAF, 0xAC,
281];
282
283/// Report a top-level box that is not on the allow-list.
284fn report_stray_box(b: &Bmff<'_>, options: &InspectOptions) -> Vec<Finding> {
285    let size = b.size;
286    if b.is(*b"uuid")
287        && b.payload.get(..16) == Some(&XMP_UUID)
288        && let Some(packet) = b.payload.get(16..)
289    {
290        return xmp::scan(packet, "uuid (XMP)", options);
291    }
292    let location = match &b.kind {
293        b"free" | b"skip" => "free space",
294        b"uuid" => "uuid",
295        _ => "box",
296    };
297    vec![Finding::new(MetadataKind::Other, location, size).with_field(xmp::name_of(&b.kind))]
298}
299
300/// Decide which items go, reporting each. Returns the removed items' input identifiers.
301fn decide(parsed: &Parsed<'_>, findings: &mut Vec<Finding>, options: &InspectOptions) -> Vec<u32> {
302    // What makes an item a thumbnail is the reference, not its codec (§3). A `thmb` runs *from*
303    // the thumbnail *to* the master image, so it is the source that goes — reading it the other
304    // way round deletes the picture and keeps the thumbnail.
305    let thumbnails: Vec<u32> = parsed
306        .references
307        .iter()
308        .filter(|r| r.kind == boxes::REFERENCE_THUMBNAIL)
309        .map(|r| r.from)
310        .collect();
311
312    let mut removed = Vec::new();
313    for item in &parsed.items {
314        let is_thumbnail = thumbnails.contains(&item.id);
315        if boxes::is_image_item(item.kind) && !is_thumbnail {
316            if !item.name.is_empty() {
317                findings.push(
318                    Finding::new(MetadataKind::Other, "iinf/infe", as_u64(item.name.len()))
319                        .with_field("item_name")
320                        .with_value(options, || MetadataValue::Text(xmp::name_of(item.name))),
321                );
322            }
323            continue;
324        }
325        removed.push(item.id);
326        findings.extend(report_item(item, is_thumbnail, options));
327    }
328    removed
329}
330
331/// Name what one removed item held.
332fn report_item(item: &Item<'_>, is_thumbnail: bool, options: &InspectOptions) -> Vec<Finding> {
333    let joined: Vec<u8> = item.data.iter().flat_map(|s| s.iter().copied()).collect();
334
335    if is_thumbnail {
336        return vec![
337            Finding::new(MetadataKind::Thumbnail, "item (thmb)", item.bytes)
338                .with_field(xmp::name_of(&item.kind)),
339        ];
340    }
341
342    if item.kind == *b"Exif" {
343        // §A.2.1: a four-byte offset to the TIFF header, then the block. Passing the whole payload
344        // would shift every offset inside it — the mistake `exif::scan`'s header warns about.
345        let start = u32::from_be_bytes([
346            joined.first().copied().unwrap_or(0),
347            joined.get(1).copied().unwrap_or(0),
348            joined.get(2).copied().unwrap_or(0),
349            joined.get(3).copied().unwrap_or(0),
350        ]);
351        let at = usize::try_from(start)
352            .unwrap_or(usize::MAX)
353            .saturating_add(4);
354        if let Some(tiff) = joined.get(at..) {
355            let scanned = exif::scan(tiff, "item (Exif)", options, &ParseLimits::default());
356            if !scanned.findings.is_empty() {
357                return scanned.findings;
358            }
359        }
360        return vec![Finding::new(MetadataKind::Other, "item (Exif)", item.bytes)];
361    }
362
363    if item.kind == *b"mime" {
364        // XMP declares `application/rdf+xml`. Others go too; the type only names the finding.
365        if xmp::contains(item.content_type, b"rdf+xml") || xmp::contains(&joined, b"<x:xmpmeta") {
366            let scanned = xmp::scan(&joined, "item (XMP)", options);
367            if !scanned.is_empty() {
368                return scanned;
369            }
370        }
371        return vec![
372            Finding::new(MetadataKind::Other, "item (mime)", item.bytes)
373                .with_field(xmp::name_of(item.content_type)),
374        ];
375    }
376
377    vec![
378        Finding::new(
379            MetadataKind::Other,
380            format!("item ({})", xmp::name_of(&item.kind)),
381            item.bytes,
382        )
383        .with_field(xmp::name_of(&item.kind)),
384    ]
385}
386
387// ---------------------------------------------------------------------------------------------
388// Reading
389// ---------------------------------------------------------------------------------------------
390
391/// Read what the rebuild needs out of the box tree.
392fn parse<'a>(
393    format: Format,
394    input: &'a [u8],
395    top: &[Bmff<'a>],
396    budget: &mut u32,
397    limits: &ParseLimits,
398) -> Result<Parsed<'a>> {
399    let ftyp = bmff::find(top, *b"ftyp")
400        .ok_or_else(|| malformed(format, MalformedDetail::MissingMarker))?;
401
402    // §6.4. Checked before the tree is read, so the refusal names the file's own declaration.
403    for brand in ftyp.payload.chunks_exact(4).skip(2) {
404        if let Ok(b) = <[u8; 4]>::try_from(brand)
405            && boxes::SEQUENCE_BRANDS.contains(&b)
406        {
407            return Err(StryptError::UnsupportedFormat {
408                format: crate::error::UnsupportedKind::MotionHeif,
409            });
410        }
411    }
412
413    let meta = bmff::find(top, *b"meta")
414        .ok_or_else(|| malformed(format, MalformedDetail::MissingMarker))?;
415    let (_, _, meta_body) = meta
416        .full()
417        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
418    let children = bmff::children_at(meta, meta_body, limits.max_depth, budget)
419        .map_err(|e| from_walk(format, e))?;
420
421    // Item protection — encryption. Refused as ODF encryption is (§7.7): ciphertext that no rule
422    // matches would be reported clean having been examined by nobody.
423    if bmff::find(&children, *b"ipro").is_some() {
424        return Err(malformed(format, MalformedDetail::UnsupportedFeature));
425    }
426
427    // Read from but never written, so absent from the keep list — which governs what is *written*.
428    let stray_meta: Vec<(BoxType, u64)> = children
429        .iter()
430        .filter(|c| !boxes::meta_kept(c.kind) && !c.is(*b"idat") && !c.is(*b"ipro"))
431        .map(|c| (c.kind, c.size))
432        .collect();
433
434    let handler_name = bmff::find(&children, *b"hdlr")
435        .and_then(|b| handler_name(b))
436        .unwrap_or_default();
437
438    let primary = bmff::find(&children, *b"pitm")
439        .and_then(|b| primary_item(b))
440        .ok_or_else(|| malformed(format, MalformedDetail::MissingMarker))?;
441
442    let infos = bmff::find(&children, *b"iinf")
443        .ok_or_else(|| malformed(format, MalformedDetail::MissingMarker))
444        .and_then(|b| item_infos(format, b, limits.max_depth, budget))?;
445
446    let idat = bmff::find(&children, *b"idat").map(|b| b.payload);
447    let locations = bmff::find(&children, *b"iloc")
448        .ok_or_else(|| malformed(format, MalformedDetail::MissingMarker))
449        .and_then(|b| item_locations(format, b))?;
450
451    let references = bmff::find(&children, *b"iref")
452        .map(|b| item_references(format, b, limits.max_depth, budget))
453        .transpose()?
454        .unwrap_or_default();
455
456    let (properties, associations) = bmff::find(&children, *b"iprp")
457        .map(|b| item_properties(format, b, limits.max_depth, budget))
458        .transpose()?
459        .unwrap_or_default();
460
461    // Resolved now, so a lying extent is refused before anything is decided about what to keep.
462    let mut items = Vec::with_capacity(infos.len());
463    for info in infos {
464        let (kind, name, content_type, protection, id) = info;
465        if protection != 0 {
466            return Err(malformed(format, MalformedDetail::UnsupportedFeature));
467        }
468        let extents = locations
469            .iter()
470            .find(|(item_id, _)| *item_id == id)
471            .map(|(_, e)| e.clone())
472            .unwrap_or_default();
473        let data = resolve(format, input, idat, &extents)?;
474        let bytes = data.iter().map(|s| as_u64(s.len())).sum();
475        items.push(Item {
476            id,
477            kind,
478            name,
479            content_type,
480            data,
481            bytes,
482        });
483    }
484
485    Ok(Parsed {
486        ftyp: ftyp.payload,
487        primary,
488        items,
489        references,
490        properties,
491        associations,
492        handler_name,
493        stray_meta,
494    })
495}
496
497/// An extent, once its construction method and base offset have been applied.
498#[derive(Clone, Copy)]
499struct Extent {
500    /// 0: an offset into the file. 1: into `idat`. 2 is refused.
501    construction: u8,
502    offset: u64,
503    length: u64,
504}
505
506/// Turn extents into the byte ranges they name, refusing any that leaves the file.
507fn resolve<'a>(
508    format: Format,
509    input: &'a [u8],
510    idat: Option<&'a [u8]>,
511    extents: &[Extent],
512) -> Result<Vec<&'a [u8]>> {
513    let mut out = Vec::with_capacity(extents.len());
514    for extent in extents {
515        let source: &[u8] = match extent.construction {
516            0 => input,
517            1 => idat.ok_or_else(|| malformed(format, MalformedDetail::MissingMarker))?,
518            // Method 2 is an offset into another item, which cannot be relocated without resolving
519            // an item graph that may be cyclic.
520            _ => return Err(malformed(format, MalformedDetail::UnsupportedFeature)),
521        };
522        let start = usize::try_from(extent.offset)
523            .map_err(|_| malformed(format, MalformedDetail::LengthOutOfRange))?;
524        let len = usize::try_from(extent.length)
525            .map_err(|_| malformed(format, MalformedDetail::LengthOutOfRange))?;
526        let end = start
527            .checked_add(len)
528            .ok_or_else(|| malformed(format, MalformedDetail::LengthOutOfRange))?;
529        let slice = source
530            .get(start..end)
531            .ok_or_else(|| malformed(format, MalformedDetail::LengthOutOfRange))?;
532        out.push(slice);
533    }
534    Ok(out)
535}
536
537/// Read `hdlr`'s trailing name field.
538fn handler_name<'a>(b: &Bmff<'a>) -> Option<&'a [u8]> {
539    let (_, _, body) = b.full()?;
540    // pre_defined(4) + handler_type(4) + reserved(12), then the name.
541    let rest = body.get(20..)?;
542    let end = rest.iter().position(|&c| c == 0).unwrap_or(rest.len());
543    rest.get(..end)
544}
545
546/// Read `pitm`'s item identifier.
547fn primary_item(b: &Bmff<'_>) -> Option<u32> {
548    let (version, _, body) = b.full()?;
549    let mut r = Reader::new(body);
550    if version == 0 {
551        r.u16_be().map(u32::from)
552    } else {
553        r.u32_be()
554    }
555}
556
557/// Type, name, content type, protection index and identifier, per item.
558type ItemInfo<'a> = (BoxType, &'a [u8], &'a [u8], u16, u32);
559
560fn item_infos<'a>(
561    format: Format,
562    b: &Bmff<'a>,
563    depth: u32,
564    budget: &mut u32,
565) -> Result<Vec<ItemInfo<'a>>> {
566    let (version, _, body) = b
567        .full()
568        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
569    let mut r = Reader::new(body);
570    let _count = if version == 0 {
571        u32::from(
572            r.u16_be()
573                .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?,
574        )
575    } else {
576        r.u32_be()
577            .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?
578    };
579    // Not trusted as a loop bound: the boxes are self-delimiting, so a lying count changes nothing.
580    let rest = r.take_rest();
581    let entries = bmff::children_at(b, rest, depth, budget).map_err(|e| from_walk(format, e))?;
582
583    let mut out = Vec::with_capacity(entries.len());
584    for entry in &entries {
585        if !entry.is(*b"infe") {
586            continue;
587        }
588        out.push(item_info(format, entry)?);
589    }
590    Ok(out)
591}
592
593/// Read one `infe`.
594fn item_info<'a>(format: Format, b: &Bmff<'a>) -> Result<ItemInfo<'a>> {
595    let (version, _, body) = b
596        .full()
597        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
598    // Versions 0 and 1 predate `item_type`, so a coded image cannot be told from a metadata blob.
599    if version < 2 {
600        return Err(malformed(format, MalformedDetail::UnsupportedFeature));
601    }
602    let mut r = Reader::new(body);
603    let id = if version == 2 {
604        u32::from(
605            r.u16_be()
606                .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?,
607        )
608    } else {
609        r.u32_be()
610            .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?
611    };
612    let protection = r
613        .u16_be()
614        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
615    let kind: BoxType = r
616        .take(4)
617        .and_then(|b| b.try_into().ok())
618        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
619    let rest = r.take_rest();
620    let (name, after) = c_string(rest);
621    let content_type = if kind == *b"mime" {
622        c_string(after).0
623    } else {
624        &[][..]
625    };
626    Ok((kind, name, content_type, protection, id))
627}
628
629/// Split a null-terminated string off `data`, returning it and the remainder.
630fn c_string(data: &[u8]) -> (&[u8], &[u8]) {
631    let end = data.iter().position(|&c| c == 0).unwrap_or(data.len());
632    let text = data.get(..end).unwrap_or_default();
633    let rest = data.get(end.saturating_add(1)..).unwrap_or_default();
634    (text, rest)
635}
636
637/// Read `iloc`.
638fn item_locations(format: Format, b: &Bmff<'_>) -> Result<Vec<(u32, Vec<Extent>)>> {
639    let (version, _, body) = b
640        .full()
641        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
642    let mut r = Reader::new(body);
643    let sizes = r
644        .u16_be()
645        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
646    let offset_size = ((sizes >> 12) & 0xF) as u8;
647    let length_size = ((sizes >> 8) & 0xF) as u8;
648    let base_offset_size = ((sizes >> 4) & 0xF) as u8;
649    let index_size = (sizes & 0xF) as u8;
650
651    let count = if version < 2 {
652        u32::from(
653            r.u16_be()
654                .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?,
655        )
656    } else {
657        r.u32_be()
658            .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?
659    };
660
661    let mut out = Vec::new();
662    for _ in 0..count {
663        let id = if version < 2 {
664            u32::from(
665                r.u16_be()
666                    .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?,
667            )
668        } else {
669            r.u32_be()
670                .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?
671        };
672        let construction = if version == 0 {
673            0
674        } else {
675            let word = r
676                .u16_be()
677                .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
678            (word & 0xF) as u8
679        };
680        // Non-zero means the payload is in another file, which cannot be cleaned here.
681        let data_reference = r
682            .u16_be()
683            .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
684        if data_reference != 0 {
685            return Err(malformed(format, MalformedDetail::UnsupportedFeature));
686        }
687        let base = read_uint(&mut r, base_offset_size)
688            .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
689        let extent_count = r
690            .u16_be()
691            .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
692
693        let mut extents = Vec::new();
694        for _ in 0..extent_count {
695            if version > 0 && index_size > 0 {
696                read_uint(&mut r, index_size)
697                    .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
698            }
699            let offset = read_uint(&mut r, offset_size)
700                .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
701            let length = read_uint(&mut r, length_size)
702                .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
703            extents.push(Extent {
704                construction,
705                offset: base.saturating_add(offset),
706                length,
707            });
708        }
709        out.push((id, extents));
710    }
711    Ok(out)
712}
713
714/// Read a big-endian unsigned integer of `size` bytes; zero reads nothing.
715fn read_uint(r: &mut Reader<'_>, size: u8) -> Option<u64> {
716    match size {
717        0 => Some(0),
718        4 => r.u32_be().map(u64::from),
719        8 => {
720            let b: [u8; 8] = r.take(8)?.try_into().ok()?;
721            Some(u64::from_be_bytes(b))
722        }
723        // §8.11.3 permits only 0, 4 and 8; anything else means the walk is lost.
724        _ => None,
725    }
726}
727
728/// Read `iref` and its per-type child boxes.
729fn item_references(
730    format: Format,
731    b: &Bmff<'_>,
732    depth: u32,
733    budget: &mut u32,
734) -> Result<Vec<Reference>> {
735    let (version, _, body) = b
736        .full()
737        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
738    let entries = bmff::children_at(b, body, depth, budget).map_err(|e| from_walk(format, e))?;
739
740    let mut out = Vec::with_capacity(entries.len());
741    for entry in &entries {
742        let mut r = Reader::new(entry.payload);
743        let wide = version != 0;
744        let from = if wide {
745            r.u32_be()
746        } else {
747            r.u16_be().map(u32::from)
748        }
749        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
750        let count = r
751            .u16_be()
752            .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
753        let mut to = Vec::with_capacity(usize::from(count));
754        for _ in 0..count {
755            let id = if wide {
756                r.u32_be()
757            } else {
758                r.u16_be().map(u32::from)
759            }
760            .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
761            to.push(id);
762        }
763        out.push(Reference {
764            kind: entry.kind,
765            from,
766            to,
767        });
768    }
769    Ok(out)
770}
771
772/// Read `iprp` — its `ipco` property list and the `ipma` associations into it.
773type Properties<'a> = (Vec<Property<'a>>, Vec<(u32, Vec<Association>)>);
774
775fn item_properties<'a>(
776    format: Format,
777    b: &Bmff<'a>,
778    depth: u32,
779    budget: &mut u32,
780) -> Result<Properties<'a>> {
781    let children =
782        bmff::children_at(b, b.payload, depth, budget).map_err(|e| from_walk(format, e))?;
783
784    let properties = match bmff::find(&children, *b"ipco") {
785        Some(ipco) => bmff::children_at(ipco, ipco.payload, depth, budget)
786            .map_err(|e| from_walk(format, e))?
787            .iter()
788            .map(|p| Property {
789                kind: p.kind,
790                payload: p.payload,
791            })
792            .collect(),
793        None => Vec::new(),
794    };
795
796    let mut associations = Vec::new();
797    for ipma in children.iter().filter(|c| c.is(*b"ipma")) {
798        let (version, flags, body) = ipma
799            .full()
800            .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
801        let mut r = Reader::new(body);
802        let count = r
803            .u32_be()
804            .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
805        for _ in 0..count {
806            let id = if version == 0 {
807                r.u16_be().map(u32::from)
808            } else {
809                r.u32_be()
810            }
811            .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
812            let n = r
813                .u8()
814                .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
815            let mut list = Vec::with_capacity(usize::from(n));
816            for _ in 0..n {
817                // §8.11.14: flag bit 0 widens the index to 15 bits; the top bit marks essential.
818                let (essential, index) = if flags & 1 == 1 {
819                    let word = r
820                        .u16_be()
821                        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
822                    ((word & 0x8000) != 0, word & 0x7FFF)
823                } else {
824                    let byte = r
825                        .u8()
826                        .ok_or_else(|| malformed(format, MalformedDetail::Truncated))?;
827                    ((byte & 0x80) != 0, u16::from(byte & 0x7F))
828                };
829                list.push(Association { index, essential });
830            }
831            associations.push((id, list));
832        }
833    }
834
835    Ok((properties, associations))
836}
837
838// ---------------------------------------------------------------------------------------------
839// Writing
840// ---------------------------------------------------------------------------------------------
841
842/// Fixed widths for the `iloc` written here, so its encoded size does not depend on the offset
843/// *values* — which is what makes the two-pass layout below exact.
844const ILOC_OFFSET_SIZE: u8 = 4;
845const ILOC_LENGTH_SIZE: u8 = 4;
846
847/// Build the output: `ftyp`, `meta`, then one `mdat` of the retained payloads.
848///
849/// `iloc` sits inside `meta` and names absolute offsets, so `meta` is written twice — once to
850/// learn its length, once with the offsets that length implies.
851fn write(parsed: &Parsed<'_>, removed: &[u32], format: Format) -> Result<Vec<u8>> {
852    let kept: Vec<&Item<'_>> = parsed
853        .items
854        .iter()
855        .filter(|i| !removed.contains(&i.id))
856        .collect();
857
858    let mut ftyp = Vec::new();
859    bmff::write_box(&mut ftyp, *b"ftyp", |out| {
860        // Copied: they declare what a reader needs, and a file not claiming them stops opening.
861        // A producer fingerprint in the sense of §4.7, which this tool addresses for no format.
862        out.extend_from_slice(parsed.ftyp);
863        Ok(())
864    })
865    .map_err(|d| malformed(format, d))?;
866
867    // First pass: offsets written as zero, purely to measure.
868    let probe = write_meta(parsed, &kept, 0, format)?;
869    let mdat_base = as_u64(ftyp.len())
870        .checked_add(as_u64(probe.len()))
871        .and_then(|n| n.checked_add(8))
872        .ok_or_else(|| malformed(format, MalformedDetail::LengthOutOfRange))?;
873    let meta = write_meta(parsed, &kept, mdat_base, format)?;
874
875    // The invariant the fixed widths guarantee. If it fails, every offset is wrong by the
876    // difference, so refuse rather than write a file that parses while pointing at the wrong bytes.
877    if meta.len() != probe.len() {
878        return Err(malformed(format, MalformedDetail::NotRoundTrippable));
879    }
880
881    let mut out = Vec::new();
882    out.extend_from_slice(&ftyp);
883    out.extend_from_slice(&meta);
884    bmff::write_box(&mut out, *b"mdat", |out| {
885        for item in &kept {
886            for chunk in &item.data {
887                out.extend_from_slice(chunk);
888            }
889        }
890        Ok(())
891    })
892    .map_err(|d| malformed(format, d))?;
893
894    Ok(out)
895}
896
897/// Write the `meta` box, with item payloads placed from `mdat_base` onwards.
898fn write_meta(
899    parsed: &Parsed<'_>,
900    kept: &[&Item<'_>],
901    mdat_base: u64,
902    format: Format,
903) -> Result<Vec<u8>> {
904    // Renumbered from 1 in write order, so nothing in the output depends on the input's numbering
905    // — the reason the PDF handler renumbers objects (ADR-0020).
906    let renumber = |old: u32| -> Option<u16> {
907        kept.iter()
908            .position(|i| i.id == old)
909            .and_then(|p| u16::try_from(p.saturating_add(1)).ok())
910    };
911
912    // `ipco` is rebuilt, so its indices change. Maps input index to output, dropping associations
913    // whose property is gone.
914    let mut kept_properties: Vec<usize> = Vec::new();
915    for (i, property) in parsed.properties.iter().enumerate() {
916        if boxes::property_kept(property.kind, property.payload) {
917            kept_properties.push(i);
918        }
919    }
920    let reindex = |old: u16| -> Option<u16> {
921        let zero_based = usize::from(old).checked_sub(1)?;
922        kept_properties
923            .iter()
924            .position(|&i| i == zero_based)
925            .and_then(|p| u16::try_from(p.saturating_add(1)).ok())
926    };
927
928    let mut out = Vec::new();
929    bmff::write_full_box(&mut out, *b"meta", 0, 0, |out| {
930        // Name written empty: the input's names the producing library.
931        bmff::write_full_box(out, *b"hdlr", 0, 0, |out| {
932            out.extend_from_slice(&[0; 4]);
933            out.extend_from_slice(b"pict");
934            out.extend_from_slice(&[0; 12]);
935            out.push(0);
936            Ok(())
937        })?;
938
939        let primary = renumber(parsed.primary).unwrap_or(1);
940        bmff::write_full_box(out, *b"pitm", 0, 0, |out| {
941            out.extend_from_slice(&primary.to_be_bytes());
942            Ok(())
943        })?;
944
945        // Fresh and self-contained: the input's could name an external file.
946        bmff::write_box(out, *b"dinf", |out| {
947            bmff::write_full_box(out, *b"dref", 0, 0, |out| {
948                out.extend_from_slice(&1_u32.to_be_bytes());
949                // Flag bit 0: the data is in this file, no URL follows.
950                bmff::write_full_box(out, *b"url ", 0, 1, |_| Ok(()))
951            })
952        })?;
953
954        write_iinf(out, kept)?;
955        write_iloc(out, kept, mdat_base)?;
956        write_iref(out, parsed, &renumber)?;
957        write_iprp(out, parsed, kept, &kept_properties, &reindex)
958    })
959    .map_err(|d| malformed(format, d))?;
960    Ok(out)
961}
962
963/// Write `iinf`, one `infe` per retained item.
964fn write_iinf(out: &mut Vec<u8>, kept: &[&Item<'_>]) -> std::result::Result<(), MalformedDetail> {
965    bmff::write_full_box(out, *b"iinf", 0, 0, |out| {
966        let count = u16::try_from(kept.len()).map_err(|_| MalformedDetail::LengthOutOfRange)?;
967        out.extend_from_slice(&count.to_be_bytes());
968        for (i, item) in kept.iter().enumerate() {
969            let id = u16::try_from(i.saturating_add(1))
970                .map_err(|_| MalformedDetail::LengthOutOfRange)?;
971            bmff::write_full_box(out, *b"infe", 2, 0, |out| {
972                out.extend_from_slice(&id.to_be_bytes());
973                out.extend_from_slice(&0_u16.to_be_bytes());
974                out.extend_from_slice(&item.kind);
975                // Name empty: the input's is free text a producer may put anything in.
976                out.push(0);
977                Ok(())
978            })?;
979        }
980        Ok(())
981    })
982}
983
984/// Write `iloc`: one extent per item, fixed widths, absolute offsets.
985fn write_iloc(
986    out: &mut Vec<u8>,
987    kept: &[&Item<'_>],
988    mdat_base: u64,
989) -> std::result::Result<(), MalformedDetail> {
990    // Version 1 carries the construction method, always written 0. An input using `idat` therefore
991    // has none in its output: its items moved into `mdat` like everything else.
992    bmff::write_full_box(out, *b"iloc", 1, 0, |out| {
993        let widths = (u16::from(ILOC_OFFSET_SIZE) << 12) | (u16::from(ILOC_LENGTH_SIZE) << 8);
994        out.extend_from_slice(&widths.to_be_bytes());
995        let count = u16::try_from(kept.len()).map_err(|_| MalformedDetail::LengthOutOfRange)?;
996        out.extend_from_slice(&count.to_be_bytes());
997
998        let mut cursor = mdat_base;
999        for (i, item) in kept.iter().enumerate() {
1000            let id = u16::try_from(i.saturating_add(1))
1001                .map_err(|_| MalformedDetail::LengthOutOfRange)?;
1002            out.extend_from_slice(&id.to_be_bytes());
1003            out.extend_from_slice(&0_u16.to_be_bytes());
1004            out.extend_from_slice(&0_u16.to_be_bytes());
1005            // One extent per item: several inputs are written back to back, so one range covers it.
1006            out.extend_from_slice(&1_u16.to_be_bytes());
1007            let offset = u32::try_from(cursor).map_err(|_| MalformedDetail::LengthOutOfRange)?;
1008            let length =
1009                u32::try_from(item.bytes).map_err(|_| MalformedDetail::LengthOutOfRange)?;
1010            out.extend_from_slice(&offset.to_be_bytes());
1011            out.extend_from_slice(&length.to_be_bytes());
1012            cursor = cursor
1013                .checked_add(item.bytes)
1014                .ok_or(MalformedDetail::LengthOutOfRange)?;
1015        }
1016        Ok(())
1017    })
1018}
1019
1020/// Write `iref`, keeping only the types that assemble the picture.
1021fn write_iref<F>(
1022    out: &mut Vec<u8>,
1023    parsed: &Parsed<'_>,
1024    renumber: &F,
1025) -> std::result::Result<(), MalformedDetail>
1026where
1027    F: Fn(u32) -> Option<u16>,
1028{
1029    let usable: Vec<&Reference> = parsed
1030        .references
1031        .iter()
1032        .filter(|r| boxes::REFERENCE_TYPES_KEPT.contains(&r.kind))
1033        .filter(|r| renumber(r.from).is_some())
1034        .collect();
1035    if usable.is_empty() {
1036        return Ok(());
1037    }
1038    bmff::write_full_box(out, *b"iref", 0, 0, |out| {
1039        for reference in usable {
1040            // Targets that went are dropped, survivors kept in order — which `grid` depends on.
1041            let targets: Vec<u16> = reference.to.iter().filter_map(|t| renumber(*t)).collect();
1042            if targets.is_empty() {
1043                continue;
1044            }
1045            let from = renumber(reference.from).ok_or(MalformedDetail::BrokenIndex)?;
1046            bmff::write_box(out, reference.kind, |out| {
1047                out.extend_from_slice(&from.to_be_bytes());
1048                let count =
1049                    u16::try_from(targets.len()).map_err(|_| MalformedDetail::LengthOutOfRange)?;
1050                out.extend_from_slice(&count.to_be_bytes());
1051                for target in &targets {
1052                    out.extend_from_slice(&target.to_be_bytes());
1053                }
1054                Ok(())
1055            })?;
1056        }
1057        Ok(())
1058    })
1059}
1060
1061/// Write `iprp`: retained properties and the associations into them.
1062fn write_iprp<F>(
1063    out: &mut Vec<u8>,
1064    parsed: &Parsed<'_>,
1065    kept: &[&Item<'_>],
1066    kept_properties: &[usize],
1067    reindex: &F,
1068) -> std::result::Result<(), MalformedDetail>
1069where
1070    F: Fn(u16) -> Option<u16>,
1071{
1072    if kept_properties.is_empty() {
1073        return Ok(());
1074    }
1075    bmff::write_box(out, *b"iprp", |out| {
1076        bmff::write_box(out, *b"ipco", |out| {
1077            for &i in kept_properties {
1078                let property = parsed
1079                    .properties
1080                    .get(i)
1081                    .ok_or(MalformedDetail::BrokenIndex)?;
1082                bmff::write_box(out, property.kind, |out| {
1083                    out.extend_from_slice(property.payload);
1084                    Ok(())
1085                })?;
1086            }
1087            Ok(())
1088        })?;
1089
1090        // Decided from the retained count before either layout pass, so both passes agree.
1091        let wide = kept_properties.len() > 0x7F;
1092        let flags = u32::from(wide);
1093        bmff::write_full_box(out, *b"ipma", 0, flags, |out| {
1094            let mut entries: Vec<(u16, Vec<Association>)> = Vec::new();
1095            for (i, item) in kept.iter().enumerate() {
1096                let id = u16::try_from(i.saturating_add(1))
1097                    .map_err(|_| MalformedDetail::LengthOutOfRange)?;
1098                let mut list = Vec::new();
1099                for (owner, associations) in &parsed.associations {
1100                    if *owner != item.id {
1101                        continue;
1102                    }
1103                    for association in associations {
1104                        if let Some(index) = reindex(association.index) {
1105                            list.push(Association {
1106                                index,
1107                                essential: association.essential,
1108                            });
1109                        }
1110                    }
1111                }
1112                if !list.is_empty() {
1113                    entries.push((id, list));
1114                }
1115            }
1116
1117            let count =
1118                u32::try_from(entries.len()).map_err(|_| MalformedDetail::LengthOutOfRange)?;
1119            out.extend_from_slice(&count.to_be_bytes());
1120            for (id, list) in entries {
1121                out.extend_from_slice(&id.to_be_bytes());
1122                let n = u8::try_from(list.len()).map_err(|_| MalformedDetail::LengthOutOfRange)?;
1123                out.push(n);
1124                for association in list {
1125                    if wide {
1126                        let mut word = association.index & 0x7FFF;
1127                        if association.essential {
1128                            word |= 0x8000;
1129                        }
1130                        out.extend_from_slice(&word.to_be_bytes());
1131                    } else {
1132                        let mut byte = u8::try_from(association.index & 0x7F).unwrap_or_default();
1133                        if association.essential {
1134                            byte |= 0x80;
1135                        }
1136                        out.push(byte);
1137                    }
1138                }
1139            }
1140            Ok(())
1141        })
1142    })
1143}
1144
1145// ---------------------------------------------------------------------------------------------
1146
1147/// Turn a walk failure into this format's typed error.
1148fn from_walk(format: Format, e: WalkError) -> StryptError {
1149    match e {
1150        WalkError::Malformed(detail) => malformed(format, detail),
1151        WalkError::Limit(limit) => StryptError::LimitExceeded { format, limit },
1152    }
1153}
1154
1155/// A malformed-file error for this format.
1156fn malformed(format: Format, detail: MalformedDetail) -> StryptError {
1157    StryptError::Malformed {
1158        format,
1159        offset: None,
1160        detail,
1161    }
1162}
1163
1164/// Widen a length for reporting.
1165fn as_u64(value: usize) -> u64 {
1166    u64::try_from(value).unwrap_or(u64::MAX)
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171    // Test code is not reachable from untrusted bytes (ADR-0006).
1172    #![allow(
1173        clippy::unwrap_used,
1174        clippy::indexing_slicing,
1175        clippy::arithmetic_side_effects
1176    )]
1177
1178    use super::*;
1179
1180    fn boxed(kind: [u8; 4], payload: &[u8]) -> Vec<u8> {
1181        let mut out = u32::try_from(payload.len() + 8)
1182            .unwrap()
1183            .to_be_bytes()
1184            .to_vec();
1185        out.extend_from_slice(&kind);
1186        out.extend_from_slice(payload);
1187        out
1188    }
1189
1190    fn full_boxed(kind: [u8; 4], version: u8, flags: u32, payload: &[u8]) -> Vec<u8> {
1191        let mut body = vec![version];
1192        body.extend_from_slice(&flags.to_be_bytes()[1..4]);
1193        body.extend_from_slice(payload);
1194        boxed(kind, &body)
1195    }
1196
1197    /// A minimal file: one `av01` item whose payload is `codestream`.
1198    fn minimal(codestream: &[u8]) -> Vec<u8> {
1199        let mut meta = Vec::new();
1200        meta.extend_from_slice(&full_boxed(
1201            *b"hdlr",
1202            0,
1203            0,
1204            &[&[0u8; 4][..], b"pict", &[0u8; 13]].concat(),
1205        ));
1206        meta.extend_from_slice(&full_boxed(*b"pitm", 0, 0, &1_u16.to_be_bytes()));
1207        let infe = full_boxed(
1208            *b"infe",
1209            2,
1210            0,
1211            &[
1212                &1_u16.to_be_bytes()[..],
1213                &0_u16.to_be_bytes(),
1214                b"av01",
1215                &[0],
1216            ]
1217            .concat(),
1218        );
1219        meta.extend_from_slice(&full_boxed(
1220            *b"iinf",
1221            0,
1222            0,
1223            &[&1_u16.to_be_bytes()[..], &infe].concat(),
1224        ));
1225
1226        let ftyp = boxed(*b"ftyp", b"avif\x00\x00\x00\x00mif1avif");
1227        // Placeholder iloc to learn the layout, then the real one.
1228        let with = |offset: u32| {
1229            let mut body = (((4_u16) << 12) | ((4_u16) << 8)).to_be_bytes().to_vec();
1230            body.extend_from_slice(&1_u16.to_be_bytes());
1231            body.extend_from_slice(&1_u16.to_be_bytes());
1232            body.extend_from_slice(&0_u16.to_be_bytes());
1233            body.extend_from_slice(&0_u16.to_be_bytes());
1234            body.extend_from_slice(&1_u16.to_be_bytes());
1235            body.extend_from_slice(&offset.to_be_bytes());
1236            body.extend_from_slice(&u32::try_from(codestream.len()).unwrap().to_be_bytes());
1237            let mut m = meta.clone();
1238            m.extend_from_slice(&full_boxed(*b"iloc", 1, 0, &body));
1239            full_boxed(*b"meta", 0, 0, &m)
1240        };
1241        let probe = with(0);
1242        let base = u32::try_from(ftyp.len() + probe.len() + 8).unwrap();
1243        let mut out = ftyp.clone();
1244        out.extend_from_slice(&with(base));
1245        out.extend_from_slice(&boxed(*b"mdat", codestream));
1246        out
1247    }
1248
1249    fn strip_ok(data: &[u8]) -> Stripped {
1250        HeifHandler::AVIF
1251            .strip(data, &StripOptions::default())
1252            .unwrap()
1253    }
1254
1255    #[test]
1256    fn a_minimal_file_round_trips_and_keeps_its_picture() {
1257        let out = strip_ok(&minimal(b"PICTURE-BYTES")).bytes;
1258        assert!(out.windows(13).any(|w| w == b"PICTURE-BYTES"));
1259    }
1260
1261    #[test]
1262    fn the_output_reads_back_clean() {
1263        let out = strip_ok(&minimal(b"PICTURE-BYTES")).bytes;
1264        let report = HeifHandler::AVIF
1265            .inspect(&out, &InspectOptions::names_only())
1266            .unwrap();
1267        assert!(report.findings.is_empty(), "{:?}", report.findings);
1268    }
1269
1270    #[test]
1271    fn stripping_twice_is_byte_identical() {
1272        let once = strip_ok(&minimal(b"PICTURE-BYTES")).bytes;
1273        let twice = strip_ok(&once).bytes;
1274        assert_eq!(once, twice);
1275    }
1276
1277    #[test]
1278    fn a_file_with_no_meta_is_refused() {
1279        let mut data = boxed(*b"ftyp", b"avif\x00\x00\x00\x00mif1avif");
1280        data.extend_from_slice(&boxed(*b"mdat", b"picture"));
1281        assert!(
1282            HeifHandler::AVIF
1283                .strip(&data, &StripOptions::default())
1284                .is_err()
1285        );
1286    }
1287
1288    #[test]
1289    fn a_moov_box_is_refused_as_a_motion_file() {
1290        let mut data = minimal(b"PICTURE");
1291        data.extend_from_slice(&boxed(*b"moov", b""));
1292        assert!(matches!(
1293            HeifHandler::HEIF.strip(&data, &StripOptions::default()),
1294            Err(StryptError::UnsupportedFormat {
1295                format: crate::error::UnsupportedKind::MotionHeif
1296            })
1297        ));
1298    }
1299
1300    #[test]
1301    fn an_iloc_width_the_format_does_not_permit_is_refused() {
1302        // §8.11.3 allows only 0, 4 and 8. A width of 2 means the walk is lost, and continuing
1303        // would read the wrong bytes with confidence.
1304        let mut data = minimal(b"PICTURE");
1305        let at = data.windows(4).position(|w| w == b"iloc").unwrap();
1306        data[at + 8] = 0x20;
1307        assert!(
1308            HeifHandler::AVIF
1309                .strip(&data, &StripOptions::default())
1310                .is_err()
1311        );
1312    }
1313
1314    #[test]
1315    fn read_uint_rejects_widths_outside_the_specification() {
1316        let mut r = Reader::new(&[0xFF; 16]);
1317        assert_eq!(read_uint(&mut r, 0), Some(0));
1318        assert!(read_uint(&mut r, 4).is_some());
1319        assert!(read_uint(&mut r, 8).is_some());
1320        assert!(read_uint(&mut r, 2).is_none());
1321        assert!(read_uint(&mut r, 3).is_none());
1322    }
1323
1324    #[test]
1325    fn a_c_string_stops_at_its_terminator() {
1326        assert_eq!(c_string(b"name\x00rest"), (&b"name"[..], &b"rest"[..]));
1327        // Unterminated: the whole slice is the string and nothing follows.
1328        assert_eq!(c_string(b"name"), (&b"name"[..], &b""[..]));
1329        assert_eq!(c_string(b""), (&b""[..], &b""[..]));
1330    }
1331
1332    #[test]
1333    fn the_report_counts_bytes_without_naming_values_by_default() {
1334        // docs/THREAT_MODEL.md §5.5: a report is durable, so values are opt-in.
1335        let mut data = minimal(b"PICTURE");
1336        data.extend_from_slice(&boxed(*b"free", b"SECRET"));
1337        let report = strip_ok(&data).report;
1338        assert!(report.removed.iter().all(|f| f.value.is_none()));
1339        assert!(report.removed.iter().any(|f| f.bytes > 0));
1340    }
1341}