Commit 585607ac for libheif

commit 585607ac15c7391831360e19bed2422320362834
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Fri Oct 2 01:40:09 2026 +0200

    unci: fetch compressed tile units once and bound their size (GHSA-fcmw-5764-7rq8)

    For an 'unci' image with generic compression and one compressed unit per
    image tile (cmpC compressed_unit_type == image_tile), the image_tile branch of
    unc_decoder::get_compressed_image_data_uncompressed() returns the whole
    decompressed unit and ignores the requested range. With tile-component
    interleave (uncC interleave_type == 4), unc_decoder::fetch_tile_data() called
    it once per component and concatenated the results. The same unit was read
    and decompressed again for each component, and the tile buffer grew to
    num_components times the unit size. None of this was charged to the memory
    budget, since the accounting in the decompressors ends when they return.
    A 22 kB file with 256 components and a unit that inflates to 20 MiB allocated
    more than 5 GiB with the default security limits.

    This is fixed with four measures:

    - fetch_tile_data() fetches a per-tile compressed unit only once.

    - The decompression functions take the maximum size of the decompressed data
      and stop with an error as soon as more data is produced. The image_tile
      branch passes the size of the tile, so that a small unit cannot inflate far
      beyond the tile size anymore.

    - A unit that decompresses to less data than the tile is rejected. The tile
      decoders would have read the missing data as zeros.

    - The tile data is charged to the memory budget through a MemoryHandle that
      the caller keeps for as long as the tile data.

    Files in which a per-tile unit does not decompress to exactly the size of the
    tile are not decoded anymore.

