Commit 0979e4a0 for libheif

commit 0979e4a0c83ca837c399feaa0f74d78eb0d4ed6f
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sun Sep 20 01:38:58 2026 +0200

    Split the GHSA-qfj5 regression test by symbol visibility

    The uncompressed_interleaved_alpha_plane test was registered for every build
    with the uncompressed codec, but its last test case drives the uncompressed
    encoder factory with a HeifPixelImage assembled through internal calls. With
    WITH_REDUCED_VISIBILITY=ON those symbols are not exported, so the test failed
    to link on AppVeyor (MSVC) and on any other reduced-visibility build.

    Move that test case into uncompressed_interleaved_alpha_plane_internal.cc,
    registered only with full symbol visibility like the other internal tests.
    The three public-API test cases, which are the primary regression coverage
    for the fix, stay in the original file and keep building everywhere.

diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index c1a22059..67160b76 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -77,6 +77,11 @@ endif()

 if (NOT WITH_REDUCED_VISIBILITY AND WITH_UNCOMPRESSED_CODEC)
     add_libheif_test(uncompressed_box)
+
+    # Companion of uncompressed_interleaved_alpha_plane below. It drives the
+    # uncompressed encoder factory with a HeifPixelImage assembled through
+    # internal calls, so it needs full symbol visibility.
+    add_libheif_test(uncompressed_interleaved_alpha_plane_internal)
 endif()

 # --- tests that only access the public API
diff --git a/tests/uncompressed_interleaved_alpha_plane.cc b/tests/uncompressed_interleaved_alpha_plane.cc
index 8e9315b4..ba373892 100644
--- a/tests/uncompressed_interleaved_alpha_plane.cc
+++ b/tests/uncompressed_interleaved_alpha_plane.cc
@@ -40,16 +40,17 @@
 // valid configuration. It is now rejected when the plane is added, and the
 // uncompressed encoder additionally refuses such an image should it be
 // assembled by another route.
+//
+// This file only uses the public API and builds in every configuration. The
+// encoder-side check needs library-internal classes and is tested in
+// uncompressed_interleaved_alpha_plane_internal.cc, which only builds with full
+// symbol visibility.

 #include "catch_amalgamated.hpp"
 #include "libheif/heif.h"
-#include "api_structs.h"
-#include "image/pixelimage.h"
-#include "codecs/uncompressed/unc_encoder.h"
 #include "test_utils.h"

 #include <cstring>
-#include <memory>

 namespace {

@@ -178,39 +179,3 @@ TEST_CASE("Planar RGB images still accept a separate alpha plane")
   heif_image_release(image);
 }

-
-// transfer_channel_from_image_as() moves a plane between images without going through
-// add_channel(). This is how a decoded alpha auxiliary image is attached to the main
-// image, so an interleaved image with a separate alpha plane can still be assembled
-// inside the library. The uncompressed encoder must refuse it instead of reading past
-// the end of the interleaved component list.
-TEST_CASE("Uncompressed encoder refuses an interleaved image carrying a separate alpha plane")
-{
-  const heif_security_limits* limits = heif_get_global_security_limits();
-
-  for (const auto& fmt : interleaved_formats) {
-    INFO(fmt.name);
-
-    auto image = std::make_shared<HeifPixelImage>();
-    image->create(WIDTH, HEIGHT, heif_colorspace_RGB, fmt.chroma);
-    REQUIRE(image->fill_new_channel(heif_channel_interleaved, 0x80, WIDTH, HEIGHT, fmt.bit_depth, limits).error_code == heif_error_Ok);
-
-    auto alpha = std::make_shared<HeifPixelImage>();
-    alpha->create(WIDTH, HEIGHT, heif_colorspace_monochrome, heif_chroma_monochrome);
-    REQUIRE(alpha->fill_new_channel(heif_channel_Y, 0xFF, WIDTH, HEIGHT, fmt.bit_depth, limits).error_code == heif_error_Ok);
-
-    REQUIRE(image->transfer_channel_from_image_as(alpha, heif_channel_Y, heif_channel_Alpha).error_code == heif_error_Ok);
-    REQUIRE(image->has_channel(heif_channel_Alpha));
-
-    // The encoder factory is where the interleaved encoders are instantiated.
-    heif_encoding_options* options = heif_encoding_options_alloc();
-    auto encoder = unc_encoder_factory::get_unc_encoder(image, *options);
-    heif_encoding_options_free(options);
-    CHECK(!encoder);
-
-    // Same through the public encode entry point.
-    heif_image wrapper;
-    wrapper.image = image;
-    CHECK(encode_uncompressed(&wrapper).code != heif_error_Ok);
-  }
-}
diff --git a/tests/uncompressed_interleaved_alpha_plane_internal.cc b/tests/uncompressed_interleaved_alpha_plane_internal.cc
new file mode 100644
index 00000000..a6732dab
--- /dev/null
+++ b/tests/uncompressed_interleaved_alpha_plane_internal.cc
@@ -0,0 +1,131 @@
+/*
+  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 GHSA-qfj5-c4pq-q998, internal-symbol part.
+//
+// The public-API part of this regression test lives in
+// uncompressed_interleaved_alpha_plane.cc. This file covers the defense-in-depth
+// check in the uncompressed encoder factory, which needs library-internal classes
+// (HeifPixelImage, unc_encoder_factory) and therefore only builds with full symbol
+// visibility (WITH_REDUCED_VISIBILITY=OFF).
+//
+// transfer_channel_from_image_as() moves a plane between images without going
+// through add_channel(). This is how a decoded alpha auxiliary image is attached to
+// the main image, so an interleaved image with a separate alpha plane can still be
+// assembled inside the library even though heif_image_add_plane() rejects it. The
+// uncompressed encoder must refuse such an image instead of reading past the end
+// of the interleaved component list.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "api_structs.h"
+#include "image/pixelimage.h"
+#include "codecs/uncompressed/unc_encoder.h"
+
+#include <memory>
+
+namespace {
+
+constexpr int WIDTH = 16;
+constexpr int HEIGHT = 16;
+
+struct InterleavedFormat
+{
+  heif_chroma chroma;
+  int bit_depth;
+  const char* name;
+};
+
+// The bit depths are chosen so that every interleaved encoder of the uncompressed
+// codec is covered: 8-bit RGB/RGBA use the pixel-interleave encoder, RRGGBB below
+// 14 bits the block-pixel encoder, and the remaining formats the byte-aligned one.
+const InterleavedFormat interleaved_formats[] = {
+    {heif_chroma_interleaved_RGB, 8, "RGB"},
+    {heif_chroma_interleaved_RGBA, 8, "RGBA"},
+    {heif_chroma_interleaved_RRGGBB_LE, 10, "RRGGBB_LE"},
+    {heif_chroma_interleaved_RRGGBB_BE, 16, "RRGGBB_BE"},
+    {heif_chroma_interleaved_RRGGBBAA_LE, 10, "RRGGBBAA_LE"},
+    {heif_chroma_interleaved_RRGGBBAA_BE, 16, "RRGGBBAA_BE"},
+};
+
+
+heif_error encode_uncompressed(heif_image* image)
+{
+  heif_context* ctx = heif_context_alloc();
+
+  heif_encoder* encoder = nullptr;
+  heif_error err = heif_context_get_encoder_for_format(ctx, heif_compression_uncompressed, &encoder);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_encoding_options* options = heif_encoding_options_alloc();
+  options->macOS_compatibility_workaround_no_nclx_profile = true;
+
+  heif_image_handle* handle = nullptr;
+  err = heif_context_encode_image(ctx, image, encoder, options, &handle);
+
+  if (handle) {
+    heif_image_handle_release(handle);
+  }
+  heif_encoding_options_free(options);
+  heif_encoder_release(encoder);
+  heif_context_free(ctx);
+
+  return err;
+}
+
+} // namespace
+
+
+TEST_CASE("Uncompressed encoder refuses an interleaved image carrying a separate alpha plane")
+{
+  const heif_security_limits* limits = heif_get_global_security_limits();
+
+  for (const auto& fmt : interleaved_formats) {
+    INFO(fmt.name);
+
+    auto image = std::make_shared<HeifPixelImage>();
+    image->create(WIDTH, HEIGHT, heif_colorspace_RGB, fmt.chroma);
+    REQUIRE(image->fill_new_channel(heif_channel_interleaved, 0x80, WIDTH, HEIGHT, fmt.bit_depth, limits).error_code == heif_error_Ok);
+
+    auto alpha = std::make_shared<HeifPixelImage>();
+    alpha->create(WIDTH, HEIGHT, heif_colorspace_monochrome, heif_chroma_monochrome);
+    REQUIRE(alpha->fill_new_channel(heif_channel_Y, 0xFF, WIDTH, HEIGHT, fmt.bit_depth, limits).error_code == heif_error_Ok);
+
+    REQUIRE(image->transfer_channel_from_image_as(alpha, heif_channel_Y, heif_channel_Alpha).error_code == heif_error_Ok);
+    REQUIRE(image->has_channel(heif_channel_Alpha));
+
+    // The encoder factory is where the interleaved encoders are instantiated.
+    heif_encoding_options* options = heif_encoding_options_alloc();
+    auto encoder = unc_encoder_factory::get_unc_encoder(image, *options);
+    heif_encoding_options_free(options);
+    CHECK(!encoder);
+
+    // Same through the public encode entry point.
+    heif_image wrapper;
+    wrapper.image = image;
+    CHECK(encode_uncompressed(&wrapper).code != heif_error_Ok);
+  }
+}