Commit 51360e19 for libheif

commit 51360e193a1288f54592aa6fc0b7037160636514
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sat Sep 5 02:54:34 2026 +0200

    Validate the input image in each encoder plugin (GHSA-fqpw-fj22-78w4)

    An image whose color channels carry different bit depths reached the AOM and
    x265 encoder plugins unconverted, and both took the sample width from the luma
    channel and applied it to the chroma planes. HeifPixelImage allocates one byte
    per sample up to 8 bits and two bytes above that, so each chroma row was read at
    twice its allocated width: a heap out-of-bounds read whose bytes were encoded
    into the output.

    Such an image is not exotic. ISO/IEC 23001-17 declares component_bit_depth once
    per component, so a 'unci' file can hold Y at 10 bits with Cb and Cr at 8, and
    decoding it natively (what heif-enc does) preserves that. The per-channel check
    added for GHSA-w7mc-p8jc-p853 does not catch it, because it lives inside
    convert_colorspace() and Encoder::convert_colorspace_for_encoding() returns
    before calling that function when the image already matches what the encoder
    asked for.

    The check does not belong in the color conversion. H.265 and H.264 signal
    bit_depth_luma_minus8 and bit_depth_chroma_minus8 separately, so those formats
    can represent such an image even though x265, x264 and kvazaar cannot produce
    it; AV1 and VVC signal one bit depth for all planes; JPEG 2000 signals a
    precision per component and genuinely supports it. What an encoder accepts is a
    property of that encoder, so each plugin now checks its own input.

    check_encoder_input_image() takes the constraints as arguments: whether the
    encoder handles monochrome, and the set of bit depths the codec allows. It
    accepts YCbCr and monochrome and refuses every other colorspace, requires the
    color channels to be present, requires them to share one bit depth, and requires
    that depth to be in the plugin's set. It is called at the top of
    *_encode_sequence_frame (jpeg_encode_image), which every *_encode_image
    delegates to and which precedes any plane read, so the still image, sequence and
    tiled encode paths are all covered.

    rav1e, svt and jpeg declare no monochrome support: they always request YCbCr and
    have no greyscale code path. The bit depth sets mirror or are supersets of what
    each plugin already enforced, so no input that worked before is rejected now,
    and the set does not replace a plugin's own check against the encoder library
    actually linked in. The AOM plugin had no bit depth validation at all, and
    libaom's own range test admits values such as 9 that are not valid AV1 bit
    depths, so that gap is closed too.

    The header carries a TODO for the real fix: a plugin API describing the input
    formats an encoder accepts, so that libheif can determine how to color-convert
    an image to fit a given encoder, and so that encoder selection can account for
    two encoders of the same output format supporting different combinations.

diff --git a/libheif/plugins/encoder_aom.cc b/libheif/plugins/encoder_aom.cc
index 18882b8a..800576d2 100644
--- a/libheif/plugins/encoder_aom.cc
+++ b/libheif/plugins/encoder_aom.cc
@@ -31,6 +31,7 @@
 #include <memory>
 #include <utility>
 #include "encoder_aom.h"
+#include "encoder_input_check.h"

 #include <deque>
 #include <aom/aom_encoder.h>