diff --git a/libheif/codecs/uncompressed/unc_codec.cc b/libheif/codecs/uncompressed/unc_codec.cc
index 465c4ee5..22f22fe2 100644
--- a/libheif/codecs/uncompressed/unc_codec.cc
+++ b/libheif/codecs/uncompressed/unc_codec.cc
@@ -462,7 +462,8 @@ Error UncompressedImageCodec::decode_uncompressed_image_tile(const HeifContext*
   decoder->ensure_channel_list(img);

   std::vector<uint8_t> tile_data;
-  Error err = decoder->fetch_tile_data(dataExtent, properties, tile_x0, tile_y0, tile_data);
+  MemoryHandle tile_data_memory_handle;
+  Error err = decoder->fetch_tile_data(dataExtent, properties, tile_x0, tile_y0, tile_data, tile_data_memory_handle);
   if (err) {
     return err;
   }
diff --git a/libheif/codecs/uncompressed/unc_decoder.cc b/libheif/codecs/uncompressed/unc_decoder.cc
index c0726001..07348ecb 100644
--- a/libheif/codecs/uncompressed/unc_decoder.cc
+++ b/libheif/codecs/uncompressed/unc_decoder.cc
@@ -62,7 +62,8 @@ unc_decoder::unc_decoder(uint32_t width, uint32_t height,
 Error unc_decoder::fetch_tile_data(const DataExtent& dataExtent,
                                    const UncompressedImageCodec::unci_properties& properties,
                                    uint32_t tile_x, uint32_t tile_y,
-                                   std::vector<uint8_t>& tile_data)
+                                   std::vector<uint8_t>& tile_data,
+                                   MemoryHandle& tile_data_memory_handle)
 {
   if (m_tile_width == 0 || m_tile_height == 0) {
     return {heif_error_Decoder_plugin_error, heif_suberror_Unspecified, "Internal error: unc_decoder tile dimensions are 0"};
@@ -75,10 +76,42 @@ Error unc_decoder::fetch_tile_data(const DataExtent& dataExtent,
   const auto& sizes = *sizesResult;
   uint32_t tileIdx = tile_x + tile_y * (m_width / m_tile_width);

-  if (sizes.size() == 1) {
+  // May be nullptr for a raw data extent, in which case no limit applies.
+  const heif_security_limits* limits = dataExtent.m_file ? dataExtent.m_file->get_security_limits() : nullptr;
+
+  const bool one_compressed_unit_per_tile = (properties.cmpC && properties.icef &&
+                                             properties.cmpC->get_compressed_unit_type() == heif_cmpC_compressed_unit_type_image_tile);
+
+  if (one_compressed_unit_per_tile) {
+    // The compressed unit is the complete tile, and get_compressed_image_data_uncompressed()
+    // returns the whole unit irrespective of the requested range. Fetch it only once.
+    // The scattered per-component reads below would decompress the same unit again for
+    // each component and concatenate the copies, growing 'tile_data' to num_components
+    // times the unit size without any memory accounting (GHSA-fcmw-5764-7rq8).
+
+    uint64_t tile_size = 0;
+    for (uint64_t size : sizes) {
+      if (size > UINT64_MAX - tile_size) {
+        return {heif_error_Invalid_input, heif_suberror_Invalid_image_size,
+                "uncompressed tile size exceeds 64-bit range"};
+      }
+      tile_size += size;
+    }
+
+    // The requested size is the size of the whole tile. A unit that decompresses to
+    // a different size is rejected.
+    Error err = get_compressed_image_data_uncompressed(dataExtent, properties, &tile_data, 0, tile_size, tileIdx, nullptr);
+    if (err) {
+      return err;
+    }
+  }
+  else if (sizes.size() == 1) {
     // Single contiguous read (component, pixel, mixed, row interleave)
     uint64_t tile_start_offset = sizes[0] * tileIdx;
-    return get_compressed_image_data_uncompressed(dataExtent, properties, &tile_data, tile_start_offset, sizes[0], tileIdx, nullptr);
+    Error err = get_compressed_image_data_uncompressed(dataExtent, properties, &tile_data, tile_start_offset, sizes[0], tileIdx, nullptr);
+    if (err) {
+      return err;
+    }
   }
   else {
     // Scattered per-component reads (tile_component interleave)
@@ -94,12 +127,23 @@ Error unc_decoder::fetch_tile_data(const DataExtent& dataExtent,
         return err;
       }

+      // Charge the component data to the memory budget before appending it, so that
+      // the accumulated tile data cannot grow beyond the security limits.
+      if (Error memErr = tile_data_memory_handle.alloc(channel_data.size(), limits, "unci tile data")) {
+        return memErr;
+      }
+
       tile_data.insert(tile_data.end(), channel_data.begin(), channel_data.end());
       component_offset += size * num_tiles;
     }
+
+    return Error::Ok;
   }

-  return Error::Ok;
+  // The reading and decompression functions only bound the memory while they run.
+  // 'tile_data' outlives them, so charge it to the memory budget for as long as the
+  // caller keeps it.
+  return tile_data_memory_handle.alloc(tile_data.size(), limits, "unci tile data");
 }


@@ -151,12 +195,25 @@ const Error unc_decoder::get_compressed_image_data_uncompressed(const DataExtent

     const std::vector<uint8_t>& compressed_bytes = *readingResult;

-    // decompress only the unit
-    auto dataResult = do_decompress_data(cmpC_box, compressed_bytes, limits);
+    // Decompress only the unit. The unit holds a single tile and 'range_size' is the
+    // size of that tile, so there cannot be more data than this in a valid file.
+    // Stopping there prevents a small unit from inflating far beyond the tile size
+    // (GHSA-fcmw-5764-7rq8).
+    auto dataResult = do_decompress_data(cmpC_box, compressed_bytes, limits, range_size);
     if (!dataResult) {
       return dataResult.error();
     }

+    // Too little data is rejected as well. The tile decoders would read the missing
+    // data as zeros.
+    if (dataResult->size() < range_size) {
+      return {
+        heif_error_Invalid_input,
+        heif_suberror_End_of_data,
+        "compressed unit of unci image contains less data than the image tile"
+      };
+    }
+
     *data = std::move(*dataResult);
   }
   else if (icef_box) {
@@ -262,11 +319,12 @@ const Error unc_decoder::get_compressed_image_data_uncompressed(const DataExtent

 Result<std::vector<uint8_t> > unc_decoder::do_decompress_data(std::shared_ptr<const Box_cmpC>& cmpC_box,
                                                               const std::vector<uint8_t>& compressed_data,
-                                                              const heif_security_limits* limits) const
+                                                              const heif_security_limits* limits,
+                                                              uint64_t max_output_size) const
 {
   if (cmpC_box->get_compression_type() == fourcc("brot")) {
 #if HAVE_BROTLI
-    return decompress_brotli(compressed_data, limits);
+    return decompress_brotli(compressed_data, limits, max_output_size);
 #else
     return Error(heif_error_Unsupported_feature,
                  heif_suberror_Unsupported_generic_compression_method,
@@ -275,7 +333,7 @@ Result<std::vector<uint8_t> > unc_decoder::do_decompress_data(std::shared_ptr<co
   }
   else if (cmpC_box->get_compression_type() == fourcc("zlib")) {
 #if HAVE_ZLIB
-    return decompress_zlib(compressed_data, limits);
+    return decompress_zlib(compressed_data, limits, max_output_size);
 #else
     return Error(heif_error_Unsupported_feature,
                  heif_suberror_Unsupported_generic_compression_method,
@@ -284,7 +342,7 @@ Result<std::vector<uint8_t> > unc_decoder::do_decompress_data(std::shared_ptr<co
   }
   else if (cmpC_box->get_compression_type() == fourcc("defl")) {
 #if HAVE_ZLIB
-    return decompress_deflate(compressed_data, limits);
+    return decompress_deflate(compressed_data, limits, max_output_size);
 #else
     return Error(heif_error_Unsupported_feature,
                  heif_suberror_Unsupported_generic_compression_method,
@@ -313,7 +371,8 @@ Error unc_decoder::decode_image(const DataExtent& extent,
   for (uint32_t tile_y0 = 0; tile_y0 < m_height; tile_y0 += tile_height)
     for (uint32_t tile_x0 = 0; tile_x0 < m_width; tile_x0 += tile_width) {
       std::vector<uint8_t> tile_data;
-      Error error = fetch_tile_data(extent, properties, tile_x0 / tile_width, tile_y0 / tile_height, tile_data);
+      MemoryHandle tile_data_memory_handle;
+      Error error = fetch_tile_data(extent, properties, tile_x0 / tile_width, tile_y0 / tile_height, tile_data, tile_data_memory_handle);
       if (error) {
         return error;
       }
diff --git a/libheif/codecs/uncompressed/unc_decoder.h b/libheif/codecs/uncompressed/unc_decoder.h
index e2a40de1..086727a8 100644
--- a/libheif/codecs/uncompressed/unc_decoder.h
+++ b/libheif/codecs/uncompressed/unc_decoder.h
@@ -30,6 +30,7 @@
 #include "unc_boxes.h"

 class HeifPixelImage;
+class MemoryHandle;
 struct DataExtent;
 struct heif_security_limits;

@@ -48,10 +49,13 @@ public:

   virtual void ensure_channel_list(std::shared_ptr<HeifPixelImage>& img) {}

+  // The size of 'tile_data' is charged to 'tile_data_memory_handle', which has to
+  // live as long as 'tile_data'.
   Error fetch_tile_data(const DataExtent& dataExtent,
                         const UncompressedImageCodec::unci_properties& properties,
                         uint32_t tile_x, uint32_t tile_y,
-                        std::vector<uint8_t>& tile_data);
+                        std::vector<uint8_t>& tile_data,
+                        MemoryHandle& tile_data_memory_handle);

   virtual Error decode_tile(const std::vector<uint8_t>& tile_data,
                             std::shared_ptr<HeifPixelImage>& img,
@@ -83,7 +87,8 @@ protected:

   Result<std::vector<uint8_t>> do_decompress_data(std::shared_ptr<const Box_cmpC>& cmpC_box,
                                                   const std::vector<uint8_t>& compressed_data,
-                                                  const heif_security_limits* limits) const;
+                                                  const heif_security_limits* limits,
+                                                  uint64_t max_output_size = UINT64_MAX) const;

   const uint32_t m_width;
   const uint32_t m_height;
diff --git a/libheif/compression.h b/libheif/compression.h
index 38efca02..b5a0f993 100644
--- a/libheif/compression.h
+++ b/libheif/compression.h
@@ -77,14 +77,18 @@ std::vector<uint8_t> compress_deflate(const uint8_t* input, size_t size);
  * This is assumed to be in RFC 1950 format, which is the normal zlib format.
  *
  * @param compressed_input the compressed data to be decompressed
- * @param output pointer to the resulting vector of decompressed data
- * @return success (Ok) or an error on failure (usually corrupt data)
+ * @param limits security limits that bound the size of the decompressed data (may be nullptr)
+ * @param max_output_size the largest size the decompressed data may have. Decompression
+ *        stops with an error as soon as more data is produced. Use this when the expected
+ *        size is known in advance.
+ * @return the decompressed data or an error on failure (usually corrupt data)
  *
  * @sa decompress_deflate
  * @sa compress_zlib
  */
 Result<std::vector<uint8_t>> decompress_zlib(const std::vector<uint8_t>& compressed_input,
-                                             const heif_security_limits* limits);
+                                             const heif_security_limits* limits,
+                                             uint64_t max_output_size = UINT64_MAX);

 /**
  * Decompress "deflate" compressed data.
@@ -92,14 +96,18 @@ Result<std::vector<uint8_t>> decompress_zlib(const std::vector<uint8_t>& compres
  * This is assumed to be in RFC 1951 format, which is the deflate format.
  *
  * @param compressed_input the compressed data to be decompressed
- * @param output pointer to the resulting vector of decompressed data
- * @return success (Ok) or an error on failure (usually corrupt data)
+ * @param limits security limits that bound the size of the decompressed data (may be nullptr)
+ * @param max_output_size the largest size the decompressed data may have. Decompression
+ *        stops with an error as soon as more data is produced. Use this when the expected
+ *        size is known in advance.
+ * @return the decompressed data or an error on failure (usually corrupt data)
  *
  * @sa decompress_zlib
  * @sa compress_deflate
  */
 Result<std::vector<uint8_t>> decompress_deflate(const std::vector<uint8_t>& compressed_input,
-                                                const heif_security_limits* limits);
+                                                const heif_security_limits* limits,
+                                                uint64_t max_output_size = UINT64_MAX);

 #endif

@@ -110,11 +118,15 @@ Result<std::vector<uint8_t>> decompress_deflate(const std::vector<uint8_t>& comp
  * Brotli is described at https://brotli.org/
  *
  * @param compressed_input the compressed data to be decompressed
- * @param output pointer to the resulting vector of decompressed data
- * @return success (Ok) or an error on failure (usually corrupt data)
+ * @param limits security limits that bound the size of the decompressed data (may be nullptr)
+ * @param max_output_size the largest size the decompressed data may have. Decompression
+ *        stops with an error as soon as more data is produced. Use this when the expected
+ *        size is known in advance.
+ * @return the decompressed data or an error on failure (usually corrupt data)
  */
 Result<std::vector<uint8_t>> decompress_brotli(const std::vector<uint8_t>& compressed_input,
-                                               const heif_security_limits* limits);
+                                               const heif_security_limits* limits,
+                                               uint64_t max_output_size = UINT64_MAX);

 std::vector<uint8_t> compress_brotli(const uint8_t* input, size_t size);
 #endif
diff --git a/libheif/compression_brotli.cc b/libheif/compression_brotli.cc
index 6bd4a085..24795a8b 100644
--- a/libheif/compression_brotli.cc
+++ b/libheif/compression_brotli.cc
@@ -35,7 +35,8 @@ const size_t BUF_SIZE = (1 << 18);


 Result<std::vector<uint8_t>> decompress_brotli(const std::vector<uint8_t> &compressed_input,
-                                               const heif_security_limits* limits)
+                                               const heif_security_limits* limits,
+                                               uint64_t max_output_size)
 {
     BrotliDecoderResult result = BROTLI_DECODER_RESULT_ERROR;
     std::vector<uint8_t> buffer(BUF_SIZE, 0);
@@ -53,27 +54,43 @@ Result<std::vector<uint8_t>> decompress_brotli(const std::vector<uint8_t> &compr
     // output, bypassing max_memory_block_size / max_total_memory (GHSA-24wx-9w62-c96w).
     MemoryHandle output_memory_handle;

+    // Append the data decoded into `buffer` to `output`.
+    auto append_decoded_data = [&]() -> Error {
+        size_t n_new_bytes = static_cast<size_t>(std::distance(buffer.data(), next_output));
+
+        // Stop as soon as there is more data than the caller expects. This keeps a
+        // decompression bomb from inflating up to the security limits when the size
+        // of the decompressed data is known in advance (GHSA-fcmw-5764-7rq8).
+        if (n_new_bytes > max_output_size - output.size()) {
+            return Error(heif_error_Invalid_input, heif_suberror_Decompression_invalid_data,
+                         "Decompressed brotli data is larger than expected.");
+        }
+
+        if (Error memErr = output_memory_handle.alloc(n_new_bytes, limits, "brotli decompression output")) {
+            return memErr;
+        }
+
+        output.insert(output.end(), buffer.data(), buffer.data() + n_new_bytes);
+        return Error::Ok;
+    };
+
     while (true)
     {
         result = BrotliDecoderDecompressStream(state.get(), &available_in, &next_in, &available_out, &next_output, 0);

         if (result == BROTLI_DECODER_RESULT_NEEDS_MORE_OUTPUT)
         {
-            size_t n_new_bytes = static_cast<size_t>(std::distance(buffer.data(), next_output));
-            if (Error memErr = output_memory_handle.alloc(n_new_bytes, limits, "brotli decompression output")) {
-                return memErr;
+            if (Error err = append_decoded_data()) {
+                return err;
             }
-            output.insert(output.end(), buffer.data(), buffer.data() + n_new_bytes);
             available_out = buffer.size();
             next_output = buffer.data();
         }
         else if (result == BROTLI_DECODER_RESULT_SUCCESS)
         {
-            size_t n_new_bytes = static_cast<size_t>(std::distance(buffer.data(), next_output));
-            if (Error memErr = output_memory_handle.alloc(n_new_bytes, limits, "brotli decompression output")) {
-                return memErr;
+            if (Error err = append_decoded_data()) {
+                return err;
             }
-            output.insert(output.end(), buffer.data(), buffer.data() + n_new_bytes);
             break;
         }
         else if (result == BROTLI_DECODER_RESULT_NEEDS_MORE_INPUT)
diff --git a/libheif/compression_zlib.cc b/libheif/compression_zlib.cc
index 2c145540..5eb91709 100644
--- a/libheif/compression_zlib.cc
+++ b/libheif/compression_zlib.cc
@@ -82,7 +82,8 @@ std::vector<uint8_t> compress(const uint8_t* input, size_t size, int windowSize)


 Result<std::vector<uint8_t>> do_inflate(const std::vector<uint8_t>& compressed_input, int windowSize,
-                                        const heif_security_limits* limits)
+                                        const heif_security_limits* limits,
+                                        uint64_t max_output_size)
 {
   if (compressed_input.empty()) {
     return Error(heif_error_Invalid_input, heif_suberror_Decompression_invalid_data,
@@ -162,6 +163,16 @@ Result<std::vector<uint8_t>> do_inflate(const std::vector<uint8_t>& compressed_i
     // account for and append decoded data to output

     size_t n_new_bytes = dst.size() - strm.avail_out;
+
+    // Stop as soon as there is more data than the caller expects. This keeps a
+    // decompression bomb from inflating up to the security limits when the size
+    // of the decompressed data is known in advance (GHSA-fcmw-5764-7rq8).
+    if (n_new_bytes > max_output_size - output.size()) {
+      inflateEnd(&strm);
+      return Error(heif_error_Invalid_input, heif_suberror_Decompression_invalid_data,
+                   "Decompressed zlib/deflate data is larger than expected.");
+    }
+
     if (Error memErr = output_memory_handle.alloc(n_new_bytes, limits, "zlib/deflate decompression output")) {
       inflateEnd(&strm);
       return memErr;
@@ -188,14 +199,16 @@ std::vector<uint8_t> compress_deflate(const uint8_t* input, size_t size)


 Result<std::vector<uint8_t>> decompress_zlib(const std::vector<uint8_t>& compressed_input,
-                                             const heif_security_limits* limits)
+                                             const heif_security_limits* limits,
+                                             uint64_t max_output_size)
 {
-  return do_inflate(compressed_input, 15, limits);
+  return do_inflate(compressed_input, 15, limits, max_output_size);
 }

 Result<std::vector<uint8_t>> decompress_deflate(const std::vector<uint8_t>& compressed_input,
-                                                const heif_security_limits* limits)
+                                                const heif_security_limits* limits,
+                                                uint64_t max_output_size)
 {
-  return do_inflate(compressed_input, -15, limits);
+  return do_inflate(compressed_input, -15, limits, max_output_size);
 }
 #endif
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 086250e7..ef8e081c 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -183,6 +183,7 @@ if (WITH_UNCOMPRESSED_CODEC)
     if (ZLIB_FOUND)
         add_libheif_test(uncompressed_decode_generic_compression)
         add_libheif_test(uncompressed_tile_range_overflow)
+        add_libheif_test(uncompressed_tile_data_fetch)
     else()
         message(WARNING "Generic compress tests of the 'uncompressed codec' are not compiled because zlib was not found")
     endif ()
diff --git a/tests/uncompressed_tile_data_fetch.cc b/tests/uncompressed_tile_data_fetch.cc
new file mode 100644
index 00000000..41c0ab14
--- /dev/null
+++ b/tests/uncompressed_tile_data_fetch.cc
@@ -0,0 +1,467 @@
+/*
+  libheif unit tests
+
+  MIT License
+
+  Copyright (c) 2026 Dirk Farin <dirk.farin@gmail.com>
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in all
+  copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+  SOFTWARE.
+*/
+
+// Regression tests for GHSA-fcmw-5764-7rq8.
+//
+// An 'unci' image with generic compression and one compressed unit per image
+// tile (cmpC compressed_unit_type == image_tile) combined with tile-component
+// interleave (uncC interleave_type == 4) made unc_decoder::fetch_tile_data()
+// fetch the tile data once per component. Since the image_tile branch of
+// get_compressed_image_data_uncompressed() returns the whole unit irrespective
+// of the requested range, the same unit was read and decompressed once for each
+// component and all copies were concatenated: the tile buffer grew to
+// num_components times the unit size, none of it charged against the memory
+// limits. With 256 components and a unit that inflates to 20 MiB, a 22 kB file
+// requested more than 5 GiB.
+//
+// The fix has four parts, each with a test below:
+//
+// 1. The unit is fetched once per tile. Because the excess memory was never
+//    accounted, it cannot be observed through the security limits. The test
+//    reads the file through a heif_reader that counts the bytes read from the
+//    compressed unit: they have to be read exactly once. It also checks the
+//    decoded pixels, as the unit holds the planes of all components.
+//
+// 2. The decompressor is told the size of the tile and stops with an error as
+//    soon as the unit yields more data than that.
+//
+// 3. A unit that yields less data than the tile is rejected, too.
+//
+// 4. The tile data is charged to the memory budget for as long as it is kept.
+//    This is independent of the generic compression, so the test uses an image
+//    without compression. A large row alignment pads each 16 byte row to 64 kiB.
+//    This makes the tile data much larger than the decoded image, so that it
+//    exceeds a memory budget that the decoded image itself fits into.
+//
+// The compressed files use deflate generic compression, so the tests only run
+// when the library was built with zlib (guarded in tests/CMakeLists.txt).
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "test_utils.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <cstring>
+#include <vector>
+
+namespace {
+
+struct UnciFileSpec {
+  uint32_t width = 2;
+  uint32_t height = 2;
+
+  // 'cmpd' component types. All components have 8 bits.
+  std::vector<uint16_t> component_types = {heif_cmpd_component_type_red,
+                                           heif_cmpd_component_type_green,
+                                           heif_cmpd_component_type_blue};
+
+  uint8_t interleave_type = 4; // tile-component
+  uint32_t row_align_size = 0;
+
+  // Whether 'item_data' is a single deflate compressed unit of type image_tile.
+  bool deflate_tile_unit = true;
+
+  std::vector<uint8_t> item_data;
+};
+
+// Tile data of the default 2x2 RGB image in tile-component interleave:
+// the R plane, the G plane, the B plane.
+const std::vector<uint8_t> kTileData = {
+    10, 11, 12, 13,   // R
+    20, 21, 22, 23,   // G
+    30, 31, 32, 33};  // B
+
+// Wrap the data into a raw deflate stream consisting of a single stored block.
+std::vector<uint8_t> deflate_stored(const std::vector<uint8_t>& data) {
+  auto len = static_cast<uint16_t>(data.size());
+  auto nlen = static_cast<uint16_t>(~len);
+
+  std::vector<uint8_t> out;
+  out.push_back(0x01); // BFINAL=1, BTYPE=00 (stored)
+  out.push_back(static_cast<uint8_t>(len & 0xFF));
+  out.push_back(static_cast<uint8_t>(len >> 8));
+  out.push_back(static_cast<uint8_t>(nlen & 0xFF));
+  out.push_back(static_cast<uint8_t>(nlen >> 8));
+  append(out, data);
+  return out;
+}
+
+// Build an 'unci' image with a single tile. The item data is stored in 'idat',
+// which is the last box of the file.
+std::vector<uint8_t> build_heif_unci(const UnciFileSpec& spec) {
+  std::vector<uint8_t> ftyp_payload;
+  append_fourcc(ftyp_payload, "mif1");
+  put_u32_be(ftyp_payload, 0);
+  append_fourcc(ftyp_payload, "mif1");
+  append_fourcc(ftyp_payload, "heic");
+  auto ftyp = make_box("ftyp", ftyp_payload);
+
+  std::vector<uint8_t> hdlr_payload;
+  put_u32_be(hdlr_payload, 0);
+  append_fourcc(hdlr_payload, "pict");
+  put_u32_be(hdlr_payload, 0);
+  put_u32_be(hdlr_payload, 0);
+  put_u32_be(hdlr_payload, 0);
+  hdlr_payload.push_back(0);
+  auto hdlr = make_box("hdlr", hdlr_payload, /*full=*/true);
+
+  std::vector<uint8_t> pitm_payload;
+  put_u16_be(pitm_payload, 1);
+  auto pitm = make_box("pitm", pitm_payload, /*full=*/true);
+
+  std::vector<uint8_t> infe_payload;
+  put_u16_be(infe_payload, 1);
+  put_u16_be(infe_payload, 0);
+  append_fourcc(infe_payload, "unci");
+  append_cstr(infe_payload, "");
+  auto infe = make_box("infe", infe_payload, /*full=*/true, /*version=*/2);
+
+  std::vector<uint8_t> iinf_payload;
+  put_u16_be(iinf_payload, 1);
+  append(iinf_payload, infe);
+  auto iinf = make_box("iinf", iinf_payload, /*full=*/true);
+
+  auto num_components = static_cast<uint16_t>(spec.component_types.size());
+
+  // ispe
+  std::vector<uint8_t> ispe_payload;
+  put_u32_be(ispe_payload, spec.width);
+  put_u32_be(ispe_payload, spec.height);
+  auto ispe = make_box("ispe", ispe_payload, /*full=*/true);
+
+  // cmpd
+  std::vector<uint8_t> cmpd_payload;
+  put_u32_be(cmpd_payload, num_components);
+  for (uint16_t type : spec.component_types) {
+    put_u16_be(cmpd_payload, type);
+  }
+  auto cmpd = make_box("cmpd", cmpd_payload);
+
+  // uncC (v0): 8-bit components, a single tile.
+  std::vector<uint8_t> uncC_payload;
+  put_u32_be(uncC_payload, 0);           // profile
+  put_u32_be(uncC_payload, num_components);
+  for (uint16_t c = 0; c < num_components; c++) {
+    put_u16_be(uncC_payload, c);         // component_index
+    uncC_payload.push_back(7);           // component_bit_depth_minus_one -> 8 bit
+    uncC_payload.push_back(0);           // component_format (unsigned)
+    uncC_payload.push_back(0);           // component_align_size
+  }
+  uncC_payload.push_back(0);             // sampling_type (no subsampling)
+  uncC_payload.push_back(spec.interleave_type);
+  uncC_payload.push_back(0);             // block_size
+  uncC_payload.push_back(0);             // flags
+  put_u32_be(uncC_payload, 0);           // pixel_size
+  put_u32_be(uncC_payload, spec.row_align_size);
+  put_u32_be(uncC_payload, 0);           // tile_align_size
+  put_u32_be(uncC_payload, 0);           // num_tile_cols_minus_one
+  put_u32_be(uncC_payload, 0);           // num_tile_rows_minus_one
+  auto uncC = make_box("uncC", uncC_payload, /*full=*/true);
+
+  std::vector<uint8_t> ipco_payload;
+  append(ipco_payload, ispe);
+  append(ipco_payload, cmpd);
+  append(ipco_payload, uncC);
+
+  uint8_t num_properties = 3;
+
+  if (spec.deflate_tile_unit) {
+    // cmpC: deflate, compressed_unit_type = image_tile (2).
+    std::vector<uint8_t> cmpC_payload;
+    append_fourcc(cmpC_payload, "defl");
+    cmpC_payload.push_back(2);
+    append(ipco_payload, make_box("cmpC", cmpC_payload, /*full=*/true));
+
+    // icef: one unit at the implied offset 0 with a 32-bit size field.
+    std::vector<uint8_t> icef_payload;
+    icef_payload.push_back(3 << 2);      // unit_offset_code = 0, unit_size_code = 3
+    put_u32_be(icef_payload, 1);         // num_compressed_units
+    put_u32_be(icef_payload, static_cast<uint32_t>(spec.item_data.size()));
+    append(ipco_payload, make_box("icef", icef_payload, /*full=*/true));
+
+    num_properties = 5;
+  }
+
+  auto ipco = make_box("ipco", ipco_payload);
+
+  std::vector<uint8_t> ipma_payload;
+  put_u32_be(ipma_payload, 1); // entry_count
+  put_u16_be(ipma_payload, 1); // item_ID 1
+  ipma_payload.push_back(num_properties); // association_count
+  for (uint8_t i = 1; i <= num_properties; i++) {
+    ipma_payload.push_back(0x80 | i);     // essential
+  }
+  auto ipma = make_box("ipma", ipma_payload, /*full=*/true);
+
+  std::vector<uint8_t> iprp_payload;
+  append(iprp_payload, ipco);
+  append(iprp_payload, ipma);
+  auto iprp = make_box("iprp", iprp_payload);
+
+  auto idat = make_box("idat", spec.item_data);
+
+  // iloc (version 1): item 1 stored in idat (construction_method=1).
+  std::vector<uint8_t> iloc_payload;
+  put_u16_be(iloc_payload, (4 << 12) | (4 << 8) | (0 << 4) | 0); // offset_size=4, length_size=4
+  put_u16_be(iloc_payload, 1);          // item_count
+  put_u16_be(iloc_payload, 1);          // item_ID
+  put_u16_be(iloc_payload, 0x0001);     // construction_method=1 (idat)
+  put_u16_be(iloc_payload, 0);          // data_reference_index
+  put_u16_be(iloc_payload, 1);          // extent_count
+  put_u32_be(iloc_payload, 0);          // extent_offset (within idat)
+  put_u32_be(iloc_payload, static_cast<uint32_t>(spec.item_data.size())); // extent_length
+  auto iloc = make_box("iloc", iloc_payload, /*full=*/true, /*version=*/1);
+
+  // idat has to be the last box: a test locates the item data at the end of the file.
+  std::vector<uint8_t> meta_payload;
+  append(meta_payload, hdlr);
+  append(meta_payload, pitm);
+  append(meta_payload, iinf);
+  append(meta_payload, iprp);
+  append(meta_payload, iloc);
+  append(meta_payload, idat);
+  auto meta = make_box("meta", meta_payload, /*full=*/true);
+
+  std::vector<uint8_t> file;
+  append(file, ftyp);
+  append(file, meta);
+  return file;
+}
+
+
+// A heif_reader on a memory buffer that counts the bytes read from one byte range.
+struct CountingReader {
+  const std::vector<uint8_t>* data = nullptr;
+  int64_t position = 0;
+
+  int64_t watched_start = 0;
+  int64_t watched_end = 0;
+  uint64_t watched_bytes_read = 0;
+};
+
+int64_t reader_get_position(void* userdata) {
+  return static_cast<CountingReader*>(userdata)->position;
+}
+
+int reader_read(void* dst, size_t size, void* userdata) {
+  auto* reader = static_cast<CountingReader*>(userdata);
+  auto file_size = static_cast<int64_t>(reader->data->size());
+  auto read_end = reader->position + static_cast<int64_t>(size);
+
+  if (read_end > file_size) {
+    return 1;
+  }
+
+  memcpy(dst, reader->data->data() + reader->position, size);
+
+  int64_t overlap_start = std::max(reader->position, reader->watched_start);
+  int64_t overlap_end = std::min(read_end, reader->watched_end);
+  if (overlap_end > overlap_start) {
+    reader->watched_bytes_read += static_cast<uint64_t>(overlap_end - overlap_start);
+  }
+
+  reader->position = read_end;
+  return 0;
+}
+
+int reader_seek(int64_t position, void* userdata) {
+  auto* reader = static_cast<CountingReader*>(userdata);
+  if (position < 0 || position > static_cast<int64_t>(reader->data->size())) {
+    return 1;
+  }
+
+  reader->position = position;
+  return 0;
+}
+
+heif_reader_grow_status reader_wait_for_file_size(int64_t target_size, void* userdata) {
+  auto* reader = static_cast<CountingReader*>(userdata);
+  return (target_size <= static_cast<int64_t>(reader->data->size()))
+             ? heif_reader_grow_status_size_reached
+             : heif_reader_grow_status_size_beyond_eof;
+}
+
+
+// Read the file from memory and decode its primary image without any color conversion.
+// 'max_total_memory' replaces the default memory budget unless it is 0.
+heif_error decode_primary_image(const std::vector<uint8_t>& file, uint64_t max_total_memory = 0) {
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+
+  if (max_total_memory != 0) {
+    heif_context_get_security_limits(ctx)->max_total_memory = max_total_memory;
+  }
+
+  heif_error err = heif_context_read_from_memory_without_copy(ctx, file.data(), file.size(), nullptr);
+  INFO("read error: " << err.message);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_image_handle* handle = nullptr;
+  err = heif_context_get_primary_image_handle(ctx, &handle);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(handle != nullptr);
+
+  heif_image* img = nullptr;
+  err = heif_decode_image(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, nullptr);
+  REQUIRE((img != nullptr) == (err.code == heif_error_Ok));
+
+  // The error message is owned by the context. The tests only compare the error codes.
+  err.message = "";
+
+  heif_image_release(img);
+  heif_image_handle_release(handle);
+  heif_context_free(ctx);
+
+  return err;
+}
+
+} // namespace
+
+TEST_CASE("unci image_tile compressed unit is fetched once for tile-component interleave") {
+  UnciFileSpec spec;
+  spec.item_data = deflate_stored(kTileData);
+  std::vector<uint8_t> file = build_heif_unci(spec);
+
+  CountingReader counting_reader;
+  counting_reader.data = &file;
+  counting_reader.watched_start = static_cast<int64_t>(file.size() - spec.item_data.size());
+  counting_reader.watched_end = static_cast<int64_t>(file.size());
+
+  heif_reader reader{};
+  reader.reader_api_version = 1;
+  reader.get_position = reader_get_position;
+  reader.read = reader_read;
+  reader.seek = reader_seek;
+  reader.wait_for_file_size = reader_wait_for_file_size;
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+
+  heif_error err = heif_context_read_from_reader(ctx, &reader, &counting_reader, nullptr);
+  INFO("read error: " << err.message);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_image_handle* handle = nullptr;
+  err = heif_context_get_primary_image_handle(ctx, &handle);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(handle != nullptr);
+
+  // Only count what the decoding reads.
+  counting_reader.watched_bytes_read = 0;
+
+  heif_image* img = nullptr;
+  err = heif_decode_image(handle, &img, heif_colorspace_RGB, heif_chroma_444, nullptr);
+  INFO("decode error: " << err.message);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(img != nullptr);
+
+  // Before the fix, the unit was read (and decompressed) once per component.
+  REQUIRE(counting_reader.watched_bytes_read == spec.item_data.size());
+
+  const heif_channel channels[3] = {heif_channel_R, heif_channel_G, heif_channel_B};
+
+  for (uint32_t c = 0; c < 3; c++) {
+    REQUIRE(heif_image_get_width(img, channels[c]) == static_cast<int>(spec.width));
+    REQUIRE(heif_image_get_height(img, channels[c]) == static_cast<int>(spec.height));
+
+    int stride = 0;
+    const uint8_t* plane = heif_image_get_plane_readonly(img, channels[c], &stride);
+    REQUIRE(plane != nullptr);
+
+    for (uint32_t y = 0; y < spec.height; y++) {
+      for (uint32_t x = 0; x < spec.width; x++) {
+        INFO("component " << c << ", pixel (" << x << "," << y << ")");
+        REQUIRE(static_cast<int>(plane[y * stride + x]) ==
+                static_cast<int>(kTileData[c * spec.width * spec.height + y * spec.width + x]));
+      }
+    }
+  }
+
+  heif_image_release(img);
+  heif_image_handle_release(handle);
+  heif_context_free(ctx);
+}
+
+
+TEST_CASE("unci image_tile compressed unit larger than the tile is rejected") {
+  std::vector<uint8_t> oversized_tile_data = kTileData;
+  oversized_tile_data.push_back(0);
+
+  UnciFileSpec spec;
+  spec.item_data = deflate_stored(oversized_tile_data);
+
+  heif_error err = decode_primary_image(build_heif_unci(spec));
+  REQUIRE(err.code == heif_error_Invalid_input);
+  REQUIRE(err.subcode == heif_suberror_Decompression_invalid_data);
+}
+
+
+TEST_CASE("unci image_tile compressed unit smaller than the tile is rejected") {
+  std::vector<uint8_t> short_tile_data = kTileData;
+  short_tile_data.pop_back();
+
+  UnciFileSpec spec;
+  spec.item_data = deflate_stored(short_tile_data);
+
+  heif_error err = decode_primary_image(build_heif_unci(spec));
+  REQUIRE(err.code == heif_error_Invalid_input);
+  REQUIRE(err.subcode == heif_suberror_End_of_data);
+}
+
+
+TEST_CASE("unci tile data is charged to the memory budget") {
+  // Each row of a component has 16 bytes of pixel data and is padded to 64 kiB.
+  // The decoded image has three planes of 256 bytes. The tile data has 1 MiB for
+  // each of the three components.
+  constexpr uint32_t size = 16;
+  constexpr uint32_t row_size = 65536;
+  constexpr uint32_t num_components = 3;
+
+  UnciFileSpec spec;
+  spec.width = size;
+  spec.height = size;
+  spec.row_align_size = row_size;
+  spec.deflate_tile_unit = false;
+  spec.item_data.assign(size * row_size * num_components, 0);
+
+  // component interleave (single read) and tile-component interleave (one read per component)
+  for (uint8_t interleave_type : {uint8_t{0}, uint8_t{4}}) {
+    INFO("interleave_type: " << static_cast<int>(interleave_type));
+
+    spec.interleave_type = interleave_type;
+    std::vector<uint8_t> file = build_heif_unci(spec);
+
+    // The image decodes with the default limits.
+    heif_error err = decode_primary_image(file);
+    REQUIRE(err.code == heif_error_Ok);
+
+    // A budget of 2 MiB is plenty for the decoded image, but not enough for the tile data.
+    err = decode_primary_image(file, 2 * 1024 * 1024);
+    REQUIRE(err.code == heif_error_Memory_allocation_error);
+    REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+  }
+}