Skip to main content

oxideav_avif/
parser.rs

1//! Top-level AVIF file walk: `ftyp` brand check → `meta` parse →
2//! resolve the primary item's payload inside `mdat` → return the AV1
3//! OBU byte slice + the properties the downstream decoder needs.
4
5use std::borrow::Cow;
6
7use crate::derived::Mif1Compliance;
8use crate::error::{AvifError as Error, Result};
9
10use crate::box_parser::{b, find_box, iter_boxes, parse_full_box, type_str, BoxType};
11use crate::meta::{
12    Amve, Cclv, Clli, Colr, IlocExtent, Ispe, ItemInfo, ItemLocation, Mdcv, Meta, Pasp, Pixi,
13    Property,
14};
15use oxideav_heif::{FileType, HeifFile, HeifFileRef};
16
17const FTYP: BoxType = b(b"ftyp");
18const META: BoxType = b(b"meta");
19const HDLR: BoxType = b(b"hdlr");
20const PITM: BoxType = b(b"pitm");
21const IINF: BoxType = b(b"iinf");
22const INFE: BoxType = b(b"infe");
23const ILOC: BoxType = b(b"iloc");
24const IPRP: BoxType = b(b"iprp");
25
26/// `avif` — AV1 image / image collection brand (av1-avif §6.2).
27pub const BRAND_AVIF: BoxType = b(b"avif");
28/// `avis` — AV1 image sequence brand (av1-avif §6.3).
29pub const BRAND_AVIS: BoxType = b(b"avis");
30/// `avio` — AV1 image / sequence intra-only profile (av1-avif §6.2 / §6.3).
31pub const BRAND_AVIO: BoxType = b(b"avio");
32/// `mif1` — HEIF image-item structural brand (HEIF §10.2.2).
33pub const BRAND_MIF1: BoxType = b(b"mif1");
34/// `msf1` — HEIF image-sequence structural brand (HEIF §10.3.2).
35pub const BRAND_MSF1: BoxType = b(b"msf1");
36/// `miaf` — Multi-image Application Format compatibility brand
37/// (ISO/IEC 23000-22 §7).
38pub const BRAND_MIAF: BoxType = b(b"miaf");
39/// `MA1B` — AVIF Baseline Profile (av1-avif §8.2).
40pub const BRAND_MA1B: BoxType = b(b"MA1B");
41/// `MA1A` — AVIF Advanced Profile (av1-avif §8.3).
42pub const BRAND_MA1A: BoxType = b(b"MA1A");
43
44pub const ITEM_TYPE_AV01: BoxType = b(b"av01");
45pub const ITEM_TYPE_GRID: BoxType = b(b"grid");
46
47/// The parsed AVIF **container** (the file, not its pixels): brands,
48/// the `meta` model, the primary `av01` item with its coded payload
49/// and properties — ready for hand-off to an AV1 OBU decoder.
50///
51/// Was `AvifImage` before 0.0.13; that name is now the decoded
52/// picture ([`crate::AvifImage`]), as the image-crate API contract
53/// requires.
54#[non_exhaustive]
55#[derive(Clone, Debug)]
56pub struct AvifFile<'a> {
57    pub major_brand: BoxType,
58    pub minor_version: u32,
59    pub compatible_brands: Vec<BoxType>,
60    pub meta: Meta,
61    pub primary_item_id: u32,
62    pub primary_item: ItemInfo,
63    /// AV1 OBU bitstream for the primary item.
64    ///
65    /// For the common case — a single-extent `construction_method == 0`
66    /// (file-offset) item — this borrows directly into `file` with no
67    /// allocation ([`Cow::Borrowed`]). When the primary item's bytes live
68    /// in the `meta` box's `idat` (`construction_method == 1`, HEIF
69    /// §8.11.3 / ISO/IEC 14496-12 §8.11.3) or span multiple extents, the
70    /// extents are concatenated into an owned buffer ([`Cow::Owned`]).
71    /// Deref-coerces to `&[u8]` for all reads.
72    pub primary_item_data: Cow<'a, [u8]>,
73    /// `av1C` configuration record body (the 4-byte AV1CodecConfigurationRecord
74    /// prefix + embedded config OBUs). `None` if the property store
75    /// doesn't ship one — a malformed file.
76    pub av1c: Option<Vec<u8>>,
77    pub ispe: Option<Ispe>,
78    pub colr: Option<Colr>,
79    pub pixi: Option<Pixi>,
80    pub pasp: Option<Pasp>,
81    /// Mastering display colour volume (SMPTE ST 2086 / CTA-861-G HDR).
82    pub mdcv: Option<Mdcv>,
83    /// Content light level info (MaxCLL / MaxFALL in cd/m²).
84    pub clli: Option<Clli>,
85    /// Colour volume luminance (`cclv` draft extension, same semantics as `clli`).
86    pub cclv: Option<Cclv>,
87    /// Ambient viewing environment HDR metadata (`amve`, AVIF §6.5.36).
88    pub amve: Option<Amve>,
89}
90
91impl<'a> AvifFile<'a> {
92    /// Every field as a positional argument, in declaration order
93    /// (the record is `#[non_exhaustive]`: build it here, or from
94    /// `Default` where one exists, then read / assign the public
95    /// fields).
96    #[allow(clippy::too_many_arguments)]
97    pub fn new(
98        major_brand: BoxType,
99        minor_version: u32,
100        compatible_brands: Vec<BoxType>,
101        meta: Meta,
102        primary_item_id: u32,
103        primary_item: ItemInfo,
104        primary_item_data: Cow<'a, [u8]>,
105        av1c: Option<Vec<u8>>,
106        ispe: Option<Ispe>,
107        colr: Option<Colr>,
108        pixi: Option<Pixi>,
109        pasp: Option<Pasp>,
110        mdcv: Option<Mdcv>,
111        clli: Option<Clli>,
112        cclv: Option<Cclv>,
113        amve: Option<Amve>,
114    ) -> Self {
115        Self {
116            major_brand,
117            minor_version,
118            compatible_brands,
119            meta,
120            primary_item_id,
121            primary_item,
122            primary_item_data,
123            av1c,
124            ispe,
125            colr,
126            pixi,
127            pasp,
128            mdcv,
129            clli,
130            cclv,
131            amve,
132        }
133    }
134}
135
136/// Header-only parse that stops after `ftyp` + `meta` have been walked.
137/// Used by callers (grid composition, AVIS) that need access to the
138/// full `Meta` without committing to the single-av01-item contract
139/// [`parse`] enforces.
140#[non_exhaustive]
141pub struct AvifHeader<'a> {
142    pub file: &'a [u8],
143    pub major_brand: BoxType,
144    pub minor_version: u32,
145    pub compatible_brands: Vec<BoxType>,
146    pub meta: Meta,
147    /// The container crate's parsed view of the same bytes — the source
148    /// of truth for item payload resolution across every `iloc`
149    /// construction method (file offset, `idat`, item offset). The
150    /// `meta` above is the AVIF-side model derived from it. Borrows
151    /// `file` (`HeifFile::parse_borrowed`): the header never copies
152    /// the input.
153    pub heif: HeifFileRef<'a>,
154}
155
156impl<'a> AvifHeader<'a> {
157    /// Every field as a positional argument, in declaration order
158    /// (the record is `#[non_exhaustive]`: build it here, or from
159    /// `Default` where one exists, then read / assign the public
160    /// fields).
161    #[allow(clippy::too_many_arguments)]
162    pub fn new(
163        file: &'a [u8],
164        major_brand: BoxType,
165        minor_version: u32,
166        compatible_brands: Vec<BoxType>,
167        meta: Meta,
168        heif: HeifFileRef<'a>,
169    ) -> Self {
170        Self {
171            file,
172            major_brand,
173            minor_version,
174            compatible_brands,
175            meta,
176            heif,
177        }
178    }
179}
180
181impl AvifHeader<'_> {
182    /// Payload bytes of `item_id` (every construction method, all
183    /// extents concatenated), borrowed from the container view when
184    /// the item is one contiguous span.
185    pub fn item_data(&self, item_id: u32) -> Result<Cow<'_, [u8]>> {
186        if self.meta.location_by_id(item_id).is_none() {
187            return Err(Error::invalid(format!(
188                "avif: item {item_id} missing in iloc"
189            )));
190        }
191        Ok(self.heif.item_data(item_id)?)
192    }
193}
194
195pub fn parse_header(file: &[u8]) -> Result<AvifHeader<'_>> {
196    // The AVIF brand rule runs on the raw `ftyp` first so a file that is
197    // not AVIF / HEIF at all is refused with the brand list in the
198    // message, before the container parse gets a say.
199    let (ftyp_payload, _) =
200        find_box(file, &FTYP)?.ok_or_else(|| Error::invalid("avif: missing ftyp"))?;
201    let (major_brand, minor_version, compatible_brands) = parse_ftyp(ftyp_payload)?;
202    classify_brands(&major_brand, &compatible_brands)?;
203    let heif = HeifFile::parse_borrowed(file)?;
204    let container = heif
205        .meta
206        .as_ref()
207        .ok_or_else(|| Error::invalid("avif: missing meta"))?;
208    let mut meta = Meta::from_container(container)?;
209    if let Some(meta_payload) = heif.top_level_payload(&META) {
210        let (_version, _flags, body) = parse_full_box(meta_payload)?;
211        if let Some((grpl, _)) = find_box(body, &b(b"grpl"))? {
212            meta.grpl = Some(grpl.to_vec());
213        }
214    }
215    Ok(AvifHeader {
216        file,
217        major_brand,
218        minor_version,
219        compatible_brands,
220        meta,
221        heif,
222    })
223}
224
225/// `(major_brand, minor_version, compatible_brands)` of a parsed `ftyp`.
226fn brands_of(ft: &FileType) -> (BoxType, u32, Vec<BoxType>) {
227    (
228        ft.major_brand,
229        ft.minor_version,
230        ft.compatible_brands.clone(),
231    )
232}
233
234/// Resolve a single-extent, `construction_method == 0` item's payload
235/// bytes via its `iloc` entry as a zero-copy slice into `file`. Public so
236/// the grid / alpha paths can independently fetch tile + alpha items.
237///
238/// Items stored in the `meta` box's `idat` (`construction_method == 1`)
239/// or spanning multiple extents are not file-offset slices; for those use
240/// [`item_bytes_with_idat`] (returns an owned [`Cow`]) instead. This
241/// helper errors on cm ≠ 0 and on multi-extent items.
242pub fn item_bytes<'a>(file: &'a [u8], loc: &ItemLocation) -> Result<&'a [u8]> {
243    resolve_item_bytes(file, loc)
244}
245
246/// Resolve an item's payload bytes via its `iloc` entry, handling both
247/// `construction_method == 0` (file-offset, bytes inside `file`) and
248/// `construction_method == 1` (idat-offset, bytes inside the `meta` box's
249/// `idat`, passed in via `idat`), and multi-extent items of either method.
250///
251/// The common single-extent cm=0 case returns a zero-copy
252/// [`Cow::Borrowed`] into `file`; idat-backed or multi-extent items
253/// concatenate into a [`Cow::Owned`] buffer.
254///
255/// Spec: ISO/IEC 14496-12 §8.11.3 — `construction_method` 0 (file_offset:
256/// absolute offsets into the file) and 1 (idat_offset: box offsets into
257/// the `idat` of the same `meta`, `data_reference_index` / `extent_index`
258/// unused, data origin = first byte of `data[]` in the ItemDataBox).
259/// `construction_method == 2` (item_offset) is not resolved here.
260pub fn item_bytes_with_idat<'a>(
261    file: &'a [u8],
262    idat: Option<&[u8]>,
263    loc: &ItemLocation,
264) -> Result<Cow<'a, [u8]>> {
265    resolve_item_bytes_with_idat(file, idat, loc)
266}
267
268/// Audit a file against the HEIF §10.2.1.1 `mif1` structural-brand
269/// requirements. The returned [`Mif1Compliance`] is informational —
270/// every flag is reported even when some are missing, so a caller that
271/// wants a strict pass / fail can decide per-flag.
272///
273/// We accept files that fail strict mif1 (lots of in-the-wild AVIFs
274/// omit one or another mif1 mandatory box) — this is a deliberate
275/// helper for callers who want stricter validation than the default
276/// reader path.
277///
278/// `claims_mif1` is set when `mif1` appears in either major_brand or
279/// the compatible_brands array. Files that don't claim mif1 can still
280/// be audited — the flag distinguishes "this file says it's mif1 and
281/// fails" from "this file makes no mif1 claim and so missing boxes
282/// are fine."
283pub fn audit_mif1(file: &[u8]) -> Result<Mif1Compliance> {
284    // Walk top-level boxes for ftyp + meta location.
285    let mut ftyp_payload: Option<&[u8]> = None;
286    let mut meta_payload: Option<&[u8]> = None;
287    for hdr in iter_boxes(file) {
288        let hdr = hdr?;
289        let payload = &file[hdr.payload_start..hdr.end()];
290        match &hdr.box_type {
291            x if x == &FTYP => ftyp_payload = Some(payload),
292            x if x == &META => meta_payload = Some(payload),
293            _ => {}
294        }
295    }
296    let mut out = Mif1Compliance::default();
297    let Some(ftyp) = ftyp_payload else {
298        return Err(Error::invalid("avif: audit_mif1 missing ftyp"));
299    };
300    let (major, _minor, compat) = parse_ftyp(ftyp)?;
301    out.claims_mif1 = major == BRAND_MIF1 || compat.contains(&BRAND_MIF1);
302    let Some(meta_p) = meta_payload else {
303        return Ok(out);
304    };
305    let (_v, _f, meta_body) = parse_full_box(meta_p)?;
306    // mif1 requires hdlr, pitm, iinf (with at least one infe), iloc,
307    // iprp directly under the meta box. Walk the body once.
308    for hdr in iter_boxes(meta_body) {
309        let hdr = hdr?;
310        let payload = &meta_body[hdr.payload_start..hdr.end()];
311        match &hdr.box_type {
312            x if x == &HDLR => out.has_hdlr = true,
313            x if x == &PITM => out.has_pitm = true,
314            x if x == &IINF => {
315                out.has_iinf = true;
316                out.infe_count = count_infe(payload)?;
317            }
318            x if x == &ILOC => out.has_iloc = true,
319            x if x == &IPRP => out.has_iprp = true,
320            _ => {}
321        }
322    }
323    Ok(out)
324}
325
326/// Count `infe` children inside an `iinf` payload without fully
327/// parsing each one. Used by [`audit_mif1`].
328fn count_infe(payload: &[u8]) -> Result<usize> {
329    let (version, _flags, body) = parse_full_box(payload)?;
330    // header carries entry_count (u16 for v0, u32 for v1) then the
331    // declared number of infe children. Use the declared count when it
332    // matches reality; otherwise walk for safety.
333    let _declared = match version {
334        0 => {
335            if body.len() < 2 {
336                0
337            } else {
338                u16::from_be_bytes([body[0], body[1]]) as usize
339            }
340        }
341        _ => {
342            if body.len() < 4 {
343                0
344            } else {
345                u32::from_be_bytes([body[0], body[1], body[2], body[3]]) as usize
346            }
347        }
348    };
349    // Walk for the actual count — robust against malformed entry_count.
350    let start = if version == 0 { 2 } else { 4 };
351    if body.len() < start {
352        return Ok(0);
353    }
354    let tail = &body[start..];
355    let mut n = 0;
356    for hdr in iter_boxes(tail) {
357        let hdr = hdr?;
358        if hdr.box_type == INFE {
359            n += 1;
360        }
361    }
362    Ok(n)
363}
364
365/// Parse an AVIF file, returning the bits needed to decode the primary
366/// image. The input must contain the whole file; box offsets in `iloc`
367/// are absolute.
368///
369/// This path errors out when the primary item type is not `av01` — use
370/// [`parse_header`] + the `grid` module to handle grid primaries.
371pub fn parse(file: &[u8]) -> Result<AvifFile<'_>> {
372    let hdr = parse_header(file)?;
373    let AvifHeader {
374        file: _,
375        major_brand,
376        minor_version,
377        compatible_brands,
378        meta,
379        heif,
380    } = hdr;
381
382    let primary_id = meta
383        .primary_item_id
384        .ok_or_else(|| Error::invalid("avif: missing pitm"))?;
385    let primary_info = meta
386        .item_by_id(primary_id)
387        .ok_or_else(|| Error::invalid("avif: pitm references unknown item"))?
388        .clone();
389    if primary_info.item_type != ITEM_TYPE_AV01 {
390        return Err(Error::unsupported(format!(
391            "avif: primary item type '{}' != 'av01'",
392            type_str(&primary_info.item_type)
393        )));
394    }
395
396    if meta.location_by_id(primary_id).is_none() {
397        return Err(Error::invalid("avif: primary item missing in iloc"));
398    }
399    let primary_data = primary_payload(file, &heif, primary_id)?;
400
401    let av1c = match meta.property_for(primary_id, b"av1C") {
402        Some(Property::Av1C(bytes)) => Some(bytes.clone()),
403        _ => None,
404    };
405    let ispe = match meta.property_for(primary_id, b"ispe") {
406        Some(Property::Ispe(v)) => Some(*v),
407        _ => None,
408    };
409    let colr = match meta.property_for(primary_id, b"colr") {
410        Some(Property::Colr(v)) => Some(v.clone()),
411        _ => None,
412    };
413    let pixi = match meta.property_for(primary_id, b"pixi") {
414        Some(Property::Pixi(v)) => Some(v.clone()),
415        _ => None,
416    };
417    let pasp = match meta.property_for(primary_id, b"pasp") {
418        Some(Property::Pasp(v)) => Some(*v),
419        _ => None,
420    };
421    let mdcv = match meta.property_for(primary_id, b"mdcv") {
422        Some(Property::Mdcv(v)) => Some(*v),
423        _ => None,
424    };
425    let clli = match meta.property_for(primary_id, b"clli") {
426        Some(Property::Clli(v)) => Some(*v),
427        _ => None,
428    };
429    let cclv = match meta.property_for(primary_id, b"cclv") {
430        Some(Property::Cclv(v)) => Some(*v),
431        _ => None,
432    };
433    let amve = match meta.property_for(primary_id, b"amve") {
434        Some(Property::Amve(v)) => Some(*v),
435        _ => None,
436    };
437
438    Ok(AvifFile {
439        major_brand,
440        minor_version,
441        compatible_brands,
442        meta,
443        primary_item_id: primary_id,
444        primary_item: primary_info,
445        primary_item_data: primary_data,
446        av1c,
447        ispe,
448        colr,
449        pixi,
450        pasp,
451        mdcv,
452        clli,
453        cclv,
454        amve,
455    })
456}
457
458pub(crate) fn parse_ftyp(payload: &[u8]) -> Result<(BoxType, u32, Vec<BoxType>)> {
459    let ft = FileType::parse(FTYP, payload)?;
460    Ok(brands_of(&ft))
461}
462
463/// The primary item's payload: zero-copy into `file` when the container
464/// reports one contiguous file-offset span, otherwise the concatenated
465/// (or `idat` / item-offset resolved) bytes, owned.
466fn primary_payload<'a>(
467    file: &'a [u8],
468    heif: &HeifFileRef<'_>,
469    item_id: u32,
470) -> Result<Cow<'a, [u8]>> {
471    if let Ok(spans) = heif.item_file_spans(item_id) {
472        if let [(start, end)] = spans.as_slice() {
473            if let Some(slice) = file.get(*start..*end) {
474                return Ok(Cow::Borrowed(slice));
475            }
476        }
477    }
478    Ok(Cow::Owned(heif.item_data_owned(item_id)?))
479}
480
481/// Classification of an AVIF / HEIF `ftyp` box per av1-avif §6 + §7 +
482/// §8 and ISO/IEC 23000-22 (MIAF) §7. Surfaces both the structural
483/// brand the file claims and the optional AVIF profile.
484#[doc(hidden)]
485#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
486pub struct BrandClass {
487    /// File declares an AV1 image / image-collection (`avif`) brand.
488    pub is_image: bool,
489    /// File declares an AV1 image sequence (`avis`) brand.
490    pub is_sequence: bool,
491    /// File declares the intra-only profile (`avio`).
492    pub is_intra_only: bool,
493    /// File declares the MIAF compatibility brand (`miaf`,
494    /// ISO/IEC 23000-22 §7).
495    pub is_miaf: bool,
496    /// HEIF structural brand: image-item (`mif1`).
497    pub has_mif1: bool,
498    /// HEIF structural brand: image-sequence (`msf1`).
499    pub has_msf1: bool,
500    /// AVIF Baseline Profile brand (`MA1B`).
501    pub is_baseline_profile: bool,
502    /// AVIF Advanced Profile brand (`MA1A`).
503    pub is_advanced_profile: bool,
504}
505
506impl BrandClass {
507    /// Every field as a positional argument, in declaration order
508    /// (the record is `#[non_exhaustive]`: build it here, or from
509    /// `Default` where one exists, then read / assign the public
510    /// fields).
511    #[allow(clippy::too_many_arguments)]
512    pub fn new(
513        is_image: bool,
514        is_sequence: bool,
515        is_intra_only: bool,
516        is_miaf: bool,
517        has_mif1: bool,
518        has_msf1: bool,
519        is_baseline_profile: bool,
520        is_advanced_profile: bool,
521    ) -> Self {
522        Self {
523            is_image,
524            is_sequence,
525            is_intra_only,
526            is_miaf,
527            has_mif1,
528            has_msf1,
529            is_baseline_profile,
530            is_advanced_profile,
531        }
532    }
533}
534
535/// Classify the brand set of a parsed `ftyp` box. Errors when the file
536/// claims neither `avif` nor `avis` nor any HEIF structural brand
537/// (`mif1` / `msf1`) — without one of those there is nothing AVIF /
538/// HEIF can do with the file. The error string lists every brand seen
539/// so the caller doesn't have to dig the bytes back out.
540///
541/// Spec references:
542///
543/// * av1-avif §6.2 — `avif` for AV1 image / image collection
544/// * av1-avif §6.3 — `avis` for AV1 image sequences
545/// * av1-avif §6.2 / §6.3 — `avio` intra-only
546/// * av1-avif §7 General constraints — file shall list `miaf`
547/// * av1-avif §8.2 / §8.3 — `MA1B` Baseline / `MA1A` Advanced profile
548/// * HEIF §10.2.2 — `mif1` image-item structural brand
549/// * HEIF §10.3.2 — `msf1` image-sequence structural brand
550/// * ISO/IEC 23000-22 §7 — `miaf` compatibility brand
551pub fn classify_brands(major: &BoxType, compat: &[BoxType]) -> Result<BrandClass> {
552    let mut cls = BrandClass::default();
553    let candidates: Vec<&BoxType> = std::iter::once(major).chain(compat.iter()).collect();
554    for brand in &candidates {
555        match **brand {
556            x if x == BRAND_AVIF => cls.is_image = true,
557            x if x == BRAND_AVIS => cls.is_sequence = true,
558            x if x == BRAND_AVIO => cls.is_intra_only = true,
559            x if x == BRAND_MIAF => cls.is_miaf = true,
560            x if x == BRAND_MIF1 => cls.has_mif1 = true,
561            x if x == BRAND_MSF1 => cls.has_msf1 = true,
562            x if x == BRAND_MA1B => cls.is_baseline_profile = true,
563            x if x == BRAND_MA1A => cls.is_advanced_profile = true,
564            _ => {}
565        }
566    }
567    // Files in the wild often omit `miaf` even though av1-avif §7
568    // requires it. We accept any combination that includes either
569    // an AVIF brand (`avif`/`avis`) or a HEIF structural brand
570    // (`mif1`/`msf1`) — anything less can't be a real AVIF / HEIF
571    // file. Strict MIAF compliance can be checked via the returned
572    // `BrandClass.is_miaf` flag.
573    let usable =
574        cls.is_image || cls.is_sequence || cls.has_mif1 || cls.has_msf1 || cls.is_intra_only;
575    if !usable {
576        return Err(Error::invalid(format!(
577            "avif: ftyp major='{}' compatible_brands=[{}] declares no AVIF/HEIF brand \
578             (need one of avif, avis, avio, mif1, msf1)",
579            type_str(major),
580            compat.iter().map(type_str).collect::<Vec<_>>().join(",")
581        )));
582    }
583    Ok(cls)
584}
585
586fn resolve_item_bytes<'a>(file: &'a [u8], loc: &ItemLocation) -> Result<&'a [u8]> {
587    if loc.construction_method != 0 {
588        return Err(Error::unsupported(format!(
589            "avif: iloc construction_method {} not supported (only file-offset is handled)",
590            loc.construction_method
591        )));
592    }
593    match loc.extents.len() {
594        0 => Err(Error::invalid("avif: iloc entry has no extents")),
595        1 => {
596            // Fast path: single extent — return a slice directly with no allocation.
597            let e = &loc.extents[0];
598            let start = loc
599                .base_offset
600                .checked_add(e.offset)
601                .ok_or_else(|| Error::invalid("avif: iloc offset overflow"))?;
602            let end = start
603                .checked_add(e.length)
604                .ok_or_else(|| Error::invalid("avif: iloc length overflow"))?;
605            let (start, end) = (start as usize, end as usize);
606            if end > file.len() {
607                return Err(Error::invalid(format!(
608                    "avif: iloc extent {start}..{end} exceeds file length {}",
609                    file.len()
610                )));
611            }
612            Ok(&file[start..end])
613        }
614        _ => {
615            // Multi-extent items: concatenate all extents. HEIF §8.11.3.3
616            // requires that the decoder assemble the item data by appending
617            // extents in order. The caller gets a fresh allocation — the
618            // returned `&[u8]` is a reference to the owned buffer stored in
619            // a leak-safe place (see `item_bytes_owned`).
620            //
621            // We surface this as an error and provide a separate
622            // `item_bytes_owned` helper that returns a `Vec<u8>`. Callers
623            // that handle multi-extent items call `item_bytes_owned` directly.
624            //
625            // The single caller of `resolve_item_bytes` that may encounter
626            // multi-extent items is `item_bytes` which is used in the grid
627            // and alpha paths. Grid tile items in the wild are always
628            // single-extent (each tile is one contiguous mdat run), so this
629            // path is extremely rare. Surface Unsupported until a real file
630            // exercises the path in production.
631            Err(Error::unsupported(format!(
632                "avif: item {} has {} extents; multi-extent items require item_bytes_owned()",
633                loc.id,
634                loc.extents.len()
635            )))
636        }
637    }
638}
639
640/// Pick the backing buffer for an `iloc` entry given its
641/// `construction_method`: `file` for method 0, the meta box's `idat` for
642/// method 1. Method 2 (item_offset) is not supported.
643fn backing_for<'a>(file: &'a [u8], idat: Option<&'a [u8]>, loc: &ItemLocation) -> Result<&'a [u8]> {
644    match loc.construction_method {
645        0 => Ok(file),
646        1 => idat.ok_or_else(|| {
647            Error::invalid(format!(
648                "avif: item {} uses idat construction_method but the meta box has no idat",
649                loc.id
650            ))
651        }),
652        m => Err(Error::unsupported(format!(
653            "avif: iloc construction_method {m} not supported (only file-offset and idat)",
654        ))),
655    }
656}
657
658/// `construction_method`-aware resolution. Returns a zero-copy
659/// [`Cow::Borrowed`] into `file` only for the single-extent cm=0 case;
660/// every idat-backed item (and any multi-extent item) concatenates into an
661/// owned buffer. Keeping the borrowed branch tied solely to `file` (and
662/// never to `idat`) lets callers move the `Meta` that owns the `idat` while
663/// still holding a cm=0 borrow.
664fn resolve_item_bytes_with_idat<'a>(
665    file: &'a [u8],
666    idat: Option<&[u8]>,
667    loc: &ItemLocation,
668) -> Result<Cow<'a, [u8]>> {
669    if loc.extents.is_empty() {
670        return Err(Error::invalid("avif: iloc entry has no extents"));
671    }
672    // Fast path: single-extent file-offset item borrows directly into
673    // `file` with no allocation.
674    if loc.construction_method == 0 && loc.extents.len() == 1 {
675        let (start, end) = extent_range(file, loc, &loc.extents[0], 0)?;
676        return Ok(Cow::Borrowed(&file[start..end]));
677    }
678    // Everything else (idat-backed, or multi-extent of either method)
679    // concatenates the extents into an owned buffer (HEIF §8.11.3.3).
680    let backing = backing_for(file, idat, loc)?;
681    let mut out = Vec::new();
682    for (i, e) in loc.extents.iter().enumerate() {
683        let (start, end) = extent_range(backing, loc, e, i)?;
684        out.extend_from_slice(&backing[start..end]);
685    }
686    Ok(Cow::Owned(out))
687}
688
689/// Bounds-check one extent against `backing` and return its byte range.
690fn extent_range(
691    backing: &[u8],
692    loc: &ItemLocation,
693    e: &IlocExtent,
694    i: usize,
695) -> Result<(usize, usize)> {
696    let start = loc
697        .base_offset
698        .checked_add(e.offset)
699        .ok_or_else(|| Error::invalid(format!("avif: iloc extent {i} offset overflow")))?;
700    let end = start
701        .checked_add(e.length)
702        .ok_or_else(|| Error::invalid(format!("avif: iloc extent {i} length overflow")))?;
703    let (start, end) = (start as usize, end as usize);
704    if end > backing.len() {
705        return Err(Error::invalid(format!(
706            "avif: iloc extent {i} {start}..{end} exceeds backing length {}",
707            backing.len()
708        )));
709    }
710    Ok((start, end))
711}
712
713/// Resolve a `construction_method == 0` item's payload bytes,
714/// concatenating multiple extents when necessary. This may allocate when
715/// the item spans more than one extent.
716///
717/// For the common single-extent case this borrows the slice and copies it
718/// to an owned buffer — a small allocation to keep the API uniform.
719/// Callers that want zero-copy access for single-extent items should use
720/// [`item_bytes`] directly; callers that need to resolve idat-backed
721/// (`construction_method == 1`) items should use
722/// [`item_bytes_owned_with_idat`].
723pub fn item_bytes_owned(file: &[u8], loc: &ItemLocation) -> Result<Vec<u8>> {
724    if loc.construction_method != 0 {
725        return Err(Error::unsupported(format!(
726            "avif: iloc construction_method {} not supported",
727            loc.construction_method
728        )));
729    }
730    resolve_item_bytes_with_idat(file, None, loc).map(Cow::into_owned)
731}
732
733/// Resolve an item's payload bytes to an owned buffer, handling both
734/// file-offset (`construction_method == 0`) and idat-offset
735/// (`construction_method == 1`, bytes inside the `meta` box's `idat`)
736/// items as well as multi-extent items of either method.
737///
738/// See [`item_bytes_with_idat`] for the zero-copy-when-possible variant.
739pub fn item_bytes_owned_with_idat(
740    file: &[u8],
741    idat: Option<&[u8]>,
742    loc: &ItemLocation,
743) -> Result<Vec<u8>> {
744    resolve_item_bytes_with_idat(file, idat, loc).map(Cow::into_owned)
745}
746
747/// `iref` reference type linking a `construction_method == 2` (item_offset)
748/// item to the item(s) supplying its data origin (ISO/IEC 14496-12
749/// §8.11.3.3 — "the item reference with referenceType 'iloc'").
750const ILOC_REF: BoxType = b(b"iloc");
751
752/// Maximum depth of `construction_method == 2` indirection we will follow
753/// before declaring the chain malformed. cm=2 items may reference other
754/// items, so a hostile / broken file could form a cycle or a very deep
755/// chain; the spec warns against self-reference "to avoid infinite
756/// recursion" (§8.11.3.3) but does not mandate a depth, so we cap it.
757const MAX_ILOC_DEPTH: usize = 16;
758
759/// Resolve an item's payload bytes to an owned buffer for *any* of the
760/// three `construction_method`s, including `construction_method == 2`
761/// (item_offset). Unlike [`item_bytes_owned_with_idat`], which only
762/// understands file-offset and idat-offset items, this resolver consults
763/// the whole [`Meta`] so it can follow the `'iloc'` item reference that a
764/// cm=2 entry uses to name its data-origin item.
765///
766/// Spec: ISO/IEC 14496-12 §8.11.3.3. For a cm=2 extent the data origin is
767/// "the first byte of the concatenated data (of all the extents) of"
768/// the referenced item. The extent's `extent_index` is the 1-based index
769/// into the `'iloc'` iref entries whose source is this item; an
770/// `extent_index` of 0 (i.e. the box carried `index_size == 0`) implies
771/// the value 1. `extent_offset` (plus `base_offset`) then indexes into
772/// that concatenated data and `extent_length` bounds it, with a length of
773/// 0 meaning "the entire referenced item".
774///
775/// The referenced item is itself resolved through this same function, so
776/// a cm=2 item may point at a cm=0 / cm=1 item (the common case) or, in
777/// principle, another cm=2 item; recursion is bounded by
778/// [`MAX_ILOC_DEPTH`] to reject cycles and pathological chains.
779pub fn item_bytes_owned_full(file: &[u8], meta: &Meta, item_id: u32) -> Result<Vec<u8>> {
780    resolve_full(file, meta, item_id, 0)
781}
782
783fn resolve_full(file: &[u8], meta: &Meta, item_id: u32, depth: usize) -> Result<Vec<u8>> {
784    if depth > MAX_ILOC_DEPTH {
785        return Err(Error::invalid(format!(
786            "avif: iloc construction_method 2 chain exceeds depth {MAX_ILOC_DEPTH} (cycle?) at item {item_id}"
787        )));
788    }
789    let loc = meta
790        .location_by_id(item_id)
791        .ok_or_else(|| Error::invalid(format!("avif: item {item_id} missing in iloc")))?;
792
793    if loc.construction_method != 2 {
794        // file-offset / idat-offset items resolve without touching the
795        // item graph.
796        return item_bytes_owned_with_idat(file, meta.idat.as_deref(), loc);
797    }
798
799    if loc.extents.is_empty() {
800        return Err(Error::invalid(format!(
801            "avif: item {item_id} construction_method 2 entry has no extents"
802        )));
803    }
804
805    // The ordered list of items this item points at via 'iloc' irefs.
806    let refs = meta.iref_targets(&ILOC_REF, item_id);
807    if refs.is_empty() {
808        return Err(Error::invalid(format!(
809            "avif: item {item_id} uses construction_method 2 but has no 'iloc' item reference"
810        )));
811    }
812
813    let mut out = Vec::new();
814    for (i, e) in loc.extents.iter().enumerate() {
815        // 1-based extent_index into `refs`; 0 implies 1 (§8.11.3.3).
816        let one_based = if e.extent_index == 0 {
817            1
818        } else {
819            e.extent_index
820        };
821        let idx = (one_based as usize)
822            .checked_sub(1)
823            .filter(|&z| z < refs.len())
824            .ok_or_else(|| {
825                Error::invalid(format!(
826                    "avif: item {item_id} extent {i} extent_index {one_based} out of range (1..={})",
827                    refs.len()
828                ))
829            })?;
830        let ref_id = refs[idx];
831        if ref_id == item_id {
832            return Err(Error::invalid(format!(
833                "avif: item {item_id} construction_method 2 self-references (infinite recursion)"
834            )));
835        }
836        // Data origin = the referenced item's full concatenated bytes.
837        let origin = resolve_full(file, meta, ref_id, depth + 1)?;
838        // base_offset + extent_offset index into that origin; a length of
839        // 0 means "the entire referenced item" from `start`.
840        let start = loc.base_offset.checked_add(e.offset).ok_or_else(|| {
841            Error::invalid(format!("avif: item {item_id} extent {i} offset overflow"))
842        })?;
843        let start = usize::try_from(start).map_err(|_| {
844            Error::invalid(format!("avif: item {item_id} extent {i} offset too large"))
845        })?;
846        if start > origin.len() {
847            return Err(Error::invalid(format!(
848                "avif: item {item_id} extent {i} offset {start} exceeds referenced item {ref_id} length {}",
849                origin.len()
850            )));
851        }
852        let end = if e.length == 0 {
853            origin.len()
854        } else {
855            let len = usize::try_from(e.length).map_err(|_| {
856                Error::invalid(format!("avif: item {item_id} extent {i} length too large"))
857            })?;
858            let end = start.checked_add(len).ok_or_else(|| {
859                Error::invalid(format!("avif: item {item_id} extent {i} length overflow"))
860            })?;
861            if end > origin.len() {
862                return Err(Error::invalid(format!(
863                    "avif: item {item_id} extent {i} {start}..{end} exceeds referenced item {ref_id} length {}",
864                    origin.len()
865                )));
866            }
867            end
868        };
869        out.extend_from_slice(&origin[start..end]);
870    }
871    Ok(out)
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877    use crate::meta::{IrefEntry, ItemLocation, Meta};
878
879    const FIXTURE: &[u8] = include_bytes!("../tests/fixtures/monochrome.avif");
880
881    /// Helper: a single-extent cm=0 (file-offset) location for `id`
882    /// pointing at `offset..offset+length` inside the file.
883    fn cm0_loc(id: u32, offset: u64, length: u64) -> ItemLocation {
884        ItemLocation {
885            id,
886            construction_method: 0,
887            data_reference_index: 0,
888            base_offset: 0,
889            extents: vec![IlocExtent {
890                offset,
891                length,
892                extent_index: 0,
893            }],
894        }
895    }
896
897    /// Helper: a cm=2 (item-offset) location whose single extent uses
898    /// `extent_index` to select an `'iloc'` iref target, slicing
899    /// `offset..offset+length` (length 0 = whole referenced item).
900    fn cm2_loc(id: u32, extent_index: u64, offset: u64, length: u64) -> ItemLocation {
901        ItemLocation {
902            id,
903            construction_method: 2,
904            data_reference_index: 0,
905            base_offset: 0,
906            extents: vec![IlocExtent {
907                offset,
908                length,
909                extent_index,
910            }],
911        }
912    }
913
914    fn iloc_iref(from_id: u32, to_ids: Vec<u32>) -> IrefEntry {
915        IrefEntry {
916            reference_type: *b"iloc",
917            from_id,
918            to_ids,
919        }
920    }
921
922    /// Build a `Meta` carrying only the locations / irefs / idat the cm=2
923    /// resolver consults (struct-update keeps clippy's
924    /// field_reassign_with_default happy).
925    fn mk_meta(locations: Vec<ItemLocation>, irefs: Vec<IrefEntry>, idat: Option<Vec<u8>>) -> Meta {
926        Meta {
927            locations,
928            irefs,
929            idat,
930            ..Meta::default()
931        }
932    }
933
934    #[test]
935    fn cm2_whole_referenced_item() {
936        // Base item 1 = bytes 4..12 of the file. cm=2 item 2 references
937        // item 1 via an 'iloc' iref (extent_index 1) and copies the whole
938        // referenced item (length 0).
939        let file: Vec<u8> = (0u8..16).collect();
940        let meta = mk_meta(
941            vec![cm0_loc(1, 4, 8), cm2_loc(2, 1, 0, 0)],
942            vec![iloc_iref(2, vec![1])],
943            None,
944        );
945        let got = item_bytes_owned_full(&file, &meta, 2).unwrap();
946        assert_eq!(got, (4u8..12).collect::<Vec<u8>>());
947    }
948
949    #[test]
950    fn cm2_sub_range_of_referenced_item() {
951        // Slice bytes 2..5 of the referenced item's data (which is file
952        // bytes 4..12, so file bytes 6..9).
953        let file: Vec<u8> = (0u8..16).collect();
954        let meta = mk_meta(
955            vec![cm0_loc(1, 4, 8), cm2_loc(2, 1, 2, 3)],
956            vec![iloc_iref(2, vec![1])],
957            None,
958        );
959        let got = item_bytes_owned_full(&file, &meta, 2).unwrap();
960        assert_eq!(got, vec![6, 7, 8]);
961    }
962
963    #[test]
964    fn cm2_extent_index_zero_implies_one() {
965        // extent_index 0 (box carried index_size==0) implies index 1.
966        let file: Vec<u8> = (0u8..16).collect();
967        let meta = mk_meta(
968            vec![cm0_loc(1, 4, 8), cm2_loc(2, 0, 0, 0)],
969            vec![iloc_iref(2, vec![1])],
970            None,
971        );
972        let got = item_bytes_owned_full(&file, &meta, 2).unwrap();
973        assert_eq!(got, (4u8..12).collect::<Vec<u8>>());
974    }
975
976    #[test]
977    fn cm2_selects_second_iloc_reference() {
978        // Two 'iloc' targets; extent_index 2 picks the second (item 3).
979        let file: Vec<u8> = (0u8..32).collect();
980        let meta = mk_meta(
981            vec![cm0_loc(1, 0, 8), cm0_loc(3, 20, 4), cm2_loc(2, 2, 0, 0)],
982            vec![iloc_iref(2, vec![1, 3])],
983            None,
984        );
985        let got = item_bytes_owned_full(&file, &meta, 2).unwrap();
986        assert_eq!(got, (20u8..24).collect::<Vec<u8>>());
987    }
988
989    #[test]
990    fn cm2_origin_resolves_through_idat() {
991        // The referenced item is itself idat-backed (cm=1); the cm=2 item
992        // must resolve through to the idat bytes.
993        let file: Vec<u8> = vec![0xff; 8];
994        let mut base = cm0_loc(1, 4, 8);
995        base.construction_method = 1; // idat-offset
996        let meta = mk_meta(
997            vec![base, cm2_loc(2, 1, 0, 0)],
998            vec![iloc_iref(2, vec![1])],
999            Some((100u8..116).collect()),
1000        );
1001        let got = item_bytes_owned_full(&file, &meta, 2).unwrap();
1002        assert_eq!(got, (104u8..112).collect::<Vec<u8>>());
1003    }
1004
1005    #[test]
1006    fn cm2_missing_iref_errors() {
1007        let file: Vec<u8> = (0u8..16).collect();
1008        // No 'iloc' iref declared for item 2.
1009        let meta = mk_meta(vec![cm0_loc(1, 4, 8), cm2_loc(2, 1, 0, 0)], vec![], None);
1010        let err = item_bytes_owned_full(&file, &meta, 2).unwrap_err();
1011        assert!(format!("{err}").contains("no 'iloc' item reference"));
1012    }
1013
1014    #[test]
1015    fn cm2_extent_index_out_of_range_errors() {
1016        let file: Vec<u8> = (0u8..16).collect();
1017        let meta = mk_meta(
1018            vec![cm0_loc(1, 4, 8), cm2_loc(2, 5, 0, 0)],
1019            vec![iloc_iref(2, vec![1])],
1020            None,
1021        );
1022        let err = item_bytes_owned_full(&file, &meta, 2).unwrap_err();
1023        assert!(format!("{err}").contains("out of range"));
1024    }
1025
1026    #[test]
1027    fn cm2_self_reference_rejected() {
1028        let file: Vec<u8> = (0u8..16).collect();
1029        let meta = mk_meta(
1030            vec![cm2_loc(2, 1, 0, 0)],
1031            vec![iloc_iref(2, vec![2])], // points at itself
1032            None,
1033        );
1034        let err = item_bytes_owned_full(&file, &meta, 2).unwrap_err();
1035        assert!(format!("{err}").contains("self-reference"));
1036    }
1037
1038    #[test]
1039    fn cm2_cycle_bounded_by_depth() {
1040        // 2 -> 3 -> 2 (mutual reference) is caught by the depth cap.
1041        let file: Vec<u8> = (0u8..16).collect();
1042        let meta = mk_meta(
1043            vec![cm2_loc(2, 1, 0, 0), cm2_loc(3, 1, 0, 0)],
1044            vec![iloc_iref(2, vec![3]), iloc_iref(3, vec![2])],
1045            None,
1046        );
1047        let err = item_bytes_owned_full(&file, &meta, 2).unwrap_err();
1048        let msg = format!("{err}");
1049        assert!(
1050            msg.contains("depth") || msg.contains("recursion"),
1051            "got: {msg}"
1052        );
1053    }
1054
1055    #[test]
1056    fn cm2_sub_range_out_of_bounds_errors() {
1057        // Referenced item is 8 bytes; offset 6 + length 4 overruns it.
1058        let file: Vec<u8> = (0u8..16).collect();
1059        let meta = mk_meta(
1060            vec![cm0_loc(1, 4, 8), cm2_loc(2, 1, 6, 4)],
1061            vec![iloc_iref(2, vec![1])],
1062            None,
1063        );
1064        let err = item_bytes_owned_full(&file, &meta, 2).unwrap_err();
1065        assert!(format!("{err}").contains("exceeds referenced item"));
1066    }
1067
1068    #[test]
1069    fn non_cm2_unaffected_by_full_resolver() {
1070        // A plain cm=0 item resolves identically through the full path.
1071        let file: Vec<u8> = (0u8..16).collect();
1072        let meta = mk_meta(vec![cm0_loc(1, 3, 5)], vec![], None);
1073        let got = item_bytes_owned_full(&file, &meta, 1).unwrap();
1074        assert_eq!(got, (3u8..8).collect::<Vec<u8>>());
1075    }
1076
1077    #[test]
1078    fn classify_brands_baseline_profile() {
1079        // Real `monochrome.avif` ftyp: major=avif compat=[mif1,avif,miaf,MA1B].
1080        let cls = classify_brands(
1081            &BRAND_AVIF,
1082            &[BRAND_MIF1, BRAND_AVIF, BRAND_MIAF, BRAND_MA1B],
1083        )
1084        .unwrap();
1085        assert!(cls.is_image);
1086        assert!(cls.is_miaf);
1087        assert!(cls.has_mif1);
1088        assert!(cls.is_baseline_profile);
1089        assert!(!cls.is_sequence);
1090        assert!(!cls.is_advanced_profile);
1091    }
1092
1093    #[test]
1094    fn classify_brands_advanced_profile() {
1095        let cls = classify_brands(&BRAND_AVIF, &[BRAND_MIF1, BRAND_MIAF, BRAND_MA1A]).unwrap();
1096        assert!(cls.is_advanced_profile);
1097        assert!(!cls.is_baseline_profile);
1098        assert!(cls.is_image);
1099    }
1100
1101    #[test]
1102    fn classify_brands_sequence_with_intra_only() {
1103        // Per av1-avif §6.3 + §8.2: avis + avio + msf1 + miaf + MA1B.
1104        let cls = classify_brands(
1105            &BRAND_AVIS,
1106            &[BRAND_AVIO, BRAND_MSF1, BRAND_MIAF, BRAND_MA1B],
1107        )
1108        .unwrap();
1109        assert!(cls.is_sequence);
1110        assert!(cls.is_intra_only);
1111        assert!(cls.has_msf1);
1112        assert!(cls.is_miaf);
1113        assert!(cls.is_baseline_profile);
1114        assert!(!cls.is_image);
1115    }
1116
1117    #[test]
1118    fn classify_brands_rejects_no_avif_or_heif_brand() {
1119        // `mp42` + `isom` is a valid generic ISOBMFF ftyp but says
1120        // nothing about AVIF/HEIF.
1121        let err = classify_brands(b"mp42", &[*b"isom"]).unwrap_err();
1122        let msg = format!("{err}");
1123        assert!(
1124            msg.contains("declares no AVIF/HEIF brand"),
1125            "expected AVIF/HEIF rejection, got: {msg}"
1126        );
1127        // Error must list the brands seen so the caller can debug.
1128        assert!(msg.contains("mp42"));
1129        assert!(msg.contains("isom"));
1130    }
1131
1132    #[test]
1133    fn classify_brands_accepts_mif1_only() {
1134        // Pure HEIF file with no AVIF brand still parses: caller can
1135        // inspect the meta to find out whether it carries an `av01` item.
1136        let cls = classify_brands(&BRAND_MIF1, &[]).unwrap();
1137        assert!(cls.has_mif1);
1138        assert!(!cls.is_image);
1139        assert!(!cls.is_sequence);
1140    }
1141
1142    #[test]
1143    fn classify_brands_lone_miaf_without_anchor_is_rejected() {
1144        // `miaf` alone isn't enough — a file must also claim either an
1145        // AVIF brand (`avif` / `avis` / `avio`) or a HEIF structural
1146        // brand (`mif1` / `msf1`).
1147        let err = classify_brands(&BRAND_MIAF, &[]).unwrap_err();
1148        assert!(format!("{err}").contains("declares no AVIF/HEIF brand"));
1149    }
1150
1151    #[test]
1152    fn parses_monochrome_fixture() {
1153        let img = parse(FIXTURE).expect("parse avif");
1154        assert_eq!(&img.major_brand, b"avif");
1155        assert!(img.compatible_brands.iter().any(|b| b == b"mif1"));
1156        assert_eq!(img.primary_item_id, 1);
1157        assert_eq!(&img.primary_item.item_type, b"av01");
1158        let ispe = img.ispe.expect("ispe for primary item");
1159        assert_eq!(ispe.width, 1280);
1160        assert_eq!(ispe.height, 720);
1161        // av1C starts with marker(1) | version(7) = 0x81.
1162        let av1c = img.av1c.as_deref().expect("av1C property");
1163        assert_eq!(av1c[0], 0x81);
1164        // The primary item bytes live in mdat and begin with an AV1
1165        // temporal-delimiter OBU (obu_type=2). Bit layout of the OBU
1166        // header byte: obu_forbidden(1) | obu_type(4) | obu_extension_flag(1)
1167        // | obu_has_size_field(1) | obu_reserved(1) — TD is type 2, and
1168        // this aom-encoded file sets has_size (low bit before reserved).
1169        let obu_type = (img.primary_item_data[0] >> 3) & 0x0f;
1170        assert_eq!(obu_type, 2, "first OBU should be temporal delimiter");
1171        // Monochrome file has a pixi with one 8-bit channel.
1172        let pixi = img.pixi.as_ref().expect("pixi");
1173        assert_eq!(pixi.bits_per_channel, vec![8]);
1174        // SDR fixture carries no HDR metadata.
1175        assert!(img.mdcv.is_none(), "SDR fixture must not have mdcv");
1176        assert!(img.clli.is_none(), "SDR fixture must not have clli");
1177        assert!(img.cclv.is_none(), "SDR fixture must not have cclv");
1178        assert!(img.amve.is_none(), "SDR fixture must not have amve");
1179    }
1180
1181    /// `item_bytes_owned` on a single-extent iloc returns the same data
1182    /// as `item_bytes`. This path allocates but produces equal bytes.
1183    #[test]
1184    fn item_bytes_owned_single_extent_matches_item_bytes() {
1185        use crate::meta::{IlocExtent, ItemLocation};
1186        let data = b"hello world";
1187        // Synthetic loc: base_offset 0, extent offset=0, length=11.
1188        let loc = ItemLocation {
1189            id: 1,
1190            construction_method: 0,
1191            data_reference_index: 0,
1192            base_offset: 0,
1193            extents: vec![IlocExtent {
1194                offset: 0,
1195                length: 11,
1196                extent_index: 0,
1197            }],
1198        };
1199        let slice = item_bytes(data, &loc).unwrap();
1200        let owned = item_bytes_owned(data, &loc).unwrap();
1201        assert_eq!(slice, owned.as_slice());
1202        assert_eq!(owned, b"hello world");
1203    }
1204
1205    /// `item_bytes_owned` on a multi-extent iloc concatenates all extents.
1206    #[test]
1207    fn item_bytes_owned_multi_extent_concatenates() {
1208        use crate::meta::{IlocExtent, ItemLocation};
1209        let data = b"AAAA____BBBB"; // 12 bytes; extents at 0..4 and 8..12
1210        let loc = ItemLocation {
1211            id: 1,
1212            construction_method: 0,
1213            data_reference_index: 0,
1214            base_offset: 0,
1215            extents: vec![
1216                IlocExtent {
1217                    offset: 0,
1218                    length: 4,
1219                    extent_index: 0,
1220                },
1221                IlocExtent {
1222                    offset: 8,
1223                    length: 4,
1224                    extent_index: 0,
1225                },
1226            ],
1227        };
1228        let owned = item_bytes_owned(data, &loc).unwrap();
1229        assert_eq!(owned, b"AAAABBBB");
1230        // item_bytes must return Unsupported for multi-extent.
1231        let err = item_bytes(data, &loc).unwrap_err();
1232        assert!(
1233            matches!(err, crate::error::AvifError::Unsupported(_)),
1234            "item_bytes must return Unsupported for multi-extent: {err:?}"
1235        );
1236    }
1237
1238    /// `item_bytes_owned` rejects construction_method != 0.
1239    #[test]
1240    fn item_bytes_owned_rejects_idat_method() {
1241        use crate::meta::{IlocExtent, ItemLocation};
1242        let loc = ItemLocation {
1243            id: 1,
1244            construction_method: 1, // idat-based
1245            data_reference_index: 0,
1246            base_offset: 0,
1247            extents: vec![IlocExtent {
1248                offset: 0,
1249                length: 4,
1250                extent_index: 0,
1251            }],
1252        };
1253        let err = item_bytes_owned(b"data", &loc).unwrap_err();
1254        assert!(matches!(err, crate::error::AvifError::Unsupported(_)));
1255    }
1256
1257    /// `item_bytes_with_idat` resolves a single-extent `construction_method
1258    /// == 1` (idat-offset) item from the `idat` slice, indexing
1259    /// base_offset + extent.offset into `data[]` (ISO/IEC 14496-12
1260    /// §8.11.3, construction method 1). The result is owned, never a
1261    /// borrow into `file`.
1262    #[test]
1263    fn item_bytes_with_idat_single_extent_idat() {
1264        use crate::meta::{IlocExtent, ItemLocation};
1265        let file = b"\x00\x00\x00\x00"; // file bytes are irrelevant for cm=1
1266        let idat = b"....HELLO...."; // payload "HELLO" lives at offset 4
1267        let loc = ItemLocation {
1268            id: 7,
1269            construction_method: 1,
1270            data_reference_index: 0,
1271            base_offset: 4,
1272            extents: vec![IlocExtent {
1273                offset: 0,
1274                length: 5,
1275                extent_index: 0,
1276            }],
1277        };
1278        let bytes = item_bytes_with_idat(file, Some(idat), &loc).unwrap();
1279        assert_eq!(&*bytes, b"HELLO");
1280        assert!(matches!(bytes, Cow::Owned(_)), "idat items are owned");
1281        // The owned helper agrees.
1282        let owned = item_bytes_owned_with_idat(file, Some(idat), &loc).unwrap();
1283        assert_eq!(owned, b"HELLO");
1284    }
1285
1286    /// A cm=0 single-extent item stays zero-copy: `item_bytes_with_idat`
1287    /// returns a borrow into `file`, with no allocation.
1288    #[test]
1289    fn item_bytes_with_idat_cm0_is_borrowed() {
1290        use crate::meta::{IlocExtent, ItemLocation};
1291        let file = b"xxxHELLOxxx";
1292        let loc = ItemLocation {
1293            id: 1,
1294            construction_method: 0,
1295            data_reference_index: 0,
1296            base_offset: 3,
1297            extents: vec![IlocExtent {
1298                offset: 0,
1299                length: 5,
1300                extent_index: 0,
1301            }],
1302        };
1303        let bytes = item_bytes_with_idat(file, None, &loc).unwrap();
1304        assert_eq!(&*bytes, b"HELLO");
1305        assert!(
1306            matches!(bytes, Cow::Borrowed(_)),
1307            "cm=0 single-extent must stay zero-copy"
1308        );
1309    }
1310
1311    /// `item_bytes_with_idat` concatenates multi-extent idat items in
1312    /// declaration order (HEIF §8.11.3.3).
1313    #[test]
1314    fn item_bytes_with_idat_multi_extent_idat() {
1315        use crate::meta::{IlocExtent, ItemLocation};
1316        let idat = b"AAAA____BBBB"; // extents 0..4 and 8..12 inside idat
1317        let loc = ItemLocation {
1318            id: 2,
1319            construction_method: 1,
1320            data_reference_index: 0,
1321            base_offset: 0,
1322            extents: vec![
1323                IlocExtent {
1324                    offset: 0,
1325                    length: 4,
1326                    extent_index: 0,
1327                },
1328                IlocExtent {
1329                    offset: 8,
1330                    length: 4,
1331                    extent_index: 0,
1332                },
1333            ],
1334        };
1335        let bytes = item_bytes_with_idat(b"", Some(idat), &loc).unwrap();
1336        assert_eq!(&*bytes, b"AAAABBBB");
1337    }
1338
1339    /// A cm=1 item with no `idat` available is an invalid file, not a
1340    /// silent empty payload.
1341    #[test]
1342    fn item_bytes_with_idat_missing_idat_errors() {
1343        use crate::meta::{IlocExtent, ItemLocation};
1344        let loc = ItemLocation {
1345            id: 9,
1346            construction_method: 1,
1347            data_reference_index: 0,
1348            base_offset: 0,
1349            extents: vec![IlocExtent {
1350                offset: 0,
1351                length: 2,
1352                extent_index: 0,
1353            }],
1354        };
1355        let err = item_bytes_with_idat(b"data", None, &loc).unwrap_err();
1356        assert!(
1357            matches!(err, crate::error::AvifError::InvalidData(_)),
1358            "missing idat for a cm=1 item must be Invalid: {err:?}"
1359        );
1360    }
1361
1362    /// An idat extent that runs past the `idat` buffer is rejected — the
1363    /// bounds check is against the idat length, not the file length.
1364    #[test]
1365    fn item_bytes_with_idat_extent_out_of_bounds() {
1366        use crate::meta::{IlocExtent, ItemLocation};
1367        let idat = b"short";
1368        let loc = ItemLocation {
1369            id: 3,
1370            construction_method: 1,
1371            data_reference_index: 0,
1372            base_offset: 0,
1373            extents: vec![IlocExtent {
1374                offset: 0,
1375                length: 100,
1376                extent_index: 0,
1377            }],
1378        };
1379        let err = item_bytes_with_idat(b"", Some(idat), &loc).unwrap_err();
1380        assert!(matches!(err, crate::error::AvifError::InvalidData(_)));
1381    }
1382
1383    /// `audit_mif1` against the Microsoft monochrome AVIF fixture
1384    /// reports full mif1 compliance plus the mif1 brand claim.
1385    /// The fixture's `ftyp` is `avif` major with `mif1` in
1386    /// compatible_brands and its `meta` ships every mif1-mandatory
1387    /// child box (hdlr / pitm / iinf with one infe / iloc / iprp).
1388    #[test]
1389    fn audit_mif1_monochrome_fixture_is_compliant() {
1390        let m = audit_mif1(FIXTURE).expect("audit");
1391        assert!(m.claims_mif1, "fixture has mif1 in compatible_brands");
1392        assert!(m.has_hdlr);
1393        assert!(m.has_pitm);
1394        assert!(m.has_iinf);
1395        assert!(m.has_iloc);
1396        assert!(m.has_iprp);
1397        assert!(m.infe_count >= 1);
1398        assert!(m.is_compliant());
1399        assert!(m.missing().is_empty());
1400    }
1401
1402    /// `audit_mif1` on bytes with no meta box reports `claims_mif1`
1403    /// from the ftyp and every other flag false. Useful gate for
1404    /// "is this even a plausible mif1 carrier".
1405    #[test]
1406    fn audit_mif1_ftyp_only_reports_brand_no_boxes() {
1407        // Synth ftyp: major='mif1' minor=0 compat=[mif1]
1408        let mut buf = Vec::new();
1409        let payload_len: u32 = 4 + 4 + 4; // major + minor + one compat
1410        let size: u32 = 8 + payload_len;
1411        buf.extend_from_slice(&size.to_be_bytes());
1412        buf.extend_from_slice(b"ftyp");
1413        buf.extend_from_slice(b"mif1");
1414        buf.extend_from_slice(&0u32.to_be_bytes());
1415        buf.extend_from_slice(b"mif1");
1416        let m = audit_mif1(&buf).expect("audit");
1417        assert!(m.claims_mif1);
1418        assert!(!m.is_compliant());
1419        let missing = m.missing();
1420        assert!(missing.contains(&"hdlr"));
1421        assert!(missing.contains(&"pitm"));
1422    }
1423}