@@ -1237,6 +1238,14 @@ static heif_error aom_start_sequence_encoding(void* encoder_raw, const heif_imag
 static heif_error aom_encode_sequence_frame(void* encoder_raw, const heif_image* image,
                                             uintptr_t frame_nr)
 {
+  // AV1 signals one bit depth for all planes, so an image whose color
+  // channels disagree cannot be encoded.
+  heif_error input_error = check_encoder_input_image(image, /*supports_monochrome=*/true,
+                                                    {8, 10, 12});
+  if (input_error.code != heif_error_Ok) {
+    return input_error;
+  }
+
   encoder_struct_aom* encoder = (encoder_struct_aom*) encoder_raw;
   aom_codec_ctx_t& codec = encoder->codec;

diff --git a/libheif/plugins/encoder_input_check.h b/libheif/plugins/encoder_input_check.h
new file mode 100644
index 00000000..b83ab5a1
--- /dev/null
+++ b/libheif/plugins/encoder_input_check.h
@@ -0,0 +1,128 @@
+/*
+ * HEIF codec.
+ * Copyright (c) 2026 Dirk Farin <dirk.farin@gmail.com>
+ *
+ * This file is part of libheif.
+ *
+ * libheif is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * libheif is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with libheif.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#ifndef LIBHEIF_ENCODER_INPUT_CHECK_H
+#define LIBHEIF_ENCODER_INPUT_CHECK_H
+
+#include "libheif/heif.h"
+#include "libheif/heif_plugin.h"
+
+#include <initializer_list>
+
+/*
+ * Input validation for encoder plugins that take planar YCbCr or monochrome
+ * images with one bit depth for all channels.
+ *
+ * A plugin cannot assume that it is handed an image it can encode. The image
+ * arrives from Encoder::convert_colorspace_for_encoding(), which returns the
+ * image unchanged whenever it already has the colorspace and chroma format the
+ * plugin asked for, so nothing on the way in inspects the per-channel bit
+ * depths. Input images may legitimately differ per channel: the 'unci' codec
+ * (ISO/IEC 23001-17) declares component_bit_depth once per component, so
+ * decoding such a file and re-encoding it produces, for example, Y at 10 bits
+ * and Cb/Cr at 8. HeifPixelImage allocates one byte per sample up to 8 bits and
+ * two bytes above that, so a plugin that derives the sample width from the luma
+ * channel and applies it to the chroma planes reads past the end of them.
+ *
+ * Whether an image can be encoded depends on the codec and on the specific
+ * encoder implementation, which is why this takes the constraints as arguments
+ * rather than hard-coding them:
+ *
+ *   - H.265 and H.264 signal bit_depth_luma_minus8 and bit_depth_chroma_minus8
+ *     separately, so the formats can represent differing luma and chroma bit
+ *     depths, but neither x265 nor x264 nor kvazaar can produce it: their APIs
+ *     carry a single bit depth.
+ *   - AV1 and VVC signal one bit depth for all planes, so the formats cannot
+ *     represent it at all.
+ *   - JPEG 2000 signals a precision per component and genuinely supports it,
+ *     which is why the OpenJPEG and OpenJPH plugins do not use this check.
+ *
+ * 'supported_bit_depths' is the set the codec allows. It is a codec level
+ * constraint and does not replace a plugin's own check against the encoder
+ * library actually linked in (x265_api_get(), uvg_api_get() and friends), which
+ * is what decides whether this particular build can do 10 or 12 bits.
+ *
+ * TODO: this per-plugin check is a stopgap. What is really needed is a proper
+ * plugin API through which an encoder describes the input formats it accepts
+ * (bit depth per channel, chroma formats, and so on). That would let libheif
+ * query how an image has to be color-converted to fit a given encoder, instead
+ * of the plugin refusing the image outright. It would also feed into encoder
+ * selection: two encoders for the same output format may well support different
+ * bit depth combinations, so the choice of plugin should depend on what the
+ * input image actually needs. Until that API exists, each plugin has to check
+ * its own input.
+ */
+static inline heif_error check_encoder_input_image(const heif_image* image,
+                                                   bool supports_monochrome,
+                                                   std::initializer_list<int> supported_bit_depths)
+{
+  const heif_channel channels[3] = {heif_channel_Y, heif_channel_Cb, heif_channel_Cr};
+  int num_color_channels;
+
+  switch (heif_image_get_colorspace(image)) {
+    case heif_colorspace_monochrome:
+      if (!supports_monochrome) {
+        return heif_error{heif_error_Encoder_plugin_error,
+                          heif_suberror_Unsupported_image_type,
+                          "Encoder cannot encode monochrome images"};
+      }
+      num_color_channels = 1;
+      break;
+
+    case heif_colorspace_YCbCr:
+      num_color_channels = 3;
+      break;
+
+    default:
+      return heif_error{heif_error_Encoder_plugin_error,
+                        heif_suberror_Unsupported_image_type,
+                        "Encoder can only encode YCbCr and monochrome images"};
+  }
+
+  for (int i = 0; i < num_color_channels; i++) {
+    if (!heif_image_has_channel(image, channels[i])) {
+      return heif_error{heif_error_Encoder_plugin_error,
+                        heif_suberror_Unsupported_image_type,
+                        "Input image is missing one of its color channels"};
+    }
+  }
+
+  int bpp = heif_image_get_bits_per_pixel_range(image, heif_channel_Y);
+
+  for (int i = 1; i < num_color_channels; i++) {
+    if (heif_image_get_bits_per_pixel_range(image, channels[i]) != bpp) {
+      return heif_error{heif_error_Encoder_plugin_error,
+                        heif_suberror_Unsupported_bit_depth,
+                        "Encoder cannot encode images in which the color channels have different bit depths"};
+    }
+  }
+
+  for (int supported_bpp : supported_bit_depths) {
+    if (bpp == supported_bpp) {
+      return heif_error_ok;
+    }
+  }
+
+  return heif_error{heif_error_Encoder_plugin_error,
+                    heif_suberror_Unsupported_bit_depth,
+                    "Encoder cannot encode images at this bit depth"};
+}
+
+#endif // LIBHEIF_ENCODER_INPUT_CHECK_H
diff --git a/libheif/plugins/encoder_jpeg.cc b/libheif/plugins/encoder_jpeg.cc
index 9ce44211..aa25d62d 100644
--- a/libheif/plugins/encoder_jpeg.cc
+++ b/libheif/plugins/encoder_jpeg.cc
@@ -21,6 +21,7 @@
 #include "libheif/heif.h"
 #include "libheif/heif_plugin.h"
 #include "encoder_jpeg.h"
+#include "encoder_input_check.h"
 #include <vector>
 #include <cstring>
 #include <cassert>
@@ -341,6 +342,14 @@ static void OnJpegError(j_common_ptr cinfo)
 heif_error jpeg_encode_image(void* encoder_raw, const heif_image* image,
                              heif_image_input_class input_class)
 {
+  // JPEG signals one sample precision for all components. The plugin always
+  // requests YCbCr input and does not implement greyscale JPEG encoding yet.
+  heif_error input_error = check_encoder_input_image(image, /*supports_monochrome=*/false,
+                                                    {8});
+  if (input_error.code != heif_error_Ok) {
+    return input_error;
+  }
+
   auto* encoder = (encoder_struct_jpeg*) encoder_raw;


diff --git a/libheif/plugins/encoder_kvazaar.cc b/libheif/plugins/encoder_kvazaar.cc
index 13a2ddfd..7affa6d2 100644
--- a/libheif/plugins/encoder_kvazaar.cc
+++ b/libheif/plugins/encoder_kvazaar.cc
@@ -21,6 +21,7 @@
 #include "libheif/heif.h"
 #include "libheif/heif_plugin.h"
 #include "encoder_kvazaar.h"
+#include "encoder_input_check.h"
 #include <memory>
 #include <string>   // apparently, this is a false positive of cpplint
 #include <cstring>
@@ -656,6 +657,14 @@ static heif_error kvazaar_start_sequence_encoding_intern(void* encoder_raw, cons
 static heif_error kvazaar_encode_sequence_frame(void* encoder_raw, const heif_image* image,
                                              uintptr_t frame_nr)
 {
+  // HEVC can signal different luma and chroma bit depths, but kvazaar has a
+  // single hard-coded bit depth and cannot produce such a stream.
+  heif_error input_error = check_encoder_input_image(image, /*supports_monochrome=*/true,
+                                                    {KVZ_BIT_DEPTH});
+  if (input_error.code != heif_error_Ok) {
+    return input_error;
+  }
+
   encoder_struct_kvazaar* encoder = (encoder_struct_kvazaar*) encoder_raw;

   // Note: it is ok to cast away the const, as the image content is not changed.
@@ -749,13 +758,6 @@ static heif_error kvazaar_encode_sequence_frame(void* encoder_raw, const heif_im

   if (!isGreyscale) {
     bit_depth_chroma = heif_image_get_bits_per_pixel_range(image, heif_channel_Cb);
-    if (bit_depth != bit_depth_chroma) {
-      return {
-        heif_error_Encoder_plugin_error,
-        heif_suberror_Unsupported_bit_depth,
-        "Luma bit depth must equal the chroma bit depth"
-      };
-    }
   }

   if (isGreyscale) {
diff --git a/libheif/plugins/encoder_rav1e.cc b/libheif/plugins/encoder_rav1e.cc
index 8f2364fc..2a3205c8 100644
--- a/libheif/plugins/encoder_rav1e.cc
+++ b/libheif/plugins/encoder_rav1e.cc
@@ -21,6 +21,7 @@
 #include "libheif/heif.h"
 #include "libheif/heif_plugin.h"
 #include "encoder_rav1e.h"
+#include "encoder_input_check.h"
 #include <vector>
 #include <memory>
 #include <cstring>
@@ -674,6 +675,14 @@ heif_error rav1e_start_sequence_encoding(void* encoder_raw, const heif_image* im

 heif_error rav1e_encode_sequence_frame(void* encoder_raw, const heif_image* image, uintptr_t frame_nr)
 {
+  // AV1 signals one bit depth for all planes. rav1e always requests YCbCr
+  // input, so a monochrome image would be missing the Cb/Cr planes read below.
+  heif_error input_error = check_encoder_input_image(image, /*supports_monochrome=*/false,
+                                                    {8, 10, 12});
+  if (input_error.code != heif_error_Ok) {
+    return input_error;
+  }
+
   auto* encoder = (encoder_struct_rav1e*) encoder_raw;
   auto& rav1eContext = encoder->rav1eContextRaw;

diff --git a/libheif/plugins/encoder_svt.cc b/libheif/plugins/encoder_svt.cc
index bd0431be..04d4d79b 100644
--- a/libheif/plugins/encoder_svt.cc
+++ b/libheif/plugins/encoder_svt.cc
@@ -21,6 +21,7 @@
 #include "libheif/heif.h"
 #include "libheif/heif_plugin.h"
 #include "encoder_svt.h"
+#include "encoder_input_check.h"
 #include <vector>
 #include <cstring>
 #include <cassert>
@@ -1022,6 +1023,14 @@ static heif_error read_encoder_output_packets(void* encoder_raw, bool done_sendi

 static heif_error svt_encode_sequence_frame(void* encoder_raw, const heif_image* image, uintptr_t frame_nr)
 {
+  // AV1 signals one bit depth for all planes. SVT always requests YCbCr
+  // input, so a monochrome image would be missing the Cb/Cr planes read below.
+  heif_error input_error = check_encoder_input_image(image, /*supports_monochrome=*/false,
+                                                    {8, 10, 12});
+  if (input_error.code != heif_error_Ok) {
+    return input_error;
+  }
+
   auto* encoder = (encoder_struct_svt*) encoder_raw;
   EbComponentType*& svt_encoder = encoder->svt_encoder;
   EbErrorType res = EB_ErrorNone;
diff --git a/libheif/plugins/encoder_uvg266.cc b/libheif/plugins/encoder_uvg266.cc
index 2542cb61..35ce56f2 100644
--- a/libheif/plugins/encoder_uvg266.cc
+++ b/libheif/plugins/encoder_uvg266.cc
@@ -21,6 +21,7 @@
 #include "libheif/heif.h"
 #include "libheif/heif_plugin.h"
 #include "encoder_uvg266.h"
+#include "encoder_input_check.h"
 #include <memory>
 #include <string>   // apparently, this is a false positive of cpplint
 #include <cstring>
@@ -652,6 +653,14 @@ static heif_error uvg266_start_sequence_encoding_intern(void* encoder_raw, const
 static heif_error uvg266_encode_sequence_frame(void* encoder_raw, const heif_image* image,
                                                uintptr_t framenr)
 {
+  // VVC signals one bit depth for all planes. Whether this build of uvg266
+  // supports the depth is checked separately via uvg_api_get().
+  heif_error input_error = check_encoder_input_image(image, /*supports_monochrome=*/true,
+                                                    {8, 10, 12});
+  if (input_error.code != heif_error_Ok) {
+    return input_error;
+  }
+
   encoder_struct_uvg266* encoder = (encoder_struct_uvg266*) encoder_raw;

   bool isGreyscale = (heif_image_get_colorspace(image) == heif_colorspace_monochrome);
diff --git a/libheif/plugins/encoder_vvenc.cc b/libheif/plugins/encoder_vvenc.cc
index 3832c10e..6e1f8305 100644
--- a/libheif/plugins/encoder_vvenc.cc
+++ b/libheif/plugins/encoder_vvenc.cc
@@ -21,6 +21,7 @@
 #include "libheif/heif.h"
 #include "libheif/heif_plugin.h"
 #include "encoder_vvenc.h"
+#include "encoder_input_check.h"
 #include <memory>
 #include <string>   // apparently, this is a false positive of cpplint
 #include <cstring>
@@ -556,6 +557,14 @@ static heif_error vvenc_start_sequence_encoding_intern(void* encoder_raw, const
 static heif_error vvenc_encode_sequence_frame(void* encoder_raw, const heif_image* image,
                                               uintptr_t framenr)
 {
+  // VVC signals one bit depth for all planes, and this plugin only
+  // implements 8 bit encoding.
+  heif_error input_error = check_encoder_input_image(image, /*supports_monochrome=*/true,
+                                                    {8});
+  if (input_error.code != heif_error_Ok) {
+    return input_error;
+  }
+
   encoder_struct_vvenc* encoder = (encoder_struct_vvenc*) encoder_raw;
   vvencEncoder* vvencoder = encoder->vvencoder;

diff --git a/libheif/plugins/encoder_x264.cc b/libheif/plugins/encoder_x264.cc
index caacdf93..d937e4d4 100644
--- a/libheif/plugins/encoder_x264.cc
+++ b/libheif/plugins/encoder_x264.cc
@@ -21,6 +21,7 @@
 #include "libheif/heif.h"
 #include "libheif/heif_plugin.h"
 #include "encoder_x264.h"
+#include "encoder_input_check.h"
 #include <memory>
 #include <string>
 #include <cstring>
@@ -958,6 +959,14 @@ static heif_error x264_start_sequence_encoding(void* encoder_raw, const heif_ima
 static heif_error x264_encode_sequence_frame(void* encoder_raw, const heif_image* image,
                                              uintptr_t frame_nr)
 {
+  // H.264 can signal different luma and chroma bit depths, but x264 has a
+  // single bit depth and cannot produce such a stream.
+  heif_error input_error = check_encoder_input_image(image, /*supports_monochrome=*/true,
+                                                    {8, 10, 12});
+  if (input_error.code != heif_error_Ok) {
+    return input_error;
+  }
+
   encoder_struct_x264* encoder = (encoder_struct_x264*) encoder_raw;
   x264_param_t& param = encoder->param;

diff --git a/libheif/plugins/encoder_x265.cc b/libheif/plugins/encoder_x265.cc
index 61505598..8a8561c5 100644
--- a/libheif/plugins/encoder_x265.cc
+++ b/libheif/plugins/encoder_x265.cc
@@ -21,6 +21,7 @@
 #include "libheif/heif.h"
 #include "libheif/heif_plugin.h"
 #include "encoder_x265.h"
+#include "encoder_input_check.h"
 #include <memory>
 #include <sstream>
 #include <string>
@@ -1101,6 +1102,16 @@ static heif_error x265_start_sequence_encoding(void* encoder_raw, const heif_ima
 static heif_error x265_encode_sequence_frame(void* encoder_raw, const heif_image* image,
                                              uintptr_t frame_nr)
 {
+  // HEVC can signal different luma and chroma bit depths, but x265 has a
+  // single internal bit depth and cannot produce such a stream. Whether this
+  // build of libx265 has 10 or 12 bit support is checked separately via
+  // x265_api_get().
+  heif_error input_error = check_encoder_input_image(image, /*supports_monochrome=*/true,
+                                                    {8, 10, 12});
+  if (input_error.code != heif_error_Ok) {
+    return input_error;
+  }
+
   encoder_struct_x265* encoder = (encoder_struct_x265*) encoder_raw;

   if (!encoder->api) {
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index bb693ad8..2345a977 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -136,6 +136,7 @@ if (WITH_UNCOMPRESSED_CODEC)
     add_libheif_test(uncompressed_sequence_odd_chroma)
     add_libheif_test(uncompressed_mixed_chroma_depth_overflow)
     add_libheif_test(uncompressed_mixed_chroma_depth_colorconv)
+    add_libheif_test(uncompressed_mixed_chroma_depth_encode)
     add_libheif_test(uncompressed_block_pixel_overpacked)
     add_libheif_test(uncompressed_idat_tiled)
     add_libheif_test(uncompressed_encode)
diff --git a/tests/uncompressed_mixed_chroma_depth_encode.cc b/tests/uncompressed_mixed_chroma_depth_encode.cc
new file mode 100644
index 00000000..1c2c5251
--- /dev/null
+++ b/tests/uncompressed_mixed_chroma_depth_encode.cc
@@ -0,0 +1,351 @@
+/*
+  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 test for the encode-path counterpart of GHSA-w7mc-p8jc-p853.
+//
+// The per-channel bit-depth check added for that advisory lives in
+// convert_colorspace(). On the encode path that function is not always reached:
+// Encoder::convert_colorspace_for_encoding() returns early when the image
+// already has the colorspace and chroma format the encoder asked for, which is
+// exactly the case for a YCbCr 4:2:0 image handed to the AV1 or HEVC encoders.
+// The image then arrived at the plugin with Y at 10 bits and Cb/Cr at 8, and
+// the aom and x265 plugins took the sample width from the luma channel and
+// applied it to the chroma planes. HeifPixelImage allocates one byte per sample
+// at 8 bits and two above that, so each chroma row was read at twice its
+// allocated width: a heap out-of-bounds read whose bytes ended up in the
+// encoded output.
+//
+// The fix does not live in the color conversion. Whether an image with
+// differing per-channel bit depths can be encoded depends on the codec and on
+// the encoder implementation (HEVC and H.264 can signal separate luma and
+// chroma bit depths, AV1 and VVC cannot, and JPEG 2000 genuinely supports a
+// precision per component), so each encoder plugin checks its own input.
+//
+// This test builds a 'unci' file with Y=10 bit and Cb=Cr=8 bit, confirms that
+// it still decodes natively to a mismatched-depth planar image (the decoder is
+// allowed to produce one), and then requires the encoders to refuse it cleanly
+// instead of over-reading the chroma planes. Under the unfixed code the encode
+// reproduces a heap-buffer-overflow READ in the encoder plugin.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "test_utils.h"
+
+#include <cstdint>
+#include <vector>
+
+namespace {
+
+// The over-read is roughly half a chroma row per row, so the image has to be
+// wide enough that it exceeds the stride padding and reaches a sanitizer
+// redzone. Small images hide the bug.
+constexpr uint32_t WIDTH = 512;
+constexpr uint32_t HEIGHT = 512;
+constexpr uint32_t CHROMA_WIDTH = WIDTH / 2;
+constexpr uint32_t CHROMA_HEIGHT = HEIGHT / 2;
+
+// 10 bits, not 16: it has to be a bit depth the encoders actually accept, or
+// they would reject the image for an unrelated reason before ever reading the
+// chroma planes, and the test would pass without testing anything.
+constexpr int LUMA_BITS = 10;
+
+// Bit-packed big-endian writer for the luma samples.
+class BitWriter {
+public:
+  explicit BitWriter(std::vector<uint8_t>& out) : m_out(out) {}
+
+  void write(uint32_t value, int bits) {
+    m_acc = (m_acc << bits) | (value & ((1u << bits) - 1));
+    m_nbits += bits;
+    while (m_nbits >= 8) {
+      m_nbits -= 8;
+      m_out.push_back(static_cast<uint8_t>((m_acc >> m_nbits) & 0xFF));
+    }
+  }
+
+  void flush() {
+    if (m_nbits > 0) {
+      m_out.push_back(static_cast<uint8_t>((m_acc << (8 - m_nbits)) & 0xFF));
+      m_nbits = 0;
+    }
+  }
+
+private:
+  std::vector<uint8_t>& m_out;
+  uint32_t m_acc = 0;
+  int m_nbits = 0;
+};
+
+// Build a minimal HEIF file with a single 'unci' item: mixed interleave,
+// 4:2:0 sampling, Y=10 bit, Cb=8 bit, Cr=8 bit, uncompressed (no cmpC).
+std::vector<uint8_t> build_heif_unci_ycbcr_mismatched_luma_depth() {
+  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);
+
+  std::vector<uint8_t> ispe_payload;
+  put_u32_be(ispe_payload, WIDTH);
+  put_u32_be(ispe_payload, HEIGHT);
+  auto ispe = make_box("ispe", ispe_payload, /*full=*/true);
+
+  std::vector<uint8_t> cmpd_payload;
+  put_u32_be(cmpd_payload, 3);
+  put_u16_be(cmpd_payload, 1); // Y
+  put_u16_be(cmpd_payload, 2); // Cb
+  put_u16_be(cmpd_payload, 3); // Cr
+  auto cmpd = make_box("cmpd", cmpd_payload);
+
+  // uncC (v0): mixed interleave, 4:2:0, Y=10 bit, Cb=8 bit, Cr=8 bit.
+  const uint8_t depths[3] = {LUMA_BITS, 8, 8};
+  std::vector<uint8_t> uncC_payload;
+  put_u32_be(uncC_payload, 0); // profile
+  put_u32_be(uncC_payload, 3); // component_count
+  for (uint16_t idx = 0; idx < 3; idx++) {
+    put_u16_be(uncC_payload, idx);                                 // component_index
+    uncC_payload.push_back(static_cast<uint8_t>(depths[idx] - 1)); // component_bit_depth_minus_one
+    uncC_payload.push_back(0);                                     // component_format (unsigned)
+    uncC_payload.push_back(0);                                     // component_align_size
+  }
+  uncC_payload.push_back(2);   // sampling_type = 4:2:0
+  uncC_payload.push_back(2);   // interleave_type = mixed
+  uncC_payload.push_back(0);   // block_size
+  uncC_payload.push_back(0);   // flags (big-endian components)
+  put_u32_be(uncC_payload, 0); // pixel_size
+  put_u32_be(uncC_payload, 0); // 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);
+
+  // colr (nclx). The values matter: if they disagree with the encoding target,
+  // Encoder::convert_colorspace_for_encoding() runs a color conversion and the
+  // guard in convert_colorspace() catches the image before it reaches the
+  // plugin. These are the defaults that heif-enc requests, so the early return
+  // is taken and the image reaches the encoder unconverted.
+  std::vector<uint8_t> colr_payload;
+  append_fourcc(colr_payload, "nclx");
+  put_u16_be(colr_payload, 1);  // colour_primaries (BT.709)
+  put_u16_be(colr_payload, 13); // transfer_characteristics (sRGB)
+  put_u16_be(colr_payload, 6);  // matrix_coefficients (BT.601)
+  colr_payload.push_back(0x80); // full_range_flag = 1
+  auto colr = make_box("colr", colr_payload);
+
+  std::vector<uint8_t> ipco_payload;
+  append(ipco_payload, ispe);
+  append(ipco_payload, cmpd);
+  append(ipco_payload, uncC);
+  append(ipco_payload, colr);
+  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(4);        // association_count
+  ipma_payload.push_back(0x80 | 1); // essential, ispe
+  ipma_payload.push_back(0x80 | 2); // essential, cmpd
+  ipma_payload.push_back(0x80 | 3); // essential, uncC
+  ipma_payload.push_back(4);        // non-essential, colr
+  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);
+
+  // Tile data: the bit-packed 10-bit luma plane, followed by the interleaved
+  // chroma block (Cb, Cr; one byte each per chroma position).
+  std::vector<uint8_t> tile_data;
+  tile_data.reserve(WIDTH * HEIGHT * LUMA_BITS / 8 + CHROMA_WIDTH * CHROMA_HEIGHT * 2);
+
+  {
+    BitWriter bw(tile_data);
+    for (uint32_t y = 0; y < HEIGHT; y++) {
+      for (uint32_t x = 0; x < WIDTH; x++) {
+        bw.write(0x200 + ((x + y) & 0xFF), LUMA_BITS);
+      }
+    }
+    bw.flush();
+  }
+
+  for (uint32_t y = 0; y < CHROMA_HEIGHT; y++) {
+    for (uint32_t x = 0; x < CHROMA_WIDTH; x++) {
+      tile_data.push_back(static_cast<uint8_t>(0x40 + ((y * CHROMA_WIDTH + x) & 0x3F))); // Cb
+      tile_data.push_back(static_cast<uint8_t>(0x80 + ((y * CHROMA_WIDTH + x) & 0x3F))); // Cr
+    }
+  }
+
+  auto idat = make_box("idat", tile_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>(tile_data.size()));
+  auto iloc = make_box("iloc", iloc_payload, /*full=*/true, /*version=*/1);
+
+  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;
+}
+
+// Decode the crafted file in its native colorspace, the way heif-enc does, so
+// that the mismatched per-channel bit depths survive into the encoder.
+heif_image* decode_mismatched_image(heif_context* ctx) {
+  heif_image_handle* handle = nullptr;
+  heif_error err = heif_context_get_primary_image_handle(ctx, &handle);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(handle != nullptr);
+
+  heif_decoding_options* options = heif_decoding_options_alloc();
+  REQUIRE(options != nullptr);
+  options->output_image_nclx_profile_passthrough = true;
+
+  heif_image* img = nullptr;
+  err = heif_decode_image(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, options);
+
+  heif_decoding_options_free(options);
+  heif_image_handle_release(handle);
+
+  INFO("decode error (" << err.code << "/" << err.subcode << "): " << err.message);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(img != nullptr);
+
+  return img;
+}
+
+// Encoding must fail cleanly. It must never over-read the chroma planes, which
+// is what a sanitizer build catches, and it must never silently succeed, which
+// would mean the over-read bytes were encoded into the output.
+void require_encode_refused(heif_image* img, heif_compression_format format) {
+  heif_context* out_ctx = heif_context_alloc();
+  REQUIRE(out_ctx != nullptr);
+
+  heif_encoder* encoder = nullptr;
+  heif_error err = heif_context_get_encoder_for_format(out_ctx, format, &encoder);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(encoder != nullptr);
+
+  heif_image_handle* out_handle = nullptr;
+  err = heif_context_encode_image(out_ctx, img, encoder, nullptr, &out_handle);
+
+  INFO("encode error (" << err.code << "/" << err.subcode << "): "
+                        << (err.message ? err.message : "(null)"));
+  REQUIRE(err.code != heif_error_Ok);
+
+  // An encoder may bail out earlier for a reason of its own (for example a
+  // build of x265 without high bit depth support), but when it is our check
+  // that fires, it must report the bit depth as the reason.
+  if (err.code == heif_error_Encoder_plugin_error) {
+    REQUIRE(err.subcode == heif_suberror_Unsupported_bit_depth);
+  }
+
+  if (out_handle != nullptr) {
+    heif_image_handle_release(out_handle);
+  }
+  heif_encoder_release(encoder);
+  heif_context_free(out_ctx);
+}
+
+} // namespace
+
+TEST_CASE("unci YCbCr with mismatched luma/chroma bit depths is refused by the encoders")
+{
+  if (!heif_have_decoder_for_format(heif_compression_uncompressed)) {
+    SKIP("Skipping test because uncompressed codec is not compiled.");
+  }
+
+  std::vector<uint8_t> file = build_heif_unci_ycbcr_mismatched_luma_depth();
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+
+  heif_error err = heif_context_read_from_memory_without_copy(ctx, file.data(), file.size(), nullptr);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_image* img = decode_mismatched_image(ctx);
+
+  // The decoder is allowed to produce this image: ISO/IEC 23001-17 declares
+  // component_bit_depth per component. It is the encoders that cannot take it.
+  REQUIRE(heif_image_get_colorspace(img) == heif_colorspace_YCbCr);
+  REQUIRE(heif_image_get_chroma_format(img) == heif_chroma_420);
+  REQUIRE(heif_image_get_bits_per_pixel_range(img, heif_channel_Y) == LUMA_BITS);
+  REQUIRE(heif_image_get_bits_per_pixel_range(img, heif_channel_Cb) == 8);
+  REQUIRE(heif_image_get_bits_per_pixel_range(img, heif_channel_Cr) == 8);
+
+  if (heif_have_encoder_for_format(heif_compression_AV1)) {
+    require_encode_refused(img, heif_compression_AV1);
+  }
+
+  if (heif_have_encoder_for_format(heif_compression_HEVC)) {
+    require_encode_refused(img, heif_compression_HEVC);
+  }
+
+  heif_image_release(img);
+  heif_context_free(ctx);
+}