Commit cd9d4d9b for libheif

commit cd9d4d9b02f274b10e3d4bf803ad50e2e42e3e20
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Mon Aug 24 20:54:04 2026 +0200

    reject unci encoding of images that have no pixel planes

    heif_image_create() only records the colorspace and chroma format; the pixel
    planes are allocated separately with heif_image_add_plane(). An image where
    that second step never happened reached the uncompressed encoders, which access
    the planes implied by the chroma format. For the interleaved formats this ended
    in get_component_ids_interleaved(), where find_storage_for_channel() returns
    null and only an assert() stood between that and a null pointer dereference in
    release builds.

    Check for the expected planes in unc_encoder_factory::get_unc_encoder(), which
    both heif_context_add_empty_unci_image() and heif_context_encode_image() pass
    through. A planar image without any plane did not crash but produced an item
    with zero components, so it is rejected as well.

diff --git a/libheif/codecs/uncompressed/unc_encoder.cc b/libheif/codecs/uncompressed/unc_encoder.cc
index 5421066d..4ba36eb0 100644
--- a/libheif/codecs/uncompressed/unc_encoder.cc
+++ b/libheif/codecs/uncompressed/unc_encoder.cc
@@ -165,6 +165,24 @@ unc_encoder::unc_encoder(const std::shared_ptr<const HeifPixelImage>& image)
 Result<std::unique_ptr<const unc_encoder> > unc_encoder_factory::get_unc_encoder(const std::shared_ptr<const HeifPixelImage>& prototype_image,
                                                                                  const heif_encoding_options& options)
 {
+  // The encoders access the pixel planes implied by the image's colorspace and chroma format.
+  // An image that never received those planes (heif_image_create() without a matching
+  // heif_image_add_plane()) would make them dereference a plane that does not exist, so reject
+  // it before we pick an encoder for it.
+
+  if (num_interleaved_components_per_plane(prototype_image->get_chroma_format()) > 1) {
+    if (!prototype_image->has_channel(heif_channel_interleaved)) {
+      return Error{heif_error_Invalid_input,
+                   heif_suberror_Unspecified,
+                   "Image has an interleaved chroma format, but no interleaved pixel plane."};
+    }
+  }
+  else if (prototype_image->get_used_planar_component_ids().empty()) {
+    return Error{heif_error_Invalid_input,
+                 heif_suberror_Unspecified,
+                 "Image has no pixel planes."};
+  }
+
   static unc_encoder_factory_rgb_pixel_interleave enc_rgb_pixel_interleave;
   static unc_encoder_factory_rgb_block_pixel_interleave enc_rgb_block_pixel_interleave;
   static unc_encoder_factory_rgb_bytealign_pixel_interleave enc_rgb_bytealign_pixel_interleave;
diff --git a/tests/uncompressed_encode.cc b/tests/uncompressed_encode.cc
index 4161e0fc..ecc6994c 100644
--- a/tests/uncompressed_encode.cc
+++ b/tests/uncompressed_encode.cc
@@ -1235,3 +1235,74 @@ TEST_CASE("Add tile rejects images that do not match the unci configuration")
     heif_image_release(prototype);
   }
 }
+
+
+// An image can be created without ever adding a pixel plane. The encoders access the planes
+// implied by the colorspace and chroma format, so such an image used to make them dereference
+// a plane that does not exist.
+TEST_CASE("Encoder rejects images without pixel planes")
+{
+  auto try_encode = [](heif_image *image) -> heif_error {
+    heif_unci_image_parameters params{};
+    params.version = 1;
+    params.image_width = 32;
+    params.image_height = 32;
+    params.tile_width = 16;
+    params.tile_height = 16;
+    params.compression = heif_unci_compression_off;
+
+    heif_context *ctx = heif_context_alloc();
+
+    heif_encoding_options *options = heif_encoding_options_alloc();
+    options->macOS_compatibility_workaround_no_nclx_profile = true;
+
+    heif_image_handle *handle = nullptr;
+    heif_error err = heif_context_add_empty_unci_image(ctx, &params, options, image, &handle);
+
+    if (handle) {
+      heif_image_handle_release(handle);
+    }
+    heif_encoding_options_free(options);
+    heif_context_free(ctx);
+
+    return err;
+  };
+
+  const heif_chroma interleaved_formats[] = {heif_chroma_interleaved_RGB,
+                                             heif_chroma_interleaved_RGBA,
+                                             heif_chroma_interleaved_RRGGBB_LE,
+                                             heif_chroma_interleaved_RRGGBBAA_BE};
+
+  for (heif_chroma chroma : interleaved_formats) {
+    heif_image *image;
+    heif_error err = heif_image_create(16, 16, heif_colorspace_RGB, chroma, &image);
+    REQUIRE(err.code == heif_error_Ok);
+    // No heif_image_add_plane() call.
+
+    REQUIRE(try_encode(image).code != heif_error_Ok);
+
+    heif_image_release(image);
+  }
+
+  SECTION("planar image without planes") {
+    heif_image *image;
+    heif_error err = heif_image_create(16, 16, heif_colorspace_YCbCr, heif_chroma_420, &image);
+    REQUIRE(err.code == heif_error_Ok);
+
+    REQUIRE(try_encode(image).code != heif_error_Ok);
+
+    heif_image_release(image);
+  }
+
+  SECTION("a properly allocated image is still accepted") {
+    heif_image *image;
+    heif_error err = heif_image_create(16, 16, heif_colorspace_RGB, heif_chroma_interleaved_RGB, &image);
+    REQUIRE(err.code == heif_error_Ok);
+    err = heif_image_add_plane(image, heif_channel_interleaved, 16, 16, 8);
+    REQUIRE(err.code == heif_error_Ok);
+
+    REQUIRE(try_encode(image).code == heif_error_Ok);
+
+    heif_image_release(image);
+  }
+}