Commit 83c35ecc for libheif

commit 83c35ecc64e88a8e832dbc9ff02bb13e1a0b6a0d
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sun Oct 25 01:09:50 2026 +0200

    Serialize image-item bit-depth queries with decoding [GHSA-35mc-3887-rr37]

    Guard public bit-depth queries with the queried item’s decode mutex so parallel derived grid tiles cannot race a shared coded input decoder. Use unlocked virtual implementations for decode paths that already hold the mutex, and cover overlay, identity, and grid tiles sharing a JPEG 2000 input with a regression test.

diff --git a/libheif/image-items/grid.cc b/libheif/image-items/grid.cc
index 76246831..9118c2e0 100644
--- a/libheif/image-items/grid.cc
+++ b/libheif/image-items/grid.cc
@@ -791,7 +791,7 @@ void ImageItem_Grid::get_tile_size(uint32_t& w, uint32_t& h) const



-int ImageItem_Grid::get_luma_bits_per_pixel() const
+int ImageItem_Grid::get_luma_bits_per_pixel_unlocked() const
 {
   auto child_result = get_context()->find_first_coded_image_id(get_id());
   if (child_result.is_error()) {
@@ -807,7 +807,7 @@ int ImageItem_Grid::get_luma_bits_per_pixel() const
 }


-int ImageItem_Grid::get_chroma_bits_per_pixel() const
+int ImageItem_Grid::get_chroma_bits_per_pixel_unlocked() const
 {
   auto child_result = get_context()->find_first_coded_image_id(get_id());
   if (child_result.is_error()) {
diff --git a/libheif/image-items/grid.h b/libheif/image-items/grid.h
index 1816d697..4890e12b 100644
--- a/libheif/image-items/grid.h
+++ b/libheif/image-items/grid.h
@@ -120,10 +120,6 @@ public:
   // (which the base populate would mis-tag as unsigned_integer).
   void populate_component_descriptions() override;

-  int get_luma_bits_per_pixel() const override;
-
-  int get_chroma_bits_per_pixel() const override;
-
   void set_tile_encoding_options(const heif_encoding_options* options);

   Result<Encoder::CodedImageData> encode(const std::shared_ptr<HeifPixelImage>& image,
@@ -163,6 +159,10 @@ public:
   void get_tile_size(uint32_t& w, uint32_t& h) const override;

 private:
+  int get_luma_bits_per_pixel_unlocked() const override;
+
+  int get_chroma_bits_per_pixel_unlocked() const override;
+
   ImageGrid m_grid_spec;
   std::vector<heif_item_id> m_grid_tile_ids;

diff --git a/libheif/image-items/iden.cc b/libheif/image-items/iden.cc
index 5042e6b2..cc884b36 100644
--- a/libheif/image-items/iden.cc
+++ b/libheif/image-items/iden.cc
@@ -116,7 +116,7 @@ Error ImageItem_iden::get_coded_image_colorspace(heif_colorspace* out_colorspace
 }


-int ImageItem_iden::get_luma_bits_per_pixel() const
+int ImageItem_iden::get_luma_bits_per_pixel_unlocked() const
 {
   auto child_result = get_context()->find_first_coded_image_id(get_id());
   if (child_result.is_error()) {
@@ -132,7 +132,7 @@ int ImageItem_iden::get_luma_bits_per_pixel() const
 }


-int ImageItem_iden::get_chroma_bits_per_pixel() const
+int ImageItem_iden::get_chroma_bits_per_pixel_unlocked() const
 {
   auto child_result = get_context()->find_first_coded_image_id(get_id());
   if (child_result.is_error()) {
diff --git a/libheif/image-items/iden.h b/libheif/image-items/iden.h
index 51e75bec..edce72ca 100644
--- a/libheif/image-items/iden.h
+++ b/libheif/image-items/iden.h
@@ -51,10 +51,6 @@ public:
   // (iden has no get_decoder()) and the handle would report 0 components.
   void populate_component_descriptions() override;

-  int get_luma_bits_per_pixel() const override;
-
-  int get_chroma_bits_per_pixel() const override;
-
   Result<Encoder::CodedImageData> encode(const std::shared_ptr<HeifPixelImage>& image,
                                          heif_encoder* encoder,
                                          const heif_encoding_options& options,
@@ -71,6 +67,11 @@ public:
   heif_brand2 get_compatible_brand() const override;

   // No check_decoded_image_size() override: the base class implementation is correct here too.
+
+private:
+  int get_luma_bits_per_pixel_unlocked() const override;
+
+  int get_chroma_bits_per_pixel_unlocked() const override;
 };


diff --git a/libheif/image-items/image_item.cc b/libheif/image-items/image_item.cc
index f468e9cc..f8fb92ff 100644
--- a/libheif/image-items/image_item.cc
+++ b/libheif/image-items/image_item.cc
@@ -544,6 +544,20 @@ Error ImageItem::get_coded_image_colorspace(heif_colorspace* out_colorspace, hei


 int ImageItem::get_luma_bits_per_pixel() const
+{
+  std::lock_guard<std::mutex> lock(m_decode_mutex);
+  return get_luma_bits_per_pixel_unlocked();
+}
+
+
+int ImageItem::get_chroma_bits_per_pixel() const
+{
+  std::lock_guard<std::mutex> lock(m_decode_mutex);
+  return get_chroma_bits_per_pixel_unlocked();
+}
+
+
+int ImageItem::get_luma_bits_per_pixel_unlocked() const
 {
   auto decoderResult = get_decoder();
   if (!decoderResult) {
@@ -556,7 +570,7 @@ int ImageItem::get_luma_bits_per_pixel() const
 }


-int ImageItem::get_chroma_bits_per_pixel() const
+int ImageItem::get_chroma_bits_per_pixel_unlocked() const
 {
   auto decoderResult = get_decoder();
   if (!decoderResult) {
@@ -1664,8 +1678,9 @@ Error ImageItem::check_decoded_image_bit_depth(const HeifPixelImage& img) const
   // the decoded image with the bit depth it got from the handle would then read out of
   // bounds, so the decoded image must not contradict the handle (GHSA-vv35-6hxg-95x8).

-  int luma_bpp = get_luma_bits_per_pixel();
-  int chroma_bpp = get_chroma_bits_per_pixel();
+  // decode_image() already holds this item's non-recursive decode mutex.
+  int luma_bpp = get_luma_bits_per_pixel_unlocked();
+  int chroma_bpp = get_chroma_bits_per_pixel_unlocked();

   bool mismatch = false;

diff --git a/libheif/image-items/image_item.h b/libheif/image-items/image_item.h
index 6e1a25ab..789025db 100644
--- a/libheif/image-items/image_item.h
+++ b/libheif/image-items/image_item.h
@@ -196,11 +196,11 @@ public:

   uint32_t get_ispe_height() const;

-  // Default behavior: forward call to Decoder
-  [[nodiscard]] virtual int get_luma_bits_per_pixel() const;
+  // Serialize queries with decode_image(), which can replace the decoder's
+  // cached item data while another thread is reading it.
+  [[nodiscard]] int get_luma_bits_per_pixel() const;

-  // Default behavior: forward call to Decoder
-  [[nodiscard]] virtual int get_chroma_bits_per_pixel() const;
+  [[nodiscard]] int get_chroma_bits_per_pixel() const;

   void set_size(uint32_t w, uint32_t h)
   {
@@ -550,6 +550,13 @@ private:
   void generate_property_boxes_for_ImageDescription();

 protected:
+  // decode_image() already holds m_decode_mutex when it checks the decoded bit
+  // depth. Its own queries use these methods to avoid locking the mutex twice.
+  // Implementations that query another item use that item's locking public method.
+  [[nodiscard]] virtual int get_luma_bits_per_pixel_unlocked() const;
+
+  [[nodiscard]] virtual int get_chroma_bits_per_pixel_unlocked() const;
+
   // Result<std::vector<uint8_t>> read_bitstream_configuration_data_override(heif_item_id itemId, heif_compression_format format) const;

   virtual Result<Encoder::CodedImageData> encode(const std::shared_ptr<HeifPixelImage>& image,
@@ -602,11 +609,11 @@ public:
     return m_item_error;
   }

-  [[nodiscard]] int get_luma_bits_per_pixel() const override { return -1; }
+private:
+  [[nodiscard]] int get_luma_bits_per_pixel_unlocked() const override { return -1; }

-  [[nodiscard]] int get_chroma_bits_per_pixel() const override { return -1; }
+  [[nodiscard]] int get_chroma_bits_per_pixel_unlocked() const override { return -1; }

-private:
   uint32_t m_item_type;
   Error m_item_error;
 };
diff --git a/libheif/image-items/mask_image.cc b/libheif/image-items/mask_image.cc
index 907813fd..951b4a03 100644
--- a/libheif/image-items/mask_image.cc
+++ b/libheif/image-items/mask_image.cc
@@ -207,7 +207,7 @@ Result<Encoder::CodedImageData> ImageItem_mask::encode(const std::shared_ptr<Hei
 }


-int ImageItem_mask::get_luma_bits_per_pixel() const
+int ImageItem_mask::get_luma_bits_per_pixel_unlocked() const
 {
   auto mskC = get_property<Box_mskC>();
   if (!mskC) {
diff --git a/libheif/image-items/mask_image.h b/libheif/image-items/mask_image.h
index 9f420b57..5fbfdec1 100644
--- a/libheif/image-items/mask_image.h
+++ b/libheif/image-items/mask_image.h
@@ -94,10 +94,6 @@ public:

   bool is_ispe_essential() const override { return true; }

-  int get_luma_bits_per_pixel() const override;
-
-  int get_chroma_bits_per_pixel() const override { return 0; }
-
   Result<std::shared_ptr<HeifPixelImage>> decode_compressed_image(const heif_decoding_options& options,
                                                                   bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
                                                                   DecodeTraversalState decode_state) const override;
@@ -106,6 +102,11 @@ public:
                                          heif_encoder* encoder,
                                          const heif_encoding_options& options,
                                          heif_image_input_class input_class) override;
+
+private:
+  int get_luma_bits_per_pixel_unlocked() const override;
+
+  int get_chroma_bits_per_pixel_unlocked() const override { return 0; }
 };

 #endif //LIBHEIF_MASK_IMAGE_H
diff --git a/libheif/image-items/overlay.cc b/libheif/image-items/overlay.cc
index 2469f01f..dda169ef 100644
--- a/libheif/image-items/overlay.cc
+++ b/libheif/image-items/overlay.cc
@@ -462,7 +462,7 @@ int ImageItem_Overlay::get_canvas_bit_depth() const

 // The overlay image is the canvas, so these report the bit depth of the canvas.

-int ImageItem_Overlay::get_luma_bits_per_pixel() const
+int ImageItem_Overlay::get_luma_bits_per_pixel_unlocked() const
 {
   if (get_first_image_bit_depth() < 0) {
     return -1;
@@ -472,9 +472,9 @@ int ImageItem_Overlay::get_luma_bits_per_pixel() const
 }


-int ImageItem_Overlay::get_chroma_bits_per_pixel() const
+int ImageItem_Overlay::get_chroma_bits_per_pixel_unlocked() const
 {
-  return get_luma_bits_per_pixel();
+  return get_luma_bits_per_pixel_unlocked();
 }


diff --git a/libheif/image-items/overlay.h b/libheif/image-items/overlay.h
index 7caeea05..43013e18 100644
--- a/libheif/image-items/overlay.h
+++ b/libheif/image-items/overlay.h
@@ -100,10 +100,6 @@ public:

   Error initialize_decoder() override;

-  int get_luma_bits_per_pixel() const override;
-
-  int get_chroma_bits_per_pixel() const override;
-
   Error get_coded_image_colorspace(heif_colorspace* out_colorspace, heif_chroma* out_chroma) const override;

   // Delegates to the first overlay-input image's component descriptions,
@@ -135,6 +131,10 @@ public:
   static Result<std::shared_ptr<ImageItem_Overlay>> add_new_overlay_item(HeifContext* ctx, const ImageOverlay& overlayspec);

 private:
+  int get_luma_bits_per_pixel_unlocked() const override;
+
+  int get_chroma_bits_per_pixel_unlocked() const override;
+
   ImageOverlay m_overlay_spec;
   std::vector<heif_item_id> m_overlay_image_ids;

diff --git a/libheif/image-items/tiled.cc b/libheif/image-items/tiled.cc
index b8a8f5e6..65e5fa33 100644
--- a/libheif/image-items/tiled.cc
+++ b/libheif/image-items/tiled.cc
@@ -1113,7 +1113,7 @@ Error ImageItem_Tiled::get_coded_image_colorspace(heif_colorspace* out_colorspac
 }


-int ImageItem_Tiled::get_luma_bits_per_pixel() const
+int ImageItem_Tiled::get_luma_bits_per_pixel_unlocked() const
 {
   DataExtent any_tile_extent;
   append_compressed_tile_data(any_tile_extent.m_raw, 0,0); // TODO: use tile that is already loaded
@@ -1122,7 +1122,7 @@ int ImageItem_Tiled::get_luma_bits_per_pixel() const
   return m_tile_decoder->get_luma_bits_per_pixel();
 }

-int ImageItem_Tiled::get_chroma_bits_per_pixel() const
+int ImageItem_Tiled::get_chroma_bits_per_pixel_unlocked() const
 {
   DataExtent any_tile_extent;
   append_compressed_tile_data(any_tile_extent.m_raw, 0,0); // TODO: use tile that is already loaded
diff --git a/libheif/image-items/tiled.h b/libheif/image-items/tiled.h
index cc1dfdb7..33282499 100644
--- a/libheif/image-items/tiled.h
+++ b/libheif/image-items/tiled.h
@@ -182,10 +182,6 @@ public:

   Error get_coded_image_colorspace(heif_colorspace* out_colorspace, heif_chroma* out_chroma) const override;

-  int get_luma_bits_per_pixel() const override;
-
-  int get_chroma_bits_per_pixel() const override;
-
   Result<Encoder::CodedImageData> encode(const std::shared_ptr<HeifPixelImage>& image,
                                          heif_encoder* encoder,
                                          const heif_encoding_options& options,
@@ -216,6 +212,10 @@ public:
   void get_tile_size(uint32_t& w, uint32_t& h) const override;

 private:
+  int get_luma_bits_per_pixel_unlocked() const override;
+
+  int get_chroma_bits_per_pixel_unlocked() const override;
+
   TiledHeader m_tild_header;
   uint64_t m_next_tild_position = 0;

diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 8ece6275..52780432 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -121,6 +121,7 @@ add_libheif_test(item_properties)
 add_libheif_test(item_writing)
 add_libheif_test(overlay_amplification)
 add_libheif_test(overlay_offsets)
+add_libheif_test(derived_images_shared_input_threads)
 add_libheif_test(error_item_decode)
 add_libheif_test(alpha_cycle_deadlock)
 add_libheif_test(alpha_composite_decode)
diff --git a/tests/derived_images_shared_input_threads.cc b/tests/derived_images_shared_input_threads.cc
new file mode 100644
index 00000000..c9afe40f
--- /dev/null
+++ b/tests/derived_images_shared_input_threads.cc
@@ -0,0 +1,296 @@
+/*
+  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.
+*/
+
+// Derived images (overlay, 'iden', grid) report the bit depth of their first coded input
+// image. They also ask for it while they are decoded (canvas of the overlay, check of the
+// decoded bit depth). A JPEG 2000 item has to read its codestream to answer, which it does
+// through the decoder object that is also used for decoding the item.
+//
+// When derived images are the tiles of a grid, they are decoded by parallel tile threads.
+// If they share an input image, one thread asked for its bit depth while another thread was
+// decoding it and replaced the cached input data of the decoder: heap-use-after-free.
+// The input image's bit-depth query now takes its decoding mutex.
+//
+// The files of this test are a grid whose tiles cycle through four derived images, which
+// all have the same JPEG 2000 item as input. As this is a race, the test relies on a
+// sanitizer build (ASan or TSan) to report a regression reliably.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "test_utils.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <cstring>
+#include <string>
+#include <vector>
+
+namespace {
+
+constexpr uint16_t TILE_SIZE = 64;
+constexpr uint8_t ROWS = 8;
+constexpr uint8_t COLUMNS = 8;
+
+constexpr uint16_t GRID_ID = 1;
+constexpr uint16_t J2K_ID = 2;
+const std::vector<uint16_t> derived_ids{10, 11, 12, 13};
+
+
+heif_error write_to_vector(heif_context*, const void* data, size_t size, void* userdata)
+{
+  auto* out = static_cast<std::vector<uint8_t>*>(userdata);
+  const auto* bytes = static_cast<const uint8_t*>(data);
+  out->insert(out->end(), bytes, bytes + size);
+  return heif_error{heif_error_Ok, heif_suberror_Unspecified, "Success"};
+}
+
+
+std::vector<uint8_t> encode_jpeg2000_image()
+{
+  heif_encoder* encoder = get_encoder_or_skip_test(heif_compression_JPEG2000);
+
+  heif_image* img = nullptr;
+  heif_error err = heif_image_create(TILE_SIZE, TILE_SIZE, heif_colorspace_YCbCr, heif_chroma_444, &img);
+  REQUIRE(err.code == heif_error_Ok);
+
+  fill_new_plane(img, heif_channel_Y, TILE_SIZE, TILE_SIZE);
+  fill_new_plane(img, heif_channel_Cb, TILE_SIZE, TILE_SIZE);
+  fill_new_plane(img, heif_channel_Cr, TILE_SIZE, TILE_SIZE);
+
+  heif_context* ctx = heif_context_alloc();
+  err = heif_context_encode_image(ctx, img, encoder, nullptr, nullptr);
+  INFO("encode: " << err.message);
+  REQUIRE(err.code == heif_error_Ok);
+
+  std::vector<uint8_t> file;
+  heif_writer writer;
+  writer.writer_api_version = 1;
+  writer.write = write_to_vector;
+  err = heif_context_write(ctx, &writer, &file);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_encoder_release(encoder);
+  heif_context_free(ctx);
+  heif_image_release(img);
+
+  return file;
+}
+
+
+// Returns the first box of the given type, including its header.
+std::vector<uint8_t> find_box(const std::vector<uint8_t>& file, const char fourcc[4])
+{
+  auto pos = std::search(file.begin() + 4, file.end(), fourcc, fourcc + 4);
+  REQUIRE(pos != file.end());
+
+  auto start = pos - 4;
+  size_t size = (size_t(start[0]) << 24) | (size_t(start[1]) << 16) | (size_t(start[2]) << 8) | size_t(start[3]);
+  REQUIRE(size >= 8);
+  REQUIRE(size <= size_t(file.end() - start));
+
+  return {start, start + size};
+}
+
+
+std::vector<uint8_t> image_grid(uint8_t rows, uint8_t columns)
+{
+  std::vector<uint8_t> data{0, 0, uint8_t(rows - 1), uint8_t(columns - 1)};
+  put_u16_be(data, columns * TILE_SIZE);
+  put_u16_be(data, rows * TILE_SIZE);
+  return data;
+}
+
+
+// An overlay with a canvas of the tile size and one input image at offset (0;0).
+std::vector<uint8_t> image_overlay()
+{
+  std::vector<uint8_t> data{0, 0};
+  const uint16_t background_color[] = {0, 0, 0, 0xFFFF};
+  for (uint16_t v : background_color) {
+    put_u16_be(data, v);
+  }
+  put_u16_be(data, TILE_SIZE);
+  put_u16_be(data, TILE_SIZE);
+  put_u16_be(data, 0);
+  put_u16_be(data, 0);
+  return data;
+}
+
+
+// Item 1 is the grid, items 10-13 are derived images of type 'derived_type' that are used in
+// turn as the grid tiles, item 2 is the JPEG 2000 image that all of them are derived from.
+std::vector<uint8_t> build_file(const std::vector<uint8_t>& jpeg2000_file, const std::string& derived_type)
+{
+  struct Item
+  {
+    uint16_t id;
+    std::string type;
+    std::vector<uint8_t> properties;
+    std::vector<uint16_t> dimg;
+    std::vector<uint8_t> data;
+  };
+
+  // properties: 1 = size of the grid, 2 = size of a tile, 3 = j2kH
+
+  std::vector<uint8_t> mdat = find_box(jpeg2000_file, "mdat");
+
+  std::vector<Item> items;
+  items.push_back({GRID_ID, "grid", {1}, {}, image_grid(ROWS, COLUMNS)});
+  items.push_back({J2K_ID, "j2k1", {2, 0x80 | 3}, {}, {mdat.begin() + 8, mdat.end()}});
+
+  for (int i = 0; i < ROWS * COLUMNS; i++) {
+    items[0].dimg.push_back(derived_ids[i % derived_ids.size()]);
+  }
+
+  for (uint16_t id : derived_ids) {
+    std::vector<uint8_t> data;
+    if (derived_type == "iovl") {
+      data = image_overlay();
+    }
+    else if (derived_type == "grid") {
+      data = image_grid(1, 1);
+    }
+
+    items.push_back({id, derived_type, {2}, {J2K_ID}, data});
+  }
+
+
+  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, "j2ki");
+
+  std::vector<uint8_t> hdlr_payload;
+  put_u32_be(hdlr_payload, 0);
+  append_fourcc(hdlr_payload, "pict");
+  hdlr_payload.resize(hdlr_payload.size() + 13);
+
+  std::vector<uint8_t> pitm_payload;
+  put_u16_be(pitm_payload, GRID_ID);
+
+  std::vector<uint8_t> iinf_payload, ipma_payload, iref_payload, iloc_payload, idat_payload;
+  put_u16_be(iinf_payload, uint16_t(items.size()));
+  put_u32_be(ipma_payload, uint32_t(items.size()));
+  iloc_payload.push_back(0x44); // offset_size=4, length_size=4
+  iloc_payload.push_back(0);
+  put_u16_be(iloc_payload, uint16_t(items.size()));
+
+  for (const Item& item : items) {
+    std::vector<uint8_t> infe_payload;
+    put_u16_be(infe_payload, item.id);
+    put_u16_be(infe_payload, 0);
+    append_fourcc(infe_payload, item.type.c_str());
+    infe_payload.push_back(0);
+    append(iinf_payload, make_box("infe", infe_payload, true, 2, item.id == GRID_ID ? 0 : 1 /* hidden */));
+
+    put_u16_be(ipma_payload, item.id);
+    ipma_payload.push_back(uint8_t(item.properties.size()));
+    append(ipma_payload, item.properties);
+
+    if (!item.dimg.empty()) {
+      std::vector<uint8_t> dimg_payload;
+      put_u16_be(dimg_payload, item.id);
+      put_u16_be(dimg_payload, uint16_t(item.dimg.size()));
+      for (uint16_t to_id : item.dimg) {
+        put_u16_be(dimg_payload, to_id);
+      }
+      append(iref_payload, make_box("dimg", dimg_payload));
+    }
+
+    put_u16_be(iloc_payload, item.id);
+    put_u16_be(iloc_payload, 1); // construction_method=1 (idat)
+    put_u16_be(iloc_payload, 0);
+    put_u16_be(iloc_payload, 1); // extent_count
+    put_u32_be(iloc_payload, uint32_t(idat_payload.size()));
+    put_u32_be(iloc_payload, uint32_t(item.data.size()));
+    append(idat_payload, item.data);
+  }
+
+  std::vector<uint8_t> grid_ispe_payload, tile_ispe_payload;
+  put_u32_be(grid_ispe_payload, COLUMNS * TILE_SIZE);
+  put_u32_be(grid_ispe_payload, ROWS * TILE_SIZE);
+  put_u32_be(tile_ispe_payload, TILE_SIZE);
+  put_u32_be(tile_ispe_payload, TILE_SIZE);
+
+  std::vector<uint8_t> ipco_payload;
+  append(ipco_payload, make_box("ispe", grid_ispe_payload, true));
+  append(ipco_payload, make_box("ispe", tile_ispe_payload, true));
+  append(ipco_payload, find_box(jpeg2000_file, "j2kH"));
+
+  std::vector<uint8_t> iprp_payload;
+  append(iprp_payload, make_box("ipco", ipco_payload));
+  append(iprp_payload, make_box("ipma", ipma_payload, true));
+
+  std::vector<uint8_t> meta_payload;
+  append(meta_payload, make_box("hdlr", hdlr_payload, true));
+  append(meta_payload, make_box("pitm", pitm_payload, true));
+  append(meta_payload, make_box("iloc", iloc_payload, true, 1));
+  append(meta_payload, make_box("iinf", iinf_payload, true));
+  append(meta_payload, make_box("iref", iref_payload, true));
+  append(meta_payload, make_box("iprp", iprp_payload));
+  append(meta_payload, make_box("idat", idat_payload));
+
+  std::vector<uint8_t> file = make_box("ftyp", ftyp_payload);
+  append(file, make_box("meta", meta_payload, true));
+  return file;
+}
+
+} // namespace
+
+
+TEST_CASE("derived images on grid tile threads share a JPEG 2000 input image")
+{
+  if (!heif_have_decoder_for_format(heif_compression_JPEG2000)) {
+    SKIP("Decoder for JPEG 2000 not found, skipping test");
+  }
+
+  std::string derived_type = GENERATE("iovl", "iden", "grid");
+  INFO("grid tiles are '" << derived_type << "' items");
+
+  std::vector<uint8_t> file = build_file(encode_jpeg2000_image(), derived_type);
+
+  heif_context* ctx = heif_context_alloc();
+  heif_error err = heif_context_read_from_memory_without_copy(ctx, file.data(), file.size(), nullptr);
+  INFO("read: " << 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(heif_image_handle_get_luma_bits_per_pixel(handle) == 8);
+
+  heif_image* img = nullptr;
+  err = heif_decode_image(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, nullptr);
+  INFO("decode: " << err.message);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(heif_image_get_primary_width(img) == COLUMNS * TILE_SIZE);
+  REQUIRE(heif_image_get_primary_height(img) == ROWS * TILE_SIZE);
+
+  heif_image_release(img);
+  heif_image_handle_release(handle);
+  heif_context_free(ctx);
+}