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);
+ }
+}