Add Rust implementation

Change-Id: I749b335ee4f123d3adb7d9d92d7c14eb9948d90c
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..099f53a
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "avifinfo"
+description = "Basic AVIF features parsing library"
+version = "1.0.0"
+authors = ["Yannis Guyon <yguyon@google.com>"]
+repository = "https://aomedia.googlesource.com/libavifinfo"
+keywords = ["avif"]
+license = "Alliance for Open Media Patent License 1.0"
+include = ["avifinfo.rs", "Cargo.toml", "README.md"]
+edition = "2021"
+
+[lib]
+name = "avifinfo"
+path = "avifinfo.rs"
diff --git a/README.md b/README.md
index d44001b..43d0b04 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,22 @@
 php avifinfo_test.php
 ```
 
+## Rust implementation
+
+The Rust implementation of libavifinfo is similar to the C API.
+
+```shell
+cargo build
+```
+
+See `tests/avifinfo_test.rs` for a usage example.
+
+### Rust test
+
+```shell
+cargo test
+```
+
 ## Development
 
 ### Submitting patches {#submitting-patches}
diff --git a/avifinfo.rs b/avifinfo.rs
new file mode 100644
index 0000000..3c20319
--- /dev/null
+++ b/avifinfo.rs
@@ -0,0 +1,884 @@
+// Copyright (c) 2024, Alliance for Open Media. All rights reserved
+//
+// This source code is subject to the terms of the BSD 2 Clause License and
+// the Alliance for Open Media Patent License 1.0. If the BSD 2 Clause License
+// was not distributed with this source code in the LICENSE file, you can
+// obtain it at www.aomedia.org/license/software. If the Alliance for Open
+// Media Patent License 1.0 was not distributed with this source code in the
+// PATENTS file, you can obtain it at www.aomedia.org/license/patent.
+
+//-----------------------------------------------------------------------------
+
+#[derive(PartialEq, Debug)]
+pub enum AvifInfoError {
+    // The input bitstream was correctly parsed until now but bytes are missing. The request
+    // should be repeated with more input bytes.
+    NotEnoughData,
+    // The input bitstream was correctly parsed until now but it is too complex. The parsing was
+    // stopped to avoid any timeout or crash.
+    TooComplex,
+    // The input bitstream is not a valid AVIF file, truncated or not.
+    InvalidFile,
+}
+
+// Ok means that the file was correctly parsed and the requested information was
+// extracted. It is not guaranteed that the input bitstream is a valid complete
+// AVIF file.
+type AvifInfoResult<T> = Result<T, AvifInfoError>;
+
+#[derive(Debug, Default, PartialEq)]
+pub struct Features {
+    // In number of pixels. Ignores crop and rotation.
+    pub width: u32,
+    pub height: u32,
+
+    // Likely 8, 10 or 12 bits per channel per pixel.
+    pub bit_depth: u8,
+
+    // Likely 1, 2, 3 or 4 channels: (1 monochrome or 3 colors) + (0 or 1 alpha)
+    pub num_channels: u8,
+
+    // True if a gain map was found.
+    pub has_gainmap: bool,
+
+    // Id of the gain map item. Assumes there is at most one. If there are several gain map items
+    // (e.g. because the main image is tiled and each tile has an independent gain map), then this
+    // is one of the ids, arbitrarily chosen.
+    pub gainmap_item_id: u8,
+
+    // Start location in bytes of the primary item id, relative to the beginning of the given
+    // payload. The primary item id is a big endian number stored on bytes primary_item_id_location
+    // to primary_item_id_location+primary_item_id_bytes-1 inclusive.
+    pub primary_item_id_location: usize,
+
+    // Number of bytes of the primary item id.
+    pub primary_item_id_bytes: u8,
+}
+
+//------------------------------------------------------------------------------
+
+// Status returned when reading the content of a box (or file).
+#[derive(PartialEq)]
+enum InternalError {
+    NotFound,  // Input correctly parsed but information is missing or elsewhere.
+    Truncated, // Input correctly parsed until missing bytes to continue.
+    Aborted,   // Input correctly parsed until stopped to avoid timeout or crash.
+    Invalid,   // Input incorrectly parsed.
+}
+
+// Ok means "Input correctly parsed and information retrieved".
+type InternalResult<T> = Result<T, InternalError>;
+
+impl From<InternalError> for AvifInfoError {
+    fn from(error: InternalError) -> Self {
+        match error {
+            InternalError::NotFound | InternalError::Truncated => AvifInfoError::NotEnoughData,
+            InternalError::Aborted => AvifInfoError::TooComplex,
+            InternalError::Invalid => AvifInfoError::InvalidFile,
+        }
+    }
+}
+
+// Be reasonable. Avoid timeouts and out-of-memory.
+const AVIFINFO_MAX_NUM_BOXES: u32 = 4096;
+// InternalFeatures uses u8 to store values.
+const AVIFINFO_MAX_VALUE: u8 = u8::MAX;
+// Maximum number of stored associations. Past that, they are skipped.
+const AVIFINFO_MAX_TILES: usize = 16;
+const AVIFINFO_MAX_PROPS: usize = 32;
+const AVIFINFO_MAX_FEATURES: usize = 8;
+const AVIFINFO_UNDEFINED: u8 = 0;
+
+//------------------------------------------------------------------------------
+// Streamed input struct and helper functions.
+
+struct Stream<'a> {
+    // The available bytes.
+    data: Option<&'a [u8]>,
+    // The known size of the parent box, if any.
+    size: Option<usize>,
+    // How many bytes were read or skipped.
+    offset: usize,
+}
+
+impl Stream<'_> {
+    fn read(&mut self, num_bytes: usize) -> InternalResult<&[u8]> {
+        if num_bytes == 0 {
+            return Ok(&[]);
+        }
+        let offset = self.offset;
+        self.skip(num_bytes)?;
+        match &self.data {
+            Some(data) if self.offset <= data.len() => Ok(&data[offset..self.offset]),
+            _ => Err(InternalError::Truncated),
+        }
+    }
+
+    fn read_u8(&mut self) -> InternalResult<u8> {
+        Ok(self.read(1)?[0])
+    }
+
+    fn read_u16(&mut self) -> InternalResult<u16> {
+        Ok(u16::from_be_bytes(self.read(2)?.try_into().unwrap()))
+    }
+
+    fn read_u24(&mut self) -> InternalResult<u32> {
+        Ok((self.read_uint(1)? << 16) | self.read_uint(2)?)
+    }
+
+    fn read_u32(&mut self) -> InternalResult<u32> {
+        Ok(u32::from_be_bytes(self.read(4)?.try_into().unwrap()))
+    }
+
+    fn read_u64(&mut self) -> InternalResult<u64> {
+        Ok(u64::from_be_bytes(self.read(8)?.try_into().unwrap()))
+    }
+
+    fn read_uint(&mut self, num_bytes: u8) -> InternalResult<u32> {
+        match num_bytes {
+            1 => Ok(self.read_u8()? as u32),
+            2 => Ok(self.read_u16()? as u32),
+            4 => Ok(self.read_u32()?),
+            _ => Err(InternalError::Aborted),
+        }
+    }
+
+    fn read_4cc(&mut self) -> InternalResult<&[u8; 4]> {
+        Ok(self.read(4)?.try_into().unwrap())
+    }
+
+    fn skip(&mut self, num_bytes: usize) -> InternalResult<()> {
+        self.offset = self.offset.checked_add(num_bytes).ok_or(InternalError::Aborted)?;
+        if let Some(size) = self.size {
+            if self.offset > size {
+                return Err(InternalError::Invalid);
+            }
+        }
+        Ok(())
+    }
+
+    fn num_read_bytes(&self) -> usize {
+        self.offset // Includes the unchecked skipped bytes.
+    }
+
+    fn has_more_bytes(&self) -> bool {
+        match self.size {
+            Some(size) => self.offset < size,
+            None => true,
+        }
+    }
+
+    // Returns a portion of the stream. The size of the portion is either given
+    // or all remaining bytes are returned.
+    fn substream(&mut self, num_bytes: Option<usize>) -> InternalResult<Stream> {
+        let offset = self.offset;
+        let num_transferred_bytes = num_bytes.unwrap_or(match &self.data {
+            Some(data) => data.len().saturating_sub(offset),
+            None => 0,
+        });
+        self.skip(num_transferred_bytes)?;
+        self.offset = offset.checked_add(num_transferred_bytes).ok_or(InternalError::Aborted)?;
+        Ok(Stream {
+            data: if let Some(data) = &self.data {
+                let available_size = data.len().saturating_sub(offset);
+                let size = std::cmp::min(available_size, num_transferred_bytes);
+                if size != 0 { Some(&data[offset..offset + size]) } else { None }
+            } else {
+                None
+            },
+            size: num_bytes,
+            offset: 0,
+        })
+    }
+}
+
+//------------------------------------------------------------------------------
+// Features are parsed into temporary property associations.
+
+#[derive(Default)]
+struct InternalTile {
+    tile_item_id: u8,
+    parent_item_id: u8,
+    dimg_idx: u8, // Index of this association in the dimg box (0-based).
+}
+
+#[derive(Default)]
+struct InternalProp {
+    property_index: u8,
+    item_id: u8,
+}
+
+#[derive(Default)]
+struct InternalDimProp {
+    property_index: u8,
+    width: u32,
+    height: u32,
+}
+
+#[derive(Default)]
+struct InternalChanProp {
+    property_index: u8,
+    bit_depth: u8,
+    num_channels: u8,
+}
+
+#[derive(Default)]
+struct InternalFeatures {
+    has_primary_item: bool,     // True if "pitm" was parsed.
+    has_alpha: bool,            // True if an alpha "auxC" was parsed.
+    gainmap_property_index: u8, // Index of the gain map auxC property.
+    primary_item_id: u8,
+    primary_item_features: Features, // Deduced from the data below.
+    data_was_skipped: bool,          // True if some loops/indices were skipped.
+    tone_mapped_item_id: u8,         // Id of the "tmap" box, > 0 if present.
+    iinf_parsed: bool,               // True if the "iinf" (item info) box was parsed.
+    iref_parsed: bool,               // True if the "iref" (item reference) box was parsed.
+
+    num_tiles: usize,
+    tiles: [InternalTile; AVIFINFO_MAX_TILES],
+    num_props: usize,
+    props: [InternalProp; AVIFINFO_MAX_PROPS],
+    num_dim_props: usize,
+    dim_props: [InternalDimProp; AVIFINFO_MAX_FEATURES],
+    num_chan_props: usize,
+    chan_props: [InternalChanProp; AVIFINFO_MAX_FEATURES],
+}
+
+impl InternalFeatures {
+    fn get_item_features(&mut self, target_item_id: u8, tile_depth: u32) -> InternalResult<()> {
+        for prop_item in 0..self.num_props {
+            if self.props[prop_item].item_id != target_item_id {
+                continue;
+            }
+            let property_index = self.props[prop_item].property_index;
+
+            // Retrieve the width and height of the primary item if not already done.
+            if target_item_id == self.primary_item_id
+                && (self.primary_item_features.width == AVIFINFO_UNDEFINED as u32
+                    || self.primary_item_features.height == AVIFINFO_UNDEFINED as u32)
+            {
+                for i in 0..self.num_dim_props {
+                    if self.dim_props[i].property_index != property_index {
+                        continue;
+                    }
+                    self.primary_item_features.width = self.dim_props[i].width;
+                    self.primary_item_features.height = self.dim_props[i].height;
+                    if self.primary_item_features.bit_depth != AVIFINFO_UNDEFINED
+                        && self.primary_item_features.num_channels != AVIFINFO_UNDEFINED
+                    {
+                        return Ok(());
+                    }
+                    break;
+                }
+            }
+            // Retrieve the bit depth and number of channels of the target item if not
+            // already done.
+            if self.primary_item_features.bit_depth == AVIFINFO_UNDEFINED
+                || self.primary_item_features.num_channels == AVIFINFO_UNDEFINED
+            {
+                for i in 0..self.num_chan_props {
+                    if self.chan_props[i].property_index != property_index {
+                        continue;
+                    }
+                    self.primary_item_features.bit_depth = self.chan_props[i].bit_depth;
+                    self.primary_item_features.num_channels = self.chan_props[i].num_channels;
+                    if self.primary_item_features.width != AVIFINFO_UNDEFINED as u32
+                        && self.primary_item_features.height != AVIFINFO_UNDEFINED as u32
+                    {
+                        return Ok(());
+                    }
+                    break;
+                }
+            }
+        }
+
+        // Check for the bit_depth and num_channels in a tile if not yet found.
+        if tile_depth < 3 {
+            for tile in 0..self.num_tiles {
+                if self.tiles[tile].parent_item_id != target_item_id {
+                    continue;
+                }
+                match self.get_item_features(self.tiles[tile].tile_item_id, tile_depth + 1) {
+                    Ok(()) => return Ok(()),
+                    Err(InternalError::NotFound) => {} // Keep searching.
+                    Err(error) => return Err(error),
+                }
+            }
+        }
+        Err(InternalError::NotFound)
+    }
+
+    // Generates the primary_item_features.
+    // Returns InternalError::NotFound if there is not enough information.
+    fn get_primary_item_features(&mut self) -> InternalResult<()> {
+        // Nothing to do without the primary item ID.
+        if !self.has_primary_item {
+            return Err(InternalError::NotFound);
+        }
+        // Early exit.
+        if self.num_dim_props == 0 || self.num_chan_props == 0 {
+            return Err(InternalError::NotFound);
+        }
+
+        // Look for a gain map.
+        // HEIF scheme: gain map is a hidden input of a derived item.
+        if self.tone_mapped_item_id != AVIFINFO_UNDEFINED {
+            for tile in 0..self.num_tiles {
+                if self.tiles[tile].parent_item_id == self.tone_mapped_item_id
+                    && self.tiles[tile].dimg_idx == 1
+                {
+                    self.primary_item_features.has_gainmap = true;
+                    self.primary_item_features.gainmap_item_id = self.tiles[tile].tile_item_id;
+                    break;
+                }
+            }
+        }
+        // Adobe scheme: gain map is an auxiliary item.
+        if !self.primary_item_features.has_gainmap && self.gainmap_property_index > 0 {
+            for prop_item in 0..self.num_props {
+                if self.props[prop_item].property_index == self.gainmap_property_index {
+                    self.primary_item_features.has_gainmap = true;
+                    self.primary_item_features.gainmap_item_id = self.props[prop_item].item_id;
+                    break;
+                }
+            }
+        }
+        // If the gain map has not been found but we haven't read all the relevant
+        // metadata, we might still find one later and cannot stop now.
+        if !self.primary_item_features.has_gainmap
+            && (!self.iinf_parsed
+                || (self.tone_mapped_item_id != AVIFINFO_UNDEFINED && !self.iref_parsed))
+        {
+            return Err(InternalError::NotFound);
+        }
+
+        self.get_item_features(self.primary_item_id, /* tile_depth= */ 0)?;
+
+        // "auxC" is parsed before the "ipma" properties so it is known now, if any.
+        if self.has_alpha {
+            self.primary_item_features.num_channels += 1;
+        }
+        Ok(())
+    }
+}
+
+//------------------------------------------------------------------------------
+// Box header parsing and various size checks.
+
+struct InternalBox {
+    box_type: [u8; 4],           // Four characters.
+    version: u8,                 // 0 or actual version if this is a full box.
+    flags: u32,                  // 0 or actual value if this is a full box.
+    content_size: Option<usize>, // If known, size of the box in bytes, header exclusive.
+}
+
+// Reads the header of a box starting at the beginning of a stream.
+fn parse_box(
+    nesting_level: u32,
+    stream: &mut Stream,
+    num_parsed_boxes: &mut u32,
+) -> InternalResult<InternalBox> {
+    // See ISO/IEC 14496-12:2012(E) 4.2
+    let mut box_header_size = 8usize; // box 32-bit size + 32-bit type (at least)
+    let mut box_size: Option<usize> =
+        Some(stream.read_u32()?.try_into().or(Err(InternalError::Aborted))?);
+    let mut box_type = *stream.read_4cc()?;
+    // box_size==1 means 64-bit size should be read after the box type.
+    // box_size==0 means this box extends to all remaining bytes.
+    if box_size == Some(1) {
+        box_header_size += 8;
+        box_size = Some(stream.read_u64()?.try_into().or(Err(InternalError::Aborted))?);
+    } else if box_size == Some(0) {
+        if nesting_level != 0 {
+            // ISO/IEC 14496-12 4.2.2:
+            //   if size is 0, then this box shall be in a top-level box
+            //   (i.e. not contained in another box)
+            return Err(InternalError::Invalid);
+        }
+        box_size = None;
+    }
+    // 16 bytes of usertype should be read here if the box type is 'uuid'.
+    // 'uuid' boxes are skipped so usertype is part of the skipped body.
+
+    let has_fullbox_header = matches!(
+        &box_type,
+        b"meta" | b"pitm" | b"ipma" | b"ispe" | b"pixi" | b"iref" | b"auxC" | b"iinf" | b"infe"
+    );
+    if has_fullbox_header {
+        box_header_size += 4;
+    }
+    let content_size = match box_size {
+        Some(box_size) => {
+            Some(box_size.checked_sub(box_header_size).ok_or(InternalError::Invalid)?)
+        }
+        None => None,
+    };
+    if let Some(size) = stream.size {
+        if content_size.unwrap_or(box_header_size) > size.saturating_sub(stream.offset) {
+            return Err(InternalError::Invalid);
+        }
+    }
+    // get_features() can be called on a full stream or on a stream
+    // where the 'ftyp' box was already read. Do not count 'ftyp' boxes towards
+    // AVIFINFO_MAX_NUM_BOXES, so that this function returns the same status in
+    // both situations (because of the AVIFINFO_MAX_NUM_BOXES check that would
+    // compare a different box count otherwise). This is fine because top-level
+    // 'ftyp' boxes are just skipped anyway.
+    if nesting_level != 0 || &box_type != b"ftyp" {
+        // Avoid timeouts. The maximum number of parsed boxes is arbitrary.
+        *num_parsed_boxes += 1;
+        if *num_parsed_boxes >= AVIFINFO_MAX_NUM_BOXES {
+            return Err(InternalError::Aborted);
+        }
+    }
+
+    let mut version = 0;
+    let mut flags = 0;
+    if has_fullbox_header {
+        version = stream.read_u8()?;
+        flags = stream.read_u24()?;
+        // See AV1 Image File Format (AVIF) 8.1
+        // at https://aomediacodec.github.io/av1-avif/#avif-boxes (available when
+        // https://github.com/AOMediaCodec/av1-avif/pull/170 is merged).
+        let is_parsable = (&box_type == b"meta" && version == 0)
+            || (&box_type == b"pitm" && version <= 1)
+            || (&box_type == b"ipma" && version <= 1)
+            || (&box_type == b"ispe" && version == 0)
+            || (&box_type == b"pixi" && version == 0)
+            || (&box_type == b"iref" && version <= 1)
+            || (&box_type == b"auxC" && version == 0)
+            || (&box_type == b"iinf" && version <= 1)
+            || (&box_type == b"infe" && version <= 2);
+        // Instead of considering this file as invalid, skip unparsable boxes.
+        if !is_parsable {
+            box_type = *b"skip"; // FreeSpaceBox. To be ignored by readers.
+        }
+    }
+
+    Ok(InternalBox { box_type, version, flags, content_size })
+}
+
+//------------------------------------------------------------------------------
+
+impl InternalFeatures {
+    // Parses a stream of an 'ipco' box.
+    // 'ispe' is used for width and height, 'pixi' and 'av1C' are used for bit depth
+    // and number of channels, and 'auxC' is used for alpha.
+    fn parse_ipco(
+        &mut self,
+        nesting_level: u32,
+        stream: &mut Stream,
+        num_parsed_boxes: &mut u32,
+    ) -> InternalResult<()> {
+        let mut box_index = 1u8; // 1-based index. Used for iterating over properties.
+        while stream.has_more_bytes() {
+            let box_features = parse_box(nesting_level, stream, num_parsed_boxes)?;
+            let mut box_stream = stream.substream(box_features.content_size)?;
+
+            match &box_features.box_type {
+                b"ispe" => {
+                    // See ISO/IEC 23008-12:2017(E) 6.5.3.2
+                    let width = box_stream.read_u32()?;
+                    let height = box_stream.read_u32()?;
+                    if width == 0 || height == 0 {
+                        return Err(InternalError::Invalid);
+                    }
+                    if self.num_dim_props < AVIFINFO_MAX_FEATURES {
+                        self.dim_props[self.num_dim_props].property_index = box_index;
+                        self.dim_props[self.num_dim_props].width = width;
+                        self.dim_props[self.num_dim_props].height = height;
+                        self.num_dim_props += 1;
+                    } else {
+                        self.data_was_skipped = true;
+                    }
+                }
+                b"pixi" => {
+                    // See ISO/IEC 23008-12:2017(E) 6.5.6.2
+                    let num_channels = box_stream.read_u8()?;
+                    if num_channels == 0 || num_channels > 3 {
+                        return Err(InternalError::Invalid);
+                    }
+                    let bit_depth = box_stream.read_u8()?;
+                    if bit_depth == 0 {
+                        return Err(InternalError::Invalid);
+                    }
+                    for _ in 1..num_channels {
+                        // Bit depth should be the same for all channels.
+                        if box_stream.read_u8()? != bit_depth {
+                            return Err(InternalError::Invalid);
+                        }
+                    }
+                    if self.num_chan_props < AVIFINFO_MAX_FEATURES {
+                        self.chan_props[self.num_chan_props].property_index = box_index;
+                        self.chan_props[self.num_chan_props].bit_depth = bit_depth;
+                        self.chan_props[self.num_chan_props].num_channels = num_channels;
+                        self.num_chan_props += 1;
+                    } else {
+                        self.data_was_skipped = true;
+                    }
+                }
+                b"av1C" => {
+                    // See AV1 Codec ISO Media File Format Binding 2.3.1
+                    // at https://aomediacodec.github.io/av1-isobmff/#av1c
+                    // Only parse the necessary third byte. Assume that the others are valid.
+                    box_stream.skip(2)?;
+                    let data = box_stream.read_u8()?;
+                    let high_bitdepth = (data & 0x40) != 0;
+                    let twelve_bit = (data & 0x20) != 0;
+                    let monochrome = (data & 0x10) != 0;
+                    if twelve_bit && !high_bitdepth {
+                        return Err(InternalError::Invalid);
+                    }
+                    if self.num_chan_props < AVIFINFO_MAX_FEATURES {
+                        self.chan_props[self.num_chan_props].property_index = box_index;
+                        self.chan_props[self.num_chan_props].bit_depth =
+                            if high_bitdepth { if twelve_bit { 12 } else { 10 } } else { 8 };
+                        self.chan_props[self.num_chan_props].num_channels =
+                            if monochrome { 1 } else { 3 };
+                        self.num_chan_props += 1;
+                    } else {
+                        self.data_was_skipped = true;
+                    }
+                }
+                b"auxC" => {
+                    // See AV1 Image File Format (AVIF) 4
+                    // at https://aomediacodec.github.io/av1-avif/#auxiliary-images
+                    const ALPHA_STR: &[u8] = b"urn:mpeg:mpegB:cicp:systems:auxiliary:alpha\0";
+                    const GAINMAP_STR: &[u8] = b"urn:com:photo:aux:hdrgainmap\0";
+                    // Check for a gain map or for an alpha plane.
+                    let content_size = box_features.content_size.unwrap();
+                    let data = box_stream.read(std::cmp::min(content_size, ALPHA_STR.len()))?;
+                    if &data[..std::cmp::min(data.len(), ALPHA_STR.len())] == ALPHA_STR {
+                        // Note: It is unlikely but possible that this alpha plane does not belong
+                        // to the primary item or a tile. Ignore this issue.
+                        self.has_alpha = true;
+                    } else if &data[..std::cmp::min(data.len(), GAINMAP_STR.len())] == GAINMAP_STR {
+                        // Note: It is unlikely but possible that this gain map does not belong to
+                        // the primary item or a tile. Ignore this issue.
+                        self.gainmap_property_index = box_index;
+                    }
+                }
+                _ => {}
+            }
+
+            if box_index == AVIFINFO_MAX_VALUE {
+                self.data_was_skipped = true;
+                return Err(InternalError::NotFound);
+            }
+            box_index += 1;
+        }
+        Err(InternalError::NotFound)
+    }
+
+    // Parses a stream of an 'iprp' box.
+    // The 'ipco' box contains the properties which are linked to items by the
+    // 'ipma' box.
+    fn parse_iprp(
+        &mut self,
+        nesting_level: u32,
+        stream: &mut Stream,
+        num_parsed_boxes: &mut u32,
+    ) -> InternalResult<()> {
+        while stream.has_more_bytes() {
+            let box_features = parse_box(nesting_level, stream, num_parsed_boxes)?;
+            let mut box_stream = stream.substream(box_features.content_size)?;
+
+            match &box_features.box_type {
+                b"ipco" => {
+                    match self.parse_ipco(nesting_level + 1, &mut box_stream, num_parsed_boxes) {
+                        Ok(()) => return Ok(()),
+                        Err(InternalError::NotFound) => {} // Keep searching.
+                        Err(error) => return Err(error),
+                    }
+                }
+                b"ipma" => {
+                    // See ISO/IEC 23008-12:2017(E) 9.3.2
+                    let entry_count = box_stream.read_u32()?;
+                    let id_num_bytes = if box_features.version < 1 { 2 } else { 4 };
+                    let index_num_bytes = if box_features.flags & 1 != 0 { 2 } else { 1 };
+                    let essential_bit_mask =
+                        if box_features.flags & 1 != 0 { 0x8000 } else { 0x80 };
+
+                    for entry in 0..entry_count as usize {
+                        if entry >= AVIFINFO_MAX_PROPS || self.num_props >= AVIFINFO_MAX_PROPS {
+                            self.data_was_skipped = true;
+                            break;
+                        }
+                        let item_id = box_stream.read_uint(id_num_bytes)?;
+                        let association_count = box_stream.read_u8()? as usize;
+                        let mut property = 0usize;
+                        while property < association_count {
+                            if property >= AVIFINFO_MAX_PROPS
+                                || self.num_props >= AVIFINFO_MAX_PROPS
+                            {
+                                self.data_was_skipped = true;
+                                break;
+                            }
+                            let value = box_stream.read_uint(index_num_bytes)?;
+                            // let essential = (value & essential_bit_mask);  // Unused.
+                            let property_index = value & (!essential_bit_mask);
+                            if property_index <= AVIFINFO_MAX_VALUE as u32
+                                && item_id <= AVIFINFO_MAX_VALUE as u32
+                            {
+                                self.props[self.num_props].property_index = property_index as u8;
+                                self.props[self.num_props].item_id = item_id as u8;
+                                self.num_props += 1;
+                            } else {
+                                self.data_was_skipped = true;
+                            }
+                            property += 1;
+                        }
+                        if property < association_count {
+                            break; // Do not read garbage.
+                        }
+                    }
+
+                    // If all features are available now, do not look further.
+                    match self.get_primary_item_features() {
+                        Ok(()) => return Ok(()),
+                        Err(InternalError::NotFound) => {}
+                        Err(error) => return Err(error),
+                    }
+                }
+                _ => {}
+            }
+        }
+        Err(InternalError::NotFound)
+    }
+
+    // Parses a stream of an 'iref' box.
+    // The 'dimg' boxes contain links between tiles and their parent items, which
+    // can be used to infer bit depth and number of channels for the primary item
+    // when the latter does not have these properties.
+    fn parse_iref(
+        &mut self,
+        nesting_level: u32,
+        stream: &mut Stream,
+        num_parsed_boxes: &mut u32,
+    ) -> InternalResult<()> {
+        self.iref_parsed = true;
+
+        while stream.has_more_bytes() {
+            let box_features = parse_box(nesting_level, stream, num_parsed_boxes)?;
+            let mut box_stream = stream.substream(box_features.content_size)?;
+
+            if let b"dimg" = &box_features.box_type {
+                // See ISO/IEC 14496-12:2015(E) 8.11.12.2
+                let num_bytes_per_id = if box_features.version == 0 { 2 } else { 4 };
+                let from_item_id = box_stream.read_uint(num_bytes_per_id)?;
+                let reference_count = box_stream.read_u16()?;
+                for i in 0..reference_count {
+                    if i as usize >= AVIFINFO_MAX_TILES {
+                        self.data_was_skipped = true;
+                        break;
+                    }
+                    let to_item_id = box_stream.read_uint(num_bytes_per_id)?;
+                    if from_item_id <= AVIFINFO_MAX_VALUE as u32
+                        && to_item_id <= AVIFINFO_MAX_VALUE as u32
+                        && self.num_tiles < AVIFINFO_MAX_TILES
+                    {
+                        self.tiles[self.num_tiles].tile_item_id = to_item_id as u8;
+                        self.tiles[self.num_tiles].parent_item_id = from_item_id as u8;
+                        self.tiles[self.num_tiles].dimg_idx = i as u8;
+                        self.num_tiles += 1;
+                    } else {
+                        self.data_was_skipped = true;
+                    }
+                }
+
+                // If all features are available now, do not look further.
+                match self.get_primary_item_features() {
+                    Ok(()) => return Ok(()),
+                    Err(InternalError::NotFound) => {}
+                    Err(error) => return Err(error),
+                }
+            }
+        }
+        Err(InternalError::NotFound)
+    }
+
+    // Parses a stream of an 'iinf' box.
+    fn parse_iinf(
+        &mut self,
+        nesting_level: u32,
+        stream: &mut Stream,
+        box_version: u8,
+        num_parsed_boxes: &mut u32,
+    ) -> InternalResult<()> {
+        self.iinf_parsed = true;
+
+        let num_bytes_per_entry_count = if box_version == 0 { 2 } else { 4 };
+        let entry_count = stream.read_uint(num_bytes_per_entry_count)?;
+        for _ in 0..entry_count {
+            let box_features = parse_box(nesting_level, stream, num_parsed_boxes)?;
+            let mut box_stream = stream.substream(box_features.content_size)?;
+
+            if let b"infe" = &box_features.box_type {
+                // See ISO/IEC 14496-12:2015(E) 8.11.6.2
+                let num_bytes_per_id = if box_features.version == 2 { 2 } else { 4 };
+                let item_id = box_stream.read_uint(num_bytes_per_id)?;
+
+                // Skip item_protection_index.
+                box_stream.skip(2)?;
+
+                if box_stream.read_4cc()? == b"tmap" {
+                    // Tone Mapped Image: indicates the presence of a gain map.
+                    if item_id <= AVIFINFO_MAX_VALUE as u32 {
+                        self.tone_mapped_item_id = item_id as u8;
+                    } else {
+                        self.data_was_skipped = true;
+                    }
+                }
+            }
+
+            if !stream.has_more_bytes() {
+                break; // Ignore entry_count bigger than box.
+            }
+        }
+        Err(InternalError::NotFound)
+    }
+
+    // Parses a stream of a 'meta' box. It looks for the primary item ID in the
+    // 'pitm' box and recurses into other boxes to find the features.
+    fn parse_meta(
+        &mut self,
+        nesting_level: u32,
+        stream_offset: usize,
+        stream: &mut Stream,
+        num_parsed_boxes: &mut u32,
+    ) -> InternalResult<()> {
+        while stream.has_more_bytes() {
+            let box_features = parse_box(nesting_level, stream, num_parsed_boxes)?;
+            let box_header_size = stream.num_read_bytes();
+            let mut box_stream = stream.substream(box_features.content_size)?;
+
+            match &box_features.box_type {
+                b"pitm" => {
+                    // See ISO/IEC 14496-12:2015(E) 8.11.4.2
+                    let num_bytes_per_id = if box_features.version == 0 { 2 } else { 4 };
+                    let primary_item_id_location =
+                        stream_offset.checked_add(box_header_size).ok_or(InternalError::Aborted)?;
+                    let primary_item_id = box_stream.read_uint(num_bytes_per_id)?;
+                    if primary_item_id > AVIFINFO_MAX_VALUE as u32 {
+                        return Err(InternalError::Aborted);
+                    }
+                    self.has_primary_item = true;
+                    self.primary_item_id = primary_item_id as u8;
+                    self.primary_item_features.primary_item_id_location = primary_item_id_location;
+                    self.primary_item_features.primary_item_id_bytes = num_bytes_per_id;
+                }
+                b"iprp" => {
+                    match self.parse_iprp(nesting_level + 1, &mut box_stream, num_parsed_boxes) {
+                        Ok(()) => return Ok(()),
+                        Err(InternalError::NotFound) => {} // Keep searching.
+                        Err(error) => return Err(error),
+                    }
+                }
+                b"iref" => {
+                    match self.parse_iref(nesting_level + 1, &mut box_stream, num_parsed_boxes) {
+                        Ok(()) => return Ok(()),
+                        Err(InternalError::NotFound) => {} // Keep searching.
+                        Err(error) => return Err(error),
+                    }
+                }
+                b"iinf" => {
+                    match self.parse_iinf(
+                        nesting_level + 1,
+                        &mut box_stream,
+                        box_features.version,
+                        num_parsed_boxes,
+                    ) {
+                        Ok(()) => return Ok(()),
+                        Err(InternalError::NotFound) => {} // Keep searching.
+                        Err(error) => return Err(error),
+                    }
+                }
+                _ => {}
+            }
+        }
+        // According to ISO/IEC 14496-12:2012(E) 8.11.1.1 there is at most one 'meta'.
+        Err(if self.data_was_skipped { InternalError::Aborted } else { InternalError::Invalid })
+    }
+}
+
+//------------------------------------------------------------------------------
+
+// Parses a file stream. The file type is checked through the 'ftyp' box.
+fn parse_ftyp(stream: &mut Stream) -> InternalResult<()> {
+    let mut num_parsed_boxes = 0u32;
+    let nesting_level = 0;
+    let box_features = parse_box(nesting_level, stream, &mut num_parsed_boxes)?;
+    if &box_features.box_type != b"ftyp" {
+        return Err(InternalError::Invalid);
+    }
+    // Consider a FileTypeBox running till the end of the file as invalid,
+    // because it should be first and a MetaBox should follow.
+    let content_size = box_features.content_size.ok_or(InternalError::Invalid)?;
+    // Iterate over brands. See ISO/IEC 14496-12:2012(E) 4.3.1
+    if content_size < 8 {
+        // major_brand,minor_version
+        return Err(InternalError::Invalid);
+    }
+    for i in 0..content_size / 4 {
+        let brand = stream.read_4cc()?;
+        if i == 1 {
+            continue; // Skip minor_version.
+        }
+        if brand == b"avif" || brand == b"avis" {
+            stream.skip(content_size - (i * 4 + 4))?;
+            return Ok(());
+        }
+        if i > 32 {
+            return Err(InternalError::Aborted); // Be reasonable.
+        }
+    }
+    Err(InternalError::Invalid)
+}
+
+// Parses a file stream. Features are extracted from the 'meta' box.
+impl InternalFeatures {
+    fn parse_file(&mut self, stream: &mut Stream) -> InternalResult<()> {
+        let mut num_parsed_boxes = 0u32;
+        loop {
+            let box_features =
+                parse_box(/* nesting_level= */ 0, stream, &mut num_parsed_boxes)?;
+            let offset = stream.num_read_bytes();
+            let mut box_stream = stream.substream(box_features.content_size)?;
+
+            if &box_features.box_type == b"meta" {
+                return self.parse_meta(
+                    /* nesting_level= */ 1,
+                    offset,
+                    &mut box_stream,
+                    &mut num_parsed_boxes,
+                );
+            } else if box_features.content_size.is_none() {
+                // This non-MetaBox runs till the end of the file. 'meta' is missing.
+                return Err(InternalError::Invalid);
+            }
+        }
+    }
+}
+
+//------------------------------------------------------------------------------
+// Fixed-size input public API
+
+pub fn identify(data: &[u8]) -> AvifInfoResult<()> {
+    match parse_ftyp(&mut Stream { data: Some(data), size: None, offset: 0 }) {
+        Ok(()) => Ok(()),
+        Err(error) => Err(error.into()),
+    }
+}
+
+pub fn get_features(data: &[u8]) -> AvifInfoResult<Features> {
+    let mut features = InternalFeatures { ..Default::default() };
+    match features.parse_file(&mut Stream { data: Some(data), size: None, offset: 0 }) {
+        Ok(()) => Ok(features.primary_item_features),
+        Err(error) => Err(error.into()),
+    }
+}
+
+//------------------------------------------------------------------------------
+// Streamed input API
+
+// There is no streamed input API yet.
diff --git a/tests/avifinfo_test.rs b/tests/avifinfo_test.rs
new file mode 100644
index 0000000..a3d685b
--- /dev/null
+++ b/tests/avifinfo_test.rs
@@ -0,0 +1,306 @@
+// Copyright (c) 2024, Alliance for Open Media. All rights reserved
+//
+// This source code is subject to the terms of the BSD 2 Clause License and
+// the Alliance for Open Media Patent License 1.0. If the BSD 2 Clause License
+// was not distributed with this source code in the LICENSE file, you can
+// obtain it at www.aomedia.org/license/software. If the Alliance for Open
+// Media Patent License 1.0 was not distributed with this source code in the
+// PATENTS file, you can obtain it at www.aomedia.org/license/patent.
+
+use avifinfo::{get_features, identify, AvifInfoError, Features};
+use std::{fs::File, io::Read};
+
+#[cfg(test)]
+fn load_file(path: &str) -> Vec<u8> {
+    let mut bytes = Vec::new();
+    File::open(path).unwrap().read_to_end(&mut bytes).unwrap();
+    bytes
+}
+
+//------------------------------------------------------------------------------
+// Positive tests
+
+#[test]
+fn single_pixel() {
+    let file = load_file("tests/avifinfo_test_1x1.avif");
+    assert_eq!(identify(file.as_slice()), Ok(()));
+    assert_eq!(
+        get_features(file.as_slice()),
+        Ok(Features {
+            width: 1,
+            height: 1,
+            bit_depth: 8,
+            num_channels: 3,
+            has_gainmap: false,
+            gainmap_item_id: 0,
+            primary_item_id_location: 96,
+            primary_item_id_bytes: 2,
+        })
+    );
+}
+
+#[test]
+fn with_alpha() {
+    let file = load_file("tests/avifinfo_test_2x2_alpha.avif");
+    assert_eq!(identify(file.as_slice()), Ok(()));
+    assert_eq!(
+        get_features(file.as_slice()),
+        Ok(Features {
+            width: 2,
+            height: 2,
+            bit_depth: 8,
+            num_channels: 4,
+            has_gainmap: false,
+            gainmap_item_id: 0,
+            primary_item_id_location: 96,
+            primary_item_id_bytes: 2,
+        })
+    );
+}
+
+#[test]
+fn with_gainmap() {
+    let file = load_file("tests/avifinfo_test_20x20_gainmap.avif");
+    assert_eq!(identify(file.as_slice()), Ok(()));
+    assert_eq!(
+        get_features(file.as_slice()),
+        Ok(Features {
+            width: 20,
+            height: 20,
+            bit_depth: 8,
+            num_channels: 3,
+            has_gainmap: true,
+            gainmap_item_id: 2,
+            primary_item_id_location: 96,
+            primary_item_id_bytes: 2,
+        })
+    );
+}
+
+#[test]
+fn set_primary_item_id_to_be_gainmap_item_id() {
+    let mut file = load_file("tests/avifinfo_test_20x20_gainmap.avif");
+    file[97] = 2;
+    // TODO(maryla-uc): find a small test file with a gainmap that is smaller
+    //                  than the main image.
+    assert_eq!(
+        get_features(file.as_slice()),
+        Ok(Features {
+            width: 20,
+            height: 20,
+            bit_depth: 8,
+            num_channels: 1, // the gainmap is monochrome
+            has_gainmap: true,
+            gainmap_item_id: 2,
+            primary_item_id_location: 96,
+            primary_item_id_bytes: 2,
+        })
+    );
+}
+
+#[test]
+fn with_gainmap_tmap() {
+    for file_path in [
+        "tests/avifinfo_test_12x34_gainmap_tmap.avif",
+        "tests/avifinfo_test_12x34_gainmap_tmap_iref_after_iprp.avif",
+    ] {
+        let file = load_file(file_path);
+        assert_eq!(identify(file.as_slice()), Ok(()));
+        assert_eq!(
+            get_features(file.as_slice()),
+            Ok(Features {
+                width: 12,
+                height: 34,
+                bit_depth: 10,
+                num_channels: 4,
+                has_gainmap: true,
+                gainmap_item_id: 4,
+                primary_item_id_location: 96,
+                primary_item_id_bytes: 2,
+            })
+        );
+    }
+}
+
+#[test]
+fn no_pixi_10b() {
+    // Same as above but "meta" box size is stored as 64 bits, "av1C" has
+    // 'high_bitdepth' set to true, "pixi" was renamed to "pixy" and "mdat" size
+    // is 0 (extends to the end of the file).
+    let file = load_file("tests/avifinfo_test_1x1_10b_nopixi_metasize64b_mdatsize0.avif");
+    assert_eq!(identify(file.as_slice()), Ok(()));
+    assert_eq!(
+        get_features(file.as_slice()),
+        Ok(Features {
+            width: 1,
+            height: 1,
+            bit_depth: 10,
+            num_channels: 3,
+            has_gainmap: false,
+            gainmap_item_id: 0,
+            primary_item_id_location: 104,
+            primary_item_id_bytes: 2,
+        })
+    );
+}
+
+#[test]
+fn enough_bytes() {
+    let mut file = load_file("tests/avifinfo_test_1x1.avif");
+    // Truncate 'input' just after the required information (discard AV1 box).
+    let mdat_position = file.windows(4).position(|window| window == b"mdat");
+    file.resize(mdat_position.unwrap(), 0);
+
+    assert_eq!(identify(file.as_slice()), Ok(()));
+    assert_eq!(
+        get_features(file.as_slice()),
+        Ok(Features {
+            width: 1,
+            height: 1,
+            bit_depth: 8,
+            num_channels: 3,
+            has_gainmap: false,
+            gainmap_item_id: 0,
+            primary_item_id_location: 96,
+            primary_item_id_bytes: 2,
+        })
+    );
+}
+
+#[test]
+fn metabox_is_big() {
+    let mut file = load_file("tests/avifinfo_test_1x1.avif");
+    let meta_position = file.windows(4).position(|window| window == b"meta");
+    // 32-bit "1" then 4-char "meta" then 64-bit size.
+    file[meta_position.unwrap() - 4] = 0;
+    file[meta_position.unwrap() - 3] = 0;
+    file[meta_position.unwrap() - 2] = 0;
+    file[meta_position.unwrap() - 1] = 1;
+    for _ in 0..8 {
+        file.insert(meta_position.unwrap() + 4, 1);
+    }
+
+    assert_eq!(identify(file.as_slice()), Ok(()));
+    assert_eq!(
+        get_features(file.as_slice()),
+        Ok(Features {
+            width: 1,
+            height: 1,
+            bit_depth: 8,
+            num_channels: 3,
+            has_gainmap: false,
+            gainmap_item_id: 0,
+            primary_item_id_location: 104,
+            primary_item_id_bytes: 2,
+        })
+    );
+}
+
+#[test]
+fn metabox_runs_till_end_of_file() {
+    let mut file = load_file("tests/avifinfo_test_1x1.avif");
+    let meta_position = file.windows(4).position(|window| window == b"meta");
+    // 32-bit "0" then 4-char "meta".
+    file[meta_position.unwrap() - 4] = 0;
+    file[meta_position.unwrap() - 3] = 0;
+    file[meta_position.unwrap() - 2] = 0;
+    file[meta_position.unwrap() - 1] = 0;
+
+    assert_eq!(identify(file.as_slice()), Ok(()));
+    assert_eq!(
+        get_features(file.as_slice()),
+        Ok(Features {
+            width: 1,
+            height: 1,
+            bit_depth: 8,
+            num_channels: 3,
+            has_gainmap: false,
+            gainmap_item_id: 0,
+            primary_item_id_location: 96,
+            primary_item_id_bytes: 2,
+        })
+    );
+}
+
+//------------------------------------------------------------------------------
+// Negative tests
+
+#[test]
+fn empty() {
+    assert_eq!(identify(&[]), Err(AvifInfoError::NotEnoughData));
+    assert_eq!(get_features(&[]), Err(AvifInfoError::NotEnoughData));
+}
+
+#[test]
+fn not_enough_bytes() {
+    let mut file = load_file("tests/avifinfo_test_1x1.avif");
+    // Truncate 'input' before having all the required information.
+    let ipma_position = file.windows(4).position(|window| window == b"ipma");
+    file.resize(ipma_position.unwrap(), 0);
+
+    assert_eq!(identify(file.as_slice()), Ok(()));
+    assert_eq!(get_features(file.as_slice()), Err(AvifInfoError::NotEnoughData));
+}
+
+#[test]
+fn broken() {
+    let mut file = load_file("tests/avifinfo_test_1x1.avif");
+    // Change "ispe" to "aspe".
+    let ispe_position = file.windows(4).position(|window| window == b"ispe");
+    file[ispe_position.unwrap()] = b'a';
+
+    assert_eq!(identify(file.as_slice()), Ok(()));
+    assert_eq!(get_features(file.as_slice()), Err(AvifInfoError::InvalidFile));
+}
+
+#[test]
+fn metabox_is_too_big() {
+    let mut file = load_file("tests/avifinfo_test_1x1.avif");
+    let meta_position = file.windows(4).position(|window| window == b"meta");
+    // 32-bit "1" then 4-char "meta" then 64-bit size.
+    file[meta_position.unwrap() - 4] = 0;
+    file[meta_position.unwrap() - 3] = 0;
+    file[meta_position.unwrap() - 2] = 0;
+    file[meta_position.unwrap() - 1] = 1;
+    for _ in 0..8 {
+        file.insert(meta_position.unwrap() + 4, 255);
+    }
+
+    assert_eq!(identify(file.as_slice()), Ok(()));
+    assert_eq!(get_features(file.as_slice()), Err(AvifInfoError::TooComplex));
+}
+
+#[test]
+fn filetypebox_runs_till_end_of_file() {
+    let mut file = load_file("tests/avifinfo_test_1x1.avif");
+    let ftyp_position = file.windows(4).position(|window| window == b"ftyp");
+    file[ftyp_position.unwrap() - 1] = 0;
+
+    assert_eq!(identify(file.as_slice()), Err(AvifInfoError::InvalidFile));
+    assert_eq!(get_features(file.as_slice()), Err(AvifInfoError::InvalidFile));
+}
+
+#[test]
+fn imagespatialextentsproperty_runs_till_end_of_file() {
+    let mut file = load_file("tests/avifinfo_test_1x1.avif");
+    let ispe_position = file.windows(4).position(|window| window == b"ispe");
+    file[ispe_position.unwrap() - 1] = 0;
+
+    assert_eq!(identify(file.as_slice()), Ok(()));
+    assert_eq!(get_features(file.as_slice()), Err(AvifInfoError::InvalidFile));
+}
+
+#[test]
+fn too_many_boxes() {
+    // Create a valid-ish input with too many boxes to parse.
+    let mut input: Vec<u8> =
+        vec![0, 0, 0, 16, b'f', b't', b'y', b'p', b'a', b'v', b'i', b'f', 0, 0, 0, 0];
+    let num_boxes = 12345;
+    input.reserve(input.len() + num_boxes * 8);
+    for _ in 0..num_boxes {
+        input.extend_from_slice(&[0, 0, 0, 8, b'a', b'b', b'c', b'd']);
+    }
+
+    assert_eq!(identify(input.as_slice()), Ok(()));
+    assert_eq!(get_features(input.as_slice()), Err(AvifInfoError::TooComplex));
+}