From 34b7f90a360ae5220b99719b5d72b185d2422b34 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 02:28:11 +0100 Subject: [PATCH 01/15] [NAT.11] Implement functional image codec core (win-x64, partial) Implements arc_image_open, arc_image_read and arc_image_close in native/arcimage-abi over OpenImageIO 3.1.14.0. Content is probed from the leading bytes (PNG, TIFF, EXR only); reads go through an arc_io_v1 proxy adapter; dimensions, pixel counts, tile memory and scanline bands are checked before any decode allocation; tile reads are capped; a 64-handle generation table backs the image handles; build info is a JSON capability manifest and the functional ABI minor is 1 (D2). Coverage (D3) is raster-order and non-overlapping; conversions and losses are reported in the metadata document (D4). Known gap, not resolved in this commit: OpenImageIO premultiplies 8-bit PNG and TIFF alpha as it reads, in 8-bit precision, and a reader configuration cannot be combined with a callback proxy in this build. Straight RGBA8 output of semi-transparent pixels is therefore inexact (worst delta 147 on the codec fixture). Two codec checks are expected to fail until the coordinator decides between direct libpng decode (native-libpng-r4 is admitted) and accepting the reported precision loss. Validated on win-x64-shim-static: library and test executables build with /W4 /WX; arcslate_image_abi.hello passes; arcslate_image_abi.codec runs 158 checks with 2 failures (round-trip byte exactness for PNG and TIFF with partial alpha). linux-x64 not run (WSL cmake/ninja missing). Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/native-abi.yml | 2 +- eng/provenance/files.json | 6 + native/arcimage-abi/CMakeLists.txt | 7 + native/arcimage-abi/README.md | 26 +- native/arcimage-abi/exports/linux.map | 3 + native/arcimage-abi/exports/macos.exports | 3 + native/arcimage-abi/exports/windows.def | 3 + .../arcimage-abi/src/arcslate_image_abi.cpp | 135 ++- native/arcimage-abi/src/image_callback_io.cpp | 141 +++ native/arcimage-abi/src/image_callback_io.hpp | 67 ++ native/arcimage-abi/src/image_coverage.hpp | 126 +++ native/arcimage-abi/src/image_session.cpp | 934 ++++++++++++++++++ native/arcimage-abi/src/image_session.hpp | 117 +++ .../tests/arcimage_codec_tests.cpp | 799 +++++++++++++++ .../tests/arcslate_image_abi_tests.cpp | 2 +- tests/NativeAbiTests/Oracle/NativeSmoke.cs | 8 +- tests/NativeAbiTests/SkeletonTests.cs | 4 +- 17 files changed, 2369 insertions(+), 14 deletions(-) create mode 100644 native/arcimage-abi/src/image_callback_io.cpp create mode 100644 native/arcimage-abi/src/image_callback_io.hpp create mode 100644 native/arcimage-abi/src/image_coverage.hpp create mode 100644 native/arcimage-abi/src/image_session.cpp create mode 100644 native/arcimage-abi/src/image_session.hpp create mode 100644 native/arcimage-abi/tests/arcimage_codec_tests.cpp diff --git a/.github/workflows/native-abi.yml b/.github/workflows/native-abi.yml index f5442d2c..087e9834 100644 --- a/.github/workflows/native-abi.yml +++ b/.github/workflows/native-abi.yml @@ -153,7 +153,7 @@ jobs: cmake --install artifacts/cmake/win-x64/%%p || exit /b 1 ) - ctest --test-dir artifacts/cmake/win-x64/shim-static --output-on-failure -R "^(arc_native_abi_c17_layout_tests|arc_native_abi_cpp20_tests)$" || exit /b 1 + ctest --test-dir artifacts/cmake/win-x64/shim-static --output-on-failure -R "^(arc_native_abi_c17_layout_tests|arc_native_abi_cpp20_tests|arcslate_image_abi\.hello|arcslate_image_abi\.codec)$" || exit /b 1 dotnet test --project tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj --filter Category=NativeAbiLayout --no-restore || exit /b 1 sccache --show-stats || exit /b 1 diff --git a/eng/provenance/files.json b/eng/provenance/files.json index dbda9b0b..11789c74 100644 --- a/eng/provenance/files.json +++ b/eng/provenance/files.json @@ -337,6 +337,12 @@ "native/arcimage-abi/generated/README.md", "native/arcimage-abi/include/arc/arc_slate_image_abi.h", "native/arcimage-abi/src/arcslate_image_abi.cpp", + "native/arcimage-abi/src/image_callback_io.cpp", + "native/arcimage-abi/src/image_callback_io.hpp", + "native/arcimage-abi/src/image_coverage.hpp", + "native/arcimage-abi/src/image_session.cpp", + "native/arcimage-abi/src/image_session.hpp", + "native/arcimage-abi/tests/arcimage_codec_tests.cpp", "native/arcimage-abi/tests/arcslate_image_abi_tests.cpp", "native/arcimage-abi/windows/arcslate_image_abi.vcxproj", "native/shared/fuzz/arc_native_abi_fuzzer.cpp", diff --git a/native/arcimage-abi/CMakeLists.txt b/native/arcimage-abi/CMakeLists.txt index 2af2a384..28cee535 100644 --- a/native/arcimage-abi/CMakeLists.txt +++ b/native/arcimage-abi/CMakeLists.txt @@ -48,4 +48,11 @@ if(ARCFORGES_BUILD_TESTS) target_compile_features(arcslate_image_abi_tests PRIVATE cxx_std_20) target_link_libraries(arcslate_image_abi_tests PRIVATE arcslate_image_abi) add_test(NAME arcslate_image_abi.hello COMMAND arcslate_image_abi_tests) + + add_executable(arcslate_image_codec_tests "tests/arcimage_codec_tests.cpp") + arcforges_declare_target_licence(arcslate_image_codec_tests) + set_target_properties(arcslate_image_codec_tests PROPERTIES ARCFORGES_TEST_ONLY TRUE) + target_compile_features(arcslate_image_codec_tests PRIVATE cxx_std_20) + target_link_libraries(arcslate_image_codec_tests PRIVATE arcslate_image_abi OpenImageIO::OpenImageIO) + add_test(NAME arcslate_image_abi.codec COMMAND arcslate_image_codec_tests) endif() diff --git a/native/arcimage-abi/README.md b/native/arcimage-abi/README.md index ce88ff72..4de4571a 100644 --- a/native/arcimage-abi/README.md +++ b/native/arcimage-abi/README.md @@ -1,4 +1,26 @@ # ArcImageNative -Hello-world C++20 implementation behind an owned C17 ABI. No upstream C++ type, allocator, exception, or -pointer ownership crosses this boundary. Capability implementation and per-RID package admission replace the hello exports with real adapters. +C++20 implementation behind the owned C17 ABI. No upstream C++ type, allocator, exception or pointer +ownership crosses the boundary. The library reports ABI 1.1 (functional minor, decision D2) and exports +`arc_image_open`, `arc_image_read` and `arc_image_close` over OpenImageIO 3.1.14.0 with OpenEXR and Imath. + +- Formats: PNG, TIFF and EXR, selected by content probing of the first bytes (decision D10). Every other + content is `ARC_UNSUPPORTED`. Opening never resolves a path: all bytes come from the caller's `arc_io_v1` + `read_at` callback through an OpenImageIO `IOProxy` adapter. +- Bounds: dimensions, pixel count, channel count, tile memory and scanline memory are checked before any + decode allocation. Region output is capped at 64 MiB, and at most 64 handles are open per library. +- Coverage: a handle accepts regions only in raster order without overlap (decision D3). A refused region + changes no state. +- Conversion: `rgba8` (straight alpha, no transfer change), `rgba32fLinearPremultiplied` (sRGB to linear + only when OpenImageIO reports sRGB, premultiply only for unassociated alpha) and `float32Interleaved` + (source channels kept). Every lossy step is listed in the metadata `loss` array (decision D4). +- Cancellation and the per-call timeout are observed at callback and tile boundaries. An in-flight codec + call is not interruptible. +- Build information is a closed JSON object that lists the capabilities and formats and embeds the build + identity. +- Metadata is a closed JSON document (`arc.image.metadata.v1`) returned by `arc_image_open`. + +Probe, build and error exports keep their shipped signatures. The shim's own tests are +`arcslate_image_abi_tests` (probe) and `arcslate_image_codec_tests` (functional). The codec tests generate +their fixtures in memory and ship none (decision D1). Production containment of untrusted decode is the +WP11 helper (NAT.31), not this library. diff --git a/native/arcimage-abi/exports/linux.map b/native/arcimage-abi/exports/linux.map index e6ddf504..0f58bc0f 100644 --- a/native/arcimage-abi/exports/linux.map +++ b/native/arcimage-abi/exports/linux.map @@ -4,5 +4,8 @@ ARCFORGES_1.0 { arc_image_get_abi_version; arc_image_get_build_info; arc_image_get_last_error; + arc_image_open; + arc_image_read; + arc_image_close; local: *; }; diff --git a/native/arcimage-abi/exports/macos.exports b/native/arcimage-abi/exports/macos.exports index 1ec218cb..77904ac9 100644 --- a/native/arcimage-abi/exports/macos.exports +++ b/native/arcimage-abi/exports/macos.exports @@ -2,3 +2,6 @@ _arc_image_get_abi_version _arc_image_get_build_info _arc_image_get_last_error +_arc_image_open +_arc_image_read +_arc_image_close diff --git a/native/arcimage-abi/exports/windows.def b/native/arcimage-abi/exports/windows.def index 4501ae95..817242e6 100644 --- a/native/arcimage-abi/exports/windows.def +++ b/native/arcimage-abi/exports/windows.def @@ -4,3 +4,6 @@ EXPORTS arc_image_get_abi_version arc_image_get_build_info arc_image_get_last_error + arc_image_open + arc_image_read + arc_image_close diff --git a/native/arcimage-abi/src/arcslate_image_abi.cpp b/native/arcimage-abi/src/arcslate_image_abi.cpp index eb63ef6a..99f65009 100644 --- a/native/arcimage-abi/src/arcslate_image_abi.cpp +++ b/native/arcimage-abi/src/arcslate_image_abi.cpp @@ -1,32 +1,94 @@ // SPDX-License-Identifier: AGPL-3.0-only #include +#include "arc_build_identity.hpp" #include "arc_native_abi_internal.hpp" +#include "image_session.hpp" #include +#include +#include +#include +#include +#include + #ifndef ARCFORGES_SHIM_STATIC_GRAPH #error ArcImageNative must be built from the shim-static dependency graph. #endif namespace { -constexpr uint32_t image_domain = 0x494D4147U; + +using arc::image::image_error_domain; +using arc::image::image_handle_kind; +using arc::image::image_session; + +// Image handles live in one table per library context, limited to Annex 06 section 2 defaults. +arc::abi::handle_table& image_handles() +{ + static arc::abi::handle_table table(arc::image::max_open_images); + return table; } +arc_status_t write_text(std::string_view text, arc_mut_buffer_t* output) +{ + const arc_status_t prepared = arc::abi::prepare_buffer_output(output, static_cast(text.size())); + if (prepared != ARC_OK) { + return prepared; + } + if (!text.empty()) { + std::memcpy(output->data, text.data(), text.size()); + } + return ARC_OK; +} + +// Build information is a closed JSON object. The identity suffix is embedded as a field, without its +// leading separator, so the document stays valid JSON. +std::string build_info_json() +{ + const std::string_view identity(arc_build_identity); + const std::string_view identity_fields = identity.empty() || identity.front() != ';' ? identity : identity.substr(1); + std::string json; + json.reserve(512); + json += "{\"library\":\"ArcImageNative\",\"abi\":{\"major\":1,\"minor\":1},"; + json += "\"openimageio\":\"3.1.14.0\","; + json += "\"capabilities\":[\"image.open\",\"image.read\",\"image.close\"],"; + json += "\"formats\":[\"png\",\"tiff\",\"exr\"],"; + json += "\"identity\":\""; + for (const char ch : identity_fields) { + if (ch == '"' || ch == '\\') { + json.push_back('\\'); + } + json.push_back(ch); + } + json += "\"}"; + return json; +} + +} // namespace + arc_status_t ARC_ABI_CALL arc_image_get_abi_version(uint32_t* out_major, uint32_t* out_minor) { - return arc::abi::get_abi_version(out_major, out_minor); + if (out_major == nullptr || out_minor == nullptr) { + return arc::abi::fail(ARC_INVALID_ARGUMENT, "ABI version outputs are required", image_error_domain); + } + *out_major = ARC_NATIVE_ABI_MAJOR; + *out_minor = ARC_NATIVE_FUNCTIONAL_ABI_MINOR; + return ARC_OK; } arc_status_t ARC_ABI_CALL arc_image_get_build_info(arc_mut_buffer_t* out_utf8) { try { if (OIIO::get_string_attribute("format_list").empty()) { - return arc::abi::fail(ARC_INTERNAL, "OpenImageIO format probe failed", image_domain); + return arc::abi::fail(ARC_INTERNAL, "OpenImageIO format probe failed", image_error_domain); } - return arc::abi::write_build_info("ArcImageNative;abi=1.0;openimageio=3.1.14.0", out_utf8); + const std::string json = build_info_json(); + return write_text(json, out_utf8); + } catch (const std::bad_alloc&) { + return arc::abi::fail(ARC_OUT_OF_MEMORY, "Build information allocation failed", image_error_domain); } catch (...) { - return arc::abi::fail(ARC_INTERNAL, "OpenImageIO build probe failed", image_domain); + return arc::abi::fail(ARC_INTERNAL, "OpenImageIO build probe failed", image_error_domain); } } @@ -34,3 +96,66 @@ arc_status_t ARC_ABI_CALL arc_image_get_last_error(arc_error_info_t* out_error) { return arc::abi::get_last_error(out_error); } + +arc_status_t ARC_ABI_CALL arc_image_open(const arc_io_v1* io, const arc_image_options_v1* options, + arc_handle_t* image, arc_mut_buffer_t* metadata, + const arc_cancel_token_t* cancel) +{ + try { + const arc_status_t prepared = arc::abi::prepare_handle_output(image); + if (prepared != ARC_OK) { + return prepared; + } + if (io == nullptr || options == nullptr || metadata == nullptr) { + return arc::abi::fail(ARC_INVALID_ARGUMENT, "Image open arguments are required", image_error_domain); + } + + std::unique_ptr session; + const arc_status_t opened = image_session::open(*io, *options, cancel, session); + if (opened != ARC_OK) { + return opened; + } + // The metadata is copied before the handle exists, so a short buffer consumes no state. + const arc_status_t written = write_text(session->metadata(), metadata); + if (written != ARC_OK) { + return written; + } + std::shared_ptr shared(std::move(session)); + return image_handles().create(image_handle_kind, shared, image); + } catch (const std::bad_alloc&) { + return arc::abi::fail(ARC_OUT_OF_MEMORY, "Image open allocation failed", image_error_domain); + } catch (...) { + return arc::abi::fail(ARC_INTERNAL, "Image open failed", image_error_domain); + } +} + +arc_status_t ARC_ABI_CALL arc_image_read(arc_handle_t image, const arc_region_v1* region, arc_mut_buffer_t* pixels, + const arc_cancel_token_t* cancel) +{ + try { + if (region == nullptr || pixels == nullptr) { + return arc::abi::fail(ARC_INVALID_ARGUMENT, "Image read arguments are required", image_error_domain); + } + arc::abi::handle_lease lease; + const arc_status_t acquired = image_handles().acquire(image, image_handle_kind, &lease); + if (acquired != ARC_OK) { + return acquired; + } + return lease.as()->read(*region, pixels, cancel); + } catch (const std::bad_alloc&) { + return arc::abi::fail(ARC_OUT_OF_MEMORY, "Image read allocation failed", image_error_domain); + } catch (...) { + return arc::abi::fail(ARC_INTERNAL, "Image read failed", image_error_domain); + } +} + +arc_status_t ARC_ABI_CALL arc_image_close(arc_handle_t image) +{ + try { + return image_handles().close(image, image_handle_kind); + } catch (const std::bad_alloc&) { + return arc::abi::fail(ARC_OUT_OF_MEMORY, "Image close allocation failed", image_error_domain); + } catch (...) { + return arc::abi::fail(ARC_INTERNAL, "Image close failed", image_error_domain); + } +} diff --git a/native/arcimage-abi/src/image_callback_io.cpp b/native/arcimage-abi/src/image_callback_io.cpp new file mode 100644 index 00000000..1ea2091f --- /dev/null +++ b/native/arcimage-abi/src/image_callback_io.cpp @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: AGPL-3.0-only +#include "image_callback_io.hpp" + +#include "arc_native_abi_internal.hpp" + +#include + +namespace arc::image { +namespace { + +// Callback reads are bounded so that cancellation and deadlines are observed at least once per MiB. +constexpr uint64_t max_callback_chunk = UINT64_C(1) << 20; +static_assert(sizeof(size_t) == sizeof(uint64_t), "ArcImageNative is a 64-bit only profile"); + +} // namespace + +callback_io::callback_io(std::string hint, const arc_io_v1& io) + : OIIO::Filesystem::IOProxy(hint, OIIO::Filesystem::IOProxy::Read), + read_at_(io.read_at), + context_(io.context), + length_(io.length) +{ +} + +const char* callback_io::proxytype() const +{ + return "arc-io-v1"; +} + +size_t callback_io::read(void* buf, size_t size) +{ + const int64_t position = m_pos; + const size_t count = pread(buf, size, position); + m_pos = position + static_cast(count); + return count; +} + +size_t callback_io::pread(void* buf, size_t size, int64_t offset) +{ + std::lock_guard lock(mutex_); + if (offset < 0 || (buf == nullptr && size != 0)) { + latch(ARC_INVALID_ARGUMENT); + return 0; + } + if (size == 0) { + return 0; + } + if (check_call_state() != ARC_OK) { + return 0; + } + + const auto start = static_cast(offset); + if (start >= length_) { + return 0; + } + + const uint64_t wanted = std::min(static_cast(size), length_ - start); + auto* const out = static_cast(buf); + uint64_t total = 0; + while (total < wanted) { + if (total != 0 && check_call_state() != ARC_OK) { + return static_cast(total); + } + const uint64_t chunk = std::min(wanted - total, max_callback_chunk); + uint64_t done = 0; + const arc_status_t status = read_at_(context_, start + total, out + total, chunk, &done); + if (status != ARC_OK) { + latch(status); + return static_cast(total); + } + if (done > chunk) { + latch(ARC_CORRUPT); + return static_cast(total); + } + total += done; + if (done < chunk) { + // A short read is only valid at the end of the source; anywhere else it is an I/O failure. + if (start + total != length_) { + latch(ARC_IO); + } + break; + } + } + return static_cast(total); +} + +bool callback_io::seek(int64_t offset) +{ + if (offset < 0) { + return false; + } + m_pos = offset; + return true; +} + +size_t callback_io::size() const +{ + return static_cast(length_); +} + +void callback_io::begin_call(const arc_cancel_token_t* cancel, std::chrono::steady_clock::time_point deadline, + bool has_deadline) noexcept +{ + std::lock_guard lock(mutex_); + cancel_ = cancel; + deadline_ = deadline; + has_deadline_ = has_deadline; + latched_ = ARC_OK; +} + +arc_status_t callback_io::latched_status() const noexcept +{ + std::lock_guard lock(mutex_); + return latched_; +} + +void callback_io::latch(arc_status_t status) noexcept +{ + if (latched_ == ARC_OK) { + latched_ = status; + } +} + +arc_status_t callback_io::check_call_state() noexcept +{ + if (latched_ != ARC_OK) { + return latched_; + } + const arc_status_t cancelled = arc::abi::check_cancelled(cancel_); + if (cancelled != ARC_OK) { + latch(cancelled); + return cancelled; + } + if (has_deadline_ && std::chrono::steady_clock::now() > deadline_) { + latch(ARC_RESOURCE_LIMIT); + return ARC_RESOURCE_LIMIT; + } + return ARC_OK; +} + +} // namespace arc::image diff --git a/native/arcimage-abi/src/image_callback_io.hpp b/native/arcimage-abi/src/image_callback_io.hpp new file mode 100644 index 00000000..dc3659d5 --- /dev/null +++ b/native/arcimage-abi/src/image_callback_io.hpp @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: AGPL-3.0-only +#ifndef ARC_IMAGE_CALLBACK_IO_HPP +#define ARC_IMAGE_CALLBACK_IO_HPP + +#include + +#include + +#include +#include +#include +#include +#include + +namespace arc::image { + +// Presents an arc_io_v1 record (caller-brokered, bounded bytes) to OpenImageIO as a read-only +// IOProxy. The adapter never opens or resolves a path: every byte comes from io.read_at. +// Callback failures are latched and mapped back to an ARC status after the codec returns, +// because OpenImageIO only sees a short read. Cancellation and the per-call deadline are checked +// before every callback, so they are observed at callback boundaries (decision D9). +class callback_io final : public OIIO::Filesystem::IOProxy { + public: + // The record is copied. Its context must stay valid until the owning image handle closes. + callback_io(std::string hint, const arc_io_v1& io); + ~callback_io() override = default; + callback_io(const callback_io&) = delete; + callback_io& operator=(const callback_io&) = delete; + callback_io(callback_io&&) = delete; + callback_io& operator=(callback_io&&) = delete; + + const char* proxytype() const override; + size_t read(void* buf, size_t size) override; + size_t pread(void* buf, size_t size, int64_t offset) override; + bool seek(int64_t offset) override; + size_t size() const override; + + // Starts one ABI call: binds its cancellation token and optional deadline and clears the + // latched failure. Call it with a null token and has_deadline false when the call returns. + void begin_call(const arc_cancel_token_t* cancel, std::chrono::steady_clock::time_point deadline, + bool has_deadline) noexcept; + + // First latched failure during the current call, or ARC_OK. + [[nodiscard]] arc_status_t latched_status() const noexcept; + + [[nodiscard]] uint64_t length() const noexcept + { + return length_; + } + + private: + void latch(arc_status_t status) noexcept; + arc_status_t check_call_state() noexcept; + + arc_read_at_fn read_at_ = nullptr; + void* context_ = nullptr; + uint64_t length_ = 0; + const arc_cancel_token_t* cancel_ = nullptr; + std::chrono::steady_clock::time_point deadline_{}; + bool has_deadline_ = false; + arc_status_t latched_ = ARC_OK; + mutable std::mutex mutex_; +}; + +} // namespace arc::image + +#endif diff --git a/native/arcimage-abi/src/image_coverage.hpp b/native/arcimage-abi/src/image_coverage.hpp new file mode 100644 index 00000000..047ba5aa --- /dev/null +++ b/native/arcimage-abi/src/image_coverage.hpp @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: AGPL-3.0-only +#ifndef ARC_IMAGE_COVERAGE_HPP +#define ARC_IMAGE_COVERAGE_HPP + +#include + +#include +#include +#include + +namespace arc::image { + +// Enforces decision D3 for one image handle. Regions must cover every pixel exactly once in +// raster order: a region is accepted only when its top-left pixel is the first pixel in raster +// order that is not yet covered, and when it overlaps no covered pixel. A refused check leaves +// the tracker unchanged. Storage is one sorted span list per pending row, bounded by +// max_intervals; rows that are fully covered are dropped as the cursor advances. +class coverage_tracker final { + public: + coverage_tracker(uint32_t width, uint32_t height, uint64_t max_intervals) noexcept + : width_(width), height_(height), max_intervals_(max_intervals) + { + } + + // ARC_OK when the region may be committed. ARC_INVALID_ARGUMENT for an out-of-bounds, + // overlapping or out-of-raster-order region. ARC_RESOURCE_LIMIT when the bounded storage + // would be exceeded. Never changes state. + [[nodiscard]] arc_status_t check(uint32_t x, uint32_t y, uint32_t w, uint32_t h) const + { + if (w == 0 || h == 0 || x > width_ || w > width_ - x || y > height_ || h > height_ - y) { + return ARC_INVALID_ARGUMENT; + } + if (first_row_ >= height_ || y != first_row_ || x != cursor_column()) { + return ARC_INVALID_ARGUMENT; + } + const uint64_t x_end = static_cast(x) + w; + const size_t rows_to_check = std::min(rows_.size(), h); + for (size_t row = 0; row < rows_to_check; ++row) { + for (const span& covered : rows_[row]) { + if (covered.begin < x_end && x < covered.end) { + return ARC_INVALID_ARGUMENT; + } + } + } + if (interval_count_ + h > max_intervals_) { + return ARC_RESOURCE_LIMIT; + } + return ARC_OK; + } + + // Commits a region that check() accepted for the same arguments. + void commit(uint32_t x, uint32_t y, uint32_t w, uint32_t h) + { + (void)y; + if (rows_.size() < h) { + rows_.resize(h); + } + const uint64_t x_end = static_cast(x) + w; + for (uint32_t row = 0; row < h; ++row) { + insert_span(rows_[row], x, static_cast(x_end)); + } + while (!rows_.empty() && is_full(rows_.front())) { + interval_count_ -= rows_.front().size(); + rows_.erase(rows_.begin()); + ++first_row_; + } + } + + [[nodiscard]] bool complete() const noexcept + { + return first_row_ >= height_; + } + + private: + struct span final { + uint32_t begin; + uint32_t end; + }; + + // The covered prefix of the first pending row, which is where the next region must start. + [[nodiscard]] uint32_t cursor_column() const noexcept + { + if (rows_.empty() || rows_.front().empty() || rows_.front().front().begin != 0) { + return 0; + } + return rows_.front().front().end; + } + + [[nodiscard]] bool is_full(const std::vector& row) const noexcept + { + return row.size() == 1 && row.front().begin == 0 && row.front().end == width_; + } + + void insert_span(std::vector& row, uint32_t begin, uint32_t end) + { + const size_t before = row.size(); + auto first = std::lower_bound(row.begin(), row.end(), begin, + [](const span& existing, uint32_t value) { return existing.end < value; }); + auto last = first; + uint32_t merged_begin = begin; + uint32_t merged_end = end; + while (last != row.end() && last->begin <= merged_end) { + merged_begin = std::min(merged_begin, last->begin); + merged_end = std::max(merged_end, last->end); + ++last; + } + const auto position = row.erase(first, last); + row.insert(position, span{merged_begin, merged_end}); + if (row.size() >= before) { + interval_count_ += row.size() - before; + } else { + interval_count_ -= before - row.size(); + } + } + + uint32_t width_; + uint32_t height_; + uint64_t max_intervals_; + uint32_t first_row_ = 0; + uint64_t interval_count_ = 0; + std::vector> rows_; +}; + +} // namespace arc::image + +#endif diff --git a/native/arcimage-abi/src/image_session.cpp b/native/arcimage-abi/src/image_session.cpp new file mode 100644 index 00000000..16fd8f46 --- /dev/null +++ b/native/arcimage-abi/src/image_session.cpp @@ -0,0 +1,934 @@ +// SPDX-License-Identifier: AGPL-3.0-only +#include "image_session.hpp" + +#include "arc_native_abi_internal.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace arc::image { +namespace { + +// Source kinds accepted after content probing (decision D10). +enum class source_kind : uint32_t { none = 0, png = 1, tiff = 2, exr = 3 }; + +constexpr size_t probe_bytes = 16; +constexpr int64_t origin_limit = INT64_C(1) << 30; +constexpr char hex_digits[] = "0123456789ABCDEF"; + +arc_status_t fail_image(arc_status_t status, const char* message) +{ + return arc::abi::fail(status, message, image_error_domain); +} + +// Identifies the container from its leading bytes only. Anything outside the PNG, TIFF and EXR +// signatures yields none, so the declared file name never selects a codec. +source_kind detect_source(const unsigned char* magic, size_t count) noexcept +{ + static constexpr unsigned char png_signature[8] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; + if (count >= sizeof(png_signature) && std::memcmp(magic, png_signature, sizeof(png_signature)) == 0) { + return source_kind::png; + } + if (count >= 4) { + const bool little_endian_tiff = magic[0] == 'I' && magic[1] == 'I' && + ((magic[2] == 0x2A && magic[3] == 0) || (magic[2] == 0x2B && magic[3] == 0)); + const bool big_endian_tiff = magic[0] == 'M' && magic[1] == 'M' && + ((magic[2] == 0 && magic[3] == 0x2A) || (magic[2] == 0 && magic[3] == 0x2B)); + if (little_endian_tiff || big_endian_tiff) { + return source_kind::tiff; + } + if (magic[0] == 0x76 && magic[1] == 0x2F && magic[2] == 0x31 && magic[3] == 0x01) { + return source_kind::exr; + } + } + return source_kind::none; +} + +// File-name hints only select the OpenImageIO plugin. The bytes are read through the callback. +const char* hint_name(source_kind source) +{ + switch (source) { + case source_kind::png: + return "arc-image.png"; + case source_kind::tiff: + return "arc-image.tif"; + default: + return "arc-image.exr"; + } +} + +const char* expected_format_name(source_kind source) +{ + switch (source) { + case source_kind::png: + return "png"; + case source_kind::tiff: + return "tiff"; + default: + return "openexr"; + } +} + +const char* metadata_format_name(source_kind source) +{ + switch (source) { + case source_kind::png: + return "png"; + case source_kind::tiff: + return "tiff"; + default: + return "exr"; + } +} + +bool supported_base_type(const OIIO::TypeDesc& type) +{ + return type == OIIO::TypeDesc::UINT8 || type == OIIO::TypeDesc::INT8 || type == OIIO::TypeDesc::UINT16 || + type == OIIO::TypeDesc::INT16 || type == OIIO::TypeDesc::UINT32 || type == OIIO::TypeDesc::INT32 || + type == OIIO::TypeDesc::HALF || type == OIIO::TypeDesc::FLOAT || type == OIIO::TypeDesc::DOUBLE; +} + +const char* type_name(const OIIO::TypeDesc& type) +{ + if (type == OIIO::TypeDesc::UINT8) { + return "uint8"; + } + if (type == OIIO::TypeDesc::INT8) { + return "int8"; + } + if (type == OIIO::TypeDesc::UINT16) { + return "uint16"; + } + if (type == OIIO::TypeDesc::INT16) { + return "int16"; + } + if (type == OIIO::TypeDesc::UINT32) { + return "uint32"; + } + if (type == OIIO::TypeDesc::INT32) { + return "int32"; + } + if (type == OIIO::TypeDesc::HALF) { + return "half"; + } + if (type == OIIO::TypeDesc::DOUBLE) { + return "double"; + } + return "float"; +} + +uint32_t type_bits(const OIIO::TypeDesc& type) +{ + if (type == OIIO::TypeDesc::UINT8 || type == OIIO::TypeDesc::INT8) { + return 8; + } + if (type == OIIO::TypeDesc::UINT16 || type == OIIO::TypeDesc::INT16 || type == OIIO::TypeDesc::HALF) { + return 16; + } + if (type == OIIO::TypeDesc::DOUBLE) { + return 64; + } + return 32; +} + +bool is_floating_type(const OIIO::TypeDesc& type) +{ + return type == OIIO::TypeDesc::HALF || type == OIIO::TypeDesc::FLOAT || type == OIIO::TypeDesc::DOUBLE; +} + +// Wide integers and doubles cannot be held exactly in the float working type. +bool loses_precision_as_float(const OIIO::TypeDesc& type) +{ + return type == OIIO::TypeDesc::UINT32 || type == OIIO::TypeDesc::INT32 || type == OIIO::TypeDesc::DOUBLE; +} + +bool multiply_u64(uint64_t left, uint64_t right, uint64_t& product) noexcept +{ + if (left != 0 && right > UINT64_MAX / left) { + return false; + } + product = left * right; + return true; +} + +bool printable_ascii_name(const std::string& name) +{ + if (name.empty() || name.size() > max_channel_name_bytes) { + return false; + } + return std::all_of(name.begin(), name.end(), [](char ch) { + const auto value = static_cast(ch); + return value >= 0x20 && value <= 0x7E; + }); +} + +bool equals_ignore_case(std::string_view left, std::string_view right) noexcept +{ + if (left.size() != right.size()) { + return false; + } + for (size_t index = 0; index < left.size(); ++index) { + if (std::tolower(static_cast(left[index])) != + std::tolower(static_cast(right[index]))) { + return false; + } + } + return true; +} + +void append_json_string(std::string& out, std::string_view text) +{ + out.push_back('"'); + for (const char raw : text) { + const auto ch = static_cast(raw); + if (ch == '"' || ch == '\\') { + out.push_back('\\'); + out.push_back(static_cast(ch)); + } else if (ch < 0x20 || ch >= 0x7F) { + out += "\\u00"; + out.push_back(hex_digits[(ch >> 4U) & 0x0FU]); + out.push_back(hex_digits[ch & 0x0FU]); + } else { + out.push_back(static_cast(ch)); + } + } + out.push_back('"'); +} + +void append_string_array(std::string& out, const std::vector& values) +{ + out.push_back('['); + for (size_t index = 0; index < values.size(); ++index) { + if (index != 0) { + out.push_back(','); + } + append_json_string(out, values[index]); + } + out.push_back(']'); +} + +float srgb_to_linear(float encoded) noexcept +{ + if (encoded <= 0.04045F) { + return encoded / 12.92F; + } + return std::pow((encoded + 0.055F) / 1.055F, 2.4F); +} + +} // namespace + +// Binds the per-call cancellation token and deadline to the session and to the callback adapter, and +// clears them when the call ends. +struct image_session::call_scope final { + call_scope(image_session& session, const arc_cancel_token_t* cancel, uint32_t timeout_ms) : session_(session) + { + session_.cancel_ = cancel; + session_.has_deadline_ = timeout_ms != 0; + session_.deadline_ = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + if (session_.proxy_) { + session_.proxy_->begin_call(cancel, session_.deadline_, session_.has_deadline_); + } + } + + ~call_scope() + { + session_.cancel_ = nullptr; + session_.has_deadline_ = false; + if (session_.proxy_) { + session_.proxy_->begin_call(nullptr, std::chrono::steady_clock::time_point{}, false); + } + } + + call_scope(const call_scope&) = delete; + call_scope& operator=(const call_scope&) = delete; + + private: + image_session& session_; +}; + +image_session::image_session() = default; + +image_session::~image_session() = default; + +arc_status_t image_session::open(const arc_io_v1& io, const arc_image_options_v1& options, + const arc_cancel_token_t* cancel, std::unique_ptr& output) +{ + output.reset(); + + arc_status_t status = arc::abi::validate_record(&io, sizeof(arc_io_v1)); + if (status != ARC_OK) { + return status; + } + status = arc::abi::validate_record(&options, sizeof(arc_image_options_v1)); + if (status != ARC_OK) { + return status; + } + status = arc::abi::validate_record(&options.limits, sizeof(arc_limits_v1)); + if (status != ARC_OK) { + return status; + } + + if (io.read_at == nullptr) { + return fail_image(ARC_INVALID_ARGUMENT, "Image input read callback is required"); + } + if (io.length > io.max_length) { + return fail_image(ARC_INVALID_ARGUMENT, "Image input length exceeds its declared maximum"); + } + if (options.reserved != 0) { + return fail_image(ARC_INVALID_ARGUMENT, "Image options reserved field must be zero"); + } + if (options.format != ARC_FORMAT_RGBA8 && options.format != ARC_FORMAT_RGBA32F_LINEAR_PREMULTIPLIED && + options.format != ARC_FORMAT_FLOAT32_INTERLEAVED) { + return fail_image(ARC_INVALID_ARGUMENT, "Image output format key is not known"); + } + + const arc_limits_v1& limits = options.limits; + const bool positive = limits.max_input_bytes != 0 && limits.max_memory_bytes != 0 && + limits.max_output_bytes != 0 && limits.max_width != 0 && limits.max_height != 0 && + limits.max_items != 0 && limits.timeout_ms != 0; + const bool within_profile = limits.max_input_bytes <= hard_max_input_bytes && + limits.max_memory_bytes <= hard_max_memory_bytes && + limits.max_output_bytes <= hard_max_region_bytes && + limits.max_width <= hard_max_dimension && limits.max_height <= hard_max_dimension && + limits.max_items <= hard_max_items; + if (!positive || !within_profile) { + return fail_image(ARC_INVALID_ARGUMENT, "Image limits are missing or exceed the producer profile"); + } + if (io.length > limits.max_input_bytes) { + return fail_image(ARC_RESOURCE_LIMIT, "Image input exceeds the input byte limit"); + } + + std::unique_ptr session(new image_session()); + session->limits_ = limits; + session->format_ = options.format; + session->proxy_ = std::make_unique("arc-image", io); + + { + call_scope scope(*session, cancel, limits.timeout_ms); + + // Content probing: the bytes choose the codec, never the declared name. + unsigned char magic[probe_bytes] = {}; + const auto probe_count = static_cast(std::min(probe_bytes, io.length)); + const size_t magic_read = session->proxy_->pread(magic, probe_count, 0); + if (session->proxy_->latched_status() != ARC_OK) { + return fail_image(session->proxy_->latched_status(), "Image input could not be read"); + } + const source_kind source = detect_source(magic, magic_read); + if (source == source_kind::none) { + return fail_image(ARC_UNSUPPORTED, "Image content is not a supported PNG, TIFF or EXR file"); + } + + // OpenImageIO associates alpha as it reads (its default). A reader configuration cannot be passed + // together with a callback proxy in this build, so the association is handled by the conversion plan. + std::unique_ptr input = + OIIO::ImageInput::open(hint_name(source), nullptr, session->proxy_.get()); + if (!input) { + if (session->proxy_->latched_status() != ARC_OK) { + return fail_image(session->proxy_->latched_status(), "Image input could not be read"); + } + return fail_image(ARC_CORRUPT, "Image header could not be parsed"); + } + if (std::string_view(input->format_name()) != expected_format_name(source)) { + return fail_image(ARC_CORRUPT, "Image header disagrees with the detected format"); + } + session->input_ = std::move(input); + + // Subimage and mip counts are bounded by max_items before any level is selected. + uint32_t subimage_count = 0; + for (;;) { + const OIIO::ImageSpec dimensions = session->input_->spec_dimensions(static_cast(subimage_count), 0); + if (dimensions.format == OIIO::TypeUnknown) { + break; + } + ++subimage_count; + if (subimage_count > limits.max_items) { + return fail_image(ARC_RESOURCE_LIMIT, "Image subimage count exceeds the item limit"); + } + } + if (subimage_count == 0) { + return fail_image(ARC_CORRUPT, "Image has no readable subimage"); + } + if (options.subimage >= subimage_count) { + return fail_image(ARC_NOT_FOUND, "Image subimage does not exist"); + } + + uint32_t mip_count = 0; + for (;;) { + const OIIO::ImageSpec dimensions = session->input_->spec_dimensions( + static_cast(options.subimage), static_cast(mip_count)); + if (dimensions.format == OIIO::TypeUnknown) { + break; + } + ++mip_count; + if (mip_count > limits.max_items) { + return fail_image(ARC_RESOURCE_LIMIT, "Image mip count exceeds the item limit"); + } + } + if (options.mip >= mip_count) { + return fail_image(ARC_NOT_FOUND, "Image mip level does not exist"); + } + + if (!session->input_->seek_subimage(static_cast(options.subimage), static_cast(options.mip))) { + return fail_image(ARC_CORRUPT, "Image subimage could not be selected"); + } + const OIIO::ImageSpec spec = + session->input_->spec(static_cast(options.subimage), static_cast(options.mip)); + if (spec.format == OIIO::TypeUnknown) { + return fail_image(ARC_CORRUPT, "Image level has no pixel format"); + } + if (spec.depth != 1) { + return fail_image(ARC_UNSUPPORTED, "Image volumes and deep images are not supported"); + } + if (spec.width <= 0 || spec.height <= 0 || spec.nchannels <= 0) { + return fail_image(ARC_CORRUPT, "Image dimensions or channel count are invalid"); + } + if (spec.x < -origin_limit || spec.x > origin_limit || spec.y < -origin_limit || spec.y > origin_limit) { + return fail_image(ARC_UNSUPPORTED, "Image origin is outside the supported range"); + } + + // Dimension and pixel-count arithmetic is checked before any decode allocation. + const auto width = static_cast(spec.width); + const auto height = static_cast(spec.height); + const uint32_t max_width = std::min(limits.max_width, hard_max_dimension); + const uint32_t max_height = std::min(limits.max_height, hard_max_dimension); + if (width > max_width || height > max_height) { + return fail_image(ARC_RESOURCE_LIMIT, "Image dimensions exceed the limit"); + } + uint64_t pixel_count = 0; + if (!multiply_u64(width, height, pixel_count) || pixel_count > hard_max_pixels) { + return fail_image(ARC_RESOURCE_LIMIT, "Image pixel count exceeds the limit"); + } + + const auto channels = static_cast(spec.nchannels); + if (channels > std::min(limits.max_items, max_channels)) { + return fail_image(ARC_RESOURCE_LIMIT, "Image channel count exceeds the limit"); + } + + // Channel names and types are bounded and closed before they reach the metadata. + const bool names_present = spec.channelnames.size() == channels; + std::vector names(channels); + std::vector types(channels); + uint32_t maximum_bits = 0; + bool range_clamp_possible = false; + bool precision_loss = false; + for (uint32_t index = 0; index < channels; ++index) { + if (names_present) { + names[index] = spec.channelnames[index]; + if (!printable_ascii_name(names[index])) { + return fail_image(ARC_UNSUPPORTED, "Image channel names must be printable ASCII up to 64 bytes"); + } + } else { + names[index] = "channel" + std::to_string(index); + } + types[index] = spec.channelformats.size() == channels ? spec.channelformats[index] : spec.format; + if (!supported_base_type(types[index])) { + return fail_image(ARC_UNSUPPORTED, "Image channel type is not supported"); + } + maximum_bits = std::max(maximum_bits, type_bits(types[index])); + range_clamp_possible = range_clamp_possible || is_floating_type(types[index]); + precision_loss = precision_loss || loses_precision_as_float(types[index]); + } + + // Tile and scanline memory bounds are checked here, before any decode allocation. + uint64_t memory_bytes = 0; + if (spec.tile_width > 0) { + if (spec.tile_height <= 0 || spec.tile_depth > 1) { + return fail_image(ARC_UNSUPPORTED, "Image tile geometry is not supported"); + } + uint64_t tile_samples = 0; + if (!multiply_u64(static_cast(spec.tile_width), static_cast(spec.tile_height), + tile_samples) || + !multiply_u64(tile_samples, channels, tile_samples) || + !multiply_u64(tile_samples, sizeof(float), memory_bytes) || memory_bytes > limits.max_memory_bytes) { + return fail_image(ARC_RESOURCE_LIMIT, "Image tile exceeds the memory limit"); + } + session->tile_width_ = static_cast(spec.tile_width); + session->tile_height_ = static_cast(spec.tile_height); + } else { + uint64_t row_samples = 0; + if (!multiply_u64(width, channels, row_samples) || !multiply_u64(row_samples, sizeof(float), memory_bytes) || + memory_bytes > limits.max_memory_bytes) { + return fail_image(ARC_RESOURCE_LIMIT, "Image scanline exceeds the memory limit"); + } + } + + // Transparency and colour metadata. The file's own association is reported: PNG alpha is straight, + // TIFF follows its association attribute when present, and EXR is associated by convention when the + // attribute is absent. Pixels read through OpenImageIO are associated whenever the source has alpha. + const bool association_reported = spec.find_attribute("oiio:UnassociatedAlpha") != nullptr; + bool file_straight = false; + if (source == source_kind::png) { + file_straight = true; + } else if (source == source_kind::tiff) { + file_straight = association_reported ? spec.get_int_attribute("oiio:UnassociatedAlpha", 0) != 0 : true; + } else { + file_straight = association_reported && spec.get_int_attribute("oiio:UnassociatedAlpha", 0) != 0; + } + const bool srgb = equals_ignore_case(spec.get_string_attribute("oiio:ColorSpace"), "sRGB"); + + // Channel plan (decision D4). Named channels win; otherwise the mapping is positional. + const auto find_named = [&names, names_present](const char* wanted) -> int32_t { + if (!names_present) { + return -1; + } + for (size_t index = 0; index < names.size(); ++index) { + if (names[index] == wanted) { + return static_cast(index); + } + } + return -1; + }; + int32_t red = -1; + int32_t green = -1; + int32_t blue = -1; + int32_t alpha = -1; + bool grey = false; + bool named_mapping = false; + const int32_t named_red = find_named("R"); + const int32_t named_green = find_named("G"); + const int32_t named_blue = find_named("B"); + const int32_t named_luma = find_named("Y"); + if (named_red >= 0 && named_green >= 0 && named_blue >= 0) { + red = named_red; + green = named_green; + blue = named_blue; + alpha = find_named("A"); + named_mapping = true; + } else if (named_luma >= 0) { + red = named_luma; + grey = true; + alpha = find_named("A"); + named_mapping = true; + } else if (channels == 1) { + red = 0; + grey = true; + } else if (channels == 2) { + red = 0; + grey = true; + alpha = 1; + } else { + red = 0; + green = 1; + blue = 2; + alpha = channels >= 4 ? 3 : -1; + } + const bool has_alpha = alpha >= 0; + + const bool identity = options.format == ARC_FORMAT_FLOAT32_INTERLEAVED; + const bool rgba32f = options.format == ARC_FORMAT_RGBA32F_LINEAR_PREMULTIPLIED; + const bool rgba8 = options.format == ARC_FORMAT_RGBA8; + session->identity_ = identity; + session->red_ = identity ? -1 : red; + session->green_ = identity ? -1 : green; + session->blue_ = identity ? -1 : blue; + session->alpha_ = identity ? -1 : alpha; + session->grey_ = !identity && grey; + session->output_channels_ = identity ? channels : 4; + session->bytes_per_pixel_ = identity ? channels * 4U : 4U * (rgba8 ? 1U : 4U); + session->srgb_to_linear_ = !identity && rgba32f && srgb; + // Pixels with alpha arrive associated. Straight 8-bit output unpremultiplies them, and the + // linear float output unpremultiplies only to apply the transfer curve and then premultiplies again. + session->unpremultiply_output_ = !identity && rgba8 && has_alpha; + + // Channels that the RGBA output does not read are dropped and reported as a loss. + std::vector used(channels, false); + for (const int32_t index : {red, green, blue, alpha}) { + if (index >= 0) { + used[static_cast(index)] = true; + } + } + const bool dropped = !identity && static_cast(std::count(used.begin(), used.end(), true)) < channels; + + // Metadata: a closed JSON document. Every conversion and loss applied to reads is listed. + const bool lossy_depth = rgba8 && maximum_bits > 8; + const bool lossy_clamp = rgba8 && range_clamp_possible; + const bool lossy_precision = !rgba8 && precision_loss; + std::vector conversions; + if (grey && !identity) { + conversions.push_back("greyToRgb"); + } + if (!identity && !has_alpha) { + conversions.push_back("alphaFilled"); + } + if (!identity && has_alpha && file_straight) { + conversions.push_back("premultiplyOnRead"); + } + if (session->unpremultiply_output_) { + conversions.push_back("unpremultiply"); + } + if (session->srgb_to_linear_) { + conversions.push_back("srgbToLinear"); + } + std::vector losses; + if (lossy_depth) { + losses.push_back("bitDepthReduction"); + } + if (lossy_clamp) { + losses.push_back("rangeClamp"); + } + if (lossy_precision) { + losses.push_back("precisionReduction"); + } + if (session->unpremultiply_output_) { + losses.push_back("unpremultiplyPrecision"); + } + if (dropped) { + losses.push_back("channelsDropped"); + } + + const char* output_name = rgba8 ? "rgba8" : (rgba32f ? "rgba32fLinearPremultiplied" : "float32Interleaved"); + const char* alpha_name = !has_alpha ? "none" : (file_straight ? "straight" : "premultiplied"); + const bool tiled = session->tile_width_ != 0; + + std::string json; + json.reserve(512); + json += "{\"schema\":\"arc.image.metadata.v1\",\"format\":"; + append_json_string(json, metadata_format_name(source)); + json += ",\"subimage\":" + std::to_string(options.subimage); + json += ",\"subimages\":" + std::to_string(subimage_count); + json += ",\"mip\":" + std::to_string(options.mip); + json += ",\"mips\":" + std::to_string(mip_count); + json += ",\"width\":" + std::to_string(width); + json += ",\"height\":" + std::to_string(height); + json += ",\"originX\":" + std::to_string(spec.x); + json += ",\"originY\":" + std::to_string(spec.y); + json += ",\"tiled\":"; + json += tiled ? "true" : "false"; + json += ",\"tileWidth\":" + std::to_string(session->tile_width_); + json += ",\"tileHeight\":" + std::to_string(session->tile_height_); + json += ",\"channels\":["; + for (uint32_t index = 0; index < channels; ++index) { + if (index != 0) { + json.push_back(','); + } + json += "{\"name\":"; + append_json_string(json, names[index]); + json += ",\"type\":"; + append_json_string(json, type_name(types[index])); + json.push_back('}'); + } + json += "],\"channelMapping\":"; + append_json_string(json, named_mapping ? "named" : "positional"); + json += ",\"alpha\":"; + append_json_string(json, alpha_name); + json += ",\"colorSpace\":"; + append_json_string(json, srgb ? "sRGB" : "unknown"); + json += ",\"sourceBitsMax\":" + std::to_string(maximum_bits); + json += ",\"outputFormat\":"; + append_json_string(json, output_name); + json += ",\"outputChannels\":" + std::to_string(session->output_channels_); + json += ",\"bytesPerPixel\":" + std::to_string(session->bytes_per_pixel_); + json += ",\"conversions\":"; + append_string_array(json, conversions); + json += ",\"loss\":"; + append_string_array(json, losses); + json.push_back('}'); + + session->width_ = width; + session->height_ = height; + session->channels_ = channels; + session->origin_x_ = spec.x; + session->origin_y_ = spec.y; + session->metadata_ = std::move(json); + session->coverage_ = coverage_tracker(width, height, max_coverage_intervals); + } + + output = std::move(session); + return ARC_OK; +} + +arc_status_t image_session::poll() const +{ + const arc_status_t cancelled = arc::abi::check_cancelled(cancel_); + if (cancelled == ARC_CANCELLED) { + return fail_image(ARC_CANCELLED, "Image operation was cancelled"); + } + if (cancelled != ARC_OK) { + return cancelled; + } + if (has_deadline_ && std::chrono::steady_clock::now() > deadline_) { + return fail_image(ARC_RESOURCE_LIMIT, "Image operation exceeded its time limit"); + } + return ARC_OK; +} + +arc_status_t image_session::decode_failure(const char* message) const +{ + const arc_status_t latched = proxy_->latched_status(); + if (latched == ARC_CANCELLED) { + return fail_image(ARC_CANCELLED, "Image operation was cancelled"); + } + if (latched == ARC_RESOURCE_LIMIT) { + return fail_image(ARC_RESOURCE_LIMIT, "Image operation exceeded its time limit"); + } + if (latched != ARC_OK) { + return fail_image(latched, "Image input callback failed"); + } + return fail_image(ARC_CORRUPT, message); +} + +arc_status_t image_session::read(const arc_region_v1& region, arc_mut_buffer_t* pixels, + const arc_cancel_token_t* cancel) +{ + std::lock_guard lock(mutex_); + + arc_status_t status = arc::abi::validate_record(®ion, sizeof(arc_region_v1)); + if (status != ARC_OK) { + return status; + } + if (region.first_sample != 0 || region.sample_count != 0) { + return fail_image(ARC_INVALID_ARGUMENT, "Image regions must leave the sample fields zero"); + } + if (region.width == 0 || region.height == 0) { + return fail_image(ARC_INVALID_ARGUMENT, "Image region size must be positive"); + } + if (static_cast(region.x) + region.width > width_ || + static_cast(region.y) + region.height > height_) { + return fail_image(ARC_INVALID_ARGUMENT, "Image region is outside the image"); + } + + const uint64_t row_bytes = static_cast(region.width) * bytes_per_pixel_; + if (region.row_stride != 0 && region.row_stride != row_bytes) { + return fail_image(ARC_INVALID_ARGUMENT, "Image region output must be packed"); + } + uint64_t output_bytes = 0; + if (!multiply_u64(row_bytes, region.height, output_bytes)) { + return fail_image(ARC_RESOURCE_LIMIT, "Image region size overflows"); + } + if (output_bytes > hard_max_region_bytes || output_bytes > limits_.max_output_bytes) { + return fail_image(ARC_RESOURCE_LIMIT, "Image region exceeds the output limit"); + } + + // Coverage (decision D3) is checked before the buffer. A refused region leaves no state behind. + status = coverage_.check(region.x, region.y, region.width, region.height); + if (status == ARC_INVALID_ARGUMENT) { + return fail_image(ARC_INVALID_ARGUMENT, "Image region overlaps covered pixels or is out of raster order"); + } + if (status != ARC_OK) { + return fail_image(status, "Image region coverage limit is reached"); + } + + status = arc::abi::prepare_buffer_output(pixels, output_bytes); + if (status != ARC_OK) { + return status; + } + + call_scope scope(*this, cancel, limits_.timeout_ms); + std::vector staging; + try { + staging.resize(static_cast(output_bytes)); + } catch (const std::bad_alloc&) { + return fail_image(ARC_OUT_OF_MEMORY, "Image region staging allocation failed"); + } + + status = tile_width_ != 0 ? decode_tiles(region.x, region.y, region.width, region.height, staging.data()) + : decode_scanlines(region.x, region.y, region.width, region.height, staging.data()); + if (status != ARC_OK) { + return status; + } + + coverage_.commit(region.x, region.y, region.width, region.height); + std::memcpy(pixels->data, staging.data(), static_cast(output_bytes)); + pixels->required = output_bytes; + return ARC_OK; +} + +// Decodes the tiles that intersect the region, one tile at a time. Tile reads are bounded by the +// tile size checked at open, and cancellation and the deadline are polled before every tile. +arc_status_t image_session::decode_tiles(uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint8_t* out) +{ + const uint64_t tile_w = tile_width_; + const uint64_t tile_h = tile_height_; + const uint64_t region_right = static_cast(x) + w; + const uint64_t region_bottom = static_cast(y) + h; + const uint64_t first_tile_x = x / tile_w; + const uint64_t last_tile_x = (region_right - 1) / tile_w; + const uint64_t first_tile_y = y / tile_h; + const uint64_t last_tile_y = (region_bottom - 1) / tile_h; + + try { + tile_buffer_.resize(static_cast(tile_w * tile_h * channels_)); + } catch (const std::bad_alloc&) { + return fail_image(ARC_OUT_OF_MEMORY, "Image tile allocation failed"); + } + + for (uint64_t tile_y = first_tile_y; tile_y <= last_tile_y; ++tile_y) { + for (uint64_t tile_x = first_tile_x; tile_x <= last_tile_x; ++tile_x) { + const arc_status_t polled = poll(); + if (polled != ARC_OK) { + return polled; + } + const uint64_t tile_left = tile_x * tile_w; + const uint64_t tile_top = tile_y * tile_h; + const int64_t absolute_x = static_cast(origin_x_) + static_cast(tile_left); + const int64_t absolute_y = static_cast(origin_y_) + static_cast(tile_top); + if (!input_->read_tile(static_cast(absolute_x), static_cast(absolute_y), 0, OIIO::TypeFloat, + tile_buffer_.data())) { + return decode_failure("Image tile could not be decoded"); + } + + const uint64_t left = std::max(x, tile_left); + const uint64_t right = std::min(region_right, tile_left + tile_w); + const uint64_t top = std::max(y, tile_top); + const uint64_t bottom = std::min(region_bottom, tile_top + tile_h); + for (uint64_t row = top; row < bottom; ++row) { + const float* source = + tile_buffer_.data() + ((row - tile_top) * tile_w + (left - tile_left)) * channels_; + uint8_t* destination = out + ((row - y) * w + (left - x)) * bytes_per_pixel_; + convert_segment(source, static_cast(right - left), destination); + } + } + } + return ARC_OK; +} + +arc_status_t image_session::decode_scanlines(uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint8_t* out) +{ + const arc_status_t rows = ensure_rows(y, y + h); + if (rows != ARC_OK) { + return rows; + } + const uint64_t row_floats = static_cast(width_) * channels_; + for (uint32_t row = y; row < y + h; ++row) { + const float* source = band_.data() + static_cast(row - band_begin_) * row_floats + + static_cast(x) * channels_; + uint8_t* destination = out + static_cast(row - y) * w * bytes_per_pixel_; + convert_segment(source, w, destination); + } + return ARC_OK; +} + +// Keeps decoded full-width rows for [first_row, end_row). Scanline decoders only move forward, and +// decision D3 keeps the region rows non-decreasing, so rows decoded for one band serve every tile +// of that band without a second decode pass. +arc_status_t image_session::ensure_rows(uint32_t first_row, uint32_t end_row) +{ + const uint64_t row_floats = static_cast(width_) * channels_; + if (band_end_ <= first_row || band_begin_ > first_row) { + band_.clear(); + band_begin_ = first_row; + band_end_ = first_row; + } else if (band_begin_ < first_row) { + const uint64_t drop = static_cast(first_row - band_begin_) * row_floats; + band_.erase(band_.begin(), band_.begin() + static_cast(drop)); + band_begin_ = first_row; + } + if (band_end_ >= end_row) { + return ARC_OK; + } + + uint64_t needed_bytes = 0; + if (!multiply_u64(static_cast(end_row - first_row), row_floats * sizeof(float), needed_bytes) || + needed_bytes > limits_.max_memory_bytes) { + return fail_image(ARC_RESOURCE_LIMIT, "Image rows exceed the memory limit"); + } + for (uint32_t row = band_end_; row < end_row; ++row) { + const arc_status_t polled = poll(); + if (polled != ARC_OK) { + return polled; + } + const size_t previous = band_.size(); + try { + band_.resize(previous + static_cast(row_floats)); + } catch (const std::bad_alloc&) { + return fail_image(ARC_OUT_OF_MEMORY, "Image row allocation failed"); + } + const int scanline = static_cast(static_cast(origin_y_) + row); + if (!input_->read_scanline(scanline, 0, OIIO::TypeFloat, band_.data() + previous)) { + band_.resize(previous); + return decode_failure("Image scanline could not be decoded"); + } + band_end_ = row + 1; + } + return ARC_OK; +} + +void image_session::convert_segment(const float* source, uint32_t count, uint8_t* out) +{ + const size_t output_floats = static_cast(count) * output_channels_; + scratch_.resize(output_floats); + for (uint32_t pixel = 0; pixel < count; ++pixel) { + convert_pixel(source + static_cast(pixel) * channels_, + scratch_.data() + static_cast(pixel) * output_channels_); + } + if (format_ == ARC_FORMAT_RGBA8) { + // Decision D4: float-to-unorm8 with clamping to [0, 1] and round-to-nearest. Written here rather + // than through OpenImageIO's convert_type, which reports failure for this pair. + for (size_t index = 0; index < output_floats; ++index) { + float value = scratch_[index]; + if (!(value > 0.0F)) { // also maps NaN to zero + value = 0.0F; + } else if (value > 1.0F) { + value = 1.0F; + } + out[index] = static_cast(value * 255.0F + 0.5F); + } + } else { + std::memcpy(out, scratch_.data(), output_floats * sizeof(float)); + } +} + +void image_session::convert_pixel(const float* source, float* destination) const +{ + if (identity_) { + for (uint32_t channel = 0; channel < channels_; ++channel) { + destination[channel] = source[channel]; + } + return; + } + + float red = source[red_]; + float green = grey_ ? red : source[green_]; + float blue = grey_ ? red : source[blue_]; + const float alpha = alpha_ >= 0 ? source[alpha_] : 1.0F; + const bool associated = alpha_ >= 0; + + if (format_ == ARC_FORMAT_RGBA8) { + // Straight alpha and no transfer change. Associated pixels are unpremultiplied; zero alpha has no + // recoverable colour and yields black. + if (unpremultiply_output_) { + if (alpha > 0.0F) { + red /= alpha; + green /= alpha; + blue /= alpha; + } else { + red = 0.0F; + green = 0.0F; + blue = 0.0F; + } + } + } else if (srgb_to_linear_) { + // The transfer curve applies to straight colour, so associated pixels are unpremultiplied first and + // premultiplied again afterwards. + if (associated) { + if (alpha > 0.0F) { + red /= alpha; + green /= alpha; + blue /= alpha; + } else { + red = 0.0F; + green = 0.0F; + blue = 0.0F; + } + } + red = srgb_to_linear(red); + green = srgb_to_linear(green); + blue = srgb_to_linear(blue); + if (associated) { + red *= alpha; + green *= alpha; + blue *= alpha; + } + } + destination[0] = red; + destination[1] = green; + destination[2] = blue; + destination[3] = alpha; +} + +} // namespace arc::image diff --git a/native/arcimage-abi/src/image_session.hpp b/native/arcimage-abi/src/image_session.hpp new file mode 100644 index 00000000..71af8801 --- /dev/null +++ b/native/arcimage-abi/src/image_session.hpp @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: AGPL-3.0-only +#ifndef ARC_IMAGE_SESSION_HPP +#define ARC_IMAGE_SESSION_HPP + +#include + +#include "image_callback_io.hpp" +#include "image_coverage.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace arc::image { + +// Error domain reported through arc_image_get_last_error (unchanged from the probe shim). +inline constexpr uint32_t image_error_domain = 0x494D4147U; +// Non-zero kind tag stored in the handle table for image sessions. +inline constexpr uint32_t image_handle_kind = 0x494D4731U; +// Annex 06 section 2 default: open image handles per library context. +inline constexpr uint32_t max_open_images = 64; + +// Hard producer profile. Caller limits may narrow these bounds but never widen them. +inline constexpr uint32_t hard_max_dimension = 65535; +inline constexpr uint64_t hard_max_pixels = UINT64_C(268435456); +inline constexpr uint64_t hard_max_region_bytes = UINT64_C(67108864); +inline constexpr uint64_t hard_max_input_bytes = UINT64_C(4294967296); +inline constexpr uint64_t hard_max_memory_bytes = UINT64_C(4294967296); +inline constexpr uint32_t hard_max_items = 256; +inline constexpr uint32_t max_channels = 64; +inline constexpr uint32_t max_channel_name_bytes = 64; +inline constexpr uint64_t max_coverage_intervals = UINT64_C(1048576); + +// One open still image. Owns the callback proxy and the OpenImageIO reader. The reader is declared +// after the proxy so that it is destroyed first. A session serialises its own reads; the ABI handle +// table adds the lease and close semantics on top. +class image_session final { + public: + ~image_session(); + image_session(const image_session&) = delete; + image_session& operator=(const image_session&) = delete; + + // Probes the content (PNG, TIFF or EXR only), validates every bound against the limits before + // any pixel allocation, and builds the metadata document. On failure output stays empty. + static arc_status_t open(const arc_io_v1& io, const arc_image_options_v1& options, + const arc_cancel_token_t* cancel, std::unique_ptr& output); + + // Closed JSON metadata document produced by open(). + [[nodiscard]] const std::string& metadata() const noexcept + { + return metadata_; + } + + // Decodes one packed region into pixels. Coverage (decision D3) and conversion (decision D4) + // apply. The output is staged, so pixels is written only after the whole region decodes. + arc_status_t read(const arc_region_v1& region, arc_mut_buffer_t* pixels, const arc_cancel_token_t* cancel); + + private: + image_session(); + + struct call_scope; + arc_status_t poll() const; + arc_status_t decode_failure(const char* message) const; + arc_status_t decode_tiles(uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint8_t* out); + arc_status_t decode_scanlines(uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint8_t* out); + arc_status_t ensure_rows(uint32_t first_row, uint32_t end_row); + void convert_segment(const float* source, uint32_t count, uint8_t* out); + void convert_pixel(const float* source, float* destination) const; + + // Declaration order matters: the reader holds a pointer to the proxy, so it is declared after it. + std::unique_ptr proxy_; + std::unique_ptr input_; + + arc_limits_v1 limits_{}; + uint32_t format_ = 0; + int32_t origin_x_ = 0; + int32_t origin_y_ = 0; + uint32_t width_ = 0; + uint32_t height_ = 0; + uint32_t channels_ = 0; + uint32_t tile_width_ = 0; // zero for scanline images + uint32_t tile_height_ = 0; + uint32_t output_channels_ = 0; + uint32_t bytes_per_pixel_ = 0; + + // Channel plan (decision D4), fixed at open. + int32_t red_ = -1; + int32_t green_ = -1; + int32_t blue_ = -1; + int32_t alpha_ = -1; + bool grey_ = false; + bool identity_ = false; + bool srgb_to_linear_ = false; + bool unpremultiply_output_ = false; + + std::vector tile_buffer_; // one decoded tile (tiled sources) + std::vector band_; // decoded full-width rows [band_begin_, band_end_) + uint32_t band_begin_ = 0; + uint32_t band_end_ = 0; + std::vector scratch_; + std::string metadata_; + coverage_tracker coverage_{1, 1, max_coverage_intervals}; + + std::mutex mutex_; + const arc_cancel_token_t* cancel_ = nullptr; + std::chrono::steady_clock::time_point deadline_{}; + bool has_deadline_ = false; +}; + +} // namespace arc::image + +#endif diff --git a/native/arcimage-abi/tests/arcimage_codec_tests.cpp b/native/arcimage-abi/tests/arcimage_codec_tests.cpp new file mode 100644 index 00000000..880f46a9 --- /dev/null +++ b/native/arcimage-abi/tests/arcimage_codec_tests.cpp @@ -0,0 +1,799 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Behavioural and failure tests for the functional ArcImageNative codec family. Fixtures are generated +// in memory by OpenImageIO writers during the run and are never shipped (decision D1). +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failures = 0; +int checks = 0; + +void expect(bool condition, const char* what) +{ + ++checks; + if (!condition) { + ++failures; + std::fprintf(stderr, "FAIL: %s\n", what); + } +} + +bool contains(const std::string& text, const char* needle) +{ + return text.find(needle) != std::string::npos; +} + +// In-memory source served through the arc_io_v1 callbacks. Failure modes are injected per call. +struct source { + const std::vector* bytes = nullptr; + int calls = 0; + int fail_after = -1; + int short_after = -1; + int sleep_ms = 0; +}; + +arc_status_t ARC_ABI_CALL read_at(void* context, uint64_t offset, void* destination, uint64_t requested, + uint64_t* done) +{ + auto* state = static_cast(context); + ++state->calls; + *done = 0; + if (state->fail_after >= 0 && state->calls > state->fail_after) { + return ARC_IO; + } + if (state->sleep_ms > 0) { + std::this_thread::sleep_for(std::chrono::milliseconds(state->sleep_ms)); + } + const uint64_t size = state->bytes->size(); + if (offset >= size) { + return ARC_OK; + } + uint64_t count = std::min(requested, size - offset); + if (state->short_after >= 0 && state->calls > state->short_after && count > 1) { + count /= 2; // a short read that is not at the end of the source + } + std::memcpy(destination, state->bytes->data() + offset, static_cast(count)); + *done = count; + return ARC_OK; +} + +arc_io_v1 make_io(source& state) +{ + arc_io_v1 io{}; + io.struct_size = sizeof(arc_io_v1); + io.struct_version = 1; + io.context = &state; + io.length = state.bytes->size(); + io.max_length = io.length; + io.read_at = read_at; + return io; +} + +struct cancel_state { + int calls = 0; + int cancel_from = -1; +}; + +arc_bool_t ARC_ABI_CALL is_cancelled(void* user_data) +{ + auto* state = static_cast(user_data); + ++state->calls; + return (state->cancel_from >= 0 && state->calls >= state->cancel_from) ? 1 : 0; +} + +arc_cancel_token_t make_token(cancel_state& state) +{ + arc_cancel_token_t token{}; + token.struct_size = sizeof(arc_cancel_token_t); + token.struct_version = 1; + token.is_cancelled = is_cancelled; + token.user_data = &state; + return token; +} + +arc_image_options_v1 make_options(uint32_t format, uint32_t subimage = 0, uint32_t mip = 0) +{ + arc_image_options_v1 options{}; + options.struct_size = sizeof(arc_image_options_v1); + options.struct_version = 1; + options.subimage = subimage; + options.mip = mip; + options.format = format; + options.limits.struct_size = sizeof(arc_limits_v1); + options.limits.struct_version = 1; + options.limits.max_input_bytes = UINT64_C(1) << 30; + options.limits.max_memory_bytes = UINT64_C(1) << 30; + options.limits.max_output_bytes = UINT64_C(64) << 20; + options.limits.max_width = 65535; + options.limits.max_height = 65535; + options.limits.max_items = 64; + options.limits.timeout_ms = 60000; + return options; +} + +arc_status_t open_image(const arc_io_v1& io, const arc_image_options_v1& options, const arc_cancel_token_t* cancel, + arc_handle_t& handle, std::string& metadata, size_t metadata_capacity = 65536) +{ + std::vector buffer(metadata_capacity); + arc_mut_buffer_t output{}; + output.data = buffer.data(); + output.capacity = buffer.size(); + const arc_status_t status = arc_image_open(&io, &options, &handle, &output, cancel); + if (status == ARC_OK) { + metadata.assign(buffer.data(), static_cast(output.required)); + } + return status; +} + +arc_status_t read_region(arc_handle_t handle, uint32_t x, uint32_t y, uint32_t w, uint32_t h, size_t bytes_per_pixel, + std::vector& pixels, const arc_cancel_token_t* cancel = nullptr) +{ + arc_region_v1 region{}; + region.struct_size = sizeof(arc_region_v1); + region.struct_version = 1; + region.x = x; + region.y = y; + region.width = w; + region.height = h; + pixels.assign(static_cast(w) * h * bytes_per_pixel, 0); + arc_mut_buffer_t output{}; + output.data = pixels.data(); + output.capacity = pixels.size(); + return arc_image_read(handle, ®ion, &output, cancel); +} + +// Encodes an in-memory image with an OpenImageIO writer. The writer uses the same proxy path as the reader. +std::vector encode(const char* hint, const OIIO::ImageSpec& spec, OIIO::TypeDesc type, const void* pixels) +{ + std::vector bytes; + OIIO::Filesystem::IOVecOutput proxy(bytes); + auto output = OIIO::ImageOutput::create(hint, &proxy); + if (!output) { + return {}; + } + if (!output->open(hint, spec)) { + return {}; + } + output->write_image(type, pixels); + output->close(); + return bytes; +} + +std::vector rgba_pattern(uint32_t width, uint32_t height, uint32_t channels) +{ + std::vector pixels(static_cast(width) * height * channels); + for (uint32_t y = 0; y < height; ++y) { + for (uint32_t x = 0; x < width; ++x) { + for (uint32_t c = 0; c < channels; ++c) { + pixels[(static_cast(y) * width + x) * channels + c] = + static_cast((x * 37U + y * 91U + c * 53U) & 255U); + } + } + } + return pixels; +} + +OIIO::ImageSpec rgba8_spec(uint32_t width, uint32_t height) +{ + OIIO::ImageSpec spec(static_cast(width), static_cast(height), 4, OIIO::TypeDesc::UINT8); + spec.channelnames = {"R", "G", "B", "A"}; + spec.alpha_channel = 3; + return spec; +} + +std::vector encode_png_rgba8(uint32_t width, uint32_t height, const std::vector& pixels) +{ + return encode("fixture.png", rgba8_spec(width, height), OIIO::TypeDesc::UINT8, pixels.data()); +} + +uint32_t crc32(const unsigned char* data, size_t count) +{ + uint32_t value = 0xFFFFFFFFU; + for (size_t index = 0; index < count; ++index) { + value ^= data[index]; + for (int bit = 0; bit < 8; ++bit) { + value = (value >> 1U) ^ (0xEDB88320U & (0U - (value & 1U))); + } + } + return ~value; +} + +void append_be32(std::vector& out, uint32_t value) +{ + out.push_back(static_cast(value >> 24U)); + out.push_back(static_cast(value >> 16U)); + out.push_back(static_cast(value >> 8U)); + out.push_back(static_cast(value)); +} + +void append_chunk(std::vector& out, const char* type, const std::vector& data) +{ + append_be32(out, static_cast(data.size())); + const size_t start = out.size(); + out.insert(out.end(), type, type + 4); + out.insert(out.end(), data.begin(), data.end()); + append_be32(out, crc32(out.data() + start, out.size() - start)); +} + +// A PNG whose header declares a 65535 x 65535 RGBA image with no real pixel data. It is refused from +// the header alone, before any decode allocation. +std::vector decompression_bomb_png() +{ + std::vector bytes = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; + std::vector header; + append_be32(header, 65535); + append_be32(header, 65535); + header.push_back(8); + header.push_back(6); + header.push_back(0); + header.push_back(0); + header.push_back(0); + append_chunk(bytes, "IHDR", header); + // One empty zlib stored block, so the stream is structurally complete but holds no pixel data. + append_chunk(bytes, "IDAT", {0x78, 0x01, 0x01, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01}); + append_chunk(bytes, "IEND", {}); + return bytes; +} + + +// ---- Tests ------------------------------------------------------------------------------------- + +void test_probe_and_build_info() +{ + uint32_t major = 0; + uint32_t minor = 0; + expect(arc_image_get_abi_version(&major, &minor) == ARC_OK && major == 1 && minor == 1, + "functional image ABI reports major 1 minor 1"); + expect(arc_image_get_abi_version(nullptr, &minor) == ARC_INVALID_ARGUMENT, "null version output is refused"); + + arc_mut_buffer_t query{}; + expect(arc_image_get_build_info(&query) == ARC_BUFFER_TOO_SMALL && query.required > 0, + "build info reports its size"); + std::vector text(static_cast(query.required)); + arc_mut_buffer_t output{}; + output.data = text.data(); + output.capacity = text.size(); + expect(arc_image_get_build_info(&output) == ARC_OK, "build info copies"); + const std::string json(text.data(), text.size()); + expect(contains(json, "\"capabilities\":[\"image.open\",\"image.read\",\"image.close\"]"), + "build info carries the closed capability list"); + expect(contains(json, "\"formats\":[\"png\",\"tiff\",\"exr\"]"), "build info carries the format allowlist"); +} + +void test_png_rgba8_round_trip() +{ + const uint32_t width = 7; + const uint32_t height = 5; + const std::vector source = rgba_pattern(width, height, 4); + const auto bytes = encode_png_rgba8(width, height, source); + expect(!bytes.empty(), "PNG fixture encodes"); + + struct source state {}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + const arc_status_t status = open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata); + expect(status == ARC_OK && handle != 0, "PNG opens as rgba8"); + expect(contains(metadata, "\"format\":\"png\"") && contains(metadata, "\"outputFormat\":\"rgba8\""), + "metadata names the codec and output format"); + expect(contains(metadata, "\"premultiplyOnRead\"") && contains(metadata, "\"unpremultiply\""), + "straight PNG alpha is associated on read and unpremultiplied for rgba8"); + expect(contains(metadata, "\"unpremultiplyPrecision\""), "rgba8 output from alpha reports the precision loss"); + + std::vector pixels; + expect(read_region(handle, 0, 0, width, height, 4, pixels) == ARC_OK, "full-image region reads"); + expect(pixels == source, "8-bit RGBA round trip is byte exact"); + expect(arc_image_close(handle) == ARC_OK, "image closes"); + expect(arc_image_close(handle) == ARC_CLOSED, "double close reports CLOSED"); +} + +void test_png_rgb_fills_alpha() +{ + const uint32_t width = 4; + const uint32_t height = 3; + const std::vector rgb = rgba_pattern(width, height, 3); + OIIO::ImageSpec spec(static_cast(width), static_cast(height), 3, OIIO::TypeDesc::UINT8); + spec.channelnames = {"R", "G", "B"}; + const auto bytes = encode("fixture.png", spec, OIIO::TypeDesc::UINT8, rgb.data()); + expect(!bytes.empty(), "RGB PNG fixture encodes"); + + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, "RGB PNG opens"); + expect(contains(metadata, "\"alpha\":\"none\"") && contains(metadata, "\"alphaFilled\""), + "missing alpha is reported as filled"); + std::vector pixels; + expect(read_region(handle, 0, 0, width, height, 4, pixels) == ARC_OK, "RGB PNG reads as rgba8"); + bool exact = true; + for (size_t pixel = 0; pixel < static_cast(width) * height; ++pixel) { + for (size_t c = 0; c < 3; ++c) { + exact = exact && pixels[pixel * 4 + c] == rgb[pixel * 3 + c]; + } + exact = exact && pixels[pixel * 4 + 3] == 255; + } + expect(exact, "RGB samples are preserved and alpha is filled with 255"); + arc_image_close(handle); +} + +void test_png_16bit_reports_bit_depth_loss() +{ + const uint32_t width = 2; + const uint32_t height = 2; + std::vector samples(static_cast(width) * height * 4); + for (size_t index = 0; index < samples.size(); ++index) { + samples[index] = static_cast(index * 4099U); + } + OIIO::ImageSpec spec(static_cast(width), static_cast(height), 4, OIIO::TypeDesc::UINT16); + spec.channelnames = {"R", "G", "B", "A"}; + spec.alpha_channel = 3; + const auto bytes = encode("fixture.png", spec, OIIO::TypeDesc::UINT16, samples.data()); + expect(!bytes.empty(), "16-bit PNG fixture encodes"); + + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, "16-bit PNG opens"); + expect(contains(metadata, "\"bitDepthReduction\""), "16-bit to rgba8 reports bit-depth reduction"); + std::vector pixels; + expect(read_region(handle, 0, 0, width, height, 4, pixels) == ARC_OK, "16-bit PNG reads as rgba8"); + const double expected = samples[0] / 65535.0 * 255.0; + expect(std::abs(static_cast(pixels[0]) - expected) <= 1.0, "16-bit samples map to 8-bit within one step"); + arc_image_close(handle); +} + +void test_png_linear_float_and_premultiply() +{ + const uint32_t width = 2; + const uint32_t height = 1; + // Two straight-alpha pixels: opaque white and white at alpha 128. + const std::vector pixels = {255, 255, 255, 255, 255, 255, 255, 128}; + const auto bytes = encode_png_rgba8(width, height, pixels); + expect(!bytes.empty(), "straight-alpha PNG fixture encodes"); + + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA32F_LINEAR_PREMULTIPLIED), nullptr, handle, metadata) == ARC_OK, + "PNG opens as linear premultiplied float"); + expect(contains(metadata, "\"premultiplyOnRead\""), "straight PNG alpha is associated on read"); + std::vector raw; + expect(read_region(handle, 0, 0, width, height, 16, raw) == ARC_OK, "float region reads"); + float values[8] = {}; + std::memcpy(values, raw.data(), sizeof(values)); + expect(std::abs(values[3] - 1.0F) < 1e-6F && std::abs(values[0] - 1.0F) < 1e-6F, "opaque pixel is unchanged"); + expect(std::abs(values[7] - 128.0F / 255.0F) < 1e-5F && std::abs(values[4] - 128.0F / 255.0F) < 1e-5F, + "premultiplied colour equals colour times alpha"); + arc_image_close(handle); +} + +void test_exr_float_round_trip() +{ + const uint32_t width = 5; + const uint32_t height = 4; + std::vector samples(static_cast(width) * height * 4); + for (size_t index = 0; index < samples.size(); ++index) { + samples[index] = static_cast(index) * 0.25F - 3.0F; + } + OIIO::ImageSpec spec(static_cast(width), static_cast(height), 4, OIIO::TypeDesc::FLOAT); + spec.channelnames = {"R", "G", "B", "A"}; + spec.alpha_channel = 3; + const auto bytes = encode("fixture.exr", spec, OIIO::TypeDesc::FLOAT, samples.data()); + expect(!bytes.empty(), "EXR fixture encodes"); + + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED), nullptr, handle, metadata) == ARC_OK, + "EXR opens as float32 interleaved"); + expect(contains(metadata, "\"format\":\"exr\"") && contains(metadata, "\"channelMapping\":\"named\""), + "EXR metadata names the codec and mapping"); + std::vector raw; + expect(read_region(handle, 0, 0, width, height, 16, raw) == ARC_OK, "EXR float region reads"); + std::vector decoded(samples.size()); + std::memcpy(decoded.data(), raw.data(), raw.size()); + expect(decoded == samples, "EXR float32 channels round trip exactly"); + arc_image_close(handle); +} + +void test_exr_premultiplied_source_is_not_premultiplied_again() +{ + OIIO::ImageSpec spec(1, 1, 4, OIIO::TypeDesc::FLOAT); + spec.channelnames = {"R", "G", "B", "A"}; + spec.alpha_channel = 3; + const float pixel[4] = {0.4F, 0.4F, 0.4F, 0.5F}; + const auto bytes = encode("fixture.exr", spec, OIIO::TypeDesc::FLOAT, pixel); + expect(!bytes.empty(), "premultiplied EXR fixture encodes"); + + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA32F_LINEAR_PREMULTIPLIED), nullptr, handle, metadata) == ARC_OK, + "EXR opens as linear float"); + expect(!contains(metadata, "\"premultiply\"") && contains(metadata, "\"alpha\":\"premultiplied\""), + "EXR associated alpha is not premultiplied again"); + std::vector raw; + expect(read_region(handle, 0, 0, 1, 1, 16, raw) == ARC_OK, "premultiplied EXR reads"); + float values[4] = {}; + std::memcpy(values, raw.data(), sizeof(values)); + expect(std::abs(values[0] - 0.4F) < 1e-6F && std::abs(values[3] - 0.5F) < 1e-6F, + "associated colour and alpha pass through unchanged"); + arc_image_close(handle); +} + +void test_tiled_tiff_edge_tiles_and_coverage() +{ + const uint32_t width = 40; + const uint32_t height = 35; + const std::vector source_pixels = rgba_pattern(width, height, 4); + OIIO::ImageSpec spec = rgba8_spec(width, height); + spec.tile_width = 16; + spec.tile_height = 16; + const auto bytes = encode("fixture.tif", spec, OIIO::TypeDesc::UINT8, source_pixels.data()); + expect(!bytes.empty(), "tiled TIFF fixture encodes"); + + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, "tiled TIFF opens"); + expect(contains(metadata, "\"tiled\":true") && contains(metadata, "\"tileWidth\":16"), "tiling is reported"); + + // Tile-grid raster order, edge tiles included. Each region must match the source block exactly. + bool blocks_match = true; + for (uint32_t y = 0; y < height; y += 16) { + for (uint32_t x = 0; x < width; x += 16) { + const uint32_t w = std::min(16, width - x); + const uint32_t h = std::min(16, height - y); + std::vector block; + blocks_match = blocks_match && read_region(handle, x, y, w, h, 4, block) == ARC_OK; + for (uint32_t row = 0; row < h; ++row) { + const size_t source_offset = ((static_cast(y) + row) * width + x) * 4; + blocks_match = blocks_match && std::memcmp(block.data() + static_cast(row) * w * 4, + source_pixels.data() + source_offset, + static_cast(w) * 4) == 0; + } + } + } + expect(blocks_match, "tiled reads with edge tiles reproduce every pixel"); + + // Coverage is complete now, so any further region overlaps covered pixels. + std::vector scratch; + expect(read_region(handle, 0, 0, 4, 4, 4, scratch) == ARC_INVALID_ARGUMENT, + "a region over covered pixels is refused"); + expect(read_region(handle, 40, 0, 1, 1, 4, scratch) == ARC_INVALID_ARGUMENT, "an out-of-bounds region is refused"); + arc_image_close(handle); +} + +void test_coverage_order_and_no_state_change() +{ + const uint32_t width = 32; + const uint32_t height = 16; + const std::vector source_pixels = rgba_pattern(width, height, 4); + const auto bytes = encode_png_rgba8(width, height, source_pixels); + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, "PNG opens for coverage"); + + std::vector block; + expect(read_region(handle, 16, 0, 16, 8, 4, block) == ARC_INVALID_ARGUMENT, + "a region that skips the raster cursor is refused"); + expect(read_region(handle, 0, 0, 16, 8, 4, block) == ARC_OK, "the raster cursor region is accepted"); + expect(read_region(handle, 8, 0, 8, 8, 4, block) == ARC_INVALID_ARGUMENT, "an overlapping region is refused"); + expect(read_region(handle, 0, 8, 32, 8, 4, block) == ARC_INVALID_ARGUMENT, + "a region below an uncovered row band is refused"); + expect(read_region(handle, 16, 0, 16, 8, 4, block) == ARC_OK, "refusals left the cursor unchanged"); + expect(read_region(handle, 0, 8, 32, 8, 4, block) == ARC_OK, "the next band completes the image"); + expect(read_region(handle, 0, 0, 32, 16, 4, block) == ARC_INVALID_ARGUMENT, "a complete image refuses more reads"); + arc_image_close(handle); +} + +void test_buffer_too_small_consumes_nothing() +{ + const auto bytes = encode_png_rgba8(4, 4, rgba_pattern(4, 4, 4)); + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + const arc_status_t short_metadata = + open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata, 1); + expect(short_metadata == ARC_BUFFER_TOO_SMALL && handle == 0, "a short metadata buffer consumes no handle"); + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, + "the same open succeeds with a large buffer"); + + arc_region_v1 region{}; + region.struct_size = sizeof(arc_region_v1); + region.struct_version = 1; + region.width = 4; + region.height = 4; + std::vector small(8); + arc_mut_buffer_t output{}; + output.data = small.data(); + output.capacity = small.size(); + expect(arc_image_read(handle, ®ion, &output, nullptr) == ARC_BUFFER_TOO_SMALL && output.required == 64, + "a short pixel buffer reports the required size"); + std::vector full; + expect(read_region(handle, 0, 0, 4, 4, 4, full) == ARC_OK, + "after a short buffer the same region still reads and was not consumed"); + arc_image_close(handle); +} + +void test_unsupported_and_corrupt_content() +{ + const std::vector bmp = {'B', 'M', 0, 0, 0, 0, 0, 0, 0, 0, 0x36, 0, 0, 0}; + const std::vector jpeg = {0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, 'J', 'F', 'I', 'F'}; + const std::vector text = {'h', 'e', 'l', 'l', 'o'}; + const std::vector*> cases = {&bmp, &jpeg, &text}; + for (const auto* bytes : cases) { + source state{}; + state.bytes = bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0xFF; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_UNSUPPORTED && handle == 0, + "content outside the PNG, TIFF and EXR allowlist is unsupported with no handle"); + } + + const auto png = encode_png_rgba8(8, 8, rgba_pattern(8, 8, 4)); + std::vector truncated(png.begin(), png.begin() + 40); + source state{}; + state.bytes = &truncated; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + const arc_status_t status = open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata); + expect((status == ARC_CORRUPT || status == ARC_IO) && handle == 0, "a truncated PNG is refused as corrupt"); +} + +void test_limits_and_decompression_bomb() +{ + const auto png = encode_png_rgba8(64, 64, rgba_pattern(64, 64, 4)); + source state{}; + state.bytes = &png; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + + arc_image_options_v1 narrow = make_options(ARC_FORMAT_RGBA8); + narrow.limits.max_width = 32; + expect(open_image(io, narrow, nullptr, handle, metadata) == ARC_RESOURCE_LIMIT && handle == 0, + "a width above the caller limit is refused before decode"); + + arc_image_options_v1 tiny_memory = make_options(ARC_FORMAT_RGBA8); + tiny_memory.limits.max_memory_bytes = 512; + expect(open_image(io, tiny_memory, nullptr, handle, metadata) == ARC_RESOURCE_LIMIT && handle == 0, + "a scanline band above the memory limit is refused before decode"); + + arc_handle_t open_handle = 0; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, open_handle, metadata) == ARC_OK, + "the fixture opens with defaults"); + arc_image_options_v1 zero_limit = make_options(ARC_FORMAT_RGBA8); + zero_limit.limits.max_output_bytes = 0; + expect(open_image(io, zero_limit, nullptr, handle, metadata) == ARC_INVALID_ARGUMENT, + "a zero limit is refused as an invalid argument"); + arc_image_options_v1 above_profile = make_options(ARC_FORMAT_RGBA8); + above_profile.limits.max_output_bytes = (UINT64_C(64) << 20) + 1; + expect(open_image(io, above_profile, nullptr, handle, metadata) == ARC_INVALID_ARGUMENT, + "a limit above the producer profile is refused"); + expect(arc_image_close(open_handle) == ARC_OK, "the default-limit handle closes"); + + const auto bomb = decompression_bomb_png(); + source bomb_state{}; + bomb_state.bytes = &bomb; + const arc_io_v1 bomb_io = make_io(bomb_state); + const arc_status_t bomb_status = open_image(bomb_io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata); + expect(bomb_status == ARC_RESOURCE_LIMIT && handle == 0, + "a 65535 x 65535 PNG header is refused from the header alone"); +} + +void test_region_validation_and_output_limit() +{ + const auto png = encode_png_rgba8(8, 8, rgba_pattern(8, 8, 4)); + source state{}; + state.bytes = &png; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + arc_image_options_v1 capped = make_options(ARC_FORMAT_RGBA8); + capped.limits.max_output_bytes = 16; + expect(open_image(io, capped, nullptr, handle, metadata) == ARC_OK, "capped fixture opens"); + + std::vector pixels; + expect(read_region(handle, 0, 0, 4, 4, 4, pixels) == ARC_RESOURCE_LIMIT, + "a region above the output limit is refused"); + expect(read_region(handle, 0, 0, 2, 2, 4, pixels) == ARC_OK, "a region within the output limit reads"); + arc_image_close(handle); + + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, "fixture reopens"); + arc_region_v1 region{}; + region.struct_size = sizeof(arc_region_v1); + region.struct_version = 1; + region.width = 2; + region.height = 2; + region.first_sample = 1; + arc_mut_buffer_t output{}; + output.data = pixels.data(); + output.capacity = pixels.size(); + expect(arc_image_read(handle, ®ion, &output, nullptr) == ARC_INVALID_ARGUMENT, + "sample fields on an image region are refused"); + region.first_sample = 0; + region.row_stride = 7; + expect(arc_image_read(handle, ®ion, &output, nullptr) == ARC_INVALID_ARGUMENT, + "a non-packed row stride is refused"); + arc_image_close(handle); +} + +void test_options_and_subimage_selection() +{ + const auto png = encode_png_rgba8(4, 4, rgba_pattern(4, 4, 4)); + source state{}; + state.bytes = &png; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(0), nullptr, handle, metadata) == ARC_INVALID_ARGUMENT, + "an unknown output format key is refused"); + arc_image_options_v1 reserved = make_options(ARC_FORMAT_RGBA8); + reserved.reserved = 1; + expect(open_image(io, reserved, nullptr, handle, metadata) == ARC_INVALID_ARGUMENT, + "a non-zero reserved field is refused"); + expect(open_image(io, make_options(ARC_FORMAT_RGBA8, 1, 0), nullptr, handle, metadata) == ARC_NOT_FOUND, + "a missing subimage is not found"); + expect(open_image(io, make_options(ARC_FORMAT_RGBA8, 0, 1), nullptr, handle, metadata) == ARC_NOT_FOUND, + "a missing mip level is not found"); + expect(handle == 0, "failed opens leave no handle"); +} + +void test_cancellation_and_deadline() +{ + const auto png = encode_png_rgba8(16, 16, rgba_pattern(16, 16, 4)); + source state{}; + state.bytes = &png; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + + cancel_state cancelled{}; + cancelled.cancel_from = 1; + arc_cancel_token_t token = make_token(cancelled); + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), &token, handle, metadata) == ARC_CANCELLED && handle == 0, + "a cancelled open returns CANCELLED with no handle"); + + cancel_state later{}; + later.cancel_from = 6; + arc_cancel_token_t late_token = make_token(later); + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, "fixture opens"); + std::vector pixels; + expect(read_region(handle, 0, 0, 16, 8, 4, pixels, &late_token) == ARC_CANCELLED, + "a read cancelled at a callback boundary returns CANCELLED"); + expect(read_region(handle, 0, 0, 16, 8, 4, pixels) == ARC_OK, "the cancelled read did not consume coverage"); + arc_image_close(handle); + + source slow{}; + slow.bytes = &png; + slow.sleep_ms = 20; + const arc_io_v1 slow_io = make_io(slow); + arc_image_options_v1 quick = make_options(ARC_FORMAT_RGBA8); + quick.limits.timeout_ms = 1; + expect(open_image(slow_io, quick, nullptr, handle, metadata) == ARC_RESOURCE_LIMIT && handle == 0, + "a deadline that expires during the open is refused"); +} + +void test_callback_failures() +{ + const auto png = encode_png_rgba8(16, 16, rgba_pattern(16, 16, 4)); + + source failing{}; + failing.bytes = &png; + failing.fail_after = 1; + const arc_io_v1 failing_io = make_io(failing); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(failing_io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_IO && handle == 0, + "a failing read callback surfaces as IO with no handle"); + + source short_read{}; + short_read.bytes = &png; + short_read.short_after = 1; + const arc_io_v1 short_io = make_io(short_read); + expect(open_image(short_io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) != ARC_OK && handle == 0, + "a short read that is not at the end of the source is refused"); + + source ok{}; + ok.bytes = &png; + ok.fail_after = 0; + const arc_io_v1 ok_io = make_io(ok); + ok.fail_after = -1; + expect(open_image(ok_io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, + "a source that succeeds opens"); + arc_image_close(handle); +} + +void test_handle_lifecycle_and_limit() +{ + const auto png = encode_png_rgba8(4, 4, rgba_pattern(4, 4, 4)); + source state{}; + state.bytes = &png; + const arc_io_v1 io = make_io(state); + std::vector handles; + std::string metadata; + for (int index = 0; index < 64; ++index) { + arc_handle_t handle = 0; + if (open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) != ARC_OK) { + break; + } + handles.push_back(handle); + } + expect(handles.size() == 64, "64 image handles open per library context"); + arc_handle_t overflow = 0; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, overflow, metadata) == ARC_RESOURCE_LIMIT && + overflow == 0, + "a 65th handle is refused with RESOURCE_LIMIT"); + for (const arc_handle_t handle : handles) { + expect(arc_image_close(handle) == ARC_OK, "each handle closes once"); + } + std::vector pixels; + expect(read_region(handles.front(), 0, 0, 1, 1, 4, pixels) == ARC_CLOSED, "a stale handle reads as CLOSED"); + expect(arc_image_close(0) == ARC_CLOSED, "the zero handle is never valid"); + + arc_error_info_t error{}; + error.struct_size = sizeof(arc_error_info_t); + error.struct_version = 1; + uint8_t message[256] = {}; + error.message_utf8.data = message; + error.message_utf8.capacity = sizeof(message); + expect(arc_image_get_last_error(&error) == ARC_OK && error.correlation_id != 0, + "the last error snapshot is readable after failures"); +} + +} // namespace + +int main() +{ + test_probe_and_build_info(); + test_png_rgba8_round_trip(); + test_png_rgb_fills_alpha(); + test_png_16bit_reports_bit_depth_loss(); + test_png_linear_float_and_premultiply(); + test_exr_float_round_trip(); + test_exr_premultiplied_source_is_not_premultiplied_again(); + test_tiled_tiff_edge_tiles_and_coverage(); + test_coverage_order_and_no_state_change(); + test_buffer_too_small_consumes_nothing(); + test_unsupported_and_corrupt_content(); + test_limits_and_decompression_bomb(); + test_region_validation_and_output_limit(); + test_options_and_subimage_selection(); + test_cancellation_and_deadline(); + test_callback_failures(); + test_handle_lifecycle_and_limit(); + std::printf("arcimage codec checks: %d, failures: %d\n", checks, failures); + return failures == 0 ? 0 : 1; +} diff --git a/native/arcimage-abi/tests/arcslate_image_abi_tests.cpp b/native/arcimage-abi/tests/arcslate_image_abi_tests.cpp index 16f8c991..d692eea4 100644 --- a/native/arcimage-abi/tests/arcslate_image_abi_tests.cpp +++ b/native/arcimage-abi/tests/arcslate_image_abi_tests.cpp @@ -12,7 +12,7 @@ int main() { uint32_t major = 0; uint32_t minor = 0; - if (arc_image_get_abi_version(&major, &minor) != ARC_OK || major != 1 || minor != 0) { + if (arc_image_get_abi_version(&major, &minor) != ARC_OK || major != 1 || minor != 1) { return 1; } arc_mut_buffer_t query{}; diff --git a/tests/NativeAbiTests/Oracle/NativeSmoke.cs b/tests/NativeAbiTests/Oracle/NativeSmoke.cs index 072dd9af..7b978df4 100644 --- a/tests/NativeAbiTests/Oracle/NativeSmoke.cs +++ b/tests/NativeAbiTests/Oracle/NativeSmoke.cs @@ -17,13 +17,15 @@ internal static unsafe class NativeSmoke private const int BufferTooSmall = 1; private const int InvalidArgument = -1; + // ArcImageNative reports the functional minor (Annex 06 section 1, decision D2); build information is JSON. public static NativeProbeResult VerifyImage() => Verify( - "ArcImageNative", ArcImageNative.GetAbiVersion, ArcImageNative.GetBuildInfo, ArcImageNative.GetLastError); + "ArcImageNative", 1u, ArcImageNative.GetAbiVersion, ArcImageNative.GetBuildInfo, ArcImageNative.GetLastError); public static IReadOnlyList VerifyAll() => [VerifyImage()]; private static unsafe NativeProbeResult Verify( string libraryName, + uint expectedMinor, VersionProbe versionProbe, BufferProbe buildProbe, ErrorProbe errorProbe) @@ -36,7 +38,7 @@ private static unsafe NativeProbeResult Verify( uint major; uint minor; int status = versionProbe(&major, &minor); - if (status != 0 || major != 1 || minor != 0) + if (status != 0 || major != 1 || minor != expectedMinor) { throw new InvalidOperationException($"{libraryName} rejected ABI negotiation ({status}, {major}.{minor})."); } @@ -63,7 +65,7 @@ private static unsafe NativeProbeResult Verify( } string buildInfo = Encoding.UTF8.GetString(bytes); - if (!buildInfo.StartsWith(libraryName, StringComparison.Ordinal)) + if (!buildInfo.StartsWith("{\"library\":\"" + libraryName + "\"", StringComparison.Ordinal)) { throw new InvalidOperationException($"{libraryName} returned unexpected build information."); } diff --git a/tests/NativeAbiTests/SkeletonTests.cs b/tests/NativeAbiTests/SkeletonTests.cs index 8e861c7f..714d941d 100644 --- a/tests/NativeAbiTests/SkeletonTests.cs +++ b/tests/NativeAbiTests/SkeletonTests.cs @@ -13,9 +13,9 @@ public void ManagedBindingsLoadAndExecuteEveryOwnedWindowsShim() Xunit.Assert.All(results, result => { Xunit.Assert.Equal(1u, result.AbiMajor); - Xunit.Assert.Equal(0u, result.AbiMinor); + Xunit.Assert.Equal(1u, result.AbiMinor); Xunit.Assert.Equal(0, result.Status); - Xunit.Assert.Contains("abi=1.0", result.BuildInfo, StringComparison.Ordinal); + Xunit.Assert.Contains("\"capabilities\":[\"image.open\",\"image.read\",\"image.close\"]", result.BuildInfo, StringComparison.Ordinal); }); } From 7354609bd2cf9e6a2a147f0604ceb053fb295b04 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 02:44:43 +0100 Subject: [PATCH 02/15] [NAT.11] Read PNG and TIFF straight alpha through the open configuration Pass the callback proxy as the oiio:ioproxy pointer attribute, with oiio:UnassociatedAlpha = 1 for PNG and TIFF, in the ImageSpec config given to ImageInput::open. OpenImageIO 3.1.14.0 honours the request in its PNG and TIFF readers (png.imageio/pnginput.cpp, tiff.imageio/tiffinput.cpp) and returns straight alpha, so rgba8 output is byte exact without a premultiply and unpremultiply round trip. Associated sources (EXR, or a TIFF that marks its alpha associated) are unpremultiplied only where straight colour is needed, and rgba32f output premultiplies straight sources after any sRGB transfer. TIFF association is read from oiio:UnassociatedAlpha. With the request in place the reader reports that attribute only for unassociated alpha, so an absent attribute means associated. The previous rule treated absence as straight, which mislabelled associated TIFF files. Test fixtures: the PNG writer rewrites colour unless the spec marks alpha unassociated, and the TIFF writer labels alpha from the same attribute, so straight fixtures are encoded with that attribute. The three rgba8 metadata assertions that described the premultiply and unpremultiply path now assert the straight path. The byte-exact round-trip check is unchanged. Added an associated-TIFF passthrough check. native/arcimage-abi/README.md records the route and why no direct libpng or libtiff decode is needed. eng/packaging/native_consumer.py (ADP-07 binding, decision D5): ArcImageNative expects ABI 1.1 and checks its JSON capability manifest and identity field, because the functional build info embeds the identity as a field and no longer ends with the ;-prefixed suffix. Other libraries are unchanged. Validated on win-x64-shim-static with /W4 /WX: arcslate_image_abi.codec reports 164 checks and 0 failures. linux-x64 not run (WSL cmake/ninja missing). Co-Authored-By: Claude Haiku 5.5 --- eng/packaging/native_consumer.py | 39 ++++++- native/arcimage-abi/README.md | 8 ++ native/arcimage-abi/src/image_session.cpp | 101 ++++++++---------- native/arcimage-abi/src/image_session.hpp | 6 +- .../tests/arcimage_codec_tests.cpp | 47 +++++++- 5 files changed, 133 insertions(+), 68 deletions(-) diff --git a/eng/packaging/native_consumer.py b/eng/packaging/native_consumer.py index c838da26..db864a89 100644 --- a/eng/packaging/native_consumer.py +++ b/eng/packaging/native_consumer.py @@ -12,6 +12,15 @@ import packages +# Functional libraries advertise a higher minor ABI and a closed capability manifest in their build info +# (Annex 06 section 1 and decision D2). Their build info is JSON whose identity is a field, not a suffix. +IMAGE_CAPABILITIES = ["image.open", "image.read", "image.close"] +IMAGE_FORMATS = ["png", "tiff", "exr"] + + +def abi_minor(entry): + return 1 if entry["library"] == "ArcImageNative" else 0 + def execute(executable, directory, env, failure=False): runtime_env = {key: value for key, value in env.items() if not key.upper().startswith(("VCPKG", "DOTNET_ROOT"))} @@ -80,18 +89,30 @@ def consume(directory, version, commit): + '\n', encoding="utf-8") program = '''using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Text.Json; using ArcForges.Native.Abstractions; [assembly: DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.System32)] +static bool StringsEqual(JsonElement element, params string[] expected) => element.EnumerateArray().Select(item => item.GetString()).SequenceEqual(expected); ''' faults = [] + identity_fields = expected_suffix[1:] for entry in selected: name = entry["dependencies"][0] family = name.split(".")[-1] api = f"{name}.{family}Abi" - program += f'''if ({api}.GetAbiVersion() != new NativeAbiVersion(1, 0)) throw new Exception("ABI mismatch"); + minor = abi_minor(entry) + if minor == 0: + identity_check = f'if (!{api}.GetBuildInfo().EndsWith({json.dumps(expected_suffix)}, StringComparison.Ordinal)) throw new Exception("Native build identity mismatch");\n' + else: + capabilities = ", ".join(json.dumps(item) for item in IMAGE_CAPABILITIES) + formats = ", ".join(json.dumps(item) for item in IMAGE_FORMATS) + identity_check = f'''using var manifest{family} = JsonDocument.Parse({api}.GetBuildInfo()); +var build{family} = manifest{family}.RootElement; +if (build{family}.GetProperty("library").GetString() != {json.dumps(entry["library"])} || build{family}.GetProperty("abi").GetProperty("minor").GetInt32() != {minor} || !StringsEqual(build{family}.GetProperty("capabilities"), {capabilities}) || !StringsEqual(build{family}.GetProperty("formats"), {formats}) || build{family}.GetProperty("identity").GetString() != {json.dumps(identity_fields)}) throw new Exception("Native capability manifest or build identity mismatch"); +''' + program += f'''if ({api}.GetAbiVersion() != new NativeAbiVersion(1, {minor})) throw new Exception("ABI mismatch"); Console.WriteLine({api}.GetBuildInfo()); -if (!{api}.GetBuildInfo().EndsWith({json.dumps(expected_suffix)}, StringComparison.Ordinal)) throw new Exception("Native build identity mismatch"); -var metadata{family} = System.Reflection.CustomAttributeExtensions.GetCustomAttributes(typeof({api}).Assembly).ToDictionary(a => a.Key, a => a.Value); +{identity_check}var metadata{family} = System.Reflection.CustomAttributeExtensions.GetCustomAttributes(typeof({api}).Assembly).ToDictionary(a => a.Key, a => a.Value); if (metadata{family}["ArcForges.SourceCommit"] != "{commit}" || metadata{family}["ArcForges.BuildId"] != "{manifest['build']['buildId']}" || metadata{family}["ArcForges.PipelineRun"] != "{manifest['build']['pipelineRun'] or 'local'}" || metadata{family}["ArcForges.SourceDateEpoch"] != "{manifest['build']['sourceDateEpoch']}") throw new Exception("Managed build identity mismatch"); if (System.Reflection.CustomAttributeExtensions.GetCustomAttribute(typeof({api}).Assembly)?.InformationalVersion.Split('+')[0] != "{version}") throw new Exception("Managed release mismatch"); if ({api}.GetLastError().Status != NativeStatus.Ok) throw new Exception("Initial error state"); @@ -183,15 +204,23 @@ def c_consumer(root, published, entries, version, env, expected_suffix): libraries.append('"' + str(installed / "sdk/win-x64/lib" / (entry["library"] + ".lib")) + '"') source += '#include \n' prefix = entry["prefix"] + minor = abi_minor(entry) + if minor == 0: + identity_expected = [expected_suffix] + else: + identity_expected = ['"capabilities":[' + ",".join(json.dumps(item) for item in IMAGE_CAPABILITIES) + "]", + '"formats":[' + ",".join(json.dumps(item) for item in IMAGE_FORMATS) + "]", + '"identity":"' + expected_suffix[1:] + '"'] + identity_test = " || ".join(f"strstr(text, {json.dumps(item)}) == NULL" for item in identity_expected) body += f'''{{ uint32_t major = 0, minor = 0; - if ({prefix}_get_abi_version(&major, &minor) != ARC_OK || major != 1 || minor != 0) return 1; + if ({prefix}_get_abi_version(&major, &minor) != ARC_OK || major != 1 || minor != {minor}) return 1; arc_mut_buffer_t query = {{0}}; if ({prefix}_get_build_info(&query) != ARC_BUFFER_TOO_SMALL || query.required > 4096 || query.required == 0) return 2; char text[4096] = {{0}}; arc_mut_buffer_t output = {{text, sizeof(text), 0}}; if ({prefix}_get_build_info(&output) != ARC_OK || output.required != query.required) return 3; - if (output.required >= sizeof(text) || strstr(text, {json.dumps(expected_suffix)}) == NULL) return 7; + if (output.required >= sizeof(text) || {identity_test}) return 7; if ({prefix}_get_abi_version(NULL, &minor) != ARC_INVALID_ARGUMENT) return 4; arc_error_info_t error = {{0}}; error.struct_size = sizeof(error); error.struct_version = 1; diff --git a/native/arcimage-abi/README.md b/native/arcimage-abi/README.md index 4de4571a..42424ab0 100644 --- a/native/arcimage-abi/README.md +++ b/native/arcimage-abi/README.md @@ -14,6 +14,14 @@ ownership crosses the boundary. The library reports ABI 1.1 (functional minor, d - Conversion: `rgba8` (straight alpha, no transfer change), `rgba32fLinearPremultiplied` (sRGB to linear only when OpenImageIO reports sRGB, premultiply only for unassociated alpha) and `float32Interleaved` (source channels kept). Every lossy step is listed in the metadata `loss` array (decision D4). +- Straight alpha: the PNG and TIFF readers are opened with the callback proxy as the `oiio:ioproxy` + pointer attribute and `oiio:UnassociatedAlpha` = 1 in the open configuration (OpenImageIO 3.1.14.0, + `ImageInput::open` with an `ImageSpec` config). The readers then return straight alpha, so `rgba8` + output is byte exact. Without the request OpenImageIO premultiplies straight alpha as it reads, and + 8-bit output is inexact. The request is honoured for both formats, so no direct libpng or libtiff decode + is used. An associated source (EXR, or a TIFF whose file marks its alpha associated) is unpremultiplied + only where straight colour is needed. The TIFF reader reports the association only when the alpha is + unassociated, so its association is read from that attribute. - Cancellation and the per-call timeout are observed at callback and tile boundaries. An in-flight codec call is not interruptible. - Build information is a closed JSON object that lists the capabilities and formats and embeds the build diff --git a/native/arcimage-abi/src/image_session.cpp b/native/arcimage-abi/src/image_session.cpp index 16fd8f46..cb0b25e9 100644 --- a/native/arcimage-abi/src/image_session.cpp +++ b/native/arcimage-abi/src/image_session.cpp @@ -323,10 +323,16 @@ arc_status_t image_session::open(const arc_io_v1& io, const arc_image_options_v1 return fail_image(ARC_UNSUPPORTED, "Image content is not a supported PNG, TIFF or EXR file"); } - // OpenImageIO associates alpha as it reads (its default). A reader configuration cannot be passed - // together with a callback proxy in this build, so the association is handled by the conversion plan. - std::unique_ptr input = - OIIO::ImageInput::open(hint_name(source), nullptr, session->proxy_.get()); + // The callback proxy and the straight-alpha request travel in the open configuration (OpenImageIO's + // documented route, ImageInput::open with an ioproxy attribute). Without the request the PNG and TIFF + // readers premultiply straight alpha as they read, which loses 8-bit precision for rgba8 output. + OIIO::ImageSpec reader_config; + OIIO::Filesystem::IOProxy* proxy = session->proxy_.get(); + reader_config.attribute("oiio:ioproxy", OIIO::TypeDesc::PTR, &proxy); + if (source == source_kind::png || source == source_kind::tiff) { + reader_config.attribute("oiio:UnassociatedAlpha", 1); + } + std::unique_ptr input = OIIO::ImageInput::open(hint_name(source), &reader_config); if (!input) { if (session->proxy_->latched_status() != ARC_OK) { return fail_image(session->proxy_->latched_status(), "Image input could not be read"); @@ -457,18 +463,12 @@ arc_status_t image_session::open(const arc_io_v1& io, const arc_image_options_v1 } } - // Transparency and colour metadata. The file's own association is reported: PNG alpha is straight, - // TIFF follows its association attribute when present, and EXR is associated by convention when the - // attribute is absent. Pixels read through OpenImageIO are associated whenever the source has alpha. - const bool association_reported = spec.find_attribute("oiio:UnassociatedAlpha") != nullptr; - bool file_straight = false; - if (source == source_kind::png) { - file_straight = true; - } else if (source == source_kind::tiff) { - file_straight = association_reported ? spec.get_int_attribute("oiio:UnassociatedAlpha", 0) != 0 : true; - } else { - file_straight = association_reported && spec.get_int_attribute("oiio:UnassociatedAlpha", 0) != 0; - } + // Transparency and colour metadata. The file's own association is reported and is what the reader returns: + // PNG alpha is straight by its specification, and TIFF and EXR report oiio:UnassociatedAlpha only when their + // alpha is unassociated, so an absent attribute means associated. The straight-alpha request keeps the + // stored association for both readers, so the pixels match the file. + const bool file_straight = + source == source_kind::png || spec.get_int_attribute("oiio:UnassociatedAlpha", 0) != 0; const bool srgb = equals_ignore_case(spec.get_string_attribute("oiio:ColorSpace"), "sRGB"); // Channel plan (decision D4). Named channels win; otherwise the mapping is positional. @@ -531,9 +531,11 @@ arc_status_t image_session::open(const arc_io_v1& io, const arc_image_options_v1 session->output_channels_ = identity ? channels : 4; session->bytes_per_pixel_ = identity ? channels * 4U : 4U * (rgba8 ? 1U : 4U); session->srgb_to_linear_ = !identity && rgba32f && srgb; - // Pixels with alpha arrive associated. Straight 8-bit output unpremultiplies them, and the - // linear float output unpremultiplies only to apply the transfer curve and then premultiplies again. - session->unpremultiply_output_ = !identity && rgba8 && has_alpha; + // Straight sources are read straight (decision D4 and the straight-alpha adjudication), so only an + // associated source needs unpremultiplying. The sRGB transfer applies to straight colour, and the + // premultiplied float output multiplies by alpha after the transfer. + session->source_associated_ = !identity && has_alpha && !file_straight; + session->premultiply_output_ = rgba32f && has_alpha && (file_straight || session->srgb_to_linear_); // Channels that the RGBA output does not read are dropped and reported as a loss. std::vector used(channels, false); @@ -555,15 +557,15 @@ arc_status_t image_session::open(const arc_io_v1& io, const arc_image_options_v1 if (!identity && !has_alpha) { conversions.push_back("alphaFilled"); } - if (!identity && has_alpha && file_straight) { - conversions.push_back("premultiplyOnRead"); - } - if (session->unpremultiply_output_) { + if (session->source_associated_ && (rgba8 || session->srgb_to_linear_)) { conversions.push_back("unpremultiply"); } if (session->srgb_to_linear_) { conversions.push_back("srgbToLinear"); } + if (session->premultiply_output_) { + conversions.push_back("premultiplyOnRead"); + } std::vector losses; if (lossy_depth) { losses.push_back("bitDepthReduction"); @@ -574,7 +576,7 @@ arc_status_t image_session::open(const arc_io_v1& io, const arc_image_options_v1 if (lossy_precision) { losses.push_back("precisionReduction"); } - if (session->unpremultiply_output_) { + if (rgba8 && session->source_associated_) { losses.push_back("unpremultiplyPrecision"); } if (dropped) { @@ -886,44 +888,29 @@ void image_session::convert_pixel(const float* source, float* destination) const float green = grey_ ? red : source[green_]; float blue = grey_ ? red : source[blue_]; const float alpha = alpha_ >= 0 ? source[alpha_] : 1.0F; - const bool associated = alpha_ >= 0; - if (format_ == ARC_FORMAT_RGBA8) { - // Straight alpha and no transfer change. Associated pixels are unpremultiplied; zero alpha has no - // recoverable colour and yields black. - if (unpremultiply_output_) { - if (alpha > 0.0F) { - red /= alpha; - green /= alpha; - blue /= alpha; - } else { - red = 0.0F; - green = 0.0F; - blue = 0.0F; - } - } - } else if (srgb_to_linear_) { - // The transfer curve applies to straight colour, so associated pixels are unpremultiplied first and - // premultiplied again afterwards. - if (associated) { - if (alpha > 0.0F) { - red /= alpha; - green /= alpha; - blue /= alpha; - } else { - red = 0.0F; - green = 0.0F; - blue = 0.0F; - } + // Straight colour is needed for rgba8 output and for the transfer curve. An associated source yields it by + // division; zero alpha has no recoverable colour and yields black. + if (source_associated_ && (format_ == ARC_FORMAT_RGBA8 || srgb_to_linear_)) { + if (alpha > 0.0F) { + red /= alpha; + green /= alpha; + blue /= alpha; + } else { + red = 0.0F; + green = 0.0F; + blue = 0.0F; } + } + if (srgb_to_linear_) { red = srgb_to_linear(red); green = srgb_to_linear(green); blue = srgb_to_linear(blue); - if (associated) { - red *= alpha; - green *= alpha; - blue *= alpha; - } + } + if (premultiply_output_) { + red *= alpha; + green *= alpha; + blue *= alpha; } destination[0] = red; destination[1] = green; diff --git a/native/arcimage-abi/src/image_session.hpp b/native/arcimage-abi/src/image_session.hpp index 71af8801..92b816e7 100644 --- a/native/arcimage-abi/src/image_session.hpp +++ b/native/arcimage-abi/src/image_session.hpp @@ -96,7 +96,11 @@ class image_session final { bool grey_ = false; bool identity_ = false; bool srgb_to_linear_ = false; - bool unpremultiply_output_ = false; + // The source pixels arrive premultiplied (EXR, or a TIFF whose file marks its alpha associated), so + // straight colour is recovered before the output needs it. + bool source_associated_ = false; + // The rgba32f output is premultiplied, so straight colour is multiplied by alpha after any transfer. + bool premultiply_output_ = false; std::vector tile_buffer_; // one decoded tile (tiled sources) std::vector band_; // decoded full-width rows [band_begin_, band_end_) diff --git a/native/arcimage-abi/tests/arcimage_codec_tests.cpp b/native/arcimage-abi/tests/arcimage_codec_tests.cpp index 880f46a9..e5d7f8b6 100644 --- a/native/arcimage-abi/tests/arcimage_codec_tests.cpp +++ b/native/arcimage-abi/tests/arcimage_codec_tests.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -154,8 +155,16 @@ arc_status_t read_region(arc_handle_t handle, uint32_t x, uint32_t y, uint32_t w } // Encodes an in-memory image with an OpenImageIO writer. The writer uses the same proxy path as the reader. -std::vector encode(const char* hint, const OIIO::ImageSpec& spec, OIIO::TypeDesc type, const void* pixels) -{ +// Fixtures hold straight (unassociated) alpha unless the caller says otherwise. OpenImageIO's PNG writer +// assumes associated input and rewrites colour unless the spec marks the alpha unassociated, and its TIFF +// writer labels the alpha from the same attribute. Marking the spec keeps the stored bytes equal to the pixels. +std::vector encode(const char* hint, OIIO::ImageSpec spec, OIIO::TypeDesc type, const void* pixels, + bool straight_alpha = true) +{ + const std::string_view name(hint); + if (straight_alpha && spec.alpha_channel != -1 && (name.ends_with(".png") || name.ends_with(".tif"))) { + spec.attribute("oiio:UnassociatedAlpha", 1); + } std::vector bytes; OIIO::Filesystem::IOVecOutput proxy(bytes); auto output = OIIO::ImageOutput::create(hint, &proxy); @@ -288,9 +297,11 @@ void test_png_rgba8_round_trip() expect(status == ARC_OK && handle != 0, "PNG opens as rgba8"); expect(contains(metadata, "\"format\":\"png\"") && contains(metadata, "\"outputFormat\":\"rgba8\""), "metadata names the codec and output format"); - expect(contains(metadata, "\"premultiplyOnRead\"") && contains(metadata, "\"unpremultiply\""), - "straight PNG alpha is associated on read and unpremultiplied for rgba8"); - expect(contains(metadata, "\"unpremultiplyPrecision\""), "rgba8 output from alpha reports the precision loss"); + expect(contains(metadata, "\"alpha\":\"straight\""), "straight PNG alpha is read as straight"); + expect(!contains(metadata, "\"premultiplyOnRead\"") && !contains(metadata, "\"unpremultiply\""), + "straight PNG alpha is passed to rgba8 without a premultiply or unpremultiply step"); + expect(!contains(metadata, "\"unpremultiplyPrecision\""), + "rgba8 output from straight alpha reports no precision loss"); std::vector pixels; expect(read_region(handle, 0, 0, width, height, 4, pixels) == ARC_OK, "full-image region reads"); @@ -443,6 +454,31 @@ void test_exr_premultiplied_source_is_not_premultiplied_again() arc_image_close(handle); } +void test_tiff_associated_alpha_is_not_premultiplied_again() +{ + // Stored with associated alpha (the TIFF marks it associated, so no unassociated attribute is reported). + const std::vector pixel = {102, 102, 102, 128}; + const auto bytes = encode("fixture.tif", rgba8_spec(1, 1), OIIO::TypeDesc::UINT8, pixel.data(), false); + expect(!bytes.empty(), "associated TIFF fixture encodes"); + + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA32F_LINEAR_PREMULTIPLIED), nullptr, handle, metadata) == ARC_OK, + "associated TIFF opens as linear premultiplied float"); + expect(contains(metadata, "\"alpha\":\"premultiplied\"") && !contains(metadata, "\"premultiplyOnRead\""), + "associated TIFF alpha is reported as premultiplied and not premultiplied again"); + std::vector raw; + expect(read_region(handle, 0, 0, 1, 1, 16, raw) == ARC_OK, "associated TIFF float region reads"); + float values[4] = {}; + std::memcpy(values, raw.data(), sizeof(values)); + expect(std::abs(values[0] - 102.0F / 255.0F) < 1e-6F && std::abs(values[3] - 128.0F / 255.0F) < 1e-6F, + "associated TIFF colour and alpha pass through unchanged"); + arc_image_close(handle); +} + void test_tiled_tiff_edge_tiles_and_coverage() { const uint32_t width = 40; @@ -784,6 +820,7 @@ int main() test_png_linear_float_and_premultiply(); test_exr_float_round_trip(); test_exr_premultiplied_source_is_not_premultiplied_again(); + test_tiff_associated_alpha_is_not_premultiplied_again(); test_tiled_tiff_edge_tiles_and_coverage(); test_coverage_order_and_no_state_change(); test_buffer_too_small_consumes_nothing(); From 9878e8a1c919c40af180c0f2cf49f61afc110e2d Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 03:12:31 +0100 Subject: [PATCH 03/15] [NAT.11] Cover codec formats, edges, limits, corruption and cancellation Unit 2 of the image family. The native codec tests now cover bit depth and metadata round trips (PNG 8 and 16 bit, TIFF 8, 16 and float, EXR half and float), partial and one-pixel edge tiles, decompression bomb, memory and deadline refusals before decode, truncated and corrupt input (CORRUPT with no output), failed codec callbacks, cancellation at every tile and callback boundary (D9), coverage refusals (D3), one-byte-short buffers, unsupported and empty content (D10), and EXR multipart and mip subimages within the item limit. Each functional case is its own CTest target, arcslate_image_abi.codec.. The CI CTest filter in native-abi.yml follows the new names (D5). A failed reader open now drains OpenImageIO's pending error, so the message does not accumulate in the calling thread. Validated on win-x64 with the CI-equivalent sequence: configure, build, install, the anchored CTest filter (36 of 36) and dotnet NativeAbiLayout. linux-x64 is recorded as not run (D8): the WSL cmake and ninja install is not present. Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/native-abi.yml | 2 +- native/arcimage-abi/CMakeLists.txt | 39 +- native/arcimage-abi/README.md | 37 +- native/arcimage-abi/src/image_session.cpp | 2 + .../tests/arcimage_codec_tests.cpp | 952 +++++++++++++++++- 5 files changed, 990 insertions(+), 42 deletions(-) diff --git a/.github/workflows/native-abi.yml b/.github/workflows/native-abi.yml index 087e9834..06118d82 100644 --- a/.github/workflows/native-abi.yml +++ b/.github/workflows/native-abi.yml @@ -153,7 +153,7 @@ jobs: cmake --install artifacts/cmake/win-x64/%%p || exit /b 1 ) - ctest --test-dir artifacts/cmake/win-x64/shim-static --output-on-failure -R "^(arc_native_abi_c17_layout_tests|arc_native_abi_cpp20_tests|arcslate_image_abi\.hello|arcslate_image_abi\.codec)$" || exit /b 1 + ctest --test-dir artifacts/cmake/win-x64/shim-static --output-on-failure -R "^(arc_native_abi_c17_layout_tests|arc_native_abi_cpp20_tests|arcslate_image_abi\.hello|arcslate_image_abi\.codec\.[a-z0-9_]+)$" || exit /b 1 dotnet test --project tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj --filter Category=NativeAbiLayout --no-restore || exit /b 1 sccache --show-stats || exit /b 1 diff --git a/native/arcimage-abi/CMakeLists.txt b/native/arcimage-abi/CMakeLists.txt index 28cee535..7edd2fd7 100644 --- a/native/arcimage-abi/CMakeLists.txt +++ b/native/arcimage-abi/CMakeLists.txt @@ -54,5 +54,42 @@ if(ARCFORGES_BUILD_TESTS) set_target_properties(arcslate_image_codec_tests PROPERTIES ARCFORGES_TEST_ONLY TRUE) target_compile_features(arcslate_image_codec_tests PRIVATE cxx_std_20) target_link_libraries(arcslate_image_codec_tests PRIVATE arcslate_image_abi OpenImageIO::OpenImageIO) - add_test(NAME arcslate_image_abi.codec COMMAND arcslate_image_codec_tests) + # One CTest target per codec test. Keep this list in step with test_cases in tests/arcimage_codec_tests.cpp. + set(ARCFORGES_IMAGE_CODEC_TESTS + probe + png_rgba8 + png_rgb_fills_alpha + png16_bit_loss + png16_float_identity + png_linear_premultiply + tiff8_straight + tiff16_float_identity + exr_float + exr_half_clamp + exr_premultiplied + tiff_associated + tiled_tiff_edges + edge_one_pixel + coverage_order + coverage_refusals + buffer_too_small + buffer_one_byte_short + unsupported_content + unsupported_formats + corrupt_input + failed_codec + limits_bomb + bomb_before_decode + region_validation + options_subimage + cancellation + cancel_each_boundary + read_deadline + callback_failures + handle_lifecycle + exr_multipart + exr_mips) + foreach(codec_test IN LISTS ARCFORGES_IMAGE_CODEC_TESTS) + add_test(NAME arcslate_image_abi.codec.${codec_test} COMMAND arcslate_image_codec_tests ${codec_test}) + endforeach() endif() diff --git a/native/arcimage-abi/README.md b/native/arcimage-abi/README.md index 42424ab0..c5a009c6 100644 --- a/native/arcimage-abi/README.md +++ b/native/arcimage-abi/README.md @@ -28,7 +28,36 @@ ownership crosses the boundary. The library reports ABI 1.1 (functional minor, d identity. - Metadata is a closed JSON document (`arc.image.metadata.v1`) returned by `arc_image_open`. -Probe, build and error exports keep their shipped signatures. The shim's own tests are -`arcslate_image_abi_tests` (probe) and `arcslate_image_codec_tests` (functional). The codec tests generate -their fixtures in memory and ship none (decision D1). Production containment of untrusted decode is the -WP11 helper (NAT.31), not this library. +Probe, build and error exports keep their shipped signatures. The shim's own tests are the CTest target +`arcslate_image_abi.hello` (probe, `arcslate_image_abi_tests`) and one CTest target per functional case, +`arcslate_image_abi.codec.` (`arcslate_image_codec_tests`, 33 cases, 368 checks on win-x64). The +codec tests generate their fixtures in test code and ship none (decision D1). Production containment of +untrusted decode is the WP11 helper (NAT.31), not this library. + +Codec test coverage: + +- Bit depth and metadata: PNG 8 and 16 bit, TIFF 8, 16 and float, EXR half and float round trips. rgba8 + clamps to [0, 1] and rounds to nearest, and every conversion or loss is reported in the metadata. +- Edges: partial tiles, one-pixel reads, and a region that crosses a tile boundary into a one-pixel edge tile. + Each pixel is covered once in raster order. +- Limits: pixel-count and tile or scanline memory limits are refused before decode. A read whose row band + exceeds the memory limit is refused before any row decodes. A read that passes its deadline between tiles is + RESOURCE_LIMIT. +- Corrupt input: truncated, bad-header-CRC and damaged-pixel PNG, truncated TIFF and EXR, and an invalid EXR + version are CORRUPT. Each writes no pixels. +- Failed codec: a callback failure during decode is IO, writes no pixels and consumes no coverage. +- Cancellation (decision D9): a read cancelled at each of its cancellation boundaries (every tile and every + source callback) is CANCELLED, writes no pixels and consumes no coverage. +- Coverage (decision D3): overlapping, out-of-raster-order, empty and out-of-bounds regions are refused and + leave the cursor unchanged. +- Buffers: a buffer one byte short reports the exact required size and is not written. +- Formats (decision D10): GIF, WebP, JPEG 2000 and empty input are UNSUPPORTED. Content decides the codec, + never the name. +- Subimages: EXR multipart and mip levels are read within the item limit. OpenImageIO's EXR writer accepts + multipart parts only when they share one size, so the multipart fixture uses equal-sized parts. Multipart + fixtures are written through a temporary file because the EXR writer does not append subimages through an + IOProxy. Mip fixtures come from OpenImageIO's texture writer, and each level is checked against OpenImageIO's + own reader. The shim only ever sees bytes through the callback. + +Not run: linux-x64 (decision D8). The WSL cmake and ninja install is not present, so the Linux CTest targets +are recorded as not run. diff --git a/native/arcimage-abi/src/image_session.cpp b/native/arcimage-abi/src/image_session.cpp index cb0b25e9..d0bf7801 100644 --- a/native/arcimage-abi/src/image_session.cpp +++ b/native/arcimage-abi/src/image_session.cpp @@ -334,6 +334,8 @@ arc_status_t image_session::open(const arc_io_v1& io, const arc_image_options_v1 } std::unique_ptr input = OIIO::ImageInput::open(hint_name(source), &reader_config); if (!input) { + // The reader's own message is replaced by ours, so drain it rather than let it accumulate. + (void)OIIO::geterror(); if (session->proxy_->latched_status() != ARC_OK) { return fail_image(session->proxy_->latched_status(), "Image input could not be read"); } diff --git a/native/arcimage-abi/tests/arcimage_codec_tests.cpp b/native/arcimage-abi/tests/arcimage_codec_tests.cpp index e5d7f8b6..4f63fa12 100644 --- a/native/arcimage-abi/tests/arcimage_codec_tests.cpp +++ b/native/arcimage-abi/tests/arcimage_codec_tests.cpp @@ -3,7 +3,10 @@ // in memory by OpenImageIO writers during the run and are never shipped (decision D1). #include +#include + #include +#include #include #include @@ -11,8 +14,11 @@ #include #include #include +#include +#include #include #include +#include #include #include @@ -35,6 +41,11 @@ bool contains(const std::string& text, const char* needle) return text.find(needle) != std::string::npos; } +bool contains(const std::string& text, const std::string& needle) +{ + return text.find(needle) != std::string::npos; +} + // In-memory source served through the arc_io_v1 callbacks. Failure modes are injected per call. struct source { const std::vector* bytes = nullptr; @@ -235,14 +246,13 @@ void append_chunk(std::vector& out, const char* type, const std:: append_be32(out, crc32(out.data() + start, out.size() - start)); } -// A PNG whose header declares a 65535 x 65535 RGBA image with no real pixel data. It is refused from -// the header alone, before any decode allocation. -std::vector decompression_bomb_png() +// A PNG whose IHDR declares an RGBA8 image of the given size, with one empty stored block and no pixel data. +std::vector png_header_only(uint32_t width, uint32_t height) { std::vector bytes = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; std::vector header; - append_be32(header, 65535); - append_be32(header, 65535); + append_be32(header, width); + append_be32(header, height); header.push_back(8); header.push_back(6); header.push_back(0); @@ -255,6 +265,12 @@ std::vector decompression_bomb_png() return bytes; } +// A PNG whose header declares a 65535 x 65535 RGBA image. It is refused from the header alone. +std::vector decompression_bomb_png() +{ + return png_header_only(65535, 65535); +} + // ---- Tests ------------------------------------------------------------------------------------- @@ -581,7 +597,7 @@ void test_buffer_too_small_consumes_nothing() arc_image_close(handle); } -void test_unsupported_and_corrupt_content() +void test_unsupported_content() { const std::vector bmp = {'B', 'M', 0, 0, 0, 0, 0, 0, 0, 0, 0x36, 0, 0, 0}; const std::vector jpeg = {0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, 'J', 'F', 'I', 'F'}; @@ -596,16 +612,6 @@ void test_unsupported_and_corrupt_content() expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_UNSUPPORTED && handle == 0, "content outside the PNG, TIFF and EXR allowlist is unsupported with no handle"); } - - const auto png = encode_png_rgba8(8, 8, rgba_pattern(8, 8, 4)); - std::vector truncated(png.begin(), png.begin() + 40); - source state{}; - state.bytes = &truncated; - const arc_io_v1 io = make_io(state); - arc_handle_t handle = 0; - std::string metadata; - const arc_status_t status = open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata); - expect((status == ARC_CORRUPT || status == ARC_IO) && handle == 0, "a truncated PNG is refused as corrupt"); } void test_limits_and_decompression_bomb() @@ -809,28 +815,902 @@ void test_handle_lifecycle_and_limit() "the last error snapshot is readable after failures"); } +// ---- Helpers for bit depth, edges, corruption, cancellation, coverage and subimages ------------- + +bool all_bytes(const std::vector& bytes, uint8_t value) +{ + return std::all_of(bytes.begin(), bytes.end(), [value](uint8_t byte) { return byte == value; }); +} + +// True when a packed block equals the same rectangle of an expected image that is expected_width pixels wide. +bool region_matches(const std::vector& block, const std::vector& expected, uint32_t expected_width, + uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint32_t bytes_per_pixel) +{ + if (block.size() != static_cast(w) * h * bytes_per_pixel) { + return false; + } + for (uint32_t row = 0; row < h; ++row) { + const size_t offset = ((static_cast(y) + row) * expected_width + x) * bytes_per_pixel; + if (std::memcmp(block.data() + static_cast(row) * w * bytes_per_pixel, expected.data() + offset, + static_cast(w) * bytes_per_pixel) != 0) { + return false; + } + } + return true; +} + +std::vector float_pattern(uint32_t width, uint32_t height, uint32_t channels, float offset) +{ + std::vector values(static_cast(width) * height * channels); + for (size_t index = 0; index < values.size(); ++index) { + values[index] = static_cast((index * 7U + 3U) % 97U) * 0.25F + offset; + } + return values; +} + +std::vector as_bytes(const std::vector& values) +{ + std::vector bytes(values.size() * sizeof(float)); + std::memcpy(bytes.data(), values.data(), bytes.size()); + return bytes; +} + +// True when float32 output equals each 16-bit sample divided by 65535, to within 1e-6. +bool decodes_as_unorm16(const std::vector& raw, const std::vector& samples) +{ + if (raw.size() != samples.size() * sizeof(float)) { + return false; + } + for (size_t index = 0; index < samples.size(); ++index) { + float value = 0.0F; + std::memcpy(&value, raw.data() + index * sizeof(float), sizeof(float)); + if (std::abs(value - static_cast(samples[index]) / 65535.0F) > 1e-6F) { + return false; + } + } + return true; +} + +// One subimage, or one mip level of the preceding subimage when mip_level is set, for encode_parts. +struct part { + OIIO::ImageSpec spec; + OIIO::TypeDesc type; + const void* pixels; + bool mip_level; +}; + +// Removes a temporary fixture file when it leaves scope. +struct temporary_file final { + std::filesystem::path path; + ~temporary_file() + { + std::error_code ignored; + std::filesystem::remove(path, ignored); + } +}; + +// A fresh temporary path with the given extension. +std::filesystem::path temporary_fixture_path(const char* extension) +{ + static int sequence = 0; + return std::filesystem::temp_directory_path() / + ("arcimage_codec_fixture_" + std::to_string(++sequence) + extension); +} + +// Writes the parts in order and returns the resulting bytes. The first part creates the file and later parts +// append. OpenImageIO's EXR writer refuses to append subimages or mip levels through an IOProxy, so these +// fixtures go through a temporary file that is read back and removed. The shim itself only ever sees the bytes. +std::vector encode_parts(const char* extension, const std::vector& parts) +{ + const temporary_file cleanup{temporary_fixture_path(extension)}; + const std::string name = cleanup.path.string(); + auto output = OIIO::ImageOutput::create(name); + if (!output) { + return {}; + } + // A format that cannot append subimages must be given every subimage spec when the file is first opened. + std::vector subimage_specs; + for (const part& item : parts) { + if (!item.mip_level) { + subimage_specs.push_back(item.spec); + } + } + const bool declare_subimages = + subimage_specs.size() > 1 && output->supports("multiimage") && !output->supports("appendsubimage"); + for (size_t index = 0; index < parts.size(); ++index) { + const part& item = parts[index]; + bool opened = false; + if (index == 0 && declare_subimages) { + opened = output->open(name, static_cast(subimage_specs.size()), subimage_specs.data()); + } else if (index == 0) { + opened = output->open(name, item.spec, OIIO::ImageOutput::Create); + } else { + opened = output->open(name, item.spec, + item.mip_level ? OIIO::ImageOutput::AppendMIPLevel + : OIIO::ImageOutput::AppendSubimage); + } + if (!opened) { + std::fprintf(stderr, "fixture part %zu open failed (declared=%d mip=%d): %s\n", index, + declare_subimages ? 1 : 0, item.mip_level ? 1 : 0, OIIO::geterror().c_str()); + return {}; + } + if (!output->write_image(item.type, item.pixels)) { + std::fprintf(stderr, "fixture part %zu write failed: %s\n", index, OIIO::geterror().c_str()); + return {}; + } + } + output->close(); + output.reset(); + + std::ifstream file(cleanup.path, std::ios::binary | std::ios::ate); + if (!file) { + return {}; + } + const auto size = static_cast(file.tellg()); + std::vector bytes(size); + file.seekg(0); + file.read(reinterpret_cast(bytes.data()), static_cast(size)); + return file ? bytes : std::vector{}; +} + +// Damaged or truncated input must be refused as CORRUPT, either at open or on the first full read, and the +// refused read must write no pixels. +void expect_corrupt(const std::vector& bytes, uint32_t width, uint32_t height, const char* what) +{ + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + const arc_status_t opened = open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata); + if (opened != ARC_OK) { + expect(opened == ARC_CORRUPT && handle == 0, what); + return; + } + std::vector pixels; + expect(read_region(handle, 0, 0, width, height, 4, pixels) == ARC_CORRUPT && all_bytes(pixels, 0), what); + arc_image_close(handle); +} + +// A copy of a PNG whose IDAT payload is damaged. The header still parses, and the chunk CRC fails on read. +std::vector png_with_damaged_pixels(const std::vector& good) +{ + std::vector bytes = good; + const size_t idat = std::string_view(reinterpret_cast(bytes.data()), bytes.size()).find("IDAT"); + if (idat != std::string_view::npos && idat + 6 < bytes.size()) { + bytes[idat + 6] ^= 0x5A; // inside the zlib stream, after its two-byte header + } + return bytes; +} + +// Reads a fixture one pixel at a time in raster order. Every pixel must match, the image is complete afterwards, +// and a further pixel is refused. +void read_pixels_one_by_one(const std::vector& bytes, uint32_t width, uint32_t height, + const std::vector& expected) +{ + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, + "fixture opens for single-pixel reads"); + bool every_pixel = true; + std::vector pixel; + for (uint32_t y = 0; y < height; ++y) { + for (uint32_t x = 0; x < width; ++x) { + every_pixel = every_pixel && read_region(handle, x, y, 1, 1, 4, pixel) == ARC_OK && + region_matches(pixel, expected, width, x, y, 1, 1, 4); + } + } + expect(every_pixel, "single-pixel raster reads reproduce every pixel once"); + std::vector extra; + expect(read_region(handle, 0, 0, 1, 1, 4, extra) == ARC_INVALID_ARGUMENT, + "a complete image refuses a further pixel"); + arc_image_close(handle); +} + +// ---- Tests: bit depth and metadata --------------------------------------------------------------- + +void test_png16_float_identity_round_trip() +{ + const uint32_t width = 3; + const uint32_t height = 2; + std::vector samples(static_cast(width) * height * 4); + for (size_t index = 0; index < samples.size(); ++index) { + samples[index] = static_cast(index * 2731U); + } + OIIO::ImageSpec spec(static_cast(width), static_cast(height), 4, OIIO::TypeDesc::UINT16); + spec.channelnames = {"R", "G", "B", "A"}; + spec.alpha_channel = 3; + const auto bytes = encode("fixture.png", spec, OIIO::TypeDesc::UINT16, samples.data()); + expect(!bytes.empty(), "16-bit PNG fixture encodes for float output"); + + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED), nullptr, handle, metadata) == ARC_OK, + "16-bit PNG opens as float32 interleaved"); + expect(contains(metadata, "\"sourceBitsMax\":16") && contains(metadata, "\"type\":\"uint16\"") && + contains(metadata, "\"loss\":[]") && contains(metadata, "\"bytesPerPixel\":16"), + "float32 output from 16-bit PNG reports the source depth and no loss"); + std::vector raw; + expect(read_region(handle, 0, 0, width, height, 16, raw) == ARC_OK, "16-bit float32 region reads"); + expect(decodes_as_unorm16(raw, samples), "16-bit samples map to float32 by 1/65535 without loss"); + arc_image_close(handle); +} + +void test_tiff8_straight_round_trip() +{ + const uint32_t width = 6; + const uint32_t height = 4; + const std::vector source_pixels = rgba_pattern(width, height, 4); + const auto bytes = encode("fixture.tif", rgba8_spec(width, height), OIIO::TypeDesc::UINT8, source_pixels.data()); + expect(!bytes.empty(), "8-bit TIFF fixture encodes"); + + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, + "8-bit untiled TIFF opens as rgba8"); + expect(contains(metadata, "\"format\":\"tiff\"") && contains(metadata, "\"tiled\":false") && + contains(metadata, "\"alpha\":\"straight\"") && contains(metadata, "\"sourceBitsMax\":8"), + "8-bit TIFF metadata reports the codec, layout, straight alpha and depth"); + std::vector pixels; + expect(read_region(handle, 0, 0, width, height, 4, pixels) == ARC_OK && + region_matches(pixels, source_pixels, width, 0, 0, width, height, 4), + "8-bit untiled TIFF round trip is byte exact"); + arc_image_close(handle); +} + +void test_tiff16_and_float_identity_round_trip() +{ + const uint32_t width = 5; + const uint32_t height = 3; + std::vector samples(static_cast(width) * height * 4); + for (size_t index = 0; index < samples.size(); ++index) { + samples[index] = static_cast(index * 1000U); + } + OIIO::ImageSpec sixteen(static_cast(width), static_cast(height), 4, OIIO::TypeDesc::UINT16); + sixteen.channelnames = {"R", "G", "B", "A"}; + sixteen.alpha_channel = 3; + const auto sixteen_bytes = encode("fixture.tif", sixteen, OIIO::TypeDesc::UINT16, samples.data()); + expect(!sixteen_bytes.empty(), "16-bit TIFF fixture encodes"); + { + source state{}; + state.bytes = &sixteen_bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED), nullptr, handle, metadata) == ARC_OK && + contains(metadata, "\"format\":\"tiff\"") && contains(metadata, "\"sourceBitsMax\":16"), + "16-bit TIFF opens as float32 and reports its source depth"); + std::vector raw; + expect(read_region(handle, 0, 0, width, height, 16, raw) == ARC_OK && decodes_as_unorm16(raw, samples), + "16-bit TIFF samples map to float32 by 1/65535 without loss"); + arc_image_close(handle); + } + + const std::vector floats = float_pattern(width, height, 4, -3.0F); + OIIO::ImageSpec single(static_cast(width), static_cast(height), 4, OIIO::TypeDesc::FLOAT); + single.channelnames = {"R", "G", "B", "A"}; + single.alpha_channel = 3; + const auto float_bytes = encode("fixture.tif", single, OIIO::TypeDesc::FLOAT, floats.data()); + expect(!float_bytes.empty(), "float TIFF fixture encodes"); + source state{}; + state.bytes = &float_bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED), nullptr, handle, metadata) == ARC_OK && + contains(metadata, "\"type\":\"float\"") && contains(metadata, "\"sourceBitsMax\":32"), + "float TIFF opens as float32 and reports its type"); + std::vector raw; + expect(read_region(handle, 0, 0, width, height, 16, raw) == ARC_OK && + region_matches(raw, as_bytes(floats), width, 0, 0, width, height, 16), + "float TIFF channels round trip exactly"); + arc_image_close(handle); +} + +void test_exr_half_round_trip_and_rgba8_clamp() +{ + const uint32_t width = 4; + const uint32_t height = 3; + std::vector halves(static_cast(width) * height * 4); + std::vector expected(halves.size()); + for (size_t index = 0; index < halves.size(); ++index) { + // Multiples of one eighth are exact in half precision over this range. + halves[index] = half((static_cast(index) - 20.0F) * 0.125F); + expected[index] = static_cast(halves[index]); + } + OIIO::ImageSpec spec(static_cast(width), static_cast(height), 4, OIIO::TypeDesc::HALF); + spec.channelnames = {"R", "G", "B", "A"}; + spec.alpha_channel = 3; + const auto bytes = encode("fixture.exr", spec, OIIO::TypeDesc::HALF, halves.data()); + expect(!bytes.empty(), "half-float EXR fixture encodes"); + + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED), nullptr, handle, metadata) == ARC_OK && + contains(metadata, "\"type\":\"half\"") && contains(metadata, "\"sourceBitsMax\":16"), + "half-float EXR opens as float32 and reports its half type"); + std::vector raw; + expect(read_region(handle, 0, 0, width, height, 16, raw) == ARC_OK && + region_matches(raw, as_bytes(expected), width, 0, 0, width, height, 16), + "half-float channels convert to float32 exactly"); + arc_image_close(handle); + + // rgba8 clamps out-of-range values and rounds to nearest. Alpha is one, so unpremultiplying leaves colour unchanged. + const float clamp_values[16] = {-0.5F, 0.0F, 0.5F, 1.0F, 2.0F, 1.0F, 0.25F, 1.0F, + 0.0F, 0.0F, 0.0F, 1.0F, 0.5F, 0.5F, 0.5F, 1.0F}; + std::vector clamp(16); + for (size_t index = 0; index < clamp.size(); ++index) { + clamp[index] = half(clamp_values[index]); + } + const std::vector clamp_expected = {0, 0, 128, 255, 255, 255, 64, 255, + 0, 0, 0, 255, 128, 128, 128, 255}; + OIIO::ImageSpec clamp_spec(4, 1, 4, OIIO::TypeDesc::HALF); + clamp_spec.channelnames = {"R", "G", "B", "A"}; + clamp_spec.alpha_channel = 3; + const auto clamp_bytes = encode("fixture.exr", clamp_spec, OIIO::TypeDesc::HALF, clamp.data()); + expect(!clamp_bytes.empty(), "clamping EXR fixture encodes"); + source clamp_state{}; + clamp_state.bytes = &clamp_bytes; + const arc_io_v1 clamp_io = make_io(clamp_state); + expect(open_image(clamp_io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK && + contains(metadata, "\"rangeClamp\"") && contains(metadata, "\"bitDepthReduction\""), + "half-float to rgba8 reports range clamping and bit-depth reduction"); + std::vector pixels; + expect(read_region(handle, 0, 0, 4, 1, 4, pixels) == ARC_OK && + region_matches(pixels, clamp_expected, 4, 0, 0, 4, 1, 4), + "rgba8 clamps to [0, 1] and rounds to nearest"); + arc_image_close(handle); +} + +// ---- Tests: edge tiles and single-pixel regions -------------------------------------------------- + +void test_edge_tiles_and_one_pixel_regions() +{ + // 17 x 9 with 16 x 16 tiles: the last tile column is one pixel wide and the last tile row is partial. + const uint32_t width = 17; + const uint32_t height = 9; + const std::vector source_pixels = rgba_pattern(width, height, 4); + OIIO::ImageSpec spec = rgba8_spec(width, height); + spec.tile_width = 16; + spec.tile_height = 16; + const auto tiled = encode("fixture.tif", spec, OIIO::TypeDesc::UINT8, source_pixels.data()); + expect(!tiled.empty(), "partial-edge tiled TIFF fixture encodes"); + read_pixels_one_by_one(tiled, width, height, source_pixels); + + // Two regions split at the tile boundary: the first ends before the one-pixel edge tile, the second crosses it. + source state{}; + state.bytes = &tiled; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, + "partial-edge tiled TIFF opens for block reads"); + std::vector block; + expect(read_region(handle, 0, 0, 15, height, 4, block) == ARC_OK && + region_matches(block, source_pixels, width, 0, 0, 15, height, 4), + "a region that ends before the last tile column reads exactly"); + expect(read_region(handle, 15, 0, 2, height, 4, block) == ARC_OK && + region_matches(block, source_pixels, width, 15, 0, 2, height, 4), + "a region that crosses the tile boundary into the one-pixel edge reads exactly"); + expect(read_region(handle, 0, 0, 1, 1, 4, block) == ARC_INVALID_ARGUMENT, + "block reads cover the image once, so a further pixel is refused"); + arc_image_close(handle); + + // Scanline PNG, one pixel at a time. + const uint32_t png_width = 5; + const uint32_t png_height = 3; + const std::vector png_pixels = rgba_pattern(png_width, png_height, 4); + read_pixels_one_by_one(encode_png_rgba8(png_width, png_height, png_pixels), png_width, png_height, png_pixels); +} + +// ---- Tests: coverage (decision D3) --------------------------------------------------------------- + +void test_coverage_refusals_keep_the_cursor() +{ + const uint32_t width = 16; + const uint32_t height = 12; + const std::vector source_pixels = rgba_pattern(width, height, 4); + const auto bytes = encode_png_rgba8(width, height, source_pixels); + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, + "PNG opens for coverage refusals"); + + std::vector block; + expect(read_region(handle, 0, 0, 8, 4, 4, block) == ARC_OK && + region_matches(block, source_pixels, width, 0, 0, 8, 4, 4), + "the first block is accepted"); + expect(read_region(handle, 0, 0, 8, 4, 4, block) == ARC_INVALID_ARGUMENT, + "an identical block overlaps covered pixels"); + expect(read_region(handle, 4, 0, 8, 4, 4, block) == ARC_INVALID_ARGUMENT, + "a block that starts inside the covered prefix overlaps it"); + expect(read_region(handle, 8, 1, 8, 2, 4, block) == ARC_INVALID_ARGUMENT, + "a block below the first pending row is out of raster order"); + expect(read_region(handle, 8, 0, 0, 4, 4, block) == ARC_INVALID_ARGUMENT, "an empty block is refused"); + expect(read_region(handle, 8, 0, 16, 4, 4, block) == ARC_INVALID_ARGUMENT, + "a block past the right edge is refused"); + expect(read_region(handle, 8, 0, 8, 4, 4, block) == ARC_OK && + region_matches(block, source_pixels, width, 8, 0, 8, 4, 4), + "the refusals left the cursor where it was"); + expect(read_region(handle, 0, 4, 16, 4, 4, block) == ARC_OK && + region_matches(block, source_pixels, width, 0, 4, 16, 4, 4), + "the next band starts on the first pending row"); + expect(read_region(handle, 0, 8, 16, 4, 4, block) == ARC_OK && + region_matches(block, source_pixels, width, 0, 8, 16, 4, 4), + "the last band completes the image"); + expect(read_region(handle, 0, 0, 1, 1, 4, block) == ARC_INVALID_ARGUMENT, + "a complete image refuses every further block"); + arc_image_close(handle); +} + +// ---- Tests: output buffers ----------------------------------------------------------------------- + +void test_buffer_one_byte_short_consumes_nothing() +{ + const uint32_t width = 8; + const uint32_t height = 8; + const std::vector source_pixels = rgba_pattern(width, height, 4); + const auto bytes = encode_png_rgba8(width, height, source_pixels); + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, + "fixture opens for the one-byte-short buffer check"); + + const uint64_t needed = static_cast(width) * height * 4; + std::vector short_buffer(static_cast(needed - 1), 0x5A); + arc_region_v1 region{}; + region.struct_size = sizeof(arc_region_v1); + region.struct_version = 1; + region.width = width; + region.height = height; + arc_mut_buffer_t output{}; + output.data = short_buffer.data(); + output.capacity = short_buffer.size(); + expect(arc_image_read(handle, ®ion, &output, nullptr) == ARC_BUFFER_TOO_SMALL && output.required == needed, + "a buffer one byte short reports the exact required size"); + expect(all_bytes(short_buffer, 0x5A), "a short buffer is not written"); + std::vector full; + expect(read_region(handle, 0, 0, width, height, 4, full) == ARC_OK && + region_matches(full, source_pixels, width, 0, 0, width, height, 4), + "the full region reads exactly after the short attempt"); + arc_image_close(handle); +} + +// ---- Tests: formats, corruption, failure and cancellation ---------------------------------------- + +void test_unsupported_formats_and_empty_input() +{ + const std::vector gif = {'G', 'I', 'F', '8', '9', 'a', 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}; + const std::vector webp = {'R', 'I', 'F', 'F', 0x10, 0x00, 0x00, 0x00, 'W', 'E', 'B', 'P', + 'V', 'P', '8', ' '}; + const std::vector jpeg2000 = {0xFF, 0x4F, 0xFF, 0x51, 0x00, 0x2F}; + const std::vector empty; + const std::vector*> cases = {&gif, &webp, &jpeg2000, &empty}; + for (const auto* bytes : cases) { + source state{}; + state.bytes = bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0xFF; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_UNSUPPORTED && + handle == 0, + "GIF, WebP, JPEG 2000 and empty input are UNSUPPORTED with no handle"); + } +} + +void test_truncated_and_corrupt_input_is_corrupt() +{ + const uint32_t width = 8; + const uint32_t height = 8; + const auto png = encode_png_rgba8(width, height, rgba_pattern(width, height, 4)); + expect(png.size() > 64, "PNG fixture is large enough to truncate"); + + const std::vector truncated_png(png.begin(), png.begin() + 40); + expect_corrupt(truncated_png, width, height, "a truncated PNG is refused as CORRUPT with no output"); + + const std::vector signature_only(png.begin(), png.begin() + 8); + expect_corrupt(signature_only, width, height, "a PNG with only its signature is refused as CORRUPT"); + + std::vector bad_header_crc = png; + bad_header_crc[29] ^= 0xFF; // the IHDR CRC starts after the 8-byte signature, 4-byte length, 4-byte type and 13 data bytes + expect_corrupt(bad_header_crc, width, height, "a PNG with a damaged IHDR CRC is refused as CORRUPT"); + + expect_corrupt(png_with_damaged_pixels(png), width, height, + "a PNG with damaged pixel data fails CORRUPT on read with no output"); + + const auto tiff = encode("fixture.tif", rgba8_spec(width, height), OIIO::TypeDesc::UINT8, + rgba_pattern(width, height, 4).data()); + expect(!tiff.empty(), "TIFF fixture encodes for corruption checks"); + const std::vector truncated_tiff(tiff.begin(), tiff.begin() + static_cast(tiff.size() / 2)); + expect_corrupt(truncated_tiff, width, height, "a truncated TIFF is refused as CORRUPT with no output"); + + const auto exr = encode("fixture.exr", [] { + OIIO::ImageSpec exr_spec(8, 8, 4, OIIO::TypeDesc::FLOAT); + exr_spec.channelnames = {"R", "G", "B", "A"}; + exr_spec.alpha_channel = 3; + return exr_spec; + }(), OIIO::TypeDesc::FLOAT, float_pattern(8, 8, 4, 0.0F).data()); + expect(!exr.empty(), "EXR fixture encodes for corruption checks"); + const std::vector truncated_exr(exr.begin(), exr.begin() + static_cast(exr.size() / 2)); + expect_corrupt(truncated_exr, width, height, "a truncated EXR is refused as CORRUPT with no output"); + + std::vector bad_exr_version = exr; + bad_exr_version[4] = 0xFF; // the version field after the 4-byte magic + bad_exr_version[5] = 0xFF; + expect_corrupt(bad_exr_version, width, height, "an EXR with an invalid version field is refused as CORRUPT"); +} + +void test_failed_codec_and_callback_failure_keep_coverage() +{ + const uint32_t width = 64; + const uint32_t height = 64; + const std::vector source_pixels = rgba_pattern(width, height, 4); + OIIO::ImageSpec spec = rgba8_spec(width, height); + spec.tile_width = 16; + spec.tile_height = 16; + const auto bytes = encode("fixture.tif", spec, OIIO::TypeDesc::UINT8, source_pixels.data()); + expect(!bytes.empty(), "tiled TIFF fixture encodes for the failed-codec check"); + + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, + "fixture opens for the failed-codec check"); + + state.fail_after = state.calls; // the next source callback fails in the middle of the decode + std::vector pixels; + expect(read_region(handle, 0, 0, width, height, 4, pixels) == ARC_IO && all_bytes(pixels, 0), + "a failing callback during decode is IO and writes no pixels"); + state.fail_after = -1; + expect(read_region(handle, 0, 0, width, height, 4, pixels) == ARC_OK && + region_matches(pixels, source_pixels, width, 0, 0, width, height, 4), + "the failed read consumed no coverage, and the retry is exact"); + arc_image_close(handle); +} + +void test_decompression_bomb_refused_before_decode() +{ + // A pixel count above the hard profile (16385 x 16385) is refused from the header alone. + const auto over_profile = png_header_only(16385, 16385); + source over_state{}; + over_state.bytes = &over_profile; + const arc_io_v1 over_io = make_io(over_state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(over_io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_RESOURCE_LIMIT && + handle == 0, + "a header above the pixel profile is refused as RESOURCE_LIMIT with no handle"); + + // Inside the pixel profile, one 16-row band of 16384-wide RGBA32F rows needs about 4 MiB, above a 1 MiB limit. + // The refusal happens before any row is decoded, so the pixels stay untouched. + const auto wide = png_header_only(16384, 16384); + source wide_state{}; + wide_state.bytes = &wide; + const arc_io_v1 wide_io = make_io(wide_state); + arc_image_options_v1 tight = make_options(ARC_FORMAT_RGBA8); + tight.limits.max_memory_bytes = UINT64_C(1) << 20; + expect(open_image(wide_io, tight, nullptr, handle, metadata) == ARC_OK, + "a header within the pixel profile opens when the row band fits the memory limit"); + std::vector pixels; + expect(read_region(handle, 0, 0, 1, 16, 4, pixels) == ARC_RESOURCE_LIMIT && all_bytes(pixels, 0), + "a row band above the memory limit is refused before any row decodes"); + arc_image_close(handle); + + // Tile memory is checked at open. A 16 x 16 RGBA8 tile needs 4096 bytes of float working memory. + OIIO::ImageSpec tile_spec = rgba8_spec(16, 16); + tile_spec.tile_width = 16; + tile_spec.tile_height = 16; + const std::vector tile_pixels = rgba_pattern(16, 16, 4); + const auto tiled = encode("fixture.tif", tile_spec, OIIO::TypeDesc::UINT8, tile_pixels.data()); + expect(!tiled.empty(), "single-tile TIFF fixture encodes"); + source tile_state{}; + tile_state.bytes = &tiled; + const arc_io_v1 tile_io = make_io(tile_state); + arc_image_options_v1 just_under = make_options(ARC_FORMAT_RGBA8); + just_under.limits.max_memory_bytes = 4095; + expect(open_image(tile_io, just_under, nullptr, handle, metadata) == ARC_RESOURCE_LIMIT && handle == 0, + "a tile one byte above the memory limit is refused at open"); + arc_image_options_v1 exact = make_options(ARC_FORMAT_RGBA8); + exact.limits.max_memory_bytes = 4096; + expect(open_image(tile_io, exact, nullptr, handle, metadata) == ARC_OK, + "a tile exactly at the memory limit opens"); + arc_image_close(handle); +} + +void test_cancellation_at_every_tile_and_callback_boundary() +{ + const uint32_t width = 64; + const uint32_t height = 64; + const std::vector source_pixels = rgba_pattern(width, height, 4); + OIIO::ImageSpec spec = rgba8_spec(width, height); + spec.tile_width = 16; + spec.tile_height = 16; + const auto bytes = encode("fixture.tif", spec, OIIO::TypeDesc::UINT8, source_pixels.data()); + expect(!bytes.empty(), "tiled TIFF fixture encodes for the cancellation sweep"); + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + std::string metadata; + + // One uncancelled full read counts its cancellation checks. Each tile and each callback is a boundary. + cancel_state counter{}; + arc_cancel_token_t counting = make_token(counter); + arc_handle_t handle = 0; + std::vector pixels; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, + "tiled fixture opens for the cancellation sweep"); + expect(read_region(handle, 0, 0, width, height, 4, pixels, &counting) == ARC_OK && + region_matches(pixels, source_pixels, width, 0, 0, width, height, 4), + "the uncancelled read is exact"); + const int boundaries = counter.calls; + arc_image_close(handle); + expect(boundaries >= 16, "each of the 16 tiles is a cancellation boundary"); + + // Cancelling at any boundary returns CANCELLED, writes no pixels and consumes no coverage. + for (int cancel_at = 1; cancel_at <= boundaries; ++cancel_at) { + cancel_state cancelled{}; + cancelled.cancel_from = cancel_at; + arc_cancel_token_t token = make_token(cancelled); + arc_handle_t fresh = 0; + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, fresh, metadata) == ARC_OK, + "fixture reopens for each cancellation point"); + expect(read_region(fresh, 0, 0, width, height, 4, pixels, &token) == ARC_CANCELLED && all_bytes(pixels, 0), + "a read cancelled at a boundary is CANCELLED with no pixels written"); + expect(read_region(fresh, 0, 0, width, height, 4, pixels) == ARC_OK && + region_matches(pixels, source_pixels, width, 0, 0, width, height, 4), + "a cancelled read consumed no coverage"); + arc_image_close(fresh); + } +} + +void test_read_deadline_refuses_before_commit() +{ + const uint32_t width = 64; + const uint32_t height = 64; + const std::vector source_pixels = rgba_pattern(width, height, 4); + OIIO::ImageSpec spec = rgba8_spec(width, height); + spec.tile_width = 16; + spec.tile_height = 16; + const auto bytes = encode("fixture.tif", spec, OIIO::TypeDesc::UINT8, source_pixels.data()); + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_image_options_v1 timed = make_options(ARC_FORMAT_RGBA8); + timed.limits.timeout_ms = 100; + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, timed, nullptr, handle, metadata) == ARC_OK, "fixture opens within its deadline"); + + // Each source callback now takes 40 ms, so the 100 ms deadline passes between tiles. + state.sleep_ms = 40; + std::vector pixels; + expect(read_region(handle, 0, 0, width, height, 4, pixels) == ARC_RESOURCE_LIMIT && all_bytes(pixels, 0), + "a read that passes its deadline between tiles is RESOURCE_LIMIT with no pixels written"); + state.sleep_ms = 0; + expect(read_region(handle, 0, 0, width, height, 4, pixels) == ARC_OK && + region_matches(pixels, source_pixels, width, 0, 0, width, height, 4), + "the timed-out read consumed no coverage"); + arc_image_close(handle); +} + +// ---- Tests: EXR subimages and mip levels within bounds ------------------------------------------- + +void test_exr_multipart_subimages_within_bounds() +{ + // OpenImageIO's EXR writer accepts parts only when they share one size, so both parts are 6 x 3 RGBA with + // different samples. + const std::vector first = float_pattern(6, 3, 4, -3.0F); + const std::vector second = float_pattern(6, 3, 4, 2.0F); + OIIO::ImageSpec part_spec(6, 3, 4, OIIO::TypeDesc::FLOAT); + part_spec.channelnames = {"R", "G", "B", "A"}; + part_spec.alpha_channel = 3; + const auto bytes = encode_parts(".exr", {part{part_spec, OIIO::TypeDesc::FLOAT, first.data(), false}, + part{part_spec, OIIO::TypeDesc::FLOAT, second.data(), false}}); + expect(!bytes.empty(), "two-part EXR fixture encodes"); + + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED, 1, 0), nullptr, handle, metadata) == ARC_OK && + contains(metadata, "\"subimage\":1") && contains(metadata, "\"subimages\":2") && + contains(metadata, "\"width\":6") && contains(metadata, "\"height\":3"), + "the second part opens with its own index and the subimage count"); + std::vector raw; + expect(read_region(handle, 0, 0, 6, 3, 16, raw) == ARC_OK && + region_matches(raw, as_bytes(second), 6, 0, 0, 6, 3, 16), + "the second part reads exactly"); + arc_image_close(handle); + + expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED, 0, 0), nullptr, handle, metadata) == ARC_OK && + contains(metadata, "\"subimage\":0") && contains(metadata, "\"subimages\":2"), + "the first part opens with the same subimage count"); + expect(read_region(handle, 0, 0, 6, 3, 16, raw) == ARC_OK && + region_matches(raw, as_bytes(first), 6, 0, 0, 6, 3, 16), + "the first part reads exactly, and differently from the second"); + arc_image_close(handle); + + expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED, 2, 0), nullptr, handle, metadata) == ARC_NOT_FOUND && + handle == 0, + "a subimage past the last part is NOT_FOUND"); + + arc_image_options_v1 one_item = make_options(ARC_FORMAT_FLOAT32_INTERLEAVED, 0, 0); + one_item.limits.max_items = 1; + expect(open_image(io, one_item, nullptr, handle, metadata) == ARC_RESOURCE_LIMIT && handle == 0, + "two subimages exceed a one-item limit before any decode"); +} + +void test_exr_mip_levels_within_bounds() +{ + const uint32_t width = 32; + const uint32_t height = 16; + const std::vector base = float_pattern(width, height, 4, -1.0F); + OIIO::ImageSpec spec(static_cast(width), static_cast(height), 4, OIIO::TypeDesc::FLOAT); + spec.channelnames = {"R", "G", "B", "A"}; + spec.alpha_channel = 3; + OIIO::ImageBuf input(spec); + input.set_pixels(OIIO::ROI(0, static_cast(width), 0, static_cast(height), 0, 1, 0, 4), + OIIO::TypeDesc::FLOAT, base.data()); + + // OpenImageIO's texture writer produces a tiled, mipmapped EXR. Its own reader is the oracle for every level. + const temporary_file cleanup{temporary_fixture_path(".exr")}; + const bool written = OIIO::ImageBufAlgo::make_texture(OIIO::ImageBufAlgo::MakeTxTexture, input, + cleanup.path.string(), OIIO::ImageSpec()); + expect(written, "the texture writer produces a mipmapped EXR fixture"); + if (!written) { + (void)OIIO::geterror(); + return; + } + + std::vector> levels; + std::vector level_specs; + { + auto oracle = OIIO::ImageInput::open(cleanup.path.string()); + expect(oracle != nullptr, "the oracle reader opens the mipmapped fixture"); + for (int mip = 0; oracle && oracle->seek_subimage(0, mip); ++mip) { + const OIIO::ImageSpec level = oracle->spec(0, mip); + std::vector pixels(static_cast(level.width) * level.height * 4); + expect(oracle->read_image(0, mip, 0, 4, OIIO::TypeDesc::FLOAT, pixels.data()), + "the oracle reads every mip level"); + levels.push_back(std::move(pixels)); + level_specs.push_back(level); + } + } + expect(levels.size() >= 2, "the fixture has a base level and at least one mip level"); + + std::ifstream file(cleanup.path, std::ios::binary | std::ios::ate); + const auto size = file ? static_cast(file.tellg()) : size_t{0}; + std::vector bytes(size); + if (file) { + file.seekg(0); + file.read(reinterpret_cast(bytes.data()), static_cast(size)); + } + expect(!bytes.empty(), "the mipmapped fixture bytes are read back"); + + source state{}; + state.bytes = &bytes; + const arc_io_v1 io = make_io(state); + const auto mip_count = static_cast(levels.size()); + for (uint32_t mip = 0; mip < mip_count; ++mip) { + const auto level_width = static_cast(level_specs[mip].width); + const auto level_height = static_cast(level_specs[mip].height); + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED, 0, mip), nullptr, handle, metadata) == ARC_OK && + contains(metadata, "\"mip\":" + std::to_string(mip)) && + contains(metadata, "\"mips\":" + std::to_string(mip_count)) && + contains(metadata, "\"width\":" + std::to_string(level_width)) && + contains(metadata, "\"height\":" + std::to_string(level_height)), + "a mip level opens with its own dimensions and the mip count"); + std::vector raw; + expect(read_region(handle, 0, 0, level_width, level_height, 16, raw) == ARC_OK && + region_matches(raw, as_bytes(levels[mip]), level_width, 0, 0, level_width, level_height, 16), + "a mip level reads exactly as the oracle reads it"); + arc_image_close(handle); + } + + arc_handle_t handle = 0; + std::string metadata; + expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED, 0, mip_count), nullptr, handle, metadata) == + ARC_NOT_FOUND && handle == 0, + "a mip level past the last one is NOT_FOUND"); + + arc_image_options_v1 one_item = make_options(ARC_FORMAT_FLOAT32_INTERLEAVED, 0, 0); + one_item.limits.max_items = 1; + expect(open_image(io, one_item, nullptr, handle, metadata) == ARC_RESOURCE_LIMIT && handle == 0, + "a mip count above a one-item limit is refused before any decode"); +} + +// ---- Registration ------------------------------------------------------------------------------- +// Each entry is one CTest target (arcslate_image_abi.codec.) in native/arcimage-abi/CMakeLists.txt. +// Keep the two lists in step. + +struct test_case final { + const char* name; + void (*run)(); +}; + +constexpr test_case test_cases[] = { + {"probe", test_probe_and_build_info}, + {"png_rgba8", test_png_rgba8_round_trip}, + {"png_rgb_fills_alpha", test_png_rgb_fills_alpha}, + {"png16_bit_loss", test_png_16bit_reports_bit_depth_loss}, + {"png16_float_identity", test_png16_float_identity_round_trip}, + {"png_linear_premultiply", test_png_linear_float_and_premultiply}, + {"tiff8_straight", test_tiff8_straight_round_trip}, + {"tiff16_float_identity", test_tiff16_and_float_identity_round_trip}, + {"exr_float", test_exr_float_round_trip}, + {"exr_half_clamp", test_exr_half_round_trip_and_rgba8_clamp}, + {"exr_premultiplied", test_exr_premultiplied_source_is_not_premultiplied_again}, + {"tiff_associated", test_tiff_associated_alpha_is_not_premultiplied_again}, + {"tiled_tiff_edges", test_tiled_tiff_edge_tiles_and_coverage}, + {"edge_one_pixel", test_edge_tiles_and_one_pixel_regions}, + {"coverage_order", test_coverage_order_and_no_state_change}, + {"coverage_refusals", test_coverage_refusals_keep_the_cursor}, + {"buffer_too_small", test_buffer_too_small_consumes_nothing}, + {"buffer_one_byte_short", test_buffer_one_byte_short_consumes_nothing}, + {"unsupported_content", test_unsupported_content}, + {"unsupported_formats", test_unsupported_formats_and_empty_input}, + {"corrupt_input", test_truncated_and_corrupt_input_is_corrupt}, + {"failed_codec", test_failed_codec_and_callback_failure_keep_coverage}, + {"limits_bomb", test_limits_and_decompression_bomb}, + {"bomb_before_decode", test_decompression_bomb_refused_before_decode}, + {"region_validation", test_region_validation_and_output_limit}, + {"options_subimage", test_options_and_subimage_selection}, + {"cancellation", test_cancellation_and_deadline}, + {"cancel_each_boundary", test_cancellation_at_every_tile_and_callback_boundary}, + {"read_deadline", test_read_deadline_refuses_before_commit}, + {"callback_failures", test_callback_failures}, + {"handle_lifecycle", test_handle_lifecycle_and_limit}, + {"exr_multipart", test_exr_multipart_subimages_within_bounds}, + {"exr_mips", test_exr_mip_levels_within_bounds}, +}; + } // namespace -int main() -{ - test_probe_and_build_info(); - test_png_rgba8_round_trip(); - test_png_rgb_fills_alpha(); - test_png_16bit_reports_bit_depth_loss(); - test_png_linear_float_and_premultiply(); - test_exr_float_round_trip(); - test_exr_premultiplied_source_is_not_premultiplied_again(); - test_tiff_associated_alpha_is_not_premultiplied_again(); - test_tiled_tiff_edge_tiles_and_coverage(); - test_coverage_order_and_no_state_change(); - test_buffer_too_small_consumes_nothing(); - test_unsupported_and_corrupt_content(); - test_limits_and_decompression_bomb(); - test_region_validation_and_output_limit(); - test_options_and_subimage_selection(); - test_cancellation_and_deadline(); - test_callback_failures(); - test_handle_lifecycle_and_limit(); +// With no argument every test runs. With one argument, only the test of that name runs, so CTest can list each. +int main(int argc, char** argv) +{ + if (argc > 2) { + std::fprintf(stderr, "usage: arcslate_image_codec_tests [test-name]\n"); + return 2; + } + const std::string_view wanted = argc == 2 ? std::string_view(argv[1]) : std::string_view(); + int selected = 0; + for (const test_case& test : test_cases) { + if (!wanted.empty() && wanted != test.name) { + continue; + } + ++selected; + test.run(); + (void)OIIO::geterror(); // clears OpenImageIO's pending message so it is not printed at exit + } + if (selected == 0) { + std::fprintf(stderr, "unknown codec test: %s\n", argv[1]); + return 2; + } + (void)OIIO::geterror(); std::printf("arcimage codec checks: %d, failures: %d\n", checks, failures); return failures == 0 ? 0 : 1; } From 75453abf8b13abc6336d6138d8d803a2ae067b5f Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 04:02:27 +0100 Subject: [PATCH 04/15] [NAT.11] Bind functional image reads in ArcForges.Native.Image Unit 3 of the image family: the managed binding for arc_image_open, arc_image_read and arc_image_close, with the ABI 1.1 functional minor verified through the JSON build-info capability manifest. - ImageAbi.Functional: LibraryImport declarations kept internal; the library must advertise minor 1, image.open/read/close and png/tiff/exr. - ImageHandle: dedicated SafeHandle owning the generation token; the callback GCHandle is freed only after a successful native close. - ImageReader: IAsyncDisposable, bounded async open/read/complete with CancellationToken, per-reader serialised native calls, managed coverage accounting that mirrors D3, and CompleteAsync that refuses missing coverage with ImageCoverageIncompleteException. - ImageOptions, ImageResults, ImageMetadata: typed options and immutable result records; no native pointer or raw options dictionary is exposed. Output format Unspecified is refused rather than defaulting silently. - Callbacks never throw across the ABI; cancellation bridges through a pinned CancellationState for the duration of one native call. Tests (tests/NativeAbiTests, D5): PNG and TIFF straight-alpha byte exact round trips, float premultiply, coverage and completion, overlap and raster-order refusal without state change, corrupt and unsupported content, cancellation without coverage, dispose, invalid input and an oversized region refused before any read. Validated locally on win-x64 through the build slot: unlocked and locked restore of the test project, build with TreatWarningsAsErrors, NativeAbiLayout 3 of 3, NativeAbi 15 of 15, native CTest filter 36 of 36, full solution locked restore, managed licence-boundary evaluation (61 projects) and source and native provenance. linux-x64 is not run (D8, WSL cmake and ninja not installed). Co-Authored-By: Claude Haiku 5.5 --- eng/provenance/files.json | 10 + .../ArcForges.Native.Image.csproj | 3 +- .../ImageAbi.Functional.cs | 108 ++++++ .../ArcForges.Native.Image/ImageByteSource.cs | 57 ++++ .../ArcForges.Native.Image/ImageCallbacks.cs | 111 ++++++ .../ArcForges.Native.Image/ImageHandle.cs | 31 ++ .../ArcForges.Native.Image/ImageMetadata.cs | 168 +++++++++ .../ArcForges.Native.Image/ImageOptions.cs | 114 +++++++ .../ArcForges.Native.Image/ImageReader.cs | 303 ++++++++++++++++ .../ArcForges.Native.Image/ImageResults.cs | 76 +++++ src/Native/ArcForges.Native.Image/README.md | 26 +- .../ArcForges.Tests.NativeAbiTests.csproj | 4 + tests/NativeAbiTests/ImageFixtures.cs | 153 +++++++++ tests/NativeAbiTests/ImageReaderTests.cs | 323 ++++++++++++++++++ tests/NativeAbiTests/README.md | 5 + tests/NativeAbiTests/packages.lock.json | 6 + 16 files changed, 1492 insertions(+), 6 deletions(-) create mode 100644 src/Native/ArcForges.Native.Image/ImageAbi.Functional.cs create mode 100644 src/Native/ArcForges.Native.Image/ImageByteSource.cs create mode 100644 src/Native/ArcForges.Native.Image/ImageCallbacks.cs create mode 100644 src/Native/ArcForges.Native.Image/ImageHandle.cs create mode 100644 src/Native/ArcForges.Native.Image/ImageMetadata.cs create mode 100644 src/Native/ArcForges.Native.Image/ImageOptions.cs create mode 100644 src/Native/ArcForges.Native.Image/ImageReader.cs create mode 100644 src/Native/ArcForges.Native.Image/ImageResults.cs create mode 100644 tests/NativeAbiTests/ImageFixtures.cs create mode 100644 tests/NativeAbiTests/ImageReaderTests.cs diff --git a/eng/provenance/files.json b/eng/provenance/files.json index 11789c74..24abc778 100644 --- a/eng/provenance/files.json +++ b/eng/provenance/files.json @@ -762,7 +762,15 @@ "src/Native/ArcForges.Native.Image.Runtime.win-x64/README.md", "src/Native/ArcForges.Native.Image.Runtime.win-x64/packages.lock.json", "src/Native/ArcForges.Native.Image/ArcForges.Native.Image.csproj", + "src/Native/ArcForges.Native.Image/ImageAbi.Functional.cs", "src/Native/ArcForges.Native.Image/ImageAbi.cs", + "src/Native/ArcForges.Native.Image/ImageByteSource.cs", + "src/Native/ArcForges.Native.Image/ImageCallbacks.cs", + "src/Native/ArcForges.Native.Image/ImageHandle.cs", + "src/Native/ArcForges.Native.Image/ImageMetadata.cs", + "src/Native/ArcForges.Native.Image/ImageOptions.cs", + "src/Native/ArcForges.Native.Image/ImageReader.cs", + "src/Native/ArcForges.Native.Image/ImageResults.cs", "src/Native/ArcForges.Native.Image/README.md", "src/Native/ArcForges.Native.Image/packages.lock.json", "tests/ArchitectureTests/ArcForges.Tests.ArchitectureTests.csproj", @@ -794,6 +802,8 @@ "tests/LocalRpcAotTests/Program.cs", "tests/LocalRpcAotTests/README.md", "tests/LocalRpcAotTests/packages.lock.json", + "tests/NativeAbiTests/ImageFixtures.cs", + "tests/NativeAbiTests/ImageReaderTests.cs", "tests/ReleaseArtifactTests/Realtime/Classification.cs", "tests/ReleaseArtifactTests/Realtime/Events.cs", "tests/ReleaseArtifactTests/Realtime/Live.cs", diff --git a/src/Native/ArcForges.Native.Image/ArcForges.Native.Image.csproj b/src/Native/ArcForges.Native.Image/ArcForges.Native.Image.csproj index 9132f775..ee2a468b 100644 --- a/src/Native/ArcForges.Native.Image/ArcForges.Native.Image.csproj +++ b/src/Native/ArcForges.Native.Image/ArcForges.Native.Image.csproj @@ -3,10 +3,11 @@ net10.0 true + true true ArcForges.Native.Image README.md - ArcForges Image existing native ABI version, build-information and error bindings. + ArcForges Image native ABI bindings: version, build information, errors and functional still-image reads. diff --git a/src/Native/ArcForges.Native.Image/ImageAbi.Functional.cs b/src/Native/ArcForges.Native.Image/ImageAbi.Functional.cs new file mode 100644 index 00000000..76e5b0b9 --- /dev/null +++ b/src/Native/ArcForges.Native.Image/ImageAbi.Functional.cs @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text.Json; +using ArcForges.Native.Abstractions; + +namespace ArcForges.Native.Image; + +// Functional image exports (Annex 06 sections 2 and 3). The declarations stay internal: product code +// reaches them only through ImageReader, which owns the generation token and the callback context. +public static unsafe partial class ImageAbi +{ + private static readonly Lazy FunctionalBuildInfo = new(VerifyFunctionalAbi, LazyThreadSafetyMode.ExecutionAndPublication); + + // Confirms ABI minor 1 and the closed capability manifest once per process. A library without the + // functional exports fails here, before any native image call is made. + internal static string RequireFunctionalAbi() => FunctionalBuildInfo.Value; + + internal static NativeStatus OpenNative(NativeIoV1* io, NativeImageOptionsV1* options, ulong* image, + NativeBuffer* metadata, NativeCancelToken* cancel) => + (NativeStatus)OpenCore(io, options, image, metadata, cancel); + + internal static NativeStatus ReadNative(ulong image, NativeRegionV1* region, NativeBuffer* pixels, + NativeCancelToken* cancel) => + (NativeStatus)ReadCore(image, region, pixels, cancel); + + internal static NativeStatus CloseNative(ulong image) => (NativeStatus)CloseCore(image); + + // Must run on the thread that made the failing call, immediately after it returns, because the + // diagnostic slot is thread local. + internal static string LastErrorMessage(NativeStatus status) + { + try + { + string message = GetLastError().Message; + return string.IsNullOrEmpty(message) ? status.ToString() : message; + } + catch (InvalidOperationException) + { + return status.ToString(); + } + } + + private static string VerifyFunctionalAbi() + { + NativeAbiVersion version = GetAbiVersion(); + if (version.Minor < NativeAbiConstants.FunctionalMinor) + { + throw new ImageNativeException(NativeStatus.VersionMismatch, + $"ArcImageNative ABI {version.Major}.{version.Minor} does not provide the functional image exports."); + } + + string info = GetBuildInfo(); + if (!AdvertisesFunctionalImage(info)) + { + throw new ImageNativeException(NativeStatus.VersionMismatch, + "ArcImageNative build information does not advertise the functional image capabilities."); + } + + return info; + } + + private static bool AdvertisesFunctionalImage(string info) + { + try + { + using JsonDocument document = JsonDocument.Parse(info); + JsonElement root = document.RootElement; + return root.GetProperty("library").GetString() == "ArcImageNative" + && root.GetProperty("abi").GetProperty("minor").GetUInt32() == NativeAbiConstants.FunctionalMinor + && ContainsAll(root.GetProperty("capabilities"), "image.open", "image.read", "image.close") + && ContainsAll(root.GetProperty("formats"), "png", "tiff", "exr"); + } + catch (Exception ex) when (ex is JsonException or KeyNotFoundException or InvalidOperationException or FormatException) + { + return false; + } + } + + private static bool ContainsAll(JsonElement array, params string[] required) + { + var present = new HashSet(StringComparer.Ordinal); + foreach (JsonElement item in array.EnumerateArray()) + { + if (item.GetString() is { } value) + { + present.Add(value); + } + } + + return required.All(present.Contains); + } + + [LibraryImport("ArcImageNative", EntryPoint = "arc_image_open")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + private static partial int OpenCore(NativeIoV1* io, NativeImageOptionsV1* options, ulong* image, + NativeBuffer* metadata, NativeCancelToken* cancel); + + [LibraryImport("ArcImageNative", EntryPoint = "arc_image_read")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + private static partial int ReadCore(ulong image, NativeRegionV1* region, NativeBuffer* pixels, + NativeCancelToken* cancel); + + [LibraryImport("ArcImageNative", EntryPoint = "arc_image_close")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + private static partial int CloseCore(ulong image); +} diff --git a/src/Native/ArcForges.Native.Image/ImageByteSource.cs b/src/Native/ArcForges.Native.Image/ImageByteSource.cs new file mode 100644 index 00000000..617666c2 --- /dev/null +++ b/src/Native/ArcForges.Native.Image/ImageByteSource.cs @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +namespace ArcForges.Native.Image; + +// Bytes of one image, read synchronously by native code through the arc_io_v1 callback. The source is +// fixed for the life of an ImageReader. ReadAt runs on the thread that is executing the native call, so it +// must not block on UI work or on the same reader. Implementations are serialised by the reader. +public abstract class ImageByteSource +{ + // Total byte length. Must not change while a reader is open. + public abstract long Length { get; } + + // Copies up to destination.Length bytes starting at offset and returns the count copied. A short count + // is allowed only at the end of the source. Returning zero at or after the end is end of input. + public abstract int ReadAt(long offset, Span destination); + + // Wraps a seekable stream. The caller keeps ownership of the stream and must not use it while a reader + // is reading from it. + public static ImageByteSource FromStream(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + if (!stream.CanSeek || !stream.CanRead) + { + throw new ArgumentException("The image stream must be readable and seekable.", nameof(stream)); + } + + return new StreamByteSource(stream); + } + + private sealed class StreamByteSource(Stream stream) : ImageByteSource + { + public override long Length => stream.Length; + + public override int ReadAt(long offset, Span destination) + { + if (offset < 0 || offset >= stream.Length || destination.IsEmpty) + { + return 0; + } + + stream.Position = offset; + int total = 0; + while (total < destination.Length) + { + int read = stream.Read(destination[total..]); + if (read == 0) + { + break; + } + + total += read; + } + + return total; + } + } +} diff --git a/src/Native/ArcForges.Native.Image/ImageCallbacks.cs b/src/Native/ArcForges.Native.Image/ImageCallbacks.cs new file mode 100644 index 00000000..a1d13b04 --- /dev/null +++ b/src/Native/ArcForges.Native.Image/ImageCallbacks.cs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using ArcForges.Native.Abstractions; + +namespace ArcForges.Native.Image; + +// Entry points that native code calls back into. They run on the thread that is executing the native +// call, never throw across the ABI, and hold no managed monitor while they run. +internal static unsafe class ImageCallbacks +{ + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + [SuppressMessage("Design", "CA1031:Do not catch general exception types", + Justification = "A managed source failure must become an IO status at the native boundary, never an exception across it.")] + internal static int ReadAt(nint context, ulong offset, nint destination, ulong count, ulong* done) + { + try + { + if (done == null) + { + return (int)NativeStatus.InvalidArgument; + } + + *done = 0; + if (count == 0) + { + return (int)NativeStatus.Ok; + } + + if (context == 0 || destination == 0 || count > int.MaxValue || offset > long.MaxValue) + { + return (int)NativeStatus.InvalidArgument; + } + + if (GCHandle.FromIntPtr(context).Target is not ImageByteSource source) + { + return (int)NativeStatus.Internal; + } + + int read = source.ReadAt((long)offset, new Span((void*)destination, (int)count)); + if (read < 0 || read > (int)count) + { + return (int)NativeStatus.Io; + } + + *done = (ulong)read; + return (int)NativeStatus.Ok; + } + catch (Exception) + { + if (done != null) + { + *done = 0; + } + + return (int)NativeStatus.Io; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + [SuppressMessage("Design", "CA1031:Do not catch general exception types", + Justification = "A cancellation probe that cannot read its state must report cancelled, so native code stops.")] + internal static byte IsCancelled(nint userData) + { + try + { + return GCHandle.FromIntPtr(userData).Target is CancellationState state && !state.Token.IsCancellationRequested + ? (byte)0 + : (byte)1; + } + catch (Exception) + { + return 1; + } + } +} + +// Carries one caller's CancellationToken into a native call. The GCHandle lives only for that call. +internal sealed class CancellationState(CancellationToken token) +{ + internal CancellationToken Token { get; } = token; +} + +// Owns the pinned cancellation state for one native call. Dispose after the call returns. +internal sealed unsafe class CancellationBridge : IDisposable +{ + private readonly GCHandle _state; + + internal CancellationBridge(CancellationToken token) + { + Enabled = token.CanBeCanceled; + _state = GCHandle.Alloc(new CancellationState(token)); + } + + internal bool Enabled { get; } + + internal NativeCancelToken Token => new() + { + StructSize = (uint)sizeof(NativeCancelToken), + StructVersion = NativeAbiConstants.RecordVersion1, + IsCancelled = &ImageCallbacks.IsCancelled, + UserData = GCHandle.ToIntPtr(_state), + }; + + public void Dispose() + { + _state.Free(); + } +} diff --git a/src/Native/ArcForges.Native.Image/ImageHandle.cs b/src/Native/ArcForges.Native.Image/ImageHandle.cs new file mode 100644 index 00000000..cbeec596 --- /dev/null +++ b/src/Native/ArcForges.Native.Image/ImageHandle.cs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +using System.Runtime.InteropServices; +using ArcForges.Native.Abstractions; + +namespace ArcForges.Native.Image; + +// Owns one arc_handle_t generation token and the GCHandle that keeps the managed byte source reachable for +// the native callbacks. The token is released through arc_image_close; the callback context is freed only +// after that close succeeds, so native code never calls into a freed source. +internal sealed class ImageHandle : NativeSafeHandle +{ + private GCHandle _context; + + internal ImageHandle(ulong token, GCHandle context) + : base(token) + { + _context = context; + } + + protected override NativeStatus ReleaseNativeToken(ulong token) + { + NativeStatus status = ImageAbi.CloseNative(token); + if ((status is NativeStatus.Ok or NativeStatus.Closed) && _context.IsAllocated) + { + _context.Free(); + } + + return status; + } +} diff --git a/src/Native/ArcForges.Native.Image/ImageMetadata.cs b/src/Native/ArcForges.Native.Image/ImageMetadata.cs new file mode 100644 index 00000000..9c2abce2 --- /dev/null +++ b/src/Native/ArcForges.Native.Image/ImageMetadata.cs @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +using System.Collections.ObjectModel; +using System.Text; +using System.Text.Json; + +namespace ArcForges.Native.Image; + +public enum ImageSourceFormat +{ + Png, + Tiff, + Exr, +} + +public enum ImageAlphaKind +{ + None, + Straight, + Premultiplied, +} + +public enum ImageChannelMapping +{ + Named, + Positional, +} + +// One source channel as the file declares it. Type is the closed OpenImageIO type name. +public sealed record ImageChannel(string Name, string Type); + +// Immutable description of the selected level. Conversions and Losses list every change applied to pixels +// that a region read returns, so callers can see bit-depth reduction, clamping and dropped channels. +public sealed record ImageMetadata( + ImageSourceFormat Format, + uint Subimage, + uint Subimages, + uint Mip, + uint Mips, + uint Width, + uint Height, + int OriginX, + int OriginY, + bool Tiled, + uint TileWidth, + uint TileHeight, + IReadOnlyList Channels, + ImageChannelMapping ChannelMapping, + ImageAlphaKind Alpha, + string ColorSpace, + uint SourceBitsMax, + ImageOutputFormat OutputFormat, + uint OutputChannels, + uint BytesPerPixel, + IReadOnlyList Conversions, + IReadOnlyList Losses) +{ + internal const string Schema = "arc.image.metadata.v1"; + + // Parses the closed arc.image.metadata.v1 document. Any missing field, unknown name or inconsistent + // size is refused, because the native library always writes this exact shape. + internal static ImageMetadata Parse(ReadOnlySpan utf8) + { + try + { + string text = new UTF8Encoding(false, true).GetString(utf8); + using JsonDocument document = JsonDocument.Parse(text); + JsonElement root = document.RootElement; + if (root.GetProperty("schema").GetString() != Schema) + { + throw new InvalidDataException("Image metadata schema is not arc.image.metadata.v1."); + } + + var channels = new List(); + foreach (JsonElement channel in root.GetProperty("channels").EnumerateArray()) + { + channels.Add(new ImageChannel(channel.GetProperty("name").GetString() ?? string.Empty, + channel.GetProperty("type").GetString() ?? string.Empty)); + } + + ImageOutputFormat output = ParseOutput(root.GetProperty("outputFormat").GetString()); + uint outputChannels = root.GetProperty("outputChannels").GetUInt32(); + uint bytesPerPixel = root.GetProperty("bytesPerPixel").GetUInt32(); + ValidatePixelSize(output, outputChannels, bytesPerPixel); + + return new ImageMetadata( + ParseFormat(root.GetProperty("format").GetString()), + root.GetProperty("subimage").GetUInt32(), + root.GetProperty("subimages").GetUInt32(), + root.GetProperty("mip").GetUInt32(), + root.GetProperty("mips").GetUInt32(), + root.GetProperty("width").GetUInt32(), + root.GetProperty("height").GetUInt32(), + root.GetProperty("originX").GetInt32(), + root.GetProperty("originY").GetInt32(), + root.GetProperty("tiled").GetBoolean(), + root.GetProperty("tileWidth").GetUInt32(), + root.GetProperty("tileHeight").GetUInt32(), + channels.AsReadOnly(), + root.GetProperty("channelMapping").GetString() switch + { + "named" => ImageChannelMapping.Named, + "positional" => ImageChannelMapping.Positional, + _ => throw new InvalidDataException("Image channel mapping is not known."), + }, + root.GetProperty("alpha").GetString() switch + { + "none" => ImageAlphaKind.None, + "straight" => ImageAlphaKind.Straight, + "premultiplied" => ImageAlphaKind.Premultiplied, + _ => throw new InvalidDataException("Image alpha kind is not known."), + }, + root.GetProperty("colorSpace").GetString() ?? string.Empty, + root.GetProperty("sourceBitsMax").GetUInt32(), + output, + outputChannels, + bytesPerPixel, + ReadStrings(root.GetProperty("conversions")), + ReadStrings(root.GetProperty("loss"))); + } + catch (Exception ex) when (ex is JsonException or KeyNotFoundException or InvalidOperationException + or FormatException or ArgumentException) + { + throw new InvalidDataException("Image metadata is malformed.", ex); + } + } + + private static ImageSourceFormat ParseFormat(string? value) => value switch + { + "png" => ImageSourceFormat.Png, + "tiff" => ImageSourceFormat.Tiff, + "exr" => ImageSourceFormat.Exr, + _ => throw new InvalidDataException("Image source format is not known."), + }; + + private static ImageOutputFormat ParseOutput(string? value) => value switch + { + "rgba8" => ImageOutputFormat.Rgba8, + "rgba32fLinearPremultiplied" => ImageOutputFormat.Rgba32fLinearPremultiplied, + "float32Interleaved" => ImageOutputFormat.Float32Interleaved, + _ => throw new InvalidDataException("Image output format is not known."), + }; + + private static void ValidatePixelSize(ImageOutputFormat output, uint channels, uint bytesPerPixel) + { + bool consistent = output switch + { + ImageOutputFormat.Rgba8 => channels == 4 && bytesPerPixel == 4, + ImageOutputFormat.Rgba32fLinearPremultiplied => channels == 4 && bytesPerPixel == 16, + _ => channels is > 0 and <= 64 && bytesPerPixel == channels * 4U, + }; + if (!consistent) + { + throw new InvalidDataException("Image pixel size is inconsistent with its output format."); + } + } + + private static ReadOnlyCollection ReadStrings(JsonElement array) + { + var values = new List(); + foreach (JsonElement item in array.EnumerateArray()) + { + values.Add(item.GetString() ?? string.Empty); + } + + return values.AsReadOnly(); + } +} diff --git a/src/Native/ArcForges.Native.Image/ImageOptions.cs b/src/Native/ArcForges.Native.Image/ImageOptions.cs new file mode 100644 index 00000000..a22640bb --- /dev/null +++ b/src/Native/ArcForges.Native.Image/ImageOptions.cs @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +using ArcForges.Native.Abstractions; + +namespace ArcForges.Native.Image; + +// Closed output formats (Annex 06 section 2). The numeric keys are the ABI values and are never exposed. +public enum ImageOutputFormat +{ + // Never a valid request; present so that the default value is refused rather than silently meaning a format. + Unspecified = 0, + + // Packed 8-bit unorm RGBA with straight alpha and no transfer change. + Rgba8 = 1, + + // Packed 32-bit float RGBA, linear light when the source declares sRGB, premultiplied. + Rgba32fLinearPremultiplied = 2, + + // Source channels kept as 32-bit float, with no transfer or alpha change. + Float32Interleaved = 3, +} + +// Resource bounds for one open. Every value narrows the producer profile and is checked before the call. +public sealed record ImageLimits +{ + public const ulong ProfileMaxInputBytes = 4UL * 1024 * 1024 * 1024; + public const ulong ProfileMaxMemoryBytes = 4UL * 1024 * 1024 * 1024; + public const ulong ProfileMaxOutputBytes = 64UL * 1024 * 1024; + public const uint ProfileMaxDimension = 65535; + public const uint ProfileMaxItems = 256; + public const uint ProfileMaxTimeoutMilliseconds = 3_600_000; + + public ulong MaxInputBytes { get; init; } = 1024UL * 1024 * 1024; + + public ulong MaxMemoryBytes { get; init; } = 1024UL * 1024 * 1024; + + // Largest single region read, in bytes of packed output. + public ulong MaxOutputBytes { get; init; } = ProfileMaxOutputBytes; + + public uint MaxWidth { get; init; } = ProfileMaxDimension; + + public uint MaxHeight { get; init; } = ProfileMaxDimension; + + public uint MaxItems { get; init; } = 256; + + // Per-call deadline. Observed between tiles and source callbacks, not inside one codec call. + public uint TimeoutMilliseconds { get; init; } = 60_000; + + internal void Validate() + { + RequireRange(MaxInputBytes, ProfileMaxInputBytes, nameof(MaxInputBytes)); + RequireRange(MaxMemoryBytes, ProfileMaxMemoryBytes, nameof(MaxMemoryBytes)); + RequireRange(MaxOutputBytes, ProfileMaxOutputBytes, nameof(MaxOutputBytes)); + RequireRange(MaxWidth, ProfileMaxDimension, nameof(MaxWidth)); + RequireRange(MaxHeight, ProfileMaxDimension, nameof(MaxHeight)); + RequireRange(MaxItems, ProfileMaxItems, nameof(MaxItems)); + RequireRange(TimeoutMilliseconds, ProfileMaxTimeoutMilliseconds, nameof(TimeoutMilliseconds)); + } + + private static void RequireRange(ulong value, ulong profileMaximum, string name) + { + if (value == 0 || value > profileMaximum) + { + throw new ArgumentOutOfRangeException(name, value, "The value must be positive and within the producer profile."); + } + } + + internal unsafe NativeLimitsV1 ToNative() => new() + { + StructSize = (uint)sizeof(NativeLimitsV1), + StructVersion = NativeAbiConstants.RecordVersion1, + MaxInputBytes = MaxInputBytes, + MaxMemoryBytes = MaxMemoryBytes, + MaxOutputBytes = MaxOutputBytes, + MaxWidth = MaxWidth, + MaxHeight = MaxHeight, + MaxItems = MaxItems, + TimeoutMs = TimeoutMilliseconds, + }; +} + +// Typed options for ImageReader.OpenAsync. Subimage and mip select one level of a multi-level file. +public sealed record ImageOpenOptions +{ + public uint Subimage { get; init; } + + public uint Mip { get; init; } + + public ImageOutputFormat Format { get; init; } = ImageOutputFormat.Rgba8; + + public ImageLimits Limits { get; init; } = new(); + + internal void Validate() + { + ArgumentNullException.ThrowIfNull(Limits); + if (Format == ImageOutputFormat.Unspecified || !Enum.IsDefined(Format)) + { + throw new ArgumentOutOfRangeException(nameof(Format), Format, "The output format is not supported."); + } + + Limits.Validate(); + } + + internal unsafe NativeImageOptionsV1 ToNative() => new() + { + StructSize = (uint)sizeof(NativeImageOptionsV1), + StructVersion = NativeAbiConstants.RecordVersion1, + Subimage = Subimage, + Mip = Mip, + Format = (uint)Format, + Reserved = 0, + Limits = Limits.ToNative(), + }; +} diff --git a/src/Native/ArcForges.Native.Image/ImageReader.cs b/src/Native/ArcForges.Native.Image/ImageReader.cs new file mode 100644 index 00000000..8dee01d3 --- /dev/null +++ b/src/Native/ArcForges.Native.Image/ImageReader.cs @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +using System.Runtime.InteropServices; +using ArcForges.Native.Abstractions; + +namespace ArcForges.Native.Image; + +// One open still image. Reads are serialised per reader (the native handle is single-caller), coverage is +// accounted here as well as natively, and DisposeAsync drains the in-flight read before closing the handle. +public sealed class ImageReader : IAsyncDisposable +{ + private const int MetadataInitialBytes = 64 * 1024; + private const int MetadataMaximumBytes = 1024 * 1024; + + private readonly ImageHandle _handle; + private readonly ImageLimits _limits; + + // Guards every native call and the coverage counters. It is never disposed, because a queued waiter + // may still be releasing it during DisposeAsync. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", + Justification = "The gate is released, not disposed, so queued waiters can finish their Release safely.")] + private readonly SemaphoreSlim _gate = new(1, 1); + + private readonly CancellationTokenSource _closing = new(); + private ulong _coveredPixels; + private uint _regionCount; + private bool _disposed; + private int _disposeStarted; + + private ImageReader(ImageHandle handle, ImageMetadata metadata, ImageLimits limits) + { + _handle = handle; + Metadata = metadata; + _limits = limits; + } + + // Immutable description of the selected level, parsed from the native metadata document. + public ImageMetadata Metadata { get; } + + // Opens one level of an image. The source is read synchronously by native code for as long as the + // reader is open. Content is probed from its bytes, so the declared name never selects a codec. + public static async Task OpenAsync(ImageByteSource source, ImageOpenOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + ImageOpenOptions effective = options ?? new ImageOpenOptions(); + effective.Validate(); + ulong length = SourceLength(source); + return await Task.Run(() => Open(source, effective, length, cancellationToken), cancellationToken) + .ConfigureAwait(false); + } + + // Opens, reads the metadata and closes again. Nothing is retained after the call returns. + public static async Task ProbeAsync(ImageByteSource source, ImageOpenOptions? options = null, + CancellationToken cancellationToken = default) + { + ImageReader reader = await OpenAsync(source, options, cancellationToken).ConfigureAwait(false); + try + { + return reader.Metadata; + } + finally + { + await reader.DisposeAsync().ConfigureAwait(false); + } + } + + // Reads one rectangle. Regions must be disjoint and arrive in raster order (decision D3). A refused or + // cancelled region changes no coverage, so the caller may retry it. + public async Task ReadRegionAsync(ImageRegion region, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeStarted) != 0, this); + ValidateRegion(region); + ulong bytes = (ulong)region.Width * region.Height * Metadata.BytesPerPixel; + if (bytes > _limits.MaxOutputBytes) + { + throw new ImageNativeException(NativeStatus.ResourceLimit, "The region exceeds the output byte limit."); + } + + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closing.Token); + await _gate.WaitAsync(linked.Token).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + byte[] pixels = GC.AllocateUninitializedArray(checked((int)bytes)); + await Task.Run(() => ReadNative(region, pixels, linked.Token), linked.Token).ConfigureAwait(false); + + // A read that finished after cancellation was requested is discarded, so it consumes no coverage and + // the caller can retry the same region. + linked.Token.ThrowIfCancellationRequested(); + _coveredPixels += (ulong)region.Width * region.Height; + _regionCount++; + return new ImagePixelRegion(region, Metadata.OutputFormat, Metadata.BytesPerPixel, pixels); + } + finally + { + _gate.Release(); + } + } + + // Completion step (decision D3). Succeeds only when successful reads covered every pixel of the level. + // Otherwise it throws ImageCoverageIncompleteException, and no completion record exists. + public async Task CompleteAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeStarted) != 0, this); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closing.Token); + await _gate.WaitAsync(linked.Token).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + ulong total = (ulong)Metadata.Width * Metadata.Height; + if (_coveredPixels != total) + { + throw new ImageCoverageIncompleteException(_coveredPixels, total); + } + + return new ImageCompletion(Metadata.Width, Metadata.Height, _coveredPixels, _regionCount); + } + finally + { + _gate.Release(); + } + } + + // Cancels an in-flight read at its next tile or source boundary, waits for it to end, and then closes the + // native handle. Calling it more than once is harmless. + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) + { + return; + } + + await _closing.CancelAsync().ConfigureAwait(false); + await _gate.WaitAsync().ConfigureAwait(false); + try + { + _disposed = true; + _handle.Dispose(); + } + finally + { + _gate.Release(); + _closing.Dispose(); + } + } + + private static ulong SourceLength(ImageByteSource source) + { + long length = source.Length; + if (length < 0) + { + throw new ArgumentException("The image source length is negative.", nameof(source)); + } + + return (ulong)length; + } + + private static unsafe ImageReader Open(ImageByteSource source, ImageOpenOptions options, ulong length, + CancellationToken cancellationToken) + { + ImageAbi.RequireFunctionalAbi(); + GCHandle context = GCHandle.Alloc(source); + bool transferred = false; + try + { + NativeImageOptionsV1 nativeOptions = options.ToNative(); + int capacity = MetadataInitialBytes; + for (int attempt = 0; attempt < 2; attempt++) + { + byte[] buffer = GC.AllocateUninitializedArray(capacity); + ulong image = 0; + ulong required; + NativeStatus status; + using (var bridge = new CancellationBridge(cancellationToken)) + { + NativeIoV1 io = new() + { + StructSize = (uint)sizeof(NativeIoV1), + StructVersion = NativeAbiConstants.RecordVersion1, + Context = GCHandle.ToIntPtr(context), + Length = length, + MaxLength = length, + ReadAt = &ImageCallbacks.ReadAt, + }; + NativeCancelToken cancel = bridge.Token; + NativeCancelToken* cancelPointer = bridge.Enabled ? &cancel : null; + fixed (byte* data = buffer) + { + NativeBuffer metadata = new() { Data = (nint)data, Capacity = (ulong)capacity }; + status = ImageAbi.OpenNative(&io, &nativeOptions, &image, &metadata, cancelPointer); + required = metadata.Required; + } + } + + if (status == NativeStatus.Ok) + { + ImageHandle? handle = new ImageHandle(image, context); + transferred = true; + try + { + if (required == 0 || required > (ulong)buffer.Length) + { + throw new ImageNativeException(NativeStatus.Internal, "The native metadata size is outside its buffer."); + } + + ImageMetadata metadata = ImageMetadata.Parse(buffer.AsSpan(0, checked((int)required))); + var reader = new ImageReader(handle, metadata, options.Limits); + handle = null; + return reader; + } + finally + { + handle?.Dispose(); + } + } + + if (status == NativeStatus.BufferTooSmall && attempt == 0 && required is > 0 and <= MetadataMaximumBytes) + { + capacity = (int)required; + continue; + } + + throw FailureFor(status, cancellationToken); + } + + throw new ImageNativeException(NativeStatus.Internal, "The image metadata size did not settle."); + } + finally + { + if (!transferred && context.IsAllocated) + { + context.Free(); + } + } + } + + private unsafe void ReadNative(ImageRegion region, byte[] pixels, CancellationToken cancellationToken) + { + bool added = false; + try + { + _handle.DangerousAddRef(ref added); + ulong image = unchecked((ulong)_handle.DangerousGetHandle().ToInt64()); + using var bridge = new CancellationBridge(cancellationToken); + NativeCancelToken cancel = bridge.Token; + NativeCancelToken* cancelPointer = bridge.Enabled ? &cancel : null; + NativeRegionV1 nativeRegion = new() + { + StructSize = (uint)sizeof(NativeRegionV1), + StructVersion = NativeAbiConstants.RecordVersion1, + X = region.X, + Y = region.Y, + Width = region.Width, + Height = region.Height, + }; + + NativeBuffer output; + NativeStatus status; + fixed (byte* data = pixels) + { + output = new NativeBuffer { Data = (nint)data, Capacity = (ulong)pixels.Length }; + status = ImageAbi.ReadNative(image, &nativeRegion, &output, cancelPointer); + } + + if (status != NativeStatus.Ok) + { + throw FailureFor(status, cancellationToken); + } + + if (output.Required != (ulong)pixels.Length) + { + throw new ImageNativeException(NativeStatus.Internal, "The native read reported an unexpected output size."); + } + } + finally + { + if (added) + { + _handle.DangerousRelease(); + } + } + } + + // Called on the thread that made the failing native call, so the thread-local diagnostic is still current. + private static Exception FailureFor(NativeStatus status, CancellationToken cancellationToken) => + status == NativeStatus.Cancelled + ? new OperationCanceledException(cancellationToken) + : new ImageNativeException(status, ImageAbi.LastErrorMessage(status)); + + private void ValidateRegion(ImageRegion region) + { + if (region.Width == 0 || region.Height == 0) + { + throw new ArgumentOutOfRangeException(nameof(region), "The region must have a positive width and height."); + } + + if ((ulong)region.X + region.Width > Metadata.Width || (ulong)region.Y + region.Height > Metadata.Height) + { + throw new ArgumentOutOfRangeException(nameof(region), "The region lies outside the image."); + } + } +} diff --git a/src/Native/ArcForges.Native.Image/ImageResults.cs b/src/Native/ArcForges.Native.Image/ImageResults.cs new file mode 100644 index 00000000..7366a45b --- /dev/null +++ b/src/Native/ArcForges.Native.Image/ImageResults.cs @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +using ArcForges.Native.Abstractions; + +namespace ArcForges.Native.Image; + +// A rectangle of one image, in pixels from the top-left of the selected level. +public readonly record struct ImageRegion(uint X, uint Y, uint Width, uint Height); + +// Packed pixels for one region. The buffer is owned by this record and never changes after creation. +public sealed record ImagePixelRegion( + ImageRegion Region, + ImageOutputFormat Format, + uint BytesPerPixel, + ReadOnlyMemory Pixels); + +// Result of the completion step. CoveredPixels equals Width times Height only when every pixel was read. +public sealed record ImageCompletion(uint Width, uint Height, ulong CoveredPixels, uint RegionCount); + +// A native call failed. Status is the ABI status; the message is the native diagnostic for that call. +public sealed class ImageNativeException : Exception +{ + public ImageNativeException() + : this(NativeStatus.Internal, "ArcImageNative failed.") + { + } + + public ImageNativeException(string message) + : this(NativeStatus.Internal, message) + { + } + + public ImageNativeException(string message, Exception innerException) + : base(message, innerException) + { + Status = NativeStatus.Internal; + } + + public ImageNativeException(NativeStatus status, string message) + : base(message) + { + Status = status; + } + + public NativeStatus Status { get; } +} + +// The completion step found pixels that no successful read covered. Nothing is returned for the image. +public sealed class ImageCoverageIncompleteException : Exception +{ + public ImageCoverageIncompleteException() + : this(0, 0) + { + } + + public ImageCoverageIncompleteException(string message) + : base(message) + { + } + + public ImageCoverageIncompleteException(string message, Exception innerException) + : base(message, innerException) + { + } + + public ImageCoverageIncompleteException(ulong coveredPixels, ulong totalPixels) + : base("The image regions do not cover every pixel.") + { + CoveredPixels = coveredPixels; + TotalPixels = totalPixels; + } + + public ulong CoveredPixels { get; } + + public ulong TotalPixels { get; } +} diff --git a/src/Native/ArcForges.Native.Image/README.md b/src/Native/ArcForges.Native.Image/README.md index 37f1623a..70f16ef3 100644 --- a/src/Native/ArcForges.Native.Image/README.md +++ b/src/Native/ArcForges.Native.Image/README.md @@ -1,9 +1,25 @@ # ArcForges.Native.Image -Source-generated bindings for the existing ABI preamble: version, dependency build information and -thread-local error queries. No media processing, image processing or timeline editing API is claimed. +Source-generated bindings for the ABI preamble (version, dependency build information and thread-local error +queries) and for the functional still-image exports `arc_image_open`, `arc_image_read` and `arc_image_close` +(ABI 1.1, decision D2). The image path reads PNG, TIFF and EXR after content probing (decision D10); every +other content is refused as `NativeStatus.Unsupported`. No timeline, video or product image-editing API is claimed. + +Public surface, all in namespace `ArcForges.Native.Image`: + +- `ImageReader.OpenAsync(ImageByteSource, ImageOpenOptions?, CancellationToken)` opens one level and parses the + immutable `ImageMetadata`. `ImageReader.ProbeAsync` returns the metadata and closes again. +- `ImageReader.ReadRegionAsync(ImageRegion, CancellationToken)` returns an `ImagePixelRegion` with packed pixels. + Regions must be disjoint and arrive in raster order (decision D3). +- `ImageReader.CompleteAsync(CancellationToken)` returns `ImageCompletion` only when every pixel was covered, and + otherwise throws `ImageCoverageIncompleteException`. +- `ImageReader` implements `IAsyncDisposable`. Disposal cancels an in-flight read at its next boundary and then + closes the native handle. +- `ImageByteSource` is the caller's byte source. `ImageByteSource.FromStream` wraps a seekable stream. +- Failures are `ImageNativeException` with the `NativeStatus` from the ABI. + +Native handles are owned by a dedicated `SafeHandle`, and no pointer or raw options dictionary is exposed. +Callbacks never throw across the ABI. The binding is AOT and trim compatible (`IsAotCompatible`). Pin the exact package version centrally and commit package locks. Also reference `ArcForges.Native.Image.Runtime.win-x64` at the same exact version and build/publish for `win-x64`. Call `ImageAbi.GetAbiVersion()`, `GetBuildInfo()` or `GetLastError()` in namespace `ArcForges.Native.Image`. -The runtime package supplies app-local DLLs and a hash manifest. Loading never searches PATH or the -current working directory. Final product installation owns signing, read-only application paths and -any later hostile-content sandbox. Current native queries do not parse untrusted content. +The runtime package supplies app-local DLLs and a hash manifest. Loading never searches PATH or the current working directory. Final product installation owns signing, read-only application paths and any later hostile-content sandbox. This binding decodes image bytes in-process and provides no sandbox: hostile input is contained only inside the WP11 helper (NAT.31), not here. diff --git a/tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj b/tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj index e2ddd8ea..9ada5abb 100644 --- a/tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj +++ b/tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj @@ -10,9 +10,13 @@ Exe true + + + $(NoWarn);CA2007;CA2000;xUnit1051 + diff --git a/tests/NativeAbiTests/ImageFixtures.cs b/tests/NativeAbiTests/ImageFixtures.cs new file mode 100644 index 00000000..ad5719ba --- /dev/null +++ b/tests/NativeAbiTests/ImageFixtures.cs @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +using System.IO.Compression; +using System.Text; + +namespace ArcForges.Tests.NativeAbiTests; + +// Generates PNG and TIFF bytes in test code (decision D1): no image file is shipped with the tests. +internal static class ImageFixtures +{ + private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + private static readonly uint[] CrcTable = BuildCrcTable(); + + // 8-bit RGBA, straight (unassociated) alpha, one filter byte per scanline. + internal static byte[] PngRgba8(int width, int height, byte[] rgba) + { + int stride = width * 4; + var raw = new byte[height * (stride + 1)]; + for (int row = 0; row < height; row++) + { + Buffer.BlockCopy(rgba, row * stride, raw, row * (stride + 1) + 1, stride); + } + + using var file = new MemoryStream(); + file.Write(PngSignature); + + using var header = new MemoryStream(); + WriteBigEndian(header, (uint)width); + WriteBigEndian(header, (uint)height); + header.WriteByte(8); // bit depth + header.WriteByte(6); // colour type RGBA + header.WriteByte(0); // compression + header.WriteByte(0); // filter method + header.WriteByte(0); // no interlace + WriteChunk(file, "IHDR", header.ToArray()); + WriteChunk(file, "IDAT", Deflate(raw)); + WriteChunk(file, "IEND", []); + return file.ToArray(); + } + + // Uncompressed 8-bit RGBA TIFF with ExtraSamples = unassociated alpha, little endian, one strip. + internal static byte[] TiffRgba8(int width, int height, byte[] rgba) + { + int dataLength = width * height * 4; + int bitsOffset = 8 + dataLength; + int directoryOffset = bitsOffset + 8; + + using var file = new MemoryStream(); + file.Write("II"u8); + WriteLittle16(file, 42); + WriteLittle32(file, (uint)directoryOffset); + file.Write(rgba); + for (int channel = 0; channel < 4; channel++) + { + WriteLittle16(file, 8); + } + + const ushort shortType = 3; + const ushort longType = 4; + Entry[] entries = + [ + new(256, shortType, 1, (uint)width), + new(257, shortType, 1, (uint)height), + new(258, shortType, 4, (uint)bitsOffset), + new(259, shortType, 1, 1), + new(262, shortType, 1, 2), + new(273, longType, 1, 8), + new(277, shortType, 1, 4), + new(278, longType, 1, (uint)height), + new(279, longType, 1, (uint)dataLength), + new(284, shortType, 1, 1), + new(338, shortType, 1, 2), + ]; + WriteLittle16(file, (ushort)entries.Length); + foreach (Entry entry in entries) + { + WriteLittle16(file, entry.Tag); + WriteLittle16(file, entry.Type); + WriteLittle32(file, entry.Count); + WriteLittle32(file, entry.Value); + } + + WriteLittle32(file, 0); + return file.ToArray(); + } + + private static byte[] Deflate(byte[] raw) + { + using var output = new MemoryStream(); + using (var zlib = new ZLibStream(output, CompressionLevel.Optimal, leaveOpen: true)) + { + zlib.Write(raw); + } + + return output.ToArray(); + } + + private static void WriteChunk(Stream file, string type, byte[] data) + { + byte[] typeBytes = Encoding.ASCII.GetBytes(type); + WriteBigEndian(file, (uint)data.Length); + file.Write(typeBytes); + file.Write(data); + + uint crc = 0xFFFFFFFFU; + foreach (byte value in typeBytes.Concat(data)) + { + crc = CrcTable[(crc ^ value) & 0xFF] ^ (crc >> 8); + } + + WriteBigEndian(file, crc ^ 0xFFFFFFFFU); + } + + private static uint[] BuildCrcTable() + { + var table = new uint[256]; + for (uint index = 0; index < 256; index++) + { + uint value = index; + for (int bit = 0; bit < 8; bit++) + { + value = (value & 1) != 0 ? 0xEDB88320U ^ (value >> 1) : value >> 1; + } + + table[index] = value; + } + + return table; + } + + private static void WriteBigEndian(Stream stream, uint value) + { + stream.WriteByte((byte)(value >> 24)); + stream.WriteByte((byte)(value >> 16)); + stream.WriteByte((byte)(value >> 8)); + stream.WriteByte((byte)value); + } + + private static void WriteLittle16(Stream stream, ushort value) + { + stream.WriteByte((byte)value); + stream.WriteByte((byte)(value >> 8)); + } + + private static void WriteLittle32(Stream stream, uint value) + { + WriteLittle16(stream, (ushort)value); + WriteLittle16(stream, (ushort)(value >> 16)); + } + + private readonly record struct Entry(ushort Tag, ushort Type, uint Count, uint Value); +} diff --git a/tests/NativeAbiTests/ImageReaderTests.cs b/tests/NativeAbiTests/ImageReaderTests.cs new file mode 100644 index 00000000..8f53b15c --- /dev/null +++ b/tests/NativeAbiTests/ImageReaderTests.cs @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +using ArcForges.Native.Abstractions; +using ArcForges.Native.Image; +using Xunit; + +namespace ArcForges.Tests.NativeAbiTests; + +// Runtime behaviour of the functional image exports through the managed binding. These tests load the +// staged ArcImageNative.dll app-locally, so they belong to the explicit NativeAbi opt-in suite. +[Trait("Category", "NativeAbi")] +public sealed class ImageReaderTests +{ + private const int Width = 3; + private const int Height = 2; + + // Straight-alpha pixels with alpha 0, partial and full coverage. Exact output proves no premultiply round trip. + private static readonly byte[] StraightRgba = + [ + 10, 50, 200, 0, + 20, 51, 199, 60, + 30, 52, 198, 128, + 40, 53, 197, 255, + 50, 54, 196, 200, + 60, 55, 195, 17, + ]; + + [Fact] + public async Task PngRgba8StraightAlphaRoundTripsByteExactAndCompletes() + { + byte[] file = ImageFixtures.PngRgba8(Width, Height, StraightRgba); + using var stream = new MemoryStream(file); + await using ImageReader reader = await ImageReader.OpenAsync(ImageByteSource.FromStream(stream)); + + Assert.Equal(ImageSourceFormat.Png, reader.Metadata.Format); + Assert.Equal((uint)Width, reader.Metadata.Width); + Assert.Equal((uint)Height, reader.Metadata.Height); + Assert.Equal(ImageAlphaKind.Straight, reader.Metadata.Alpha); + Assert.Equal(ImageOutputFormat.Rgba8, reader.Metadata.OutputFormat); + Assert.Equal(4u, reader.Metadata.BytesPerPixel); + + ImagePixelRegion region = await reader.ReadRegionAsync(new ImageRegion(0, 0, Width, Height)); + Assert.Equal(StraightRgba, region.Pixels.ToArray()); + + ImageCompletion completion = await reader.CompleteAsync(); + Assert.Equal((ulong)(Width * Height), completion.CoveredPixels); + Assert.Equal(1u, completion.RegionCount); + } + + [Fact] + public async Task TiffRgba8UnassociatedAlphaRoundTripsByteExact() + { + byte[] file = ImageFixtures.TiffRgba8(Width, Height, StraightRgba); + using var stream = new MemoryStream(file); + ImageMetadata metadata = await ImageReader.ProbeAsync(ImageByteSource.FromStream(stream)); + + Assert.Equal(ImageSourceFormat.Tiff, metadata.Format); + Assert.Equal(ImageAlphaKind.Straight, metadata.Alpha); + Assert.Equal(4u, metadata.OutputChannels); + Assert.Empty(metadata.Losses); + + stream.Position = 0; + await using ImageReader reader = await ImageReader.OpenAsync(ImageByteSource.FromStream(stream)); + ImagePixelRegion region = await reader.ReadRegionAsync(new ImageRegion(0, 0, Width, Height)); + Assert.Equal(StraightRgba, region.Pixels.ToArray()); + } + + [Fact] + public async Task FloatOutputPremultipliesStraightColourOnRead() + { + byte[] file = ImageFixtures.PngRgba8(Width, Height, StraightRgba); + using var stream = new MemoryStream(file); + var options = new ImageOpenOptions { Format = ImageOutputFormat.Rgba32fLinearPremultiplied }; + await using ImageReader reader = await ImageReader.OpenAsync(ImageByteSource.FromStream(stream), options); + Assert.Equal(16u, reader.Metadata.BytesPerPixel); + + ImagePixelRegion region = await reader.ReadRegionAsync(new ImageRegion(0, 0, Width, Height)); + ReadOnlyMemory bytes = region.Pixels; + for (int pixel = 0; pixel < Width * Height; pixel++) + { + float alpha = StraightRgba[(pixel * 4) + 3] / 255f; + for (int channel = 0; channel < 3; channel++) + { + float expected = StraightRgba[(pixel * 4) + channel] / 255f * alpha; + float actual = BitConverter.ToSingle(bytes.Span.Slice((pixel * 16) + (channel * 4), 4)); + Assert.InRange(actual, expected - 1e-4f, expected + 1e-4f); + } + + float actualAlpha = BitConverter.ToSingle(bytes.Span.Slice((pixel * 16) + 12, 4)); + Assert.InRange(actualAlpha, alpha - 1e-4f, alpha + 1e-4f); + } + } + + [Fact] + public async Task RegionsCoverEveryPixelOnlyWhenEveryBandIsRead() + { + byte[] file = ImageFixtures.PngRgba8(Width, Height, StraightRgba); + using var stream = new MemoryStream(file); + await using ImageReader reader = await ImageReader.OpenAsync(ImageByteSource.FromStream(stream)); + + await reader.ReadRegionAsync(new ImageRegion(0, 0, Width, 1)); + ImageCoverageIncompleteException missing = await Assert.ThrowsAsync( + () => reader.CompleteAsync()); + Assert.Equal((ulong)Width, missing.CoveredPixels); + Assert.Equal((ulong)(Width * Height), missing.TotalPixels); + + await reader.ReadRegionAsync(new ImageRegion(0, 1, Width, 1)); + ImageCompletion completion = await reader.CompleteAsync(); + Assert.Equal((ulong)(Width * Height), completion.CoveredPixels); + Assert.Equal(2u, completion.RegionCount); + } + + [Fact] + public async Task OverlappingAndOutOfOrderRegionsAreRefusedWithoutStateChange() + { + byte[] file = ImageFixtures.PngRgba8(Width, Height, StraightRgba); + using var stream = new MemoryStream(file); + await using ImageReader reader = await ImageReader.OpenAsync(ImageByteSource.FromStream(stream)); + + await reader.ReadRegionAsync(new ImageRegion(0, 0, Width, 1)); + ImageNativeException overlap = await Assert.ThrowsAsync( + () => reader.ReadRegionAsync(new ImageRegion(0, 0, 1, 1))); + Assert.Equal(NativeStatus.InvalidArgument, overlap.Status); + + await reader.ReadRegionAsync(new ImageRegion(0, 1, Width, 1)); + ImageCompletion completion = await reader.CompleteAsync(); + Assert.Equal((ulong)(Width * Height), completion.CoveredPixels); + } + + [Fact] + public async Task OutOfRasterOrderRegionIsRefusedWithoutStateChange() + { + byte[] file = ImageFixtures.PngRgba8(Width, Height, StraightRgba); + using var stream = new MemoryStream(file); + await using ImageReader reader = await ImageReader.OpenAsync(ImageByteSource.FromStream(stream)); + + // The first region must start at the first uncovered pixel in raster order, so a row-1 read is refused. + ImageNativeException firstRead = await Assert.ThrowsAsync( + () => reader.ReadRegionAsync(new ImageRegion(0, 1, Width, 1))); + Assert.Equal(NativeStatus.InvalidArgument, firstRead.Status); + + await reader.ReadRegionAsync(new ImageRegion(0, 0, 1, 1)); + ImageNativeException early = await Assert.ThrowsAsync( + () => reader.ReadRegionAsync(new ImageRegion(0, 1, Width, 1))); + Assert.Equal(NativeStatus.InvalidArgument, early.Status); + + await reader.ReadRegionAsync(new ImageRegion(1, 0, Width - 1, 1)); + await reader.ReadRegionAsync(new ImageRegion(0, 1, Width, 1)); + ImageCompletion completion = await reader.CompleteAsync(); + Assert.Equal((ulong)(Width * Height), completion.CoveredPixels); + Assert.Equal(3u, completion.RegionCount); + } + + [Fact] + public async Task CorruptHeaderIsRefusedAtOpen() + { + byte[] file = ImageFixtures.PngRgba8(Width, Height, StraightRgba); + file[29] ^= 0xFF; // first byte of the IHDR CRC + using var stream = new MemoryStream(file); + + ImageNativeException corrupt = await Assert.ThrowsAsync( + () => ImageReader.OpenAsync(ImageByteSource.FromStream(stream))); + Assert.Equal(NativeStatus.Corrupt, corrupt.Status); + } + + [Fact] + public async Task UnsupportedContentIsRefusedByContentNotName() + { + // GIF89a header and trailer only: the content is recognised as a format the reader does not decode. + byte[] gif = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x3B]; + using var stream = new MemoryStream(gif); + + ImageNativeException unsupported = await Assert.ThrowsAsync( + () => ImageReader.OpenAsync(ImageByteSource.FromStream(stream))); + Assert.Equal(NativeStatus.Unsupported, unsupported.Status); + } + + [Fact] + public async Task CancelledReadConsumesNoCoverageAndCanBeRetried() + { + byte[] file = ImageFixtures.PngRgba8(Width, Height, StraightRgba); + using var stream = new MemoryStream(file); + await using ImageReader reader = await ImageReader.OpenAsync(ImageByteSource.FromStream(stream)); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync( + () => reader.ReadRegionAsync(new ImageRegion(0, 0, Width, Height), cancellation.Token)); + ImageCoverageIncompleteException missing = await Assert.ThrowsAsync( + () => reader.CompleteAsync()); + Assert.Equal(0UL, missing.CoveredPixels); + + ImagePixelRegion region = await reader.ReadRegionAsync(new ImageRegion(0, 0, Width, Height)); + Assert.Equal(StraightRgba, region.Pixels.ToArray()); + ImageCompletion completion = await reader.CompleteAsync(); + Assert.Equal((ulong)(Width * Height), completion.CoveredPixels); + } + + // Cancellation requested from inside a source callback may or may not be observed by native code, depending on + // how the codec batches its input. Either way, coverage must match the outcome the caller saw. + [Fact] + public async Task CancellationDuringNativeReadKeepsCoverageConsistentWithOutcome() + { + byte[] file = ImageFixtures.PngRgba8(Width, Height, StraightRgba); + using var stream = new MemoryStream(file); + using var cancellation = new CancellationTokenSource(); + var source = new ArmableSource(ImageByteSource.FromStream(stream), () => cancellation.Cancel()); + await using ImageReader reader = await ImageReader.OpenAsync(source); + + source.Arm(); + bool completed = true; + try + { + await reader.ReadRegionAsync(new ImageRegion(0, 0, Width, Height), cancellation.Token); + } + catch (OperationCanceledException) + { + completed = false; + } + + source.Disarm(); + if (!completed) + { + ImageCoverageIncompleteException missing = await Assert.ThrowsAsync( + () => reader.CompleteAsync()); + Assert.Equal(0UL, missing.CoveredPixels); + await reader.ReadRegionAsync(new ImageRegion(0, 0, Width, Height)); + } + + ImageCompletion completion = await reader.CompleteAsync(); + Assert.Equal((ulong)(Width * Height), completion.CoveredPixels); + } + + [Fact] + public async Task PreCancelledOpenDoesNotReachNative() + { + byte[] file = ImageFixtures.PngRgba8(Width, Height, StraightRgba); + using var stream = new MemoryStream(file); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync( + () => ImageReader.OpenAsync(ImageByteSource.FromStream(stream), cancellationToken: cancellation.Token)); + } + + [Fact] + public async Task DisposeAsyncClosesTheHandleAndRefusesFurtherUse() + { + byte[] file = ImageFixtures.PngRgba8(Width, Height, StraightRgba); + using var stream = new MemoryStream(file); + ImageReader reader = await ImageReader.OpenAsync(ImageByteSource.FromStream(stream)); + + await reader.DisposeAsync(); + await reader.DisposeAsync(); + + await Assert.ThrowsAsync( + () => reader.ReadRegionAsync(new ImageRegion(0, 0, Width, Height))); + } + + [Fact] + public async Task InvalidRegionsAndOptionsAreRefusedBeforeNativeCalls() + { + byte[] file = ImageFixtures.PngRgba8(Width, Height, StraightRgba); + using var stream = new MemoryStream(file); + await using ImageReader reader = await ImageReader.OpenAsync(ImageByteSource.FromStream(stream)); + + await Assert.ThrowsAsync( + () => reader.ReadRegionAsync(new ImageRegion(0, 0, 0, 1))); + await Assert.ThrowsAsync( + () => reader.ReadRegionAsync(new ImageRegion(2, 0, 2, 1))); + + var options = new ImageOpenOptions { Limits = new ImageLimits { MaxOutputBytes = 0 } }; + using var second = new MemoryStream(file); + await Assert.ThrowsAsync( + () => ImageReader.OpenAsync(ImageByteSource.FromStream(second), options)); + } + + [Fact] + public async Task RegionLargerThanOutputLimitIsRefusedBeforeAnyRead() + { + byte[] file = ImageFixtures.PngRgba8(Width, Height, StraightRgba); + using var stream = new MemoryStream(file); + var options = new ImageOpenOptions { Limits = new ImageLimits { MaxOutputBytes = 8 } }; + await using ImageReader reader = await ImageReader.OpenAsync(ImageByteSource.FromStream(stream), options); + + ImageNativeException refused = await Assert.ThrowsAsync( + () => reader.ReadRegionAsync(new ImageRegion(0, 0, Width, Height))); + Assert.Equal(NativeStatus.ResourceLimit, refused.Status); + + ImageCoverageIncompleteException missing = await Assert.ThrowsAsync( + () => reader.CompleteAsync()); + Assert.Equal(0UL, missing.CoveredPixels); + } + + // Forwards to a real source, and cancels the caller's token on the first read after it is armed. The + // callback runs on the thread that is inside the native call, which is how cancellation is observed. + private sealed class ArmableSource(ImageByteSource inner, Action onArmedRead) : ImageByteSource + { + private volatile bool _armed; + private bool _fired; + + public override long Length => inner.Length; + + public void Arm() + { + _fired = false; + _armed = true; + } + + public void Disarm() => _armed = false; + + public override int ReadAt(long offset, Span destination) + { + if (_armed && !_fired) + { + _fired = true; + onArmedRead(); + } + + return inner.ReadAt(offset, destination); + } + } +} diff --git a/tests/NativeAbiTests/README.md b/tests/NativeAbiTests/README.md index e52c3b3f..5b3338bc 100644 --- a/tests/NativeAbiTests/README.md +++ b/tests/NativeAbiTests/README.md @@ -1,3 +1,8 @@ # Native ABI probes After staging ArcImageNative app-local, this explicit local opt-in suite exercises the retained image ABI/version/build-info/error calls through LibraryImport. It verifies the ABI foundation, not image decoding, sandbox containment or product acceptance. CI compiles the producer and packages it but does not execute this runtime suite. + +The `NativeAbi` category also covers the functional image path (`ImageReaderTests`): PNG and TIFF round trips +generated in test code (`ImageFixtures`, decision D1), coverage and completion, refusals, cancellation and +disposal. It needs `ArcImageNative.dll` and `ArcImageNative.manifest.json` beside the test assembly. The CI +`NativeAbiLayout` filter never runs this category. diff --git a/tests/NativeAbiTests/packages.lock.json b/tests/NativeAbiTests/packages.lock.json index 9a470ff9..01a1b6bf 100644 --- a/tests/NativeAbiTests/packages.lock.json +++ b/tests/NativeAbiTests/packages.lock.json @@ -177,6 +177,12 @@ }, "arcforges.native.abstractions": { "type": "Project" + }, + "arcforges.native.image": { + "type": "Project", + "dependencies": { + "ArcForges.Native.Abstractions": "[1.0.0, )" + } } } } From 4950da32dc1f2d6281a6b76206d1fe808bc1c605 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 04:16:00 +0100 Subject: [PATCH 05/15] [NAT.11] Format the image shim and rename the codec test source Apply clang-format to the image shim sources (line wrapping only, no behaviour change). Rename tests/arcimage_codec_tests.cpp to tests/arcslate_image_codec_tests.cpp so the test source follows the arcslate_* naming of the target and the header (decision D6). The naming policy excludes the ArcImage product name, so the lowercase token is removed from the path and the fixture and summary strings. The summary now reads as ArcImageNative. Co-Authored-By: Claude Haiku 5.5 --- eng/provenance/files.json | 2 +- native/arcimage-abi/CMakeLists.txt | 4 +- .../arcimage-abi/src/arcslate_image_abi.cpp | 8 +- native/arcimage-abi/src/image_callback_io.cpp | 4 +- native/arcimage-abi/src/image_session.cpp | 29 ++++--- native/arcimage-abi/src/image_session.hpp | 10 +-- ...sts.cpp => arcslate_image_codec_tests.cpp} | 77 ++++++++++--------- 7 files changed, 68 insertions(+), 66 deletions(-) rename native/arcimage-abi/tests/{arcimage_codec_tests.cpp => arcslate_image_codec_tests.cpp} (97%) diff --git a/eng/provenance/files.json b/eng/provenance/files.json index 24abc778..6afab6cb 100644 --- a/eng/provenance/files.json +++ b/eng/provenance/files.json @@ -342,8 +342,8 @@ "native/arcimage-abi/src/image_coverage.hpp", "native/arcimage-abi/src/image_session.cpp", "native/arcimage-abi/src/image_session.hpp", - "native/arcimage-abi/tests/arcimage_codec_tests.cpp", "native/arcimage-abi/tests/arcslate_image_abi_tests.cpp", + "native/arcimage-abi/tests/arcslate_image_codec_tests.cpp", "native/arcimage-abi/windows/arcslate_image_abi.vcxproj", "native/shared/fuzz/arc_native_abi_fuzzer.cpp", "native/shared/include/arc/arc_native_abi.h", diff --git a/native/arcimage-abi/CMakeLists.txt b/native/arcimage-abi/CMakeLists.txt index 7edd2fd7..f7d2cce9 100644 --- a/native/arcimage-abi/CMakeLists.txt +++ b/native/arcimage-abi/CMakeLists.txt @@ -49,12 +49,12 @@ if(ARCFORGES_BUILD_TESTS) target_link_libraries(arcslate_image_abi_tests PRIVATE arcslate_image_abi) add_test(NAME arcslate_image_abi.hello COMMAND arcslate_image_abi_tests) - add_executable(arcslate_image_codec_tests "tests/arcimage_codec_tests.cpp") + add_executable(arcslate_image_codec_tests "tests/arcslate_image_codec_tests.cpp") arcforges_declare_target_licence(arcslate_image_codec_tests) set_target_properties(arcslate_image_codec_tests PROPERTIES ARCFORGES_TEST_ONLY TRUE) target_compile_features(arcslate_image_codec_tests PRIVATE cxx_std_20) target_link_libraries(arcslate_image_codec_tests PRIVATE arcslate_image_abi OpenImageIO::OpenImageIO) - # One CTest target per codec test. Keep this list in step with test_cases in tests/arcimage_codec_tests.cpp. + # One CTest target per codec test. Keep this list in step with test_cases in tests/arcslate_image_codec_tests.cpp. set(ARCFORGES_IMAGE_CODEC_TESTS probe png_rgba8 diff --git a/native/arcimage-abi/src/arcslate_image_abi.cpp b/native/arcimage-abi/src/arcslate_image_abi.cpp index 99f65009..5fd2eb59 100644 --- a/native/arcimage-abi/src/arcslate_image_abi.cpp +++ b/native/arcimage-abi/src/arcslate_image_abi.cpp @@ -47,7 +47,8 @@ arc_status_t write_text(std::string_view text, arc_mut_buffer_t* output) std::string build_info_json() { const std::string_view identity(arc_build_identity); - const std::string_view identity_fields = identity.empty() || identity.front() != ';' ? identity : identity.substr(1); + const std::string_view identity_fields = + identity.empty() || identity.front() != ';' ? identity : identity.substr(1); std::string json; json.reserve(512); json += "{\"library\":\"ArcImageNative\",\"abi\":{\"major\":1,\"minor\":1},"; @@ -97,9 +98,8 @@ arc_status_t ARC_ABI_CALL arc_image_get_last_error(arc_error_info_t* out_error) return arc::abi::get_last_error(out_error); } -arc_status_t ARC_ABI_CALL arc_image_open(const arc_io_v1* io, const arc_image_options_v1* options, - arc_handle_t* image, arc_mut_buffer_t* metadata, - const arc_cancel_token_t* cancel) +arc_status_t ARC_ABI_CALL arc_image_open(const arc_io_v1* io, const arc_image_options_v1* options, arc_handle_t* image, + arc_mut_buffer_t* metadata, const arc_cancel_token_t* cancel) { try { const arc_status_t prepared = arc::abi::prepare_handle_output(image); diff --git a/native/arcimage-abi/src/image_callback_io.cpp b/native/arcimage-abi/src/image_callback_io.cpp index 1ea2091f..b03d1efc 100644 --- a/native/arcimage-abi/src/image_callback_io.cpp +++ b/native/arcimage-abi/src/image_callback_io.cpp @@ -15,9 +15,7 @@ static_assert(sizeof(size_t) == sizeof(uint64_t), "ArcImageNative is a 64-bit on } // namespace callback_io::callback_io(std::string hint, const arc_io_v1& io) - : OIIO::Filesystem::IOProxy(hint, OIIO::Filesystem::IOProxy::Read), - read_at_(io.read_at), - context_(io.context), + : OIIO::Filesystem::IOProxy(hint, OIIO::Filesystem::IOProxy::Read), read_at_(io.read_at), context_(io.context), length_(io.length) { } diff --git a/native/arcimage-abi/src/image_session.cpp b/native/arcimage-abi/src/image_session.cpp index d0bf7801..ef5f5f6e 100644 --- a/native/arcimage-abi/src/image_session.cpp +++ b/native/arcimage-abi/src/image_session.cpp @@ -288,14 +288,13 @@ arc_status_t image_session::open(const arc_io_v1& io, const arc_image_options_v1 } const arc_limits_v1& limits = options.limits; - const bool positive = limits.max_input_bytes != 0 && limits.max_memory_bytes != 0 && - limits.max_output_bytes != 0 && limits.max_width != 0 && limits.max_height != 0 && - limits.max_items != 0 && limits.timeout_ms != 0; - const bool within_profile = limits.max_input_bytes <= hard_max_input_bytes && - limits.max_memory_bytes <= hard_max_memory_bytes && - limits.max_output_bytes <= hard_max_region_bytes && - limits.max_width <= hard_max_dimension && limits.max_height <= hard_max_dimension && - limits.max_items <= hard_max_items; + const bool positive = limits.max_input_bytes != 0 && limits.max_memory_bytes != 0 && limits.max_output_bytes != 0 && + limits.max_width != 0 && limits.max_height != 0 && limits.max_items != 0 && + limits.timeout_ms != 0; + const bool within_profile = + limits.max_input_bytes <= hard_max_input_bytes && limits.max_memory_bytes <= hard_max_memory_bytes && + limits.max_output_bytes <= hard_max_region_bytes && limits.max_width <= hard_max_dimension && + limits.max_height <= hard_max_dimension && limits.max_items <= hard_max_items; if (!positive || !within_profile) { return fail_image(ARC_INVALID_ARGUMENT, "Image limits are missing or exceed the producer profile"); } @@ -367,8 +366,8 @@ arc_status_t image_session::open(const arc_io_v1& io, const arc_image_options_v1 uint32_t mip_count = 0; for (;;) { - const OIIO::ImageSpec dimensions = session->input_->spec_dimensions( - static_cast(options.subimage), static_cast(mip_count)); + const OIIO::ImageSpec dimensions = + session->input_->spec_dimensions(static_cast(options.subimage), static_cast(mip_count)); if (dimensions.format == OIIO::TypeUnknown) { break; } @@ -459,8 +458,8 @@ arc_status_t image_session::open(const arc_io_v1& io, const arc_image_options_v1 session->tile_height_ = static_cast(spec.tile_height); } else { uint64_t row_samples = 0; - if (!multiply_u64(width, channels, row_samples) || !multiply_u64(row_samples, sizeof(float), memory_bytes) || - memory_bytes > limits.max_memory_bytes) { + if (!multiply_u64(width, channels, row_samples) || + !multiply_u64(row_samples, sizeof(float), memory_bytes) || memory_bytes > limits.max_memory_bytes) { return fail_image(ARC_RESOURCE_LIMIT, "Image scanline exceeds the memory limit"); } } @@ -799,8 +798,8 @@ arc_status_t image_session::decode_scanlines(uint32_t x, uint32_t y, uint32_t w, } const uint64_t row_floats = static_cast(width_) * channels_; for (uint32_t row = y; row < y + h; ++row) { - const float* source = band_.data() + static_cast(row - band_begin_) * row_floats + - static_cast(x) * channels_; + const float* source = + band_.data() + static_cast(row - band_begin_) * row_floats + static_cast(x) * channels_; uint8_t* destination = out + static_cast(row - y) * w * bytes_per_pixel_; convert_segment(source, w, destination); } @@ -865,7 +864,7 @@ void image_session::convert_segment(const float* source, uint32_t count, uint8_t // than through OpenImageIO's convert_type, which reports failure for this pair. for (size_t index = 0; index < output_floats; ++index) { float value = scratch_[index]; - if (!(value > 0.0F)) { // also maps NaN to zero + if (!(value > 0.0F)) { // also maps NaN to zero value = 0.0F; } else if (value > 1.0F) { value = 1.0F; diff --git a/native/arcimage-abi/src/image_session.hpp b/native/arcimage-abi/src/image_session.hpp index 92b816e7..66909d3a 100644 --- a/native/arcimage-abi/src/image_session.hpp +++ b/native/arcimage-abi/src/image_session.hpp @@ -47,8 +47,8 @@ class image_session final { // Probes the content (PNG, TIFF or EXR only), validates every bound against the limits before // any pixel allocation, and builds the metadata document. On failure output stays empty. - static arc_status_t open(const arc_io_v1& io, const arc_image_options_v1& options, - const arc_cancel_token_t* cancel, std::unique_ptr& output); + static arc_status_t open(const arc_io_v1& io, const arc_image_options_v1& options, const arc_cancel_token_t* cancel, + std::unique_ptr& output); // Closed JSON metadata document produced by open(). [[nodiscard]] const std::string& metadata() const noexcept @@ -83,7 +83,7 @@ class image_session final { uint32_t width_ = 0; uint32_t height_ = 0; uint32_t channels_ = 0; - uint32_t tile_width_ = 0; // zero for scanline images + uint32_t tile_width_ = 0; // zero for scanline images uint32_t tile_height_ = 0; uint32_t output_channels_ = 0; uint32_t bytes_per_pixel_ = 0; @@ -102,8 +102,8 @@ class image_session final { // The rgba32f output is premultiplied, so straight colour is multiplied by alpha after any transfer. bool premultiply_output_ = false; - std::vector tile_buffer_; // one decoded tile (tiled sources) - std::vector band_; // decoded full-width rows [band_begin_, band_end_) + std::vector tile_buffer_; // one decoded tile (tiled sources) + std::vector band_; // decoded full-width rows [band_begin_, band_end_) uint32_t band_begin_ = 0; uint32_t band_end_ = 0; std::vector scratch_; diff --git a/native/arcimage-abi/tests/arcimage_codec_tests.cpp b/native/arcimage-abi/tests/arcslate_image_codec_tests.cpp similarity index 97% rename from native/arcimage-abi/tests/arcimage_codec_tests.cpp rename to native/arcimage-abi/tests/arcslate_image_codec_tests.cpp index 4f63fa12..8d6957cb 100644 --- a/native/arcimage-abi/tests/arcimage_codec_tests.cpp +++ b/native/arcimage-abi/tests/arcslate_image_codec_tests.cpp @@ -55,8 +55,7 @@ struct source { int sleep_ms = 0; }; -arc_status_t ARC_ABI_CALL read_at(void* context, uint64_t offset, void* destination, uint64_t requested, - uint64_t* done) +arc_status_t ARC_ABI_CALL read_at(void* context, uint64_t offset, void* destination, uint64_t requested, uint64_t* done) { auto* state = static_cast(context); ++state->calls; @@ -271,7 +270,6 @@ std::vector decompression_bomb_png() return png_header_only(65535, 65535); } - // ---- Tests ------------------------------------------------------------------------------------- void test_probe_and_build_info() @@ -304,7 +302,7 @@ void test_png_rgba8_round_trip() const auto bytes = encode_png_rgba8(width, height, source); expect(!bytes.empty(), "PNG fixture encodes"); - struct source state {}; + struct source state{}; state.bytes = &bytes; const arc_io_v1 io = make_io(state); arc_handle_t handle = 0; @@ -524,9 +522,9 @@ void test_tiled_tiff_edge_tiles_and_coverage() blocks_match = blocks_match && read_region(handle, x, y, w, h, 4, block) == ARC_OK; for (uint32_t row = 0; row < h; ++row) { const size_t source_offset = ((static_cast(y) + row) * width + x) * 4; - blocks_match = blocks_match && std::memcmp(block.data() + static_cast(row) * w * 4, - source_pixels.data() + source_offset, - static_cast(w) * 4) == 0; + blocks_match = + blocks_match && std::memcmp(block.data() + static_cast(row) * w * 4, + source_pixels.data() + source_offset, static_cast(w) * 4) == 0; } } } @@ -551,7 +549,8 @@ void test_coverage_order_and_no_state_change() const arc_io_v1 io = make_io(state); arc_handle_t handle = 0; std::string metadata; - expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, "PNG opens for coverage"); + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, + "PNG opens for coverage"); std::vector block; expect(read_region(handle, 16, 0, 16, 8, 4, block) == ARC_INVALID_ARGUMENT, @@ -574,8 +573,7 @@ void test_buffer_too_small_consumes_nothing() const arc_io_v1 io = make_io(state); arc_handle_t handle = 0; std::string metadata; - const arc_status_t short_metadata = - open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata, 1); + const arc_status_t short_metadata = open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata, 1); expect(short_metadata == ARC_BUFFER_TOO_SMALL && handle == 0, "a short metadata buffer consumes no handle"); expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_OK, "the same open succeeds with a large buffer"); @@ -609,7 +607,8 @@ void test_unsupported_content() const arc_io_v1 io = make_io(state); arc_handle_t handle = 0xFF; std::string metadata; - expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_UNSUPPORTED && handle == 0, + expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) == ARC_UNSUPPORTED && + handle == 0, "content outside the PNG, TIFF and EXR allowlist is unsupported with no handle"); } } @@ -894,7 +893,7 @@ std::filesystem::path temporary_fixture_path(const char* extension) { static int sequence = 0; return std::filesystem::temp_directory_path() / - ("arcimage_codec_fixture_" + std::to_string(++sequence) + extension); + ("arcslate_image_codec_fixture_" + std::to_string(++sequence) + extension); } // Writes the parts in order and returns the resulting bytes. The first part creates the file and later parts @@ -925,9 +924,9 @@ std::vector encode_parts(const char* extension, const std::vector } else if (index == 0) { opened = output->open(name, item.spec, OIIO::ImageOutput::Create); } else { - opened = output->open(name, item.spec, - item.mip_level ? OIIO::ImageOutput::AppendMIPLevel - : OIIO::ImageOutput::AppendSubimage); + opened = + output->open(name, item.spec, + item.mip_level ? OIIO::ImageOutput::AppendMIPLevel : OIIO::ImageOutput::AppendSubimage); } if (!opened) { std::fprintf(stderr, "fixture part %zu open failed (declared=%d mip=%d): %s\n", index, @@ -1147,15 +1146,15 @@ void test_exr_half_round_trip_and_rgba8_clamp() "half-float channels convert to float32 exactly"); arc_image_close(handle); - // rgba8 clamps out-of-range values and rounds to nearest. Alpha is one, so unpremultiplying leaves colour unchanged. + // rgba8 clamps out-of-range values and rounds to nearest. Alpha is one, so unpremultiplying leaves colour + // unchanged. const float clamp_values[16] = {-0.5F, 0.0F, 0.5F, 1.0F, 2.0F, 1.0F, 0.25F, 1.0F, - 0.0F, 0.0F, 0.0F, 1.0F, 0.5F, 0.5F, 0.5F, 1.0F}; + 0.0F, 0.0F, 0.0F, 1.0F, 0.5F, 0.5F, 0.5F, 1.0F}; std::vector clamp(16); for (size_t index = 0; index < clamp.size(); ++index) { clamp[index] = half(clamp_values[index]); } - const std::vector clamp_expected = {0, 0, 128, 255, 255, 255, 64, 255, - 0, 0, 0, 255, 128, 128, 128, 255}; + const std::vector clamp_expected = {0, 0, 128, 255, 255, 255, 64, 255, 0, 0, 0, 255, 128, 128, 128, 255}; OIIO::ImageSpec clamp_spec(4, 1, 4, OIIO::TypeDesc::HALF); clamp_spec.channelnames = {"R", "G", "B", "A"}; clamp_spec.alpha_channel = 3; @@ -1299,8 +1298,8 @@ void test_buffer_one_byte_short_consumes_nothing() void test_unsupported_formats_and_empty_input() { const std::vector gif = {'G', 'I', 'F', '8', '9', 'a', 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}; - const std::vector webp = {'R', 'I', 'F', 'F', 0x10, 0x00, 0x00, 0x00, 'W', 'E', 'B', 'P', - 'V', 'P', '8', ' '}; + const std::vector webp = {'R', 'I', 'F', 'F', 0x10, 0x00, 0x00, 0x00, + 'W', 'E', 'B', 'P', 'V', 'P', '8', ' '}; const std::vector jpeg2000 = {0xFF, 0x4F, 0xFF, 0x51, 0x00, 0x2F}; const std::vector empty; const std::vector*> cases = {&gif, &webp, &jpeg2000, &empty}; @@ -1330,24 +1329,28 @@ void test_truncated_and_corrupt_input_is_corrupt() expect_corrupt(signature_only, width, height, "a PNG with only its signature is refused as CORRUPT"); std::vector bad_header_crc = png; - bad_header_crc[29] ^= 0xFF; // the IHDR CRC starts after the 8-byte signature, 4-byte length, 4-byte type and 13 data bytes + bad_header_crc[29] ^= + 0xFF; // the IHDR CRC starts after the 8-byte signature, 4-byte length, 4-byte type and 13 data bytes expect_corrupt(bad_header_crc, width, height, "a PNG with a damaged IHDR CRC is refused as CORRUPT"); expect_corrupt(png_with_damaged_pixels(png), width, height, "a PNG with damaged pixel data fails CORRUPT on read with no output"); - const auto tiff = encode("fixture.tif", rgba8_spec(width, height), OIIO::TypeDesc::UINT8, - rgba_pattern(width, height, 4).data()); + const auto tiff = + encode("fixture.tif", rgba8_spec(width, height), OIIO::TypeDesc::UINT8, rgba_pattern(width, height, 4).data()); expect(!tiff.empty(), "TIFF fixture encodes for corruption checks"); const std::vector truncated_tiff(tiff.begin(), tiff.begin() + static_cast(tiff.size() / 2)); expect_corrupt(truncated_tiff, width, height, "a truncated TIFF is refused as CORRUPT with no output"); - const auto exr = encode("fixture.exr", [] { - OIIO::ImageSpec exr_spec(8, 8, 4, OIIO::TypeDesc::FLOAT); - exr_spec.channelnames = {"R", "G", "B", "A"}; - exr_spec.alpha_channel = 3; - return exr_spec; - }(), OIIO::TypeDesc::FLOAT, float_pattern(8, 8, 4, 0.0F).data()); + const auto exr = encode( + "fixture.exr", + [] { + OIIO::ImageSpec exr_spec(8, 8, 4, OIIO::TypeDesc::FLOAT); + exr_spec.channelnames = {"R", "G", "B", "A"}; + exr_spec.alpha_channel = 3; + return exr_spec; + }(), + OIIO::TypeDesc::FLOAT, float_pattern(8, 8, 4, 0.0F).data()); expect(!exr.empty(), "EXR fixture encodes for corruption checks"); const std::vector truncated_exr(exr.begin(), exr.begin() + static_cast(exr.size() / 2)); expect_corrupt(truncated_exr, width, height, "a truncated EXR is refused as CORRUPT with no output"); @@ -1432,8 +1435,7 @@ void test_decompression_bomb_refused_before_decode() "a tile one byte above the memory limit is refused at open"); arc_image_options_v1 exact = make_options(ARC_FORMAT_RGBA8); exact.limits.max_memory_bytes = 4096; - expect(open_image(tile_io, exact, nullptr, handle, metadata) == ARC_OK, - "a tile exactly at the memory limit opens"); + expect(open_image(tile_io, exact, nullptr, handle, metadata) == ARC_OK, "a tile exactly at the memory limit opens"); arc_image_close(handle); } @@ -1551,7 +1553,8 @@ void test_exr_multipart_subimages_within_bounds() "the first part reads exactly, and differently from the second"); arc_image_close(handle); - expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED, 2, 0), nullptr, handle, metadata) == ARC_NOT_FOUND && + expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED, 2, 0), nullptr, handle, metadata) == + ARC_NOT_FOUND && handle == 0, "a subimage past the last part is NOT_FOUND"); @@ -1617,7 +1620,8 @@ void test_exr_mip_levels_within_bounds() const auto level_height = static_cast(level_specs[mip].height); arc_handle_t handle = 0; std::string metadata; - expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED, 0, mip), nullptr, handle, metadata) == ARC_OK && + expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED, 0, mip), nullptr, handle, metadata) == + ARC_OK && contains(metadata, "\"mip\":" + std::to_string(mip)) && contains(metadata, "\"mips\":" + std::to_string(mip_count)) && contains(metadata, "\"width\":" + std::to_string(level_width)) && @@ -1633,7 +1637,8 @@ void test_exr_mip_levels_within_bounds() arc_handle_t handle = 0; std::string metadata; expect(open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED, 0, mip_count), nullptr, handle, metadata) == - ARC_NOT_FOUND && handle == 0, + ARC_NOT_FOUND && + handle == 0, "a mip level past the last one is NOT_FOUND"); arc_image_options_v1 one_item = make_options(ARC_FORMAT_FLOAT32_INTERLEAVED, 0, 0); @@ -1711,6 +1716,6 @@ int main(int argc, char** argv) return 2; } (void)OIIO::geterror(); - std::printf("arcimage codec checks: %d, failures: %d\n", checks, failures); + std::printf("ArcImageNative codec checks: %d, failures: %d\n", checks, failures); return failures == 0 ? 0 : 1; } From d7fb931e8e8005144edd5427d4961f48b87d82c7 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 04:16:07 +0100 Subject: [PATCH 06/15] [NAT.11] Record successor dependency receipt nat-11-r1 and pin the NAT.11 project blobs Successor receipt nat-11-r1 chains from gov-30-r1 and names reviewer w-deku-20261008-rev-nat-11. The bound inputs that changed are the ArcForges.Native.Image csproj, the NativeAbiTests csproj and its lock (project reference only). The NuGet closure (58) and Python closure (10) are unchanged. The independent review is pending. Reconciliation active project pins move to the committed blobs of the three NAT.11 project files (the arcimage CMake file, the Native.Image csproj and the NativeAbiTests csproj). Co-Authored-By: Claude Haiku 5.5 --- eng/policy/dependency-policy.json | 49 +- eng/policy/dependency-reviews/nat-11-r1.json | 1026 +++++++++++++++++ .../reconciliation/active-projects.json | 6 +- 3 files changed, 1049 insertions(+), 32 deletions(-) create mode 100644 eng/policy/dependency-reviews/nat-11-r1.json diff --git a/eng/policy/dependency-policy.json b/eng/policy/dependency-policy.json index 583627b3..e4beebaf 100644 --- a/eng/policy/dependency-policy.json +++ b/eng/policy/dependency-policy.json @@ -266,7 +266,7 @@ "src/Native/ArcForges.Native.Abstractions/packages.lock.json": "09f91faca7c23b7ef4c0a4a6a4afa1f5d4e17e2e3dcbd9aca08ff12aec384059", "src/Native/ArcForges.Native.Image.Runtime.win-x64/ArcForges.Native.Image.Runtime.win-x64.csproj": "e7e89b53d8f01b17385174d0723d480c91159f6209b61eae265bf189fe5645f6", "src/Native/ArcForges.Native.Image.Runtime.win-x64/packages.lock.json": "09f91faca7c23b7ef4c0a4a6a4afa1f5d4e17e2e3dcbd9aca08ff12aec384059", - "src/Native/ArcForges.Native.Image/ArcForges.Native.Image.csproj": "f40721a408237dd02ae4d0a244731463245a88dfa66fb9e6fe94921090b9e651", + "src/Native/ArcForges.Native.Image/ArcForges.Native.Image.csproj": "e271da616a6950d704aca666c8d64a50e928467d3488a63669b432991552769c", "src/Native/ArcForges.Native.Image/packages.lock.json": "cd7ed1d6af3e01a3bc233a62e7ae062e55434f0892d3f61c011387cd9ed0e576", "tests/ArchitectureTests/ArcForges.Tests.ArchitectureTests.csproj": "fe6d0b2a9bf1cb70fc496515ea35e07356a0758898ba22a3816d696fcebf3c3a", "tests/ArchitectureTests/packages.lock.json": "e2090c8b97e17e0ec5899d8819ac16997a6e0e1df1f3b545c55e5cb39d823168", @@ -274,8 +274,8 @@ "tests/AssistantAbstractionsTests/packages.lock.json": "b242f814d6724e1c8c5d82e1a969b846f51cb907c9acfd4d426556059785d8c8", "tests/LocalRpcAotTests/LocalRpcAotTests.csproj": "28e7f64ff9d289bd4943a5e14e36302350ba3ff00d8a36756dae07da38b673f8", "tests/LocalRpcAotTests/packages.lock.json": "14db0500440e6499b5f5f6e448642420d65d5d2ef6e3cd52c2bb7631f5d53ea8", - "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj": "def3becd3d047014df118bd686a671e55ef7db133c5b03495bd19945b6ebe6e0", - "tests/NativeAbiTests/packages.lock.json": "062e863bfceb7a467a5f004424324c80bb0f3a6f885a9b7de89aa92b90fe6cf1", + "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj": "14abae6f85b4424deab0e0c749aa3c0a850401b578e1ea45e910a7164a0cafa0", + "tests/NativeAbiTests/packages.lock.json": "da237463bcfea624e3a008de4079e0eeb57a71fcb31d7a3ed012556fc885da5a", "tests/PersistenceTests/ArcForges.Tests.PersistenceTests.csproj": "5a947697cc93979c5e6c149b100fee204d706695e0e4d596af7bf95a674c3008", "tests/PersistenceTests/packages.lock.json": "c94488ff2560372433bf2ca0d6f04a69429d10cde23265edca24ea6f798b6778", "tests/ReleaseArtifactTests/GrpcWeb/Directory.Packages.props": "75389a85362198cb44500b21765fda9a7a83ce13a17e6fbeb5b5ec9d9f8b68c7", @@ -767,19 +767,19 @@ "nativeGate": "eng/native_provenance.py", "sourceGate": "eng/check_provenance.py", "review": { - "owner": "w-deku-20261008-gov-30", - "reviewer": "w-deku-20261008-rev-gov-30", - "reviewedOn": "2026-10-08", - "baselineCommit": "e0f2e563bfb1a134a415b4b085dab121c66d00f9", - "maintenanceAssessment": "GOV.30 adds no dependency. It removes the two macOS-RID ILCompiler 10.0.11 runtime-pack admissions (MIT) and the macOS-RID net10.0 lock sections of the ContentSandbox, ContentSandbox Fixture, LocalRpcAotTests, ReleaseArtifactTests GrpcWeb and Realtime locks, because eng/build/desktop-rids.props no longer lists the two macOS RIDs (P2-023). The remaining desktop RID set is win-x64, win-arm64 and linux-x64; linux-arm64 is not added. Every other coordinate, version, content hash and source record is unchanged, and the locks equal their predecessors with only the macOS sections removed. This successor chains from the receipt active on the base branch and preserves all earlier receipts.", + "owner": "w-deku-20261008-nat-11", + "reviewer": "w-deku-20261008-rev-nat-11", + "reviewedOn": "2026-10-09", + "baselineCommit": "6b84ca17f6be64f69107dc3172ac9879ed510dcb", + "maintenanceAssessment": "NAT.11 adds no third-party dependency coordinate, version or content hash. The bound inputs that change are src/Native/ArcForges.Native.Image/ArcForges.Native.Image.csproj (IsAotCompatible and the description for the functional still-image reads), tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj (a ProjectReference to ArcForges.Native.Image and test-only NoWarn entries for CA2007, CA2000 and xUnit1051) and tests/NativeAbiTests/packages.lock.json (the project-reference entry, with no package entry). The NuGet closure (58 coordinates) and the Python closure (10 coordinates) are unchanged from gov-30-r1. The independent review of this successor is pending from w-deku-20261008-rev-nat-11.", "upgradeChecks": { - "compilation": "With the pinned SDK 10.0.400 in the local WSL2 Debian distribution (Debian 13, kernel 6.18.40.1-microsoft-standard-WSL2) on a Linux-native copy: the five affected locks were regenerated by restore (--use-lock-file) and every affected project passes --locked-mode restore; a Release build of the five affected projects completes with 0 warnings and 0 errors; the ArcForges.ContentSandbox.Tests project restores locked and its test run passes. The regenerated locks differ from their predecessors only by the removed macOS-RID sections.", - "aot-trim": "No trim, AOT or analyzer setting changes; the win-x64 and linux-x64 Native AOT publish paths are unchanged. Affected Native AOT probes are not re-published by this successor.", - "compatibility": "No public API, wire schema or contract changes; the LocalRpcAotTests publish help text and README drop only the macOS RID lines.", - "licence-provenance": "Removes two MIT coordinates with their recorded nuspec evidence; no coordinate is added and no source or licence record changes.", - "security-sbom": "The remaining locked graph is unchanged and keeps its NuGet audit; removing an unused RID admission reduces the admitted closure. No claim of absent current advisories is made.", - "runtime-performance-migration": "No runtime, performance or migration change; no macOS runtime or CI obligation is created or claimed.", - "framework-runtime-posture": "No framework major, native ABI, Android or SDK change; global.json remains on .NET SDK 10.0.400." + "compilation": "Win-x64 local validation at the reviewed head through the workstation build slot with the pinned SDK 10.0.400: dotnet restore DesktopPlatform.slnx --locked-mode reports all projects up to date; the shim-static CMake build and install succeed; the CI CTest filter passes 36 of 36; the NativeAbiLayout suite passes 3 of 3; ArcForges.Native.Image and ArcForges.Tests.NativeAbiTests build with 0 warnings and 0 errors; the NativeAbi functional suite passes 15 of 15 against the shim-static ArcImageNative.dll. linux-x64 is not run (decision D8); hosted CI is the authority.", + "aot-trim": "ArcForges.Native.Image sets IsAotCompatible true, so the repository AOT analyzers run on the binding in the local build with 0 warnings. No Native AOT publish is run locally; the existing AOT probe jobs in CI remain the publish authority.", + "compatibility": "Existing exports, signatures, the common ABI 1.0 layout and the ArcForges.Native.Image member set are unchanged; the functional members are additive. ArcImageNative reports functional ABI 1.1 through the unchanged arc_image_get_abi_version signature and a closed JSON build-info manifest (decision D2). The native_consumer expectation changes for ArcImageNative only; that script is a local diagnostic that was compile-checked and not executed, because it needs packed output.", + "licence-provenance": "No third-party coordinate, version or source is added by this receipt. The image library links the OpenImageIO 3.1.14.0, OpenEXR and Imath closure already admitted in the native-win-x64-r4 profile at vcpkg 36677bbd; OpenColorIO stays in the closure unchanged (decision D11). New first-party source and test files are inventoried in eng/provenance/files.json; check_provenance and native_provenance pass; the managed licence-boundary evaluation passes for 61 projects.", + "security-sbom": "The NuGet and Python closures are unchanged (58 NuGet and 10 Python coordinates), so no SBOM input changes. The decoder is first-party code over the admitted libraries, runs in-process with bounded reads and no path resolution. Hostile-input containment in the WP11 helper is NAT.31 and PLT.54 and is not claimed by this receipt (decision D1). No advisory status is claimed.", + "runtime-performance-migration": "No migration and no public runtime change. Reads are bounded by pixel, tile, scanline, output and handle limits and observe cancellation at callback and tile boundaries (decision D9); an in-flight codec call is not interruptible.", + "framework-runtime-posture": "No framework, SDK or runtime major change; global.json remains on the .NET SDK 10.0.400 and the vcpkg baseline is unchanged." }, "inputHashes": { ".python-version": "922fe0c3de073b01988e23348ea184456161678c5e329e6f34be89be24383f93", @@ -1046,7 +1046,7 @@ "src/Native/ArcForges.Native.Abstractions/packages.lock.json": "09f91faca7c23b7ef4c0a4a6a4afa1f5d4e17e2e3dcbd9aca08ff12aec384059", "src/Native/ArcForges.Native.Image.Runtime.win-x64/ArcForges.Native.Image.Runtime.win-x64.csproj": "e7e89b53d8f01b17385174d0723d480c91159f6209b61eae265bf189fe5645f6", "src/Native/ArcForges.Native.Image.Runtime.win-x64/packages.lock.json": "09f91faca7c23b7ef4c0a4a6a4afa1f5d4e17e2e3dcbd9aca08ff12aec384059", - "src/Native/ArcForges.Native.Image/ArcForges.Native.Image.csproj": "f40721a408237dd02ae4d0a244731463245a88dfa66fb9e6fe94921090b9e651", + "src/Native/ArcForges.Native.Image/ArcForges.Native.Image.csproj": "e271da616a6950d704aca666c8d64a50e928467d3488a63669b432991552769c", "src/Native/ArcForges.Native.Image/packages.lock.json": "cd7ed1d6af3e01a3bc233a62e7ae062e55434f0892d3f61c011387cd9ed0e576", "tests/ArchitectureTests/ArcForges.Tests.ArchitectureTests.csproj": "fe6d0b2a9bf1cb70fc496515ea35e07356a0758898ba22a3816d696fcebf3c3a", "tests/ArchitectureTests/packages.lock.json": "e2090c8b97e17e0ec5899d8819ac16997a6e0e1df1f3b545c55e5cb39d823168", @@ -1054,8 +1054,8 @@ "tests/AssistantAbstractionsTests/packages.lock.json": "b242f814d6724e1c8c5d82e1a969b846f51cb907c9acfd4d426556059785d8c8", "tests/LocalRpcAotTests/LocalRpcAotTests.csproj": "28e7f64ff9d289bd4943a5e14e36302350ba3ff00d8a36756dae07da38b673f8", "tests/LocalRpcAotTests/packages.lock.json": "14db0500440e6499b5f5f6e448642420d65d5d2ef6e3cd52c2bb7631f5d53ea8", - "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj": "def3becd3d047014df118bd686a671e55ef7db133c5b03495bd19945b6ebe6e0", - "tests/NativeAbiTests/packages.lock.json": "062e863bfceb7a467a5f004424324c80bb0f3a6f885a9b7de89aa92b90fe6cf1", + "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj": "14abae6f85b4424deab0e0c749aa3c0a850401b578e1ea45e910a7164a0cafa0", + "tests/NativeAbiTests/packages.lock.json": "da237463bcfea624e3a008de4079e0eeb57a71fcb31d7a3ed012556fc885da5a", "tests/PersistenceTests/ArcForges.Tests.PersistenceTests.csproj": "5a947697cc93979c5e6c149b100fee204d706695e0e4d596af7bf95a674c3008", "tests/PersistenceTests/packages.lock.json": "c94488ff2560372433bf2ca0d6f04a69429d10cde23265edca24ea6f798b6778", "tests/ReleaseArtifactTests/GrpcWeb/Directory.Packages.props": "75389a85362198cb44500b21765fda9a7a83ce13a17e6fbeb5b5ec9d9f8b68c7", @@ -1067,18 +1067,9 @@ "frameworkVersions": { "dotnetSdk": "10.0.400" }, - "previousReceipt": "eng/policy/dependency-reviews/nat-32-r1.json", - "namingToolCandidate": { - "package": "ArcForges.Contracts.Validation", - "version": "1.0.0-ci.205.1", - "contentHash": "OWvMF30zpN6ms2KlQCtax5P7TACrgK50Vb0drN5zy4iXgk2TZd+5UU+LDGaA2ioowA1EQIZx8ZUyRoayZaHadA==", - "sourceCommit": "696242d16034262ce8b7268e8d157cd0e5bee544", - "licence": "Apache-2.0", - "metadata": "https://api.nuget.org/v3/catalog0/data/2026.09.27.22.53.05/arcforges.contracts.validation.1.0.0-ci.205.1.json", - "publication": "https://github.com/ArcForges/Contracts/actions/runs/36356146302" - } + "previousReceipt": "eng/policy/dependency-reviews/gov-30-r1.json" }, - "reviewReceipt": "eng/policy/dependency-reviews/gov-30-r1.json", + "reviewReceipt": "eng/policy/dependency-reviews/nat-11-r1.json", "pythonClosure": { "pre-commit/4.6.0": { "hashes": [ diff --git a/eng/policy/dependency-reviews/nat-11-r1.json b/eng/policy/dependency-reviews/nat-11-r1.json new file mode 100644 index 00000000..be4fede8 --- /dev/null +++ b/eng/policy/dependency-reviews/nat-11-r1.json @@ -0,0 +1,1026 @@ +{ + "review": { + "owner": "w-deku-20261008-nat-11", + "reviewer": "w-deku-20261008-rev-nat-11", + "reviewedOn": "2026-10-09", + "baselineCommit": "6b84ca17f6be64f69107dc3172ac9879ed510dcb", + "maintenanceAssessment": "NAT.11 adds no third-party dependency coordinate, version or content hash. The bound inputs that change are src/Native/ArcForges.Native.Image/ArcForges.Native.Image.csproj (IsAotCompatible and the description for the functional still-image reads), tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj (a ProjectReference to ArcForges.Native.Image and test-only NoWarn entries for CA2007, CA2000 and xUnit1051) and tests/NativeAbiTests/packages.lock.json (the project-reference entry, with no package entry). The NuGet closure (58 coordinates) and the Python closure (10 coordinates) are unchanged from gov-30-r1. The independent review of this successor is pending from w-deku-20261008-rev-nat-11.", + "upgradeChecks": { + "compilation": "Win-x64 local validation at the reviewed head through the workstation build slot with the pinned SDK 10.0.400: dotnet restore DesktopPlatform.slnx --locked-mode reports all projects up to date; the shim-static CMake build and install succeed; the CI CTest filter passes 36 of 36; the NativeAbiLayout suite passes 3 of 3; ArcForges.Native.Image and ArcForges.Tests.NativeAbiTests build with 0 warnings and 0 errors; the NativeAbi functional suite passes 15 of 15 against the shim-static ArcImageNative.dll. linux-x64 is not run (decision D8); hosted CI is the authority.", + "aot-trim": "ArcForges.Native.Image sets IsAotCompatible true, so the repository AOT analyzers run on the binding in the local build with 0 warnings. No Native AOT publish is run locally; the existing AOT probe jobs in CI remain the publish authority.", + "compatibility": "Existing exports, signatures, the common ABI 1.0 layout and the ArcForges.Native.Image member set are unchanged; the functional members are additive. ArcImageNative reports functional ABI 1.1 through the unchanged arc_image_get_abi_version signature and a closed JSON build-info manifest (decision D2). The native_consumer expectation changes for ArcImageNative only; that script is a local diagnostic that was compile-checked and not executed, because it needs packed output.", + "licence-provenance": "No third-party coordinate, version or source is added by this receipt. The image library links the OpenImageIO 3.1.14.0, OpenEXR and Imath closure already admitted in the native-win-x64-r4 profile at vcpkg 36677bbd; OpenColorIO stays in the closure unchanged (decision D11). New first-party source and test files are inventoried in eng/provenance/files.json; check_provenance and native_provenance pass; the managed licence-boundary evaluation passes for 61 projects.", + "security-sbom": "The NuGet and Python closures are unchanged (58 NuGet and 10 Python coordinates), so no SBOM input changes. The decoder is first-party code over the admitted libraries, runs in-process with bounded reads and no path resolution. Hostile-input containment in the WP11 helper is NAT.31 and PLT.54 and is not claimed by this receipt (decision D1). No advisory status is claimed.", + "runtime-performance-migration": "No migration and no public runtime change. Reads are bounded by pixel, tile, scanline, output and handle limits and observe cancellation at callback and tile boundaries (decision D9); an in-flight codec call is not interruptible.", + "framework-runtime-posture": "No framework, SDK or runtime major change; global.json remains on the .NET SDK 10.0.400 and the vcpkg baseline is unchanged." + }, + "inputHashes": { + ".python-version": "922fe0c3de073b01988e23348ea184456161678c5e329e6f34be89be24383f93", + "Directory.Build.props": "5755571e24d77ac71c2acee2d14e511b308269d84fc8b01ef6be19cdf08fa418", + "Directory.Build.targets": "f38a795efa407c99b0190316cbfece23d0e84d179b5ec63ba88257746412a917", + "Directory.Packages.props": "14603721322414e9d7cb8f5ba847d4539deecdb35e8ebda94fbf68317575dea8", + "NuGet.config": "274d3bbf1913f74ace4d77a96223ea744018536aca462b58b6283ac94cfc2f07", + "benchmarks/probes/acquisition/AcquisitionProbe.csproj": "02bf424b1c64762d0303d4a24a8715a3c2123ac60e3aac146c14be245b5d44bd", + "benchmarks/probes/acquisition/packages.lock.json": "cbbc653c41d6c060a74f249099ac50c04062869ec4bd91000fe234164f04b7e6", + "eng/acceptance/foundation/Directory.Packages.props": "6c073bc8da6fad53cdc35adb042590e2e6b8cb4fbca8e3e1c2ea799476021ad4", + "eng/acceptance/foundation/Foundation.Acceptance.csproj": "df9c2b1ef09c4ffe7473c9a89ed0cccf8a5bfc009a38c81c0899da7ca06ff65a", + "eng/acceptance/foundation/package-lock.json": "2caa7295ba1f8ecd23890a8c8671f21412a186f5c91f8117ef428f2a918c91b7", + "eng/acceptance/foundation/package.json": "32361db70e2072b195385c9141b7bd5b003fd51f6a919b566fcb2c152da8b780", + "eng/acceptance/foundation/packages.lock.json": "36a2e2d446d5bf69b04398db18abcaa73d7c08855b046107cc2136695e2de27c", + "eng/build/desktop-aot.props": "eda4a383b69a800f73e7bb4572e0695d33012ff275c4f27436caea2b11d276ef", + "eng/build/desktop-rids.props": "6c238bf81f2ab3a89b033da2c2aaff9f0f30d916626700cc6a501c521bf849a9", + "eng/native/vcpkg/system-dependencies.v1.json": "54049f3a631bd9388181675ffaaec55be4157dfa3f9288ebe037c068581baa31", + "eng/native/vcpkg/triplets/x64-windows-static-md.cmake": "6935956ac45214d00f49e0eec2b33ef85495b9c2e91ec0f27d5e5225a904320f", + "eng/native/vcpkg/triplets/x64-windows.cmake": "1f0d1876aafd0730d364461ff5e3d60398404c70a237e57d50f879e44208125a", + "eng/packaging/packages.json": "6cb689cac903c5c6065f8cc5c1163547e33bc68c7d8cac884f023651ae207907", + "eng/policy/naming-package.json": "5324d0cc765a48a8b3cee0ab83242156a5ed3e6d34bd6df7e29011f269f7f727", + "eng/provenance/artifact-profiles/native-win-x64-r1.json": "1c708739c47c84c6684665696c0411e4b9055898294ef52bd0798e7fba9b3032", + "eng/provenance/artifact-profiles/native-win-x64-r2.json": "2469bb7ff266028a7c072522d12beb97f6fe11d44a109b1c31932a541657e10d", + "eng/provenance/artifact-profiles/native-win-x64-r3.json": "4663b812996a60f91c5620c1cd58675203a870bdf28001f60cef7a67ae585775", + "eng/provenance/artifact-profiles/native-win-x64-r4.json": "1cf40d8075ae94ca095607fb60cb5ea2cb9ed42f9008c92a6e643660c1756589", + "eng/provenance/records/canonical-agpl-legal-r1.json": "876a4d67f4f1cdba5e43a2431f73a242d3e34fa81b45620b8d45a76781d23dd9", + "eng/provenance/records/contracts-apache-legal-r1.json": "d436592a4d2f88a0de71a04bcf1c7f52cdf313049ef1b713e525a3ef57025b59", + "eng/provenance/records/contracts-provenance-tools-r1.json": "734af233a171876f4f246e0d7f99c7bc5366be65da9801acbd2a1f5a7588bfce", + "eng/provenance/records/contracts-provenance-tools-r2.json": "c83a17cc144e82979a35e0a66cadc1577acfe65aa557c08aa9142a54ac6113d2", + "eng/provenance/records/design-policy-export-r1.json": "8146b3e232890bc712de566049e672c0f6d783c86b4afb04052a130e4ceaeb06", + "eng/provenance/records/design-policy-export-r2.json": "f56cf25acb6448ca7b7ad53b258e7ed7c5a5d43fc7e7bf142c55c4c2fe4fbe29", + "eng/provenance/records/design-policy-export-r3.json": "e1133747e9d6a43f4b6d35c04f91ad1522a8a1d47d9f64e47b44359567d59799", + "eng/provenance/records/design-policy-export-r4.json": "9dbd02e1c0f8466928dc5dfd4bfecf4f25ee04c5d138d51e125ac71cc8f79300", + "eng/provenance/records/native-amd-amf-r1.json": "906e812cba398a382dd9c4788fcad5361bca9704fdd07fe6d0d1358f9d08f3de", + "eng/provenance/records/native-amd-amf-r2.json": "07f5d3fa3f6003f1b4360315cd9becfcfa68bdf3df53c0191bdf65d85660caf9", + "eng/provenance/records/native-amd-amf-r3.json": "b79e83311b0dcc6812fbe4c960c9e8e836e198c375208ffc8c900aecb0ced9d1", + "eng/provenance/records/native-bzip2-r1.json": "ccd1913cbe0f61b1e5bc67b5099a5ebb3f431bc351f0b22195ed5042aec422e4", + "eng/provenance/records/native-bzip2-r2.json": "3252bf8b054c8c036bdb6c6ed0935a55fad71729b14d9fc6a9c09b51e6e564a5", + "eng/provenance/records/native-bzip2-r3.json": "2cdfe7772e8a31d5eb0e9fea9f57313500beac73873d28872d2a5301e198db69", + "eng/provenance/records/native-bzip2-r4.json": "1b963de638e07697ded680d62f9c69d5ac357a95e36fd3ee4c6ee8d7d01168f1", + "eng/provenance/records/native-expat-r1.json": "a4fb403414b2e808708729356a2fdeafdd6bb9d7bb017546d02ab3f61438d97a", + "eng/provenance/records/native-expat-r2.json": "d65ee576bf2f991ad930763c39a378bf4b74e4fe00200010c4a4f483f8c98e97", + "eng/provenance/records/native-expat-r3.json": "01a11f605a4a082c91ad6866c849a0629078dea18affc840fe11fca1fa51eb94", + "eng/provenance/records/native-expat-r4.json": "a479f09846fd7d2f5735a405e63f716ec3125430c18b6afc48e6482fd359b2e9", + "eng/provenance/records/native-ffmpeg-bin2c-r1.json": "b62c66155d964d01040c5258747b8774ef7f2fec24400f4a2ba62d4581353a38", + "eng/provenance/records/native-ffmpeg-bin2c-r2.json": "c0bf5ef98c0890d10779eb6698f453b3606f54053c09a272316b5eb620c34425", + "eng/provenance/records/native-ffmpeg-bin2c-r3.json": "a829f636e5a8819c28dbbd366c64dc16d43f7fd577109fc68773ad74dac40eaf", + "eng/provenance/records/native-ffmpeg-r1.json": "9f829be583d78dd1765e9bc5a66e4fc3ce11d90093324847c0ccfd2d94c9c619", + "eng/provenance/records/native-ffmpeg-r2.json": "4c0f8ba025a34afaaaa75d88357dd3d394bcaa0b1fd2f7591adc1a15454ddfbf", + "eng/provenance/records/native-ffmpeg-r3.json": "40266121cd9f30e074b457fcdf107f8936edb64700bbd6bb63f8bf06a2a8cbee", + "eng/provenance/records/native-ffnvcodec-r1.json": "ed29951ba6f59d6a3fb0b479dc160cfe41b390dca047b40209e7179b60b21cf0", + "eng/provenance/records/native-ffnvcodec-r2.json": "56004e44566872a71808c9077f33729bc3db863c3560bb5534293733cd28792a", + "eng/provenance/records/native-ffnvcodec-r3.json": "a34e91ec0113ef0d1558c1b68c92870abc74382b461d1f1ebab59c857a511cfc", + "eng/provenance/records/native-fmt-r1.json": "aad9dc5f7f06294883a531749dd126d714a49c61c499b5de22448a9e6318b796", + "eng/provenance/records/native-fmt-r2.json": "fe2717b9b3d9577973f9d58ed4e4136fa05ea13bdefe7acb2a202a04eefe74a7", + "eng/provenance/records/native-fmt-r3.json": "0fb8b32a4e04fc4f6cc7b435f33745c4048c200b01b6778bd4c81c8a77de8098", + "eng/provenance/records/native-fmt-r4.json": "48a194a6aa0d710c0d42fdce2e9c878584e2a9ab6b83142c5a70b4e9edbb068a", + "eng/provenance/records/native-imath-r1.json": "9c75fb08b77d704d4a02d1f0dc42c1b9faf9987b152fce8349256991c14dbca0", + "eng/provenance/records/native-imath-r2.json": "fa154ba424b28147ae658266e9e619c0aa3ce1fa8caf62b9d4adcd30d1913bc0", + "eng/provenance/records/native-imath-r3.json": "7d20bda714f8f2a4e4941060cc04306f3d2623462217d26830ad55dc53826de3", + "eng/provenance/records/native-imath-r4.json": "9f33784a7544ed336b1048fefecb385755f981dc04679dfa0ab116d8387f739c", + "eng/provenance/records/native-libdeflate-r1.json": "6497100993a2d241d1f5a853ef0dc06bceb1e2e34a8139c70661a1505f8cabcf", + "eng/provenance/records/native-libdeflate-r2.json": "eb5d80799ed240163c2e2e8189dee9f743125b17d9fc239221ea7f7b4de65c3c", + "eng/provenance/records/native-libdeflate-r3.json": "669c68620aaba0205eb6716e8bd275f2daad62dbe68a3304055cb9c34d5eaa31", + "eng/provenance/records/native-libdeflate-r4.json": "4882ca6c915b23d0f5c7e060c231aae872f2aea28aa138282afec4cff66970d6", + "eng/provenance/records/native-libjpeg-turbo-r1.json": "32a61377fd43596b779f712b51562a151faa80b528555b50632047e66851810d", + "eng/provenance/records/native-libjpeg-turbo-r2.json": "6ddb760641dbb1f9798f89751f2aa5bda342ce7923b6534fcf3f2c22d21c36e0", + "eng/provenance/records/native-libjpeg-turbo-r3.json": "a6064580570926734b13f0a4d60aebb75bf5fb42f6d5713964996eae0de3d54f", + "eng/provenance/records/native-libjpeg-turbo-r4.json": "acaa0a7e1e840f7d8510437747727ca12ffcd966b3aa1c2d4997ba90383d3447", + "eng/provenance/records/native-liblzma-r1.json": "fd0c1a82bddc7c399f87ae3fc7f6a37c752a717a8de8209cf29dd2681f4a1a08", + "eng/provenance/records/native-liblzma-r2.json": "1a730b5fdeb98996507972e0e5fd9bac8ce0f1834a4821781ff8557c911bfe2f", + "eng/provenance/records/native-liblzma-r3.json": "9b3cdcc9d491ce667d1d998cfa1342fc62848b0d1a0f7a9dd0ea65c952216f6d", + "eng/provenance/records/native-liblzma-r4.json": "0d3229f7b3659b2b78ef02a4ec3e06ffbc773c2686684da782e2e15482d8e5ef", + "eng/provenance/records/native-libpng-r1.json": "94026969444009ec7c6ca4b8434b0388d76d9adc798e8aa64a0c1c76c312ce58", + "eng/provenance/records/native-libpng-r2.json": "49aa9cb729aa12b9f020306d354b7cca8f01e7cb99f6fb9d6f3c05e497e9a1cf", + "eng/provenance/records/native-libpng-r3.json": "8c4d16283d49c7633a36ff3e692d6ca9f2cb5998a82449e544e7b048cb1210fd", + "eng/provenance/records/native-libpng-r4.json": "b982a80e4ae7f6039674e7e37c6ca74055ac1e020f431191260ea6b8d19800c3", + "eng/provenance/records/native-libusb-r1.json": "9892918e3a2226df2b3826402228096d7793281794547497425dda07a2fa2468", + "eng/provenance/records/native-libusb-r2.json": "050058749be0db4519746165dfcba9b551a68f78c32c0714bf532a6a6769071d", + "eng/provenance/records/native-libusb-r3.json": "74c3aa1e2db6a79a091bb385689b63ff81e52031d15c1c656b700819f42c1df0", + "eng/provenance/records/native-libvpl-r1.json": "7892c43d72e2bd6ab6bfb474bf9d0e0067715783e4e9dc9d53861e872df1bc32", + "eng/provenance/records/native-libvpl-r2.json": "2f98d16c5dfc1c529a546138150ab0914fdbf5bd5be84ca7ae90e02ea03ac8d8", + "eng/provenance/records/native-libvpl-r3.json": "2ed43feaaf7bf4dcb2b2839c1b852b6735db0698f64fbbe9cd13b5506d6d9432", + "eng/provenance/records/native-miniaudio-r1.json": "8177aa80a28da91a83e1fff09c9e4988182dd19e99c0b5b27352a237ee114892", + "eng/provenance/records/native-miniaudio-r2.json": "c08db68f0e152462419d4cb6cc230c0ae435d676441c2952e6f6b4138bc8c066", + "eng/provenance/records/native-miniaudio-r3.json": "7578a40ae8d52de26b8089ec89cefe35166b3b674f6757e722f79e48449c3a59", + "eng/provenance/records/native-minizip-ng-r1.json": "11ecbd550ecdc36687727b0aeca764c9717e5c03d8c64a235e07319da5830045", + "eng/provenance/records/native-minizip-ng-r2.json": "0caccfd1def5804e25957e78c878d5ba2106e895d5937fb676bd4c3c11b15c60", + "eng/provenance/records/native-minizip-ng-r3.json": "39a5a719bf2056d358b06a6707d137775bde40ec45e3693b567d50a4349c0ae0", + "eng/provenance/records/native-minizip-ng-r4.json": "2b724d8b7660f0ec0836ff98cb3947e238c4a6dbb6d5ba0ecbecf53e9f4e5948", + "eng/provenance/records/native-opencolorio-r1.json": "138eb9210518c5b8f64016559d33cdbbcc4925f3d65a86253166f98a11b53bb1", + "eng/provenance/records/native-opencolorio-r2.json": "b674c499bb0e8751337cfa454e81cf65a5cc3e535ea6b4830674446aed7562f3", + "eng/provenance/records/native-opencolorio-r3.json": "0fbeb25ba5df2da0eb60078803737141ceb69d3c115ffc07de890c370cf3c41b", + "eng/provenance/records/native-opencolorio-r4.json": "bee01300d717e136a69d0beac3b8f7fda1c23fdb0c0f26180d1ca04ec77b2b3b", + "eng/provenance/records/native-openexr-r1.json": "591e426761379fcbbf762633f3c4b13a923222d20b1da8528651120950877580", + "eng/provenance/records/native-openexr-r2.json": "681453d9597b9f6d4d9d062133609b464d7dbfdc05ca6d07cf0e5f5766f50395", + "eng/provenance/records/native-openexr-r3.json": "3922bff104cc175dac6555569f4480b1f05fb10441ee8de21363b8a01da678e2", + "eng/provenance/records/native-openexr-r4.json": "3499e79923acd345db4f6bcc38b6a479d476baadb939e9bc293956941a0ae09f", + "eng/provenance/records/native-openimageio-r1.json": "7eff2e066559b33257701d1880f74efb9a556978dc7dc8b5ee1dc9be76a8da9e", + "eng/provenance/records/native-openimageio-r2.json": "2d860bbe419719a72dd96e046141e97e26487cc25886cd02575ce10df4bb8c6d", + "eng/provenance/records/native-openimageio-r3.json": "aa1c8be91b17a1e0b1ffb0530d1d23f71d7faee6e195bcc26f8dae378f574dd0", + "eng/provenance/records/native-openimageio-r4.json": "7eb9f10547a390f60aa1cbcc22a69654bdc713c7af9eb87cc0b9e7c995cb5e8a", + "eng/provenance/records/native-openjph-r1.json": "79779e9a031d2811f1d13830d35f3153cff511f6cb9aa86c28d6a5d905d60079", + "eng/provenance/records/native-openjph-r2.json": "9554e5d133939dd9cdd3cf3df98bff1bdcffa25344fd2ba2633af67bcbc5239d", + "eng/provenance/records/native-openjph-r3.json": "501ed6d3645a8661cabfce92f0bdc411b2bdaa9492a7fc6773f7e45cd3d0cbb1", + "eng/provenance/records/native-openjph-r4.json": "2956db9c112c71584da4ddf8df7fccc61b5b540a6ccbe4ce91269953cb1c73d3", + "eng/provenance/records/native-opentimelineio-r1.json": "72074bcf82b6e813c3aaf4f8fe741498cd0865089f96720f9a832559982f5f0b", + "eng/provenance/records/native-opentimelineio-r2.json": "747303a95d89615a8080d45dc98b1faef71e081fa467c74fd98acca847e00671", + "eng/provenance/records/native-opentimelineio-r3.json": "3fa020da888e7e7942b1eb528d534018159e4d0befc2a7d4505ad04fd352f3b3", + "eng/provenance/records/native-pkgconf-r1.json": "92fefa78ecc8a7cb6c5fb44308800f2d231e49520b92b931e5964ac193aaa7c1", + "eng/provenance/records/native-pkgconf-r2.json": "19801f80373877c21932d2ff5dd506193464f2b21d21ec2d821ec6f0e27246b7", + "eng/provenance/records/native-pkgconf-r3.json": "9f91d9226843ada3def1eeab75f9d4489dfed944dfa2845ca048707925eadc95", + "eng/provenance/records/native-pystring-r1.json": "e519aca475db1028a01c040612b0fd939b92f89c0c00ae163f014d1b382f0db0", + "eng/provenance/records/native-pystring-r2.json": "c0ca0d1783fc12a813ad87867d65ec6b746c15d2502dd8e4f711d67a59e608a6", + "eng/provenance/records/native-pystring-r3.json": "c3a95bd9e99c690f16894e1bc87508495b01a16e19b8f5d501163e5ce11be43f", + "eng/provenance/records/native-pystring-r4.json": "7deba0b0bb547dda8363e1baab8b50040953fffb3c37dcd9b50cee2678931d98", + "eng/provenance/records/native-rapidjson-r1.json": "7c774b26ec3c50f988c507a68a77c3f48782144d91e71f69a5b510b764068b22", + "eng/provenance/records/native-rapidjson-r2.json": "5c8f775457759fece260a38d777e9e69998ea97a57b11d66327b3c0eab17d822", + "eng/provenance/records/native-rapidjson-r3.json": "2cc34eb7e0023cabd4ea674e22b4ddd69155b1fd7eb0809de9bfa34cb707a2db", + "eng/provenance/records/native-robin-map-r1.json": "2358156c1d3e4ba5ec70736b189006fde32d2ea3b20e06c3c55ccc0990f4bb18", + "eng/provenance/records/native-robin-map-r2.json": "78ce58ff330d99f2d931f5ef4884df07fd4a4ff9c08cb88ca33725bb98260391", + "eng/provenance/records/native-robin-map-r3.json": "b05c11319fbff03ccef6bf5c8fdb53a685415d5be4a007afff30a4470f3893c7", + "eng/provenance/records/native-robin-map-r4.json": "455cba8974902c464dcbf352bfbd660c91af54d9798d61f26900d61971aaae0c", + "eng/provenance/records/native-tiff-r1.json": "0ee90f1c9f5bf69f5100537e8b8c42738635836fd9378803eaaa8828505e0c70", + "eng/provenance/records/native-tiff-r2.json": "97b5a80217fae7f0e8e65d5c74e96c1933a6eae793462feb0227d04fe866f93b", + "eng/provenance/records/native-tiff-r3.json": "25917b5ffaa9305c3543f833153be313b53ddeebfa02b0451552f6fed478e597", + "eng/provenance/records/native-tiff-r4.json": "c0de6d50f3ada630a8f9e961b28f4ca03f52bb41542ba6f3343204c211a19953", + "eng/provenance/records/native-vcpkg-cmake-config-r1.json": "26ea3210bcf7e2999f6f1095757dc95d988a9250032be28395e34f0d6ab599db", + "eng/provenance/records/native-vcpkg-cmake-config-r2.json": "9579380b5378c5f6089823ca4e50c1e7117d8496e2bc440c7b2393b5b24d9062", + "eng/provenance/records/native-vcpkg-cmake-config-r3.json": "bf6d10434787c3a5650eddb78f197e3ff4613f991f95de113c92f88c274096d3", + "eng/provenance/records/native-vcpkg-cmake-config-r4.json": "11c5aaa3d5838fe60cf5303912f85dc3e2adff5bb235459e27cc81252074f2aa", + "eng/provenance/records/native-vcpkg-cmake-get-vars-r1.json": "71b698733503d4e18b415ea18fc455540cdc312f0ed4d7ee11ab8dd74cbe7cee", + "eng/provenance/records/native-vcpkg-cmake-get-vars-r2.json": "ec2ff3a4ea0f4a463a62f520e5bd731a790d1ced5c8b7510b19cffeb5280a836", + "eng/provenance/records/native-vcpkg-cmake-get-vars-r3.json": "f38967e2bee6dee771be8d52bc13e4adad16c17bf5300ea7199a83dcb4f6ee30", + "eng/provenance/records/native-vcpkg-cmake-r1.json": "a490a0794543455f34670cc8bd30c264920fabfb4624e800d6193393f5efdf68", + "eng/provenance/records/native-vcpkg-cmake-r2.json": "6c6c7aef911720cec3a134d2290cead6714503705e290c41b7859a52e0f214e2", + "eng/provenance/records/native-vcpkg-cmake-r3.json": "6d480d2873a92fd81ac6bbb42b565d8298da6c7a5f02d4ec8e93231f0449d362", + "eng/provenance/records/native-vcpkg-cmake-r4.json": "3aa9dee30b1c863e48c6c1634de31226ff659f8f41c8861407eb5ba6a71c88b9", + "eng/provenance/records/native-vcpkg-msbuild-r1.json": "1b46af2740931a423f4f0eea0dc1ee13695de9d6f9069c2139df5f5ae4670920", + "eng/provenance/records/native-vcpkg-msbuild-r2.json": "e67619f14992bccef01a67e4865702fa4d4537cafcea06c2fb7bbbfa26a4d938", + "eng/provenance/records/native-vcpkg-msbuild-r3.json": "58c1a2888a05cf7cd1cd4938ef58a4fe12211764df8be606532e23b2dc90ff86", + "eng/provenance/records/native-vcpkg-pkgconfig-get-modules-r1.json": "964dfde1c96acb59a3c3e3ac0dfd9e31aa90d057bf87957189c6fcffe141e1b8", + "eng/provenance/records/native-vcpkg-pkgconfig-get-modules-r2.json": "b1ce3f87b360587156233bafe3ff40cd7544ccd69dda30de878d0f9e790c79e6", + "eng/provenance/records/native-vcpkg-pkgconfig-get-modules-r3.json": "acb1cb2249c13e46a0b2f1b3fb3356a5d116e00b0c7b40133ab414413532245c", + "eng/provenance/records/native-vcpkg-tool-meson-r1.json": "fdef3cb36ad9bd88f67f131a14ce85bf2a09cd83306a4c06843070b14eb6aed4", + "eng/provenance/records/native-vcpkg-tool-meson-r2.json": "2ef534d5c42dc85c60dee2bd095237a06d9f811a8ec79176998bdb89fe5ccc4f", + "eng/provenance/records/native-vcpkg-tool-meson-r3.json": "edf632bc088572ee7178d03ba567e92cb44bc534d4a5f290d263e1b963388b65", + "eng/provenance/records/native-vulkan-headers-r1.json": "0f2593eef072458ff73a3280261129601857e3e5355e13478bf6a99804fe2fc0", + "eng/provenance/records/native-vulkan-headers-r2.json": "89c0592ecf81d4f6be04d59135f24a7f3147abe9d4ce7bdd960efc52aee270cd", + "eng/provenance/records/native-vulkan-headers-r3.json": "816650d83a037ce53749066e5750f1c19432249aa3057fb3c8c21464e49372c4", + "eng/provenance/records/native-yaml-cpp-r1.json": "d1f713849eb1c20d062bbcefbae527f7d6a935be19ffa9c0f8ac76bc5226b376", + "eng/provenance/records/native-yaml-cpp-r2.json": "e0441da6447c9c6e0180f714359c4977b87839bb7095146ffb7ba8b94ba3dab1", + "eng/provenance/records/native-yaml-cpp-r3.json": "57bff8322b7436c303e91a2934e4e64ab8497e6074cfacf2e2a632fd451c459c", + "eng/provenance/records/native-yaml-cpp-r4.json": "6759fa15d23c11c8af5c8b9b8da85c051a347fa2fe6e13263ae932667141585c", + "eng/provenance/records/native-zlib-r1.json": "0c055cc9d8e6be306aa96e9e07c5759f250864b7782682830ff6737ce0303ea4", + "eng/provenance/records/native-zlib-r2.json": "55bb98856e9b931d908caa9dc318e781617959d0bd906c9f36a0911eaff86037", + "eng/provenance/records/native-zlib-r3.json": "15de8fcc0d0afbc23d700fc28158279a86fae71ac0e536ef03472f2a1f92b203", + "eng/provenance/records/native-zlib-r4.json": "5a6473b9f6c01f925d9d53e5f507f07e0b4d0df94e939083137cf6388606662f", + "eng/provenance/records/native-zstd-r1.json": "52ea5f89f1ecdb8b9b4b1c7188a979b45c626d62fec31ba6586e5b44a8e4913b", + "eng/provenance/records/native-zstd-r2.json": "8e0b4aef35606a46f12a785009a11a97c3b795233f6032c08219777ffe7cf127", + "eng/provenance/records/native-zstd-r3.json": "3aee1adfbc2a58523a46023bee145bfac1dd37e5be35f34ba8b072e58d147171", + "eng/provenance/records/native-zstd-r4.json": "3b888e67a3ff11e3d60fe2199d910e2d689b06e5ab6105ea8581b398224ed262", + "eng/provenance/records/otio-legal-r1.json": "b1cafe73055423716f77ce6fa9d85079da10f41ff51b78eaf5ebe12a54dd2b3d", + "eng/provenance/records/otio-python-optional-patch-r1.json": "d666eb12d972e45b1c72ad9fdaa3bb4aa5d3e7475b19728545964b0528354ff1", + "eng/provenance/records/plan-delivery-graph-r1.json": "5715bd29050c9a78dda2db639ea0a7e5deb23037b02f2a10340f6f9ce3ab6287", + "eng/requirements-ci.txt": "fe6a76cb5334fd1b96af6203baec8723653e97453f0649f61b8703fd81787c25", + "eng/verification/probe-evidence/Directory.Packages.props": "970a2681511caf88a41e1128c6240ccbc319ab09fcdfec02e4e53d1963d29ab7", + "eng/verification/probe-evidence/Prf09.TableViewProbe.csproj": "29600888434657600898b01782870d17a2f2c1dee6136cffbbbce802a7beae36", + "eng/verification/probe-evidence/packages.lock.json": "ebaf3ff575a65e6b3a68a661fa19ea987f1abcf901846b80060d2c6ae3afbd02", + "global.json": "d17cfeed29d17cb8e75b56e26767c591ab560a2d7204f28bcb3ec3f47bb4263e", + "native/windows/ArcForges.Native.props": "a3d1e3c574b15b3c428f8b7db98476d120aa276dc566e86a1ade42e910516224", + "src/Build/ArcForges.Build.Policy/ArcForges.Build.Policy.csproj": "8514894388ac30b13550b223dfb94d37d37dbf79816766685e6438ea8aac01da", + "src/Build/ArcForges.Build.Policy/Architecture/ArchitecturePolicy.props": "b49df8103b7251e16412b236bcb2e4a9b665365b3f1b034840a4801653fdeb96", + "src/Build/ArcForges.Build.Policy/build/ArcForges.Build.Metadata.targets": "c0d516a0c0b956689ddfc78ff061d6f3add7a9fe22b4b05baa31bbfed1aca985", + "src/Build/ArcForges.Build.Policy/build/ArcForges.Build.Policy.props": "5310beac8a834ebff477d2d86d2d8937b2f635aa741294225c99a111d8b72600", + "src/Build/ArcForges.Build.Policy/build/ArcForges.Build.Policy.targets": "8c15dc21c5bf599b458e41ea126d2a22157d70f32c81512d25d7f886c504e34e", + "src/Build/ArcForges.Build.Policy/packages.lock.json": "09f91faca7c23b7ef4c0a4a6a4afa1f5d4e17e2e3dcbd9aca08ff12aec384059", + "src/BuildingBlocks/ArcForges.Application.Abstractions/ArcForges.Application.Abstractions.csproj": "270f378987937ebeb8d457ba332d56a8fb0f50a734386402f5358a430db3ebf6", + "src/BuildingBlocks/ArcForges.Application.Abstractions/packages.lock.json": "35787730aacd19510564fce9726ad522b0cea67a7f2380dadaf9bbb0d9bda2fc", + "src/BuildingBlocks/ArcForges.Assistant.Abstractions/ArcForges.Assistant.Abstractions.csproj": "38f313d8eaa527683cbcf2f72eeb78a79fad7a341acf0b10f34cf26159a2859e", + "src/BuildingBlocks/ArcForges.Assistant.Abstractions/packages.lock.json": "33c199ae62f7e0558d6e0b79086a73b6261cceb3c9cc9bbc1f21e9847c426aed", + "src/BuildingBlocks/ArcForges.Capabilities/ArcForges.Capabilities.csproj": "ae05aef953a581e9c2baf98d65d9106145c3edf673779d8ec55daa8ced3ffe7f", + "src/BuildingBlocks/ArcForges.Capabilities/Tests/ArcForges.Capabilities.Tests.csproj": "01ea51cef17b0abe6aa30d7a7cb88c28a619fbd0abc8fb338e6353d5efe961c7", + "src/BuildingBlocks/ArcForges.Capabilities/Tests/packages.lock.json": "b83120656ca0890664744fcf05c3d75dc1c56ccc7a493a118f63c70c7069643b", + "src/BuildingBlocks/ArcForges.Capabilities/packages.lock.json": "35cd295d2a07470c88259af06ca22114c709fa2c4e4b929abd599c96a9c26b0c", + "src/BuildingBlocks/ArcForges.Contributions/ArcForges.Contributions.csproj": "beb382d6fc12a32758741a33ac8d2372360b0dba96536b8edff6f9a9fd9fdec4", + "src/BuildingBlocks/ArcForges.Contributions/Tests/ArcForges.Contributions.Tests.csproj": "fa29f3b8c84f80eef414926739f81f696c7be205b27e0b6da0463f52eace587c", + "src/BuildingBlocks/ArcForges.Contributions/Tests/packages.lock.json": "2329c3088610c295fbaaf35a03c858c17d9b748d10fc733707dd4fde0967de8f", + "src/BuildingBlocks/ArcForges.Contributions/packages.lock.json": "03ee63c6a4335b1c141cef2016983cd9064532846d582ee6fe03dce8ccf866f5", + "src/BuildingBlocks/ArcForges.Desktop.Experience/ArcForges.Desktop.Experience.csproj": "d0620806bcaa6e7770e7617ac1ba671fd6d87d1aee76a7c4f734c9b85f263826", + "src/BuildingBlocks/ArcForges.Desktop.Experience/packages.lock.json": "09f91faca7c23b7ef4c0a4a6a4afa1f5d4e17e2e3dcbd9aca08ff12aec384059", + "src/BuildingBlocks/ArcForges.Desktop.Graphics/ArcForges.Desktop.Graphics.csproj": "2b1f2e7754125794ca2e15fd689455dcc20d4216cc90e4f0109cf7a372c4c528", + "src/BuildingBlocks/ArcForges.Desktop.Graphics/packages.lock.json": "09f91faca7c23b7ef4c0a4a6a4afa1f5d4e17e2e3dcbd9aca08ff12aec384059", + "src/BuildingBlocks/ArcForges.Desktop.Preview/ArcForges.Desktop.Preview.csproj": "dd3771d911a65c24d62a2cffee8d6a72aea18be6817a9d7aa484d14a8ddedddd", + "src/BuildingBlocks/ArcForges.Desktop.Preview/packages.lock.json": "09f91faca7c23b7ef4c0a4a6a4afa1f5d4e17e2e3dcbd9aca08ff12aec384059", + "src/BuildingBlocks/ArcForges.Desktop.RichContent/ArcForges.Desktop.RichContent.csproj": "1c860029517d0882df14a33386c11dee44d1e6fc85adc84e2ef224abf7edf34a", + "src/BuildingBlocks/ArcForges.Desktop.RichContent/packages.lock.json": "09f91faca7c23b7ef4c0a4a6a4afa1f5d4e17e2e3dcbd9aca08ff12aec384059", + "src/BuildingBlocks/ArcForges.Desktop.Text/ArcForges.Desktop.Text.csproj": "fad00ed9768093a4b5ac43855c4cbcd15a9fec6e4b3bdde7a183398b88fbc664", + "src/BuildingBlocks/ArcForges.Desktop.Text/packages.lock.json": "09f91faca7c23b7ef4c0a4a6a4afa1f5d4e17e2e3dcbd9aca08ff12aec384059", + "src/BuildingBlocks/ArcForges.Foundation/ArcForges.Foundation.csproj": "a8eaa0048788cad95cbb73f66b0edad0bab27b312d39f9134aea6e854eb8d2b6", + "src/BuildingBlocks/ArcForges.Foundation/Tests/ArcForges.Foundation.Tests.csproj": "a3dd1928395674cabc3d0a1a88fd2d15c081165620e796767dbe85f5b29d8d1f", + "src/BuildingBlocks/ArcForges.Foundation/Tests/packages.lock.json": "a66aa29dc746ed2999d9cfffd2f3ed75f3e73738e85135eb4df026a58491eb09", + "src/BuildingBlocks/ArcForges.Foundation/packages.lock.json": "275edc2c07cd106e7cafdb851474a56dc233c4326849b290a96ffb2c2cba81c6", + "src/BuildingBlocks/ArcForges.LocalRpc/ArcForges.LocalRpc.csproj": "3422bcba137d44a20048e882ac4fb0f8304ed7b0698330baea939e1371061963", + "src/BuildingBlocks/ArcForges.LocalRpc/Tests/ArcForges.LocalRpc.Tests.csproj": "14e19eb581b42412afe5420aa766b3150839d0bf93a61227457be6665e3ca5be", + "src/BuildingBlocks/ArcForges.LocalRpc/Tests/packages.lock.json": "4526b2979cf62d58566584946bc831f0d32aee9fa8511a4b81faa621fefd2329", + "src/BuildingBlocks/ArcForges.LocalRpc/packages.lock.json": "6fd6cb14bb463d5a940b14742188f214c3b267eff1af3690ee1eea347664f164", + "src/BuildingBlocks/ArcForges.NativeInterop/ArcForges.NativeInterop.csproj": "e62c58cd27cbef29eb9ad0761618fb68fed64ec8e46b03e1e5fd7b57d15c7850", + "src/BuildingBlocks/ArcForges.NativeInterop/packages.lock.json": "09f91faca7c23b7ef4c0a4a6a4afa1f5d4e17e2e3dcbd9aca08ff12aec384059", + "src/BuildingBlocks/ArcForges.Observability.Desktop/ArcForges.Observability.Desktop.csproj": "33f6707a0b23250af70690211ee31ad4ff762bb68ae1b42ef26671a8f41af8a4", + "src/BuildingBlocks/ArcForges.Observability.Desktop/Tests/ArcForges.Observability.Desktop.Tests.csproj": "f530a19cd569d1291e8593a07232a5d30ad41ec966d659f5deb522905a32ebb6", + "src/BuildingBlocks/ArcForges.Observability.Desktop/Tests/packages.lock.json": "041d44c07cced9649ead71a03bf645210c4f7ce105f2667539400dbd85362238", + "src/BuildingBlocks/ArcForges.Observability.Desktop/packages.lock.json": "65a20afa293666592531d349f51b909d3a26327991444dfade78b881d9318252", + "src/BuildingBlocks/ArcForges.Observability/ArcForges.Observability.csproj": "5d3cda9047f57949fd6af419054ae287278533daf02ea825f31c90b9e9af2df0", + "src/BuildingBlocks/ArcForges.Observability/Tests/ArcForges.Observability.Tests.csproj": "e6a49e4f207fcbb3e10fc956a7f4bfbc5c816656dab85f23d28fe2ea1c7cb585", + "src/BuildingBlocks/ArcForges.Observability/Tests/packages.lock.json": "c2bb444ba5d39e0d920b203aa02ccc36512d3d941b9568ddb6a3dc6cb86accd2", + "src/BuildingBlocks/ArcForges.Observability/packages.lock.json": "03ee63c6a4335b1c141cef2016983cd9064532846d582ee6fe03dce8ccf866f5", + "src/BuildingBlocks/ArcForges.Persistence.Derived/ArcForges.Persistence.Derived.csproj": "c811b4409a75567b7140dd928db29cd130b4d3161f0384b5702ec50e8a7950e1", + "src/BuildingBlocks/ArcForges.Persistence.Derived/Tests/ArcForges.Persistence.Derived.Tests.csproj": "9591a3c2c55e3a8ca4ce0b419c9678a37673615eee7226e3074001f0e335c16c", + "src/BuildingBlocks/ArcForges.Persistence.Derived/Tests/packages.lock.json": "59de52cf05cb0fca8c726a6013779f86e239c9499c0ce2b661fd87240a0cc0e7", + "src/BuildingBlocks/ArcForges.Persistence.Derived/packages.lock.json": "09f91faca7c23b7ef4c0a4a6a4afa1f5d4e17e2e3dcbd9aca08ff12aec384059", + "src/BuildingBlocks/ArcForges.Persistence.Resources/ArcForges.Persistence.Resources.csproj": "cef718cfec9d7cf24b15bc0800f1e2553dc2f1f8967b7199e4319759b7c8ff9d", + "src/BuildingBlocks/ArcForges.Persistence.Resources/Tests/ArcForges.Persistence.Resources.Tests.csproj": "3e77a1b68b5009b2393b59c1534d87c9ab26684c850828f03354df89de22d2d7", + "src/BuildingBlocks/ArcForges.Persistence.Resources/Tests/packages.lock.json": "6b3f5df901b107d2f1cc78dca31fe656cb44dffa811be58fb82e26facf5ee112", + "src/BuildingBlocks/ArcForges.Persistence.Resources/packages.lock.json": "35787730aacd19510564fce9726ad522b0cea67a7f2380dadaf9bbb0d9bda2fc", + "src/BuildingBlocks/ArcForges.Persistence.Sqlite/ArcForges.Persistence.Sqlite.csproj": "67f162a18dfcb1c752902e0fd4e8b28c934b8ccbb6636699df3acadb63101f7a", + "src/BuildingBlocks/ArcForges.Persistence.Sqlite/packages.lock.json": "fbb5c962d7866fbf1d129edab2498f277a1fe71afdc0641d3db2e90a671b7408", + "src/BuildingBlocks/ArcForges.Security.Audit/ArcForges.Security.Audit.csproj": "16db2e79a289f9954d871f5d96ff6fef69f9f3a9e05218026b34408f5a4190c7", + "src/BuildingBlocks/ArcForges.Security.Audit/Tests/ArcForges.Security.Audit.Tests.csproj": "e170254575a6b0723efb271ab7c1216f006c7a816677bd5b447cfff258e79082", + "src/BuildingBlocks/ArcForges.Security.Audit/Tests/packages.lock.json": "1c93de4dbc04b8f0a15fef30f352c476b7543ff26f381cbdd348c0803fc092fd", + "src/BuildingBlocks/ArcForges.Security.Audit/packages.lock.json": "ba8caa1127c0dc5f1036d45aea694e80008a6395dbe6c5a1d96e3fc4a2bf4352", + "src/BuildingBlocks/ArcForges.Security.Secrets/ArcForges.Security.Secrets.csproj": "8ff03fdcaf23b09386fac47dd5e3e4f9689a665a569137f726456c9e59a9ecef", + "src/BuildingBlocks/ArcForges.Security.Secrets/Tests/ArcForges.Security.Secrets.Tests.csproj": "d6798bbbcf980fab4467c6d831871b075e55aeef6e09c7df67c4d72cd2334120", + "src/BuildingBlocks/ArcForges.Security.Secrets/Tests/packages.lock.json": "ba013fd952ee15fd79f34bfdd37c1113b5933b94d89d83bdb9373974caee6e62", + "src/BuildingBlocks/ArcForges.Security.Secrets/packages.lock.json": "a70e1701b6e854e19dc5aee64ca29bd9c48b408eb0251d7bb181d4d1bd55e1bd", + "src/BuildingBlocks/ArcForges.Security/ArcForges.Security.csproj": "948f9509206ab6dc95542f773cf62728ce72f54097f0d89028f65b32afed0f70", + "src/BuildingBlocks/ArcForges.Security/CapabilityEnforcement/ArcForges.Security.CapabilityEnforcement.csproj": "13989b4b7f680da049d034c5c21529f6d15042394a9247d480902008370396b4", + "src/BuildingBlocks/ArcForges.Security/CapabilityEnforcement/packages.lock.json": "d853a740c8ddb1da79ae2aad7d54fcecf80ed0a73dac94df14443c29d4eae7f7", + "src/BuildingBlocks/ArcForges.Security/LocalRpcBoundary/ArcForges.Security.LocalRpcBoundary.csproj": "16b8227d01b3a11c82c7111c7da3cbf5d9d8701a3c449c47ca40dd3f245638d9", + "src/BuildingBlocks/ArcForges.Security/LocalRpcBoundary/packages.lock.json": "c9cd093bc0d4fe58c08f1bd0fc591d4f3086c8f302b6a374b319efcc9ec1d3e9", + "src/BuildingBlocks/ArcForges.Security/Tests/ArcForges.Security.Tests.csproj": "0a151cdf32bf378434d9e82659d24df6a8802b52d8163bd97cfefb208236433a", + "src/BuildingBlocks/ArcForges.Security/Tests/packages.lock.json": "790d82612130bcf77c69c55ea91686a1dd63531ad54c422bc6304c2815e40e39", + "src/BuildingBlocks/ArcForges.Security/packages.lock.json": "35787730aacd19510564fce9726ad522b0cea67a7f2380dadaf9bbb0d9bda2fc", + "src/DesignSystem/ArcForges.DesignSystem/ArcForges.DesignSystem.csproj": "c3e379b6f09ee3ddcea9360c742fdd37b45e936e0cb17586714a0a82d9cbb1eb", + "src/DesignSystem/ArcForges.DesignSystem/Tests/ArcForges.DesignSystem.Tests.csproj": "5d5dd7d6656b8e5b78de998771eeaa3158f7a5363cc22538dbb9e19f7b7b656e", + "src/DesignSystem/ArcForges.DesignSystem/Tests/packages.lock.json": "56db8dfaee7c7680201c53073b0243910102e886292a26de735333bb830e8465", + "src/DesignSystem/ArcForges.DesignSystem/packages.lock.json": "687884c0b3e6057ab3732f9f1d042cc81a4cb3b170460a8ac9b140137e436acc", + "src/DesignSystem/ArcForges.Desktop.Shell/ArcForges.Desktop.Shell.csproj": "c0d4c9c68da4779c0d5e6c9e44998f27dc5c4edc0108e396790394c43d5675c3", + "src/DesignSystem/ArcForges.Desktop.Shell/Tests/ArcForges.Desktop.Shell.Tests.csproj": "1ee39b61f57a3740202cfcdd372e6444fbe5eb7494bbc25fadaf1d504b4a94fe", + "src/DesignSystem/ArcForges.Desktop.Shell/Tests/packages.lock.json": "fb8ee728a3353478b0d217bf06125769240b4cc3307b392e80f5c43f61bfd6de", + "src/DesignSystem/ArcForges.Desktop.Shell/packages.lock.json": "a88f5fef8077a6678c68eb12ec52c89cc8449b68fb27190f9b0c92a0768d50b5", + "src/DesktopHelpers/ArcForges.ContentSandbox.Broker/ArcForges.ContentSandbox.Broker.csproj": "9f38eb49ade9e02d64fc9ce8db904f21631d89f7edb1b8c92325130e072d4c50", + "src/DesktopHelpers/ArcForges.ContentSandbox.Broker/packages.lock.json": "d484757d0de8539423641c61aea8b03bbc681b39826035d3a550326205779f07", + "src/DesktopHelpers/ArcForges.ContentSandbox.Contracts/ArcForges.ContentSandbox.Contracts.csproj": "9cf9cdf2e72c9c55da5b9fda99a2efc07d7d918738ca89ae24456d2e762bd660", + "src/DesktopHelpers/ArcForges.ContentSandbox.Contracts/packages.lock.json": "fac7c3ce73f75023748acd57900b6fea97e1913991e28811f2c18ffe88c44e36", + "src/DesktopHelpers/ArcForges.ContentSandbox/ArcForges.ContentSandbox.csproj": "c03396c8d83d259ce0ec1eb3e74b9992e1df662a8161ffa8f06abdd98f8d5c1d", + "src/DesktopHelpers/ArcForges.ContentSandbox/Fixture/ArcForges.ContentSandbox.HostileFixture.csproj": "c902b6c2d32ec042573459b66d6984cc92a6698e1ef7a5908a0bf98b843ce306", + "src/DesktopHelpers/ArcForges.ContentSandbox/Fixture/packages.lock.json": "9b033b97704da1ed12f3b531bb19df97ce05c9382396081f43384be6b925e759", + "src/DesktopHelpers/ArcForges.ContentSandbox/Tests/ArcForges.ContentSandbox.Tests.csproj": "9d97cbef2c2c5585c3698a2c5f7ade78aed10ff806ebc513965a97cada38bc1b", + "src/DesktopHelpers/ArcForges.ContentSandbox/Tests/packages.lock.json": "b0995a525ab6ee3403c30f3857bed38332a518c5a00ef58ced76de21d08e0ac4", + "src/DesktopHelpers/ArcForges.ContentSandbox/packages.lock.json": "131658a4c474dbf89d9d86fdba9fc943a9c18b279fc829230723aa3540ce52b3", + "src/Directory.Build.props": "bf118d90b53ceb536479db4a803b370cd47fc15aa47d98a88902b8ab0dd54047", + "src/Native/ArcForges.Native.Abstractions/ArcForges.Native.Abstractions.csproj": "874cb5b91a52e1a3f01adad7819f20c70bdbf8641d4f65c0bc44dcbe70337d0b", + "src/Native/ArcForges.Native.Abstractions/packages.lock.json": "09f91faca7c23b7ef4c0a4a6a4afa1f5d4e17e2e3dcbd9aca08ff12aec384059", + "src/Native/ArcForges.Native.Image.Runtime.win-x64/ArcForges.Native.Image.Runtime.win-x64.csproj": "e7e89b53d8f01b17385174d0723d480c91159f6209b61eae265bf189fe5645f6", + "src/Native/ArcForges.Native.Image.Runtime.win-x64/packages.lock.json": "09f91faca7c23b7ef4c0a4a6a4afa1f5d4e17e2e3dcbd9aca08ff12aec384059", + "src/Native/ArcForges.Native.Image/ArcForges.Native.Image.csproj": "e271da616a6950d704aca666c8d64a50e928467d3488a63669b432991552769c", + "src/Native/ArcForges.Native.Image/packages.lock.json": "cd7ed1d6af3e01a3bc233a62e7ae062e55434f0892d3f61c011387cd9ed0e576", + "tests/ArchitectureTests/ArcForges.Tests.ArchitectureTests.csproj": "fe6d0b2a9bf1cb70fc496515ea35e07356a0758898ba22a3816d696fcebf3c3a", + "tests/ArchitectureTests/packages.lock.json": "e2090c8b97e17e0ec5899d8819ac16997a6e0e1df1f3b545c55e5cb39d823168", + "tests/AssistantAbstractionsTests/AssistantAbstractionsTests.csproj": "674ea930fead5b76640b5013f9c0348ef175c03184fa8a1382437c7ccd3fa594", + "tests/AssistantAbstractionsTests/packages.lock.json": "b242f814d6724e1c8c5d82e1a969b846f51cb907c9acfd4d426556059785d8c8", + "tests/LocalRpcAotTests/LocalRpcAotTests.csproj": "28e7f64ff9d289bd4943a5e14e36302350ba3ff00d8a36756dae07da38b673f8", + "tests/LocalRpcAotTests/packages.lock.json": "14db0500440e6499b5f5f6e448642420d65d5d2ef6e3cd52c2bb7631f5d53ea8", + "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj": "14abae6f85b4424deab0e0c749aa3c0a850401b578e1ea45e910a7164a0cafa0", + "tests/NativeAbiTests/packages.lock.json": "da237463bcfea624e3a008de4079e0eeb57a71fcb31d7a3ed012556fc885da5a", + "tests/PersistenceTests/ArcForges.Tests.PersistenceTests.csproj": "5a947697cc93979c5e6c149b100fee204d706695e0e4d596af7bf95a674c3008", + "tests/PersistenceTests/packages.lock.json": "c94488ff2560372433bf2ca0d6f04a69429d10cde23265edca24ea6f798b6778", + "tests/ReleaseArtifactTests/GrpcWeb/Directory.Packages.props": "75389a85362198cb44500b21765fda9a7a83ce13a17e6fbeb5b5ec9d9f8b68c7", + "tests/ReleaseArtifactTests/GrpcWeb/GrpcWebAotProbe.csproj": "51a16e4d5b8f0370359b0eaed5677866f656e6dab53f079138b3f5d9b3c68b04", + "tests/ReleaseArtifactTests/GrpcWeb/packages.lock.json": "5b968c7f0f2acbaebd14a3e5ababfe3a15fcd39f9e61b70b7d8f93180c092cde", + "tests/ReleaseArtifactTests/Realtime/RealtimeAotProbe.csproj": "a99e35c36a3e4a19b446d2d2deebff6e60945ed68688c207d23fdfb691c7f2bb", + "tests/ReleaseArtifactTests/Realtime/packages.lock.json": "92536e4260ac2fc1239251a302042610671214d9945fadf3a03724ec7b9ea1ac" + }, + "frameworkVersions": { + "dotnetSdk": "10.0.400" + }, + "previousReceipt": "eng/policy/dependency-reviews/gov-30-r1.json" + }, + "nugetClosure": { + "arcforges.application.abstractions/1.0.0-ci.29.1": { + "contentHash": "8ICXm8LvJsaXgNz6RfiHSoxxqxE3jLJMz8IWCcweW0FIzv6isEl3SMH+pbBATNKIqApDmCN78FT/csCAqMBMRA==", + "licence": "AGPL-3.0-only", + "nuspecSha256": "ee357e2c21126b5278bba1b94212302e1dd30e25af1adbd45d185f0863ec634a", + "source": "https://api.nuget.org/v3-flatcontainer/arcforges.application.abstractions/1.0.0-ci.29.1/arcforges.application.abstractions.nuspec", + "internalPublisher": { + "repository": "ArcForges/DesktopPlatform", + "workflow": "publish-nuget.yml", + "sourceCommit": "ace60538849047184954baed09fb05ec7676d367", + "feed": "https://api.nuget.org/v3/index.json" + }, + "classification": "Exact existing DesktopPlatform publication consumed only by the local FND.07 acceptance runner; same AGPL boundary." + }, + "arcforges.contracts.events/1.0.0-ci.270.1": { + "contentHash": "kFPTrTBtWkekjE+OT6weJOGqfnJNBCeXtjSnRK6VDFHNkSujKFHJwHRm27xa8lNi0tmDEmjXb5G8shckXgXaUA==", + "licence": "Apache-2.0", + "nuspecSha256": "d8ccfdc8593ab55022acb0d7acb76b6538e8a938ab227604f20b4998ab7f6d8f", + "source": "https://api.nuget.org/v3-flatcontainer/arcforges.contracts.events/1.0.0-ci.270.1/arcforges.contracts.events.nuspec", + "classification": "Exact CON.11 Events candidate (Contracts source 4047930) providing the generated EventService and ExecutionService clients and the StreamFrame records; used only by the non-packable PRF.06 realtime AOT probe; project-conditioned pin.", + "internalPublisher": { + "repository": "ArcForges/Contracts", + "workflow": "ci.yml", + "sourceCommit": "4047930ef2207e8a4c6de9e7ef679f0d9359a8e4", + "feed": "https://api.nuget.org/v3/index.json" + } + }, + "arcforges.contracts.foundation/1.0.0-ci.113.1": { + "contentHash": "k51yFjKnec92FJEfOM3/6YEhnbi1wfO4gztqjlhLumg7USTjVnA7ab7rE4X/H1cDb62Ny4c2ufn8Xi/CtBaZnA==", + "licence": "Apache-2.0", + "nuspecSha256": "7726a233f9ce81c4373a2ab9423c33d566b919bb2cb90d2b5577cf844bbbbfc4", + "source": "https://api.nuget.org/v3-flatcontainer/arcforges.contracts.foundation/1.0.0-ci.113.1/arcforges.contracts.foundation.nuspec", + "classification": "Exact published Contracts.Foundation producer and its generated protobuf runtime; Apache-2.0/BSD-3-Clause permitted inside AGPL consumers." + }, + "arcforges.contracts.foundation/1.0.0-ci.216.1": { + "contentHash": "+RGxNiaoOBt+j2IOSpUa2WWD4Bnkk8Ten7jbOVFwdUVF8jUQRCCvAGhrPTzZpnr6Sk5MSoA95+pJYgN8T+jEtg==", + "licence": "Apache-2.0", + "nuspecSha256": "b2704457f3245e4c44200ed4e8dd87054a81cbe53faa31cd384aad92db78839e", + "source": "https://api.nuget.org/v3-flatcontainer/arcforges.contracts.foundation/1.0.0-ci.216.1/arcforges.contracts.foundation.nuspec", + "classification": "Internal ArcForges Contracts.Foundation dependency already admitted at exact 1.0.0-ci.216.1; consumed by the existing PRF.04 non-packable AOT probe and PLT.19 ArcForges.Capabilities/ArcForges.Capabilities.Tests consumers. Source commit and original candidate publication are recorded in the Plan ledger.", + "internalPublisher": { + "repository": "ArcForges/Contracts", + "workflow": "ci.yml", + "sourceCommit": "eb650e5b9ef7fc06f0eb79ee167b896e4252b50e", + "feed": "https://api.nuget.org/v3/index.json" + } + }, + "arcforges.contracts.foundation/1.0.0-ci.270.1": { + "contentHash": "zYmoUzz2qQcT8IzCmDzVxWSrgxDQFUqf7Z8OkJ0FLPTNM3VC8uSrlN6McQNKt1a1kOHLMOhbWqEE1TPmVR6M5Q==", + "licence": "Apache-2.0", + "nuspecSha256": "818e4e3e34012bf54a430708ba5ab6a002e653740881fea9a679c532b0eeddea", + "source": "https://api.nuget.org/v3-flatcontainer/arcforges.contracts.foundation/1.0.0-ci.270.1/arcforges.contracts.foundation.nuspec", + "classification": "Exact CON.11 Foundation candidate (Contracts source 4047930, CI run 36809684358), consumed only by the non-packable PRF.06 realtime AOT probe through a project-conditioned version update.", + "internalPublisher": { + "repository": "ArcForges/Contracts", + "workflow": "ci.yml", + "sourceCommit": "4047930ef2207e8a4c6de9e7ef679f0d9359a8e4", + "feed": "https://api.nuget.org/v3/index.json" + } + }, + "arcforges.contracts.localrpc.platform/1.0.0-ci.216.1": { + "contentHash": "TdmfXUx/V6ImX9AijHou/1pzwWfQPOid3pHR2OnYNDPXj7IxylfUmiDzSIrhwaJKXTgxM3UpuAiiNJxnqjZB2A==", + "licence": "Apache-2.0", + "nuspecSha256": "2e9af7a67637cdff08601ce3fb2d20a445fd34c7d535dc4d3508ff10bc9a3c1b", + "source": "https://api.nuget.org/v3-flatcontainer/arcforges.contracts.localrpc.platform/1.0.0-ci.216.1/arcforges.contracts.localrpc.platform.nuspec", + "classification": "Exact CON.05 generated LocalRpc producer consumed only by the PRF.04 non-packable AOT probe; source commit and original candidate publication are recorded in the Plan ledger.", + "internalPublisher": { + "repository": "ArcForges/Contracts", + "workflow": "ci.yml", + "sourceCommit": "eb650e5b9ef7fc06f0eb79ee167b896e4252b50e", + "feed": "https://api.nuget.org/v3/index.json" + } + }, + "arcforges.contracts.localrpc.sandbox/1.0.0-ci.216.1": { + "contentHash": "u0NGE5MZvolG4Bc2t2nPKiccsP8fKcIPQcnAFCSmMBgL1+q6KwHgr9mlFF1FDyW7Seu9JVINWYOsK38RH5hfkg==", + "licence": "Apache-2.0", + "nuspecSha256": "c65862c9eedf3402636ddc28c67fbd9a54a9a5b6214bf4bb816b4bb2c47805b6", + "source": "https://api.nuget.org/v3-flatcontainer/arcforges.contracts.localrpc.sandbox/1.0.0-ci.216.1/arcforges.contracts.localrpc.sandbox.nuspec", + "classification": "Exact CON.04 generated ContentSandboxService producer (same Contracts source commit as the admitted LocalRpc.Platform candidate), consumed by the PLT.45 non-packable ContentSandbox contracts facade, broker, helper host, test project and test-only fixture; its nuspec dependencies (Foundation 1.0.0-ci.216.1, Google.Protobuf 3.36.1, Grpc.Core.Api 2.84.0) are already admitted. Source commit and original candidate publication are recorded in the Plan ledger.", + "internalPublisher": { + "repository": "ArcForges/Contracts", + "workflow": "ci.yml", + "sourceCommit": "eb650e5b9ef7fc06f0eb79ee167b896e4252b50e", + "feed": "https://api.nuget.org/v3/index.json" + } + }, + "arcforges.contracts.publicapi/1.0.0-ci.216.1": { + "licence": "Apache-2.0", + "nuspecSha256": "2001724ed917f3754749a16ff5cd458ec50ae6d8a26025456ad21732f4954fad", + "classification": "Exact published Contracts candidate. It is a transitive dependency of ArcForges.Sdk.Contracts 1.0.0-ci.216.1 in the existing Capabilities and Contributions graphs, and the non-packable PRF.05 probe also references it directly for the generated HelloService client at the same version. Apache-2.0 is permitted inside AGPL consumers.", + "contentHash": "cj1wcu/v8bSGL0pGbKYMcJGFFs7SGF3jsG6GXm49cpj3Cal/tl2orj5jdoDBweOH0LeWZisNfFhfXkvnKR9wfQ==", + "source": "https://api.nuget.org/v3-flatcontainer/arcforges.contracts.publicapi/1.0.0-ci.216.1/arcforges.contracts.publicapi.nuspec", + "internalPublisher": { + "repository": "ArcForges/Contracts", + "workflow": "ci.yml", + "sourceCommit": "eb650e5b9ef7fc06f0eb79ee167b896e4252b50e", + "feed": "https://api.nuget.org/v3/index.json" + } + }, + "arcforges.contracts.publicapi/1.0.0-ci.270.1": { + "contentHash": "0dz653V64rZFFKgDDfEKbH2lqzJ5GVeRMd1dK7ZOr156pEPQMqZL89RQY//WZ9JiSVWSxWemX2JI6JyKKjmibQ==", + "licence": "Apache-2.0", + "nuspecSha256": "f18f43b539a1ad039c3f11e7a5eddeb3463836186b4f28c7eafb50636015fdd6", + "source": "https://api.nuget.org/v3-flatcontainer/arcforges.contracts.publicapi/1.0.0-ci.270.1/arcforges.contracts.publicapi.nuspec", + "classification": "Exact CON.11 PublicApi candidate (Contracts source 4047930), a direct reference of the non-packable PRF.06 realtime AOT probe for ExecutionOwner and the shared annex 10 records; project-conditioned pin.", + "internalPublisher": { + "repository": "ArcForges/Contracts", + "workflow": "ci.yml", + "sourceCommit": "4047930ef2207e8a4c6de9e7ef679f0d9359a8e4", + "feed": "https://api.nuget.org/v3/index.json" + } + }, + "arcforges.foundation/1.0.0-ci.29.1": { + "contentHash": "P5FRjzq8/DOhNS4bI4PAtjjnkIN2XPufXV0vnfyRFyDN12A/QcV1nteBCu4q/Kxg9R0Enlftut9QxYNtMPzDTg==", + "licence": "AGPL-3.0-only", + "nuspecSha256": "e935bdbe249491c131cdc2c9a07dc55a1b9724c664acd3064bd8393a646bdc59", + "source": "https://api.nuget.org/v3-flatcontainer/arcforges.foundation/1.0.0-ci.29.1/arcforges.foundation.nuspec", + "internalPublisher": { + "repository": "ArcForges/DesktopPlatform", + "workflow": "publish-nuget.yml", + "sourceCommit": "ace60538849047184954baed09fb05ec7676d367", + "feed": "https://api.nuget.org/v3/index.json" + }, + "classification": "Exact existing DesktopPlatform publication consumed only by the local FND.07 acceptance runner; same AGPL boundary." + }, + "arcforges.sdk.contracts/1.0.0-ci.216.1": { + "licence": "Apache-2.0", + "nuspecSha256": "c7de5c36751d21de28171308dd99c25ec6a451baaf08a0a824534a73260c8a12", + "classification": "Exact published CON producer required to consume generated Invocation from ArcForges.Sdk.Contracts 1.0.0-ci.216.1; direct consumer ArcForges.Capabilities, transitively locked for the existing Capabilities and Contributions production/test projects. Apache-2.0 is permitted inside AGPL consumers.", + "contentHash": "aN/9J61hb7TTPnOBugNHD4Ic3aBc/3zTIsmseTdaCNO/RiaOTwFnsteg7UKg4pYAfuiL3AkTjjSrxmZ3rNIPMg==", + "source": "https://api.nuget.org/v3-flatcontainer/arcforges.sdk.contracts/1.0.0-ci.216.1/arcforges.sdk.contracts.nuspec", + "internalPublisher": { + "repository": "ArcForges/Contracts", + "workflow": "ci.yml", + "sourceCommit": "eb650e5b9ef7fc06f0eb79ee167b896e4252b50e", + "feed": "https://api.nuget.org/v3/index.json" + } + }, + "avalonia.buildservices/11.3.2": { + "contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ==", + "licence": "MIT", + "nuspecSha256": "a8846537f7d074b8f9ed753c03678128c36bd046939ee245fad8c575a7cd3e9b", + "source": "https://api.nuget.org/v3-flatcontainer/avalonia.buildservices/11.3.2/avalonia.buildservices.nuspec", + "classification": "Build-only transitive dependency of the isolated PRF.09 verification probe; not admitted to any shipped product closure or package allowlist." + }, + "avalonia.remote.protocol/12.1.3": { + "contentHash": "HcF69DKyihgg5coLOSp7GTqxQvWAy58LMKauwcHA28uSBJVwu0h5+gTNdJA8Sn/nqJVTRDU98zYsk6i1QSwDVQ==", + "licence": "MIT", + "nuspecSha256": "4c0cd5ec137d2a376bab20e62bb2cbbdc9b71ffcb044acf41adeaf25cf5025b1", + "source": "https://api.nuget.org/v3-flatcontainer/avalonia.remote.protocol/12.1.3/avalonia.remote.protocol.nuspec", + "classification": "Build-only transitive dependency of the isolated PRF.09 verification probe; not admitted to any shipped product closure or package allowlist." + }, + "avalonia/12.1.3": { + "contentHash": "l0VGyGn1OwqFrhmIhIHivvrQP0DtBD4Vn2F1vLYgGA2X2qHg0DMVRAln/6xOTLBUYoTopiVAujN0jtH8MpVSeA==", + "licence": "MIT", + "nuspecSha256": "8fd64a84c229548a71e61390f4a310219f50529af0ee53162ffd39e7874ff4dc", + "source": "https://api.nuget.org/v3-flatcontainer/avalonia/12.1.3/avalonia.nuspec", + "classification": "Build-only verification dependency for the isolated PRF.09 Native AOT probe; not admitted to any shipped product closure or package allowlist." + }, + "coverlet.collector/10.0.1": { + "contentHash": "27jXSV/0DbVqF5jDrAxuQFZ9oaz6gmG03p8ttxAFk+X0M4woFYj7MoWDLCna5EGLb0CE6OE7X6ZH3Wt5smTtaA==", + "licence": "MIT", + "nuspecSha256": "d73992a0c9eb178bf52fde96055930e0ac5b05e53c73ca5c0595f01e61210910", + "source": "https://api.nuget.org/v3-flatcontainer/coverlet.collector/10.0.1/coverlet.collector.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "google.protobuf/3.36.1": { + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==", + "licence": "BSD-3-Clause", + "nuspecSha256": "d6a4a567ce4889e25d098fd39d13a65ce05c847e8574818edc0d1eee6277eb40", + "source": "https://api.nuget.org/v3-flatcontainer/google.protobuf/3.36.1/google.protobuf.nuspec", + "classification": "Exact published Contracts.Foundation producer and its generated protobuf runtime; Apache-2.0/BSD-3-Clause permitted inside AGPL consumers." + }, + "grpc.aspnetcore.server/2.83.0": { + "contentHash": "rWeSaXaSPIIerc82lhHKqmzlp/sgLnG+Zw1c3YEEVp/un+hJKPxJOXWuGUOS2cs3EvcZBZ2tliXsnAg5Mdk2Ew==", + "licence": "Apache-2.0", + "nuspecSha256": "b9a51de789abe619de7ee4bb9ec9f30452c4bca13d514e53ac574a0bb96116e3", + "source": "https://api.nuget.org/v3-flatcontainer/grpc.aspnetcore.server/2.83.0/grpc.aspnetcore.server.nuspec", + "classification": "Exact permitted gRPC ASP.NET Core server candidate; used by the non-packable PRF.04 AOT proof only." + }, + "grpc.core.api/2.84.0": { + "contentHash": "p2SOMl6q/GZ4/5MLkgboC/z55g4zKUEruRn/g46QgppjQBVnsLZVBU/9VO7n60ll38G3Eo8Zu9b7x+W01GKFVg==", + "licence": "Apache-2.0", + "nuspecSha256": "bba00f5d5be8423dd351662e313648b0abd13100ef67d55f6fc29de44c544a96", + "source": "https://api.nuget.org/v3-flatcontainer/grpc.core.api/2.84.0/grpc.core.api.nuspec", + "classification": "Exact CON.05 transitive and permitted gRPC client dependency; consumed through ArcForges.Sdk.Contracts 1.0.0-ci.216.1 by the PLT.24 Capabilities/Contributions lock graphs and used by the non-packable PRF.04 AOT proof only as a build-time candidate." + }, + "grpc.net.client.web/2.84.0": { + "contentHash": "6KB6IFGx+KfGMaN06nATBVgvgsOEVf12/nJHvdAPsY2mULmWCSn4moFUT8UnCnQ19C3tpGbc2CnB1/e0hVUmaA==", + "licence": "Apache-2.0", + "nuspecSha256": "da47ce027ddbbbf7c0b1f242b1efe64a5aeaa2959da54716a100fcc6e4520efb", + "source": "https://api.nuget.org/v3-flatcontainer/grpc.net.client.web/2.84.0/grpc.net.client.web.nuspec", + "classification": "Exact direct gRPC-Web client handler shared by PRF.05 and PRF.06; the net10.0 dependency group is empty, so it adds no transitive coordinate. Used only by the non-packable PRF.05 Native AOT probe." + }, + "grpc.net.client/2.84.0": { + "contentHash": "My6Fms2YCyAbihTBZZ3kLS39iTY2pkMAzGDBTC6XhPOujg70eB07AEJ7tPiW3tZ70bhFzHcc3+d4j3DaIPF3aA==", + "licence": "Apache-2.0", + "nuspecSha256": "32a43f3448ab90bb71c946533db4d5135cef4728a3ee29bf88d9a0cbf1e67413", + "source": "https://api.nuget.org/v3-flatcontainer/grpc.net.client/2.84.0/grpc.net.client.nuspec", + "classification": "Exact permitted gRPC .NET client candidate; used by the non-packable PRF.04 AOT proof only." + }, + "grpc.net.common/2.84.0": { + "contentHash": "DPxm/H8G/lDRrz997wM0M2zN/PGqVeZKDakhBbtABEbv6WIW4HI9g3BvntBQT6ZUROuGacMzfyyF9e5kiTVFfQ==", + "licence": "Apache-2.0", + "nuspecSha256": "f621aa566e63340b572a4f0bd2b996b934407c77b8cdf121f38578e93ee796cb", + "source": "https://api.nuget.org/v3-flatcontainer/grpc.net.common/2.84.0/grpc.net.common.nuspec", + "classification": "Exact shared gRPC transport dependency selected by the permitted server/client candidates; used by the non-packable PRF.04 AOT proof only." + }, + "microcom.runtime/0.11.6": { + "contentHash": "NdNWGDiZ6eS/Mf/9+QHR91cj1K7Hy+PX9yrHI/zM7xFYuj9IWT2uxtB6sCHjrnxAeLV9fut1R6zHDUGKX6f9lQ==", + "licence": "MIT", + "nuspecSha256": "c7aeab4b73acdba3bb8e66865c2bbe67566b8593d972be2089181344adef345f", + "source": "https://api.nuget.org/v3-flatcontainer/microcom.runtime/0.11.6/microcom.runtime.nuspec", + "classification": "Build-only transitive dependency of the isolated PRF.09 verification probe; not admitted to any shipped product closure or package allowlist." + }, + "microsoft.applicationinsights/2.23.0": { + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==", + "licence": "MIT", + "nuspecSha256": "184c649613d487d2b11466882ad83e375bf94f6aa228afae78dd4005bb0bb2a0", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.applicationinsights/2.23.0/microsoft.applicationinsights.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "microsoft.bcl.asyncinterfaces/6.0.0": { + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==", + "licence": "MIT", + "nuspecSha256": "3a965c424d56f1cee169d88cc9c04f2c04f01bd65f20147b26ac093d761beec9", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.bcl.asyncinterfaces/6.0.0/microsoft.bcl.asyncinterfaces.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "microsoft.codeanalysis.netanalyzers/10.0.401": { + "contentHash": "qGPLk4hknvVp6j9Lowa0qykw1cK7CyEyyy2aVs4dkOGroOMHJLRsMpUT53IKvYT6q78jxv96FZhzy3BrKCPgmA==", + "licence": "MIT", + "nuspecSha256": "a8e2d43ca608c2f6e4039020149653abd188821fd8db1bed4fceb60fde2f21e1", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.codeanalysis.netanalyzers/10.0.401/microsoft.codeanalysis.netanalyzers.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "microsoft.codecoverage/18.10.1": { + "contentHash": "tV7tCyp/t+DuwQugLkCeUN7NBFFB4fEGRPr0gkMQ2nQgAvQQjmGlZZi1dsLzbyoe/A0mbnKybmkIh8qhT9p4Zw==", + "licence": "MIT", + "nuspecSha256": "e7473877d0450d94c304469a0e89932731f4ba498edd3583211a0cda6ca68c57", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.codecoverage/18.10.1/microsoft.codecoverage.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "microsoft.data.sqlite.core/10.0.12": { + "contentHash": "2aL9eL5HQr0V4b8V909SD+jQAx6qnTTl7sT/v3D8dIvmr2UGdpZIQxqm1pj2Js3e4EYTvpw0oMH7DjapJJuPPw==", + "licence": "MIT", + "nuspecSha256": "56030907327ba858664e4c2cbc6547ad56d766d900bc323c14408fcd42fc46a0", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.data.sqlite.core/10.0.12/microsoft.data.sqlite.core.nuspec", + "classification": "runtime dependency of ArcForges.Persistence.Sqlite; exact package retained as NuGet dependency, not copied source" + }, + "microsoft.data.sqlite/10.0.12": { + "contentHash": "xUq9xUc653ue7ac2rMfCaf8UQN+U3zS8poWYlugycpvJllplMM0Mk7v/+MM0CTlu9zGvbGML4BPH8YWhrUOfGw==", + "licence": "MIT", + "nuspecSha256": "8e1c47c77197daaac1584979b7bb42a9a8f6deaff9f6d18029b3a689a2107c6c", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.data.sqlite/10.0.12/microsoft.data.sqlite.nuspec", + "classification": "runtime dependency of ArcForges.Persistence.Sqlite; exact package retained as NuGet dependency, not copied source" + }, + "microsoft.dotnet.ilcompiler/10.0.11": { + "contentHash": "yloFUsIQOzspIsn2cDHrbgikKaWHAZspzj0EWxnWJJpFC89IBW64OXgCECsrrnzGWQvy1iLX9r5/R4zBf7Am3g==", + "licence": "MIT", + "nuspecSha256": "2c16a1f000bd7a7e5c0864d2b097d5d00afc5d723770751bc6228bd463c6cc56", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.dotnet.ilcompiler/10.0.11/microsoft.dotnet.ilcompiler.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "microsoft.extensions.dependencyinjection.abstractions/8.0.1": { + "contentHash": "fGLiCRLMYd00JYpClraLjJTNKLmMJPnqxMaiRzEBIIvevlzxz33mXy39Lkd48hu1G+N21S7QpaO5ZzKsI6FRuA==", + "licence": "MIT", + "nuspecSha256": "1ed9fd0c77d67cc75208b3268df565762916a992f3e246973430dbd77ede2413", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.extensions.dependencyinjection.abstractions/8.0.1/microsoft.extensions.dependencyinjection.abstractions.nuspec", + "classification": "Transitive test-host dependency of the ArcForges.LocalRpc.Tests xUnit/Kestrel closure; consumed only by the non-packable offline test project." + }, + "microsoft.extensions.logging.abstractions/8.0.1": { + "contentHash": "RIFgaqoaINxkM2KTOw72dmilDmTrYA0ns2KW4lDz4gZ2+o6IQ894CzmdL3StM2oh7QQq44nCWiqKqc4qUI9Jmg==", + "licence": "MIT", + "nuspecSha256": "7593932df3e935a8e6670efad230c82967bc45ea4065186d9b886670408b95cc", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.extensions.logging.abstractions/8.0.1/microsoft.extensions.logging.abstractions.nuspec", + "classification": "Transitive test-host dependency of the ArcForges.LocalRpc.Tests xUnit/Kestrel closure; consumed only by the non-packable offline test project." + }, + "microsoft.net.illink.tasks/10.0.11": { + "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==", + "licence": "MIT", + "nuspecSha256": "6c10c93cd2958d71fc46d60c940044071e15b57a75d469d68adf80f0dff57da1", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.net.illink.tasks/10.0.11/microsoft.net.illink.tasks.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "microsoft.net.test.sdk/18.10.1": { + "contentHash": "SxCFvJE/2ltUQgCgv6+Ixagohw4sZc9hM7Wid2foaNM7paBscktoHLoRDz91fW8wbHG87Rg9Ku59/8PXumqUrA==", + "licence": "MIT", + "nuspecSha256": "84c823b74b806f5b1f4cce2bdaf81ba78abbe0ae93c9828f578205f3d48abca8", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.net.test.sdk/18.10.1/microsoft.net.test.sdk.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "microsoft.testing.extensions.telemetry/2.4.0": { + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "licence": "MIT", + "nuspecSha256": "5a13a0f3b3da2e1ea48c7faac4f0d3a8ca570a562ec4928e8e59d8f10efa9738", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.testing.extensions.telemetry/2.4.0/microsoft.testing.extensions.telemetry.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "microsoft.testing.extensions.trxreport.abstractions/2.4.0": { + "contentHash": "uRb+4qM42dFDg4kWJZ2kFEcwESNVCRZJjItp5vETorN3rSJGysP617LqYirOcrCYmxg+obPreHMM5JcsNDKtBw==", + "licence": "MIT", + "nuspecSha256": "f99165b201b607209e5f03db0b2be881d020d45da108028c011fa2b2f6b57b99", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.testing.extensions.trxreport.abstractions/2.4.0/microsoft.testing.extensions.trxreport.abstractions.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "microsoft.testing.platform.msbuild/2.4.0": { + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "licence": "MIT", + "nuspecSha256": "1fe1b7f9981ae944b65a9cf851ca791bd5e0dd66dedd5d114f838aeecf5bea4c", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.testing.platform.msbuild/2.4.0/microsoft.testing.platform.msbuild.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "microsoft.testing.platform/2.4.0": { + "contentHash": "dp1N3P1ujb0ztwFgqz2o/ItEvq+pm19/AiA+Xq7Zpcy7oPMcxDBZLYGtf0LlU1MveEBJuKVjZhI+LnkfcH/0jQ==", + "licence": "MIT", + "nuspecSha256": "66933f17979c2b024e80d6c8b4144150ecc5773153ee6a485aa430645da96b5d", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.testing.platform/2.4.0/microsoft.testing.platform.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "microsoft.testplatform.objectmodel/18.10.1": { + "contentHash": "o+PuLDRVp1iwiVAPBADZItXgm9Ck23XtPhE4kZtZVi0uYnMiiz1Dx9Cjh75kJ+ijfqnORL3VOTOaQVKi0f/ixg==", + "licence": "MIT", + "nuspecSha256": "3af65b097b8d4c5bc7c1242e3f56fdb3578a38b313f3bdd7472257f1cdd8d98b", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.testplatform.objectmodel/18.10.1/microsoft.testplatform.objectmodel.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "microsoft.testplatform.testhost/18.10.1": { + "contentHash": "KYz2omqkoz9E2mWa4fdgT+Ycie1wi5968EmyJlusjlp87RxxtA2FvDm8tcBEYIDzLnkWGnsKjE4d13Z7/rxkQA==", + "licence": "MIT", + "nuspecSha256": "801559164f0ab5079bad2f8bfa206046919f6a97ae2af8ef77d7a0bc80317457", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.testplatform.testhost/18.10.1/microsoft.testplatform.testhost.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "microsoft.win32.registry/5.0.0": { + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "licence": "MIT", + "nuspecSha256": "eb8c4b9d4ebe62d8528dec6ab2a752c2e3488f28f882a6c45a33dcc21eacc58e", + "source": "https://api.nuget.org/v3-flatcontainer/microsoft.win32.registry/5.0.0/microsoft.win32.registry.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "runtime.linux-x64.microsoft.dotnet.ilcompiler/10.0.11": { + "contentHash": "0lJviBev2CvBdrLhMCJ1hmwOKgjbk1ccRy+brEewywtK4TMft1WaVXv112j6OuTQg8ZMbnN/i3KRD3S6o4NJDw==", + "licence": "MIT", + "nuspecSha256": "0d304c7cb54460b35cc188ca792aeb0f81772a6ac85d891e23d98889101aa3a0", + "source": "https://api.nuget.org/v3-flatcontainer/runtime.linux-x64.microsoft.dotnet.ilcompiler/10.0.11/runtime.linux-x64.microsoft.dotnet.ilcompiler.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "runtime.win-arm64.microsoft.dotnet.ilcompiler/10.0.11": { + "contentHash": "vy/AJM7NafhIa+4EsJrh//5Ba3HW3purZh+KR0Bpt/R20w3s06ZrUsdwHywUs+5E7PfEtbXBcLlebKFusSLBkQ==", + "licence": "MIT", + "nuspecSha256": "3f546e9d7d6ba67a8a82544e042b34b02df722122ad56b0a7aa6c33cbd0fc006", + "source": "https://api.nuget.org/v3-flatcontainer/runtime.win-arm64.microsoft.dotnet.ilcompiler/10.0.11/runtime.win-arm64.microsoft.dotnet.ilcompiler.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "runtime.win-x64.microsoft.dotnet.ilcompiler/10.0.11": { + "contentHash": "D6ZKMo3NeTnos7l5S8bMmiPm4LK+y15Q/uduyPw7FX+xKbeWVWdwlcZGkLLPjLvW4ZO6o3ACanq5HsSXJgrt0g==", + "licence": "MIT", + "nuspecSha256": "e65322f78f21c985eea3b00e9f7cbc413d7b26a997ae861b08ee5265fc49b412", + "source": "https://api.nuget.org/v3-flatcontainer/runtime.win-x64.microsoft.dotnet.ilcompiler/10.0.11/runtime.win-x64.microsoft.dotnet.ilcompiler.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "sqlitepclraw.bundle_e_sqlite3/2.1.12": { + "contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==", + "licence": "Apache-2.0", + "nuspecSha256": "2faa9f418c3b8831efdf92df6aca8322ffa9f7a556e915b46e5daa8e1f8e3233", + "source": "https://api.nuget.org/v3-flatcontainer/sqlitepclraw.bundle_e_sqlite3/2.1.12/sqlitepclraw.bundle_e_sqlite3.nuspec", + "classification": "runtime dependency of ArcForges.Persistence.Sqlite; exact package retained as NuGet dependency, not copied source" + }, + "sqlitepclraw.core/2.1.12": { + "contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg==", + "licence": "Apache-2.0", + "nuspecSha256": "8ab9189071aef856b12a9a3d68ac055a01ec6f8fc51b85ac1029e7c5ba12eecc", + "source": "https://api.nuget.org/v3-flatcontainer/sqlitepclraw.core/2.1.12/sqlitepclraw.core.nuspec", + "classification": "runtime dependency of ArcForges.Persistence.Sqlite; exact package retained as NuGet dependency, not copied source" + }, + "sqlitepclraw.lib.e_sqlite3/2.1.12": { + "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w==", + "licence": "Apache-2.0", + "nuspecSha256": "0e82e5cda14ccb8c546596d39393a6a9ba94146ffeeac86069df29ce9d57a731", + "source": "https://api.nuget.org/v3-flatcontainer/sqlitepclraw.lib.e_sqlite3/2.1.12/sqlitepclraw.lib.e_sqlite3.nuspec", + "classification": "runtime dependency of ArcForges.Persistence.Sqlite; exact package retained as NuGet dependency, not copied source" + }, + "sqlitepclraw.provider.e_sqlite3/2.1.12": { + "contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==", + "licence": "Apache-2.0", + "nuspecSha256": "7e5b604b8ad8121fa75f01eec61f56989b717fadfd530188a57d35759d93f573", + "source": "https://api.nuget.org/v3-flatcontainer/sqlitepclraw.provider.e_sqlite3/2.1.12/sqlitepclraw.provider.e_sqlite3.nuspec", + "classification": "runtime dependency of ArcForges.Persistence.Sqlite; exact package retained as NuGet dependency, not copied source" + }, + "system.security.accesscontrol/6.0.1": { + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==", + "licence": "MIT", + "nuspecSha256": "2fb3325fbcea2d5eee1eb5666fd391fac8498f7acefe34f922aaceafea5e9df3", + "source": "https://api.nuget.org/v3-flatcontainer/system.security.accesscontrol/6.0.1/system.security.accesscontrol.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "xunit.analyzers/2.1.0": { + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==", + "licence": "Apache-2.0", + "nuspecSha256": "8d8fb2ff8c8a1505020e13d190a093af179b994eebce42ce03457ed9497e7b68", + "source": "https://api.nuget.org/v3-flatcontainer/xunit.analyzers/2.1.0/xunit.analyzers.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "xunit.runner.visualstudio/3.1.5": { + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==", + "licence": "Apache-2.0", + "nuspecSha256": "0e34c46e1420cea8e5260772ff19bcb3d3faa7709136d2d3ba157f48a97362ba", + "source": "https://api.nuget.org/v3-flatcontainer/xunit.runner.visualstudio/3.1.5/xunit.runner.visualstudio.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "xunit.v3.assert/4.0.1": { + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==", + "licence": "Apache-2.0", + "nuspecSha256": "3f2aef448f6c8e12cb7fa5d6ba111ea5311f530862a3e5fd9a63f153bec1f3af", + "source": "https://api.nuget.org/v3-flatcontainer/xunit.v3.assert/4.0.1/xunit.v3.assert.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "xunit.v3.common/4.0.1": { + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "licence": "Apache-2.0", + "nuspecSha256": "bdf4e653bae8f75b5392928a37bf108fcad4d2a5308524420828e82f3e79ded5", + "source": "https://api.nuget.org/v3-flatcontainer/xunit.v3.common/4.0.1/xunit.v3.common.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "xunit.v3.core.mtp-v2/4.0.1": { + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "licence": "Apache-2.0", + "nuspecSha256": "51c8cc20af9bb6071e1df8454579c9048597f5c169db3470dc5057be9844f1da", + "source": "https://api.nuget.org/v3-flatcontainer/xunit.v3.core.mtp-v2/4.0.1/xunit.v3.core.mtp-v2.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "xunit.v3.extensibility.core/4.0.1": { + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "licence": "Apache-2.0", + "nuspecSha256": "0def7df8a35e99e328a1b8799e169592b961ee06a3f7aac556a42ee8464cecf8", + "source": "https://api.nuget.org/v3-flatcontainer/xunit.v3.extensibility.core/4.0.1/xunit.v3.extensibility.core.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "xunit.v3.mtp-v2/4.0.1": { + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "licence": "Apache-2.0", + "nuspecSha256": "9134992ab09b0b8545f7fa31f6ab3c84a659b58493c7afca586a4977ee8998e2", + "source": "https://api.nuget.org/v3-flatcontainer/xunit.v3.mtp-v2/4.0.1/xunit.v3.mtp-v2.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "xunit.v3.runner.common/4.0.1": { + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "licence": "Apache-2.0", + "nuspecSha256": "2c80dbdeee78193f042e5b5e32c06d3b1d828659fb156c132f82574a5330799f", + "source": "https://api.nuget.org/v3-flatcontainer/xunit.v3.runner.common/4.0.1/xunit.v3.runner.common.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "xunit.v3.runner.inproc.console/4.0.1": { + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "licence": "Apache-2.0", + "nuspecSha256": "621fc57c6e5ab43360c5933415c98727f36abf658d1a7d9793159a2b827d4a5f", + "source": "https://api.nuget.org/v3-flatcontainer/xunit.v3.runner.inproc.console/4.0.1/xunit.v3.runner.inproc.console.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + }, + "xunit.v3/4.0.1": { + "contentHash": "KpDYf6jFjYfOchuRJOAXPOpwN03hLE1AF4kWzCUSnhr8JZMiGH4BT1YidCcm6BE1H4E90GyJzuKoCwXbg9PXmA==", + "licence": "Apache-2.0", + "nuspecSha256": "c0407f2bfbba8b98dfc27d55a8707c47afffa3debf66b1b562aba818159f43e8", + "source": "https://api.nuget.org/v3-flatcontainer/xunit.v3/4.0.1/xunit.v3.nuspec", + "classification": "build-test-tooling; runtime package closure remains packaging allowlist owned" + } + }, + "pythonClosure": { + "pre-commit/4.6.0": { + "hashes": [ + "718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", + "e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b" + ], + "licence": "MIT", + "evidence": [ + { + "source": "pre_commit-4.6.0.dist-info/LICENSE", + "sha256": "ea2ca27cba7cc35822d95a46d59bcd3cc88e196592e6390d1949a359ffc990e8" + }, + { + "source": "pre_commit-4.6.0.dist-info/METADATA", + "sha256": "4db1e1d0aa58955d863cb9e0382470ff2b73a2baad9110c14094c70e9f29dd42" + } + ], + "scope": "CI hook tooling only; hash-required requirements restore, not shipped in capability packages" + }, + "cfgv/3.5.0": { + "hashes": [ + "a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", + "d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132" + ], + "licence": "MIT", + "evidence": [ + { + "source": "cfgv-3.5.0.dist-info/LICENSE", + "sha256": "01fc3f8031a672b3f5d7d8ac262e432f3ea812809f5697d6bc5b270bf6446561" + }, + { + "source": "cfgv-3.5.0.dist-info/METADATA", + "sha256": "ae126b80e15f39d0feff7368cefea2ea6478fdcedcde52918d0f58b668e19137" + } + ], + "scope": "CI hook tooling only; hash-required requirements restore, not shipped in capability packages" + }, + "identify/2.6.19": { + "hashes": [ + "20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", + "6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842" + ], + "licence": "MIT", + "evidence": [ + { + "source": "identify-2.6.19.dist-info/LICENSE", + "sha256": "edbc2ad3b7084fac873e1b1b450fd370d914eb97f7f31263b4ff65f47dc10c43" + }, + { + "source": "identify-2.6.19.dist-info/METADATA", + "sha256": "6bd3f4b6084b3aa9bbfdd4631b2edffdc2908b396dfc2971891d0356bccb4965" + } + ], + "scope": "CI hook tooling only; hash-required requirements restore, not shipped in capability packages" + }, + "nodeenv/1.10.0": { + "hashes": [ + "5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", + "996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb" + ], + "licence": "BSD-3-Clause", + "evidence": [ + { + "source": "nodeenv-1.10.0.dist-info/METADATA", + "sha256": "a8424896a12fd1be10526834176fc754cd296c900f40da655fb2e03c1b7eb921" + }, + { + "source": "nodeenv-1.10.0.dist-info/licenses/LICENSE", + "sha256": "606faf42d48b54d539dae99db6fecd48544535587e27275b319a3f36113cb6fa" + } + ], + "scope": "CI hook tooling only; hash-required requirements restore, not shipped in capability packages" + }, + "pyyaml/6.0.3": { + "hashes": [ + "00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", + "0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", + "02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", + "02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", + "0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", + "10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", + "16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", + "1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", + "1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", + "214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", + "2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", + "22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6", + "27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", + "28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", + "2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", + "34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", + "37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", + "3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", + "3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295", + "41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", + "418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", + "44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", + "4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", + "4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", + "501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", + "5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", + "5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", + "5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b", + "5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", + "5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", + "5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", + "5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", + "6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369", + "64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", + "652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", + "66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", + "66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", + "6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", + "79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", + "7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", + "7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", + "8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", + "850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", + "8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", + "8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", + "8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", + "9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", + "93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", + "96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", + "9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4", + "9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", + "9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", + "a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", + "a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", + "b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", + "b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", + "b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", + "b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", + "ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", + "bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", + "c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", + "c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f", + "c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", + "c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", + "d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", + "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", + "ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", + "eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", + "ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", + "efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3", + "f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", + "fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", + "fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" + ], + "licence": "MIT", + "evidence": [ + { + "source": "pyyaml-6.0.3.dist-info/METADATA", + "sha256": "847a701020334386852b6e07bad1053cb0ce3da9ee1846e7e9d02b24e8bb3f8d" + }, + { + "source": "pyyaml-6.0.3.dist-info/licenses/LICENSE", + "sha256": "8d3928f9dc4490fd635707cb88eb26bd764102a7282954307d3e5167a577e8a4" + } + ], + "scope": "CI hook tooling only; hash-required requirements restore, not shipped in capability packages" + }, + "virtualenv/21.5.1": { + "hashes": [ + "55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", + "dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8" + ], + "licence": "MIT", + "evidence": [ + { + "source": "virtualenv-21.5.1.dist-info/METADATA", + "sha256": "a2b4e6261451dbe7dfc3ff6b720292889b9b02edb9023c4f3f93337bfa2f3187" + }, + { + "source": "virtualenv-21.5.1.dist-info/licenses/LICENSE", + "sha256": "5c15919378c5b2aaab7b19cea70d8cdc75f76879e32454e4c0399f8b71d171e9" + } + ], + "scope": "CI hook tooling only; hash-required requirements restore, not shipped in capability packages" + }, + "distlib/0.4.3": { + "hashes": [ + "4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", + "f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed" + ], + "licence": "PSF-2.0", + "evidence": [ + { + "source": "distlib-0.4.3.dist-info/METADATA", + "sha256": "6c988f8c23faa0ef8734f6863b32976d65f5851333161e8a8df3d80fc57e080d" + }, + { + "source": "distlib-0.4.3.dist-info/licenses/LICENSE.txt", + "sha256": "808e10c8a6ab8deb149ff9b3fb19f447a808094606d712a9ca57fead3552599d" + } + ], + "scope": "CI hook tooling only; hash-required requirements restore, not shipped in capability packages" + }, + "filelock/3.29.5": { + "hashes": [ + "6e6034c57a00a020e767f2614a5539863f056de7e7991d6d1473aef7ff73f156", + "8af830889ba3a0ffcefbd6c7d2af8a54012058103771f2e10848222f476a1693" + ], + "licence": "MIT", + "evidence": [ + { + "source": "filelock-3.29.5.dist-info/METADATA", + "sha256": "0221323a2a7129ed6f8672eecd33c5b0e4b78ff2f179ba751ed89b3de8538fec" + }, + { + "source": "filelock-3.29.5.dist-info/licenses/LICENSE", + "sha256": "608c89d5060ae9921adccf3236695bc654a9946e12323ef6c021dfa04e294d48" + } + ], + "scope": "CI hook tooling only; hash-required requirements restore, not shipped in capability packages" + }, + "platformdirs/4.10.0": { + "hashes": [ + "31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", + "fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a" + ], + "licence": "MIT", + "evidence": [ + { + "source": "platformdirs-4.10.0.dist-info/METADATA", + "sha256": "c4ee18f5b6d2b38512a30c289200b1463f298a8261c8fa63f2584ea97e93cf78" + }, + { + "source": "platformdirs-4.10.0.dist-info/licenses/LICENSE", + "sha256": "29e0fd62e929850e86eb28c3fdccf0cefdf4fa94879011cffb3d0d4bed6d4db6" + } + ], + "scope": "CI hook tooling only; hash-required requirements restore, not shipped in capability packages" + }, + "python-discovery/1.4.3": { + "hashes": [ + "ad57d7045a862460d4a235986c33f13ed707d3aeb9153fa47eb7dfd0d4673289", + "b6e1e4a7d9e3f6948c39746ffe8218225162d738ba39d05ab1d2f6c1cac4878c" + ], + "licence": "MIT", + "evidence": [ + { + "source": "python_discovery-1.4.3.dist-info/METADATA", + "sha256": "ffd0a6dec984955e2582e78b7183037478c5ce8ebab633b5b6a1fe2ba2b0906d" + }, + { + "source": "python_discovery-1.4.3.dist-info/licenses/LICENSE", + "sha256": "90e25e1faf2a4aaea8d95ee8e7f5738b052a1acc0c9c58068101f931a86bd588" + } + ], + "scope": "CI hook tooling only; hash-required requirements restore, not shipped in capability packages" + } + } +} diff --git a/eng/policy/reconciliation/active-projects.json b/eng/policy/reconciliation/active-projects.json index ed3db00d..b4c79fd4 100644 --- a/eng/policy/reconciliation/active-projects.json +++ b/eng/policy/reconciliation/active-projects.json @@ -24,7 +24,7 @@ }, { "path": "native/arcimage-abi/CMakeLists.txt", - "blob": "2af2a384ad1d1dc509a4c86921983ca6ed067931" + "blob": "f7d2cce91d635e0f8b8f48b51dbdeae0022a8114" }, { "path": "native/arcimage-abi/windows/arcslate_image_abi.vcxproj", @@ -176,7 +176,7 @@ }, { "path": "src/Native/ArcForges.Native.Image/ArcForges.Native.Image.csproj", - "blob": "9132f77575e4d1940f4648f7f208d86cf6087bca" + "blob": "ee2a468b6334eda9ade893cddff0622db8a4af22" }, { "path": "tests/ArchitectureTests/ArcForges.Tests.ArchitectureTests.csproj", @@ -188,7 +188,7 @@ }, { "path": "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj", - "blob": "e2ddd8ea87705280a92fdbba21630610fe2ff503" + "blob": "9ada5abbca2131fa7f6a3a1bc586b95d8c9cf94f" }, { "path": "tests/PersistenceTests/ArcForges.Tests.PersistenceTests.csproj", From 923994222a17e1a5db44626789b437863113eb90 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 04:17:28 +0100 Subject: [PATCH 07/15] [NAT.11] Inventory the successor receipt nat-11-r1 as first-party Co-Authored-By: Claude Haiku 5.5 --- eng/provenance/files.json | 1 + 1 file changed, 1 insertion(+) diff --git a/eng/provenance/files.json b/eng/provenance/files.json index 6afab6cb..1e97bc49 100644 --- a/eng/provenance/files.json +++ b/eng/provenance/files.json @@ -108,6 +108,7 @@ "eng/policy/dependency-reviews/gov17-gov18-r2.json", "eng/policy/dependency-reviews/nat-03-r1.json", "eng/policy/dependency-reviews/nat-06-r1.json", + "eng/policy/dependency-reviews/nat-11-r1.json", "eng/policy/dependency-reviews/plt-01-02-04-r1.json", "eng/policy/dependency-reviews/plt-01-02-04-r2.json", "eng/policy/dependency-reviews/plt-06-r1.json", From e4c7ba581c66287e375304d8ed33796e549bdbda Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 04:18:50 +0100 Subject: [PATCH 08/15] [NAT.11] Point the GOV.30 receipt guard at the successor nat-11-r1 The guard already names the successor; it now names nat-11-r1 and checks that the successor chains from gov-30-r1. Co-Authored-By: Claude Haiku 5.5 --- eng/test_desktop_rids.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/eng/test_desktop_rids.py b/eng/test_desktop_rids.py index 19fbd5a1..bd6baff1 100644 --- a/eng/test_desktop_rids.py +++ b/eng/test_desktop_rids.py @@ -69,7 +69,9 @@ def test_locks_have_no_osx_section_or_runtime_admission(self): def test_dependency_policy_has_no_osx_admission_and_names_the_successor(self): policy = json.loads(self.read('eng/policy/dependency-policy.json')) self.assertEqual([key for key in policy['nugetClosure'] if OSX.search(key)], []) - self.assertEqual(policy['reviewReceipt'], 'eng/policy/dependency-reviews/gov-30-r1.json') + self.assertEqual(policy['reviewReceipt'], 'eng/policy/dependency-reviews/nat-11-r1.json') + successor = json.loads(self.read(policy['reviewReceipt'])) + self.assertEqual(successor['review']['previousReceipt'], 'eng/policy/dependency-reviews/gov-30-r1.json') self.assertTrue((ROOT / policy['reviewReceipt']).is_file()) def test_directory_inventory_has_no_osx_row_and_its_count_matches(self): From 7ac75362b9967ba7ecd5ae3d146f03eadf31c433 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 04:35:01 +0100 Subject: [PATCH 09/15] [NAT.11] Record the local win-x64 validation and toolchain drift Unit 5 of the image family ran the CI-equivalent win-x64 sequence from a clean shim-static build: CTest with the anchored CI filter (36 of 36), dotnet restore --locked-mode, NativeAbiLayout (3 of 3), the NativeAbi functional managed tests (15 of 15) through a manifest-checked local ArcImageNative.dll pair, a zero-warning managed build, and dotnet format --verify-no-changes. The local CMake (4.4.3) and Ninja (1.13.2) differ from the owned CMake 4.3.3 and Ninja 1.13.1, so eng/packaging/native.py stage refuses the owned generator. The staged packages, the managed Runtime pack and the package-guard tests therefore remain CI authority and are not run locally. linux-x64 is not run (D8). Co-Authored-By: Claude Haiku 5.5 --- native/arcimage-abi/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/native/arcimage-abi/README.md b/native/arcimage-abi/README.md index c5a009c6..4cc0290f 100644 --- a/native/arcimage-abi/README.md +++ b/native/arcimage-abi/README.md @@ -61,3 +61,23 @@ Codec test coverage: Not run: linux-x64 (decision D8). The WSL cmake and ninja install is not present, so the Linux CTest targets are recorded as not run. + +Local win-x64 validation (NAT.11 unit 5; decision D12). The clean shim-static build used the CI sequence on +Visual Studio 2026 Community (MSVC 19.51.36257, toolset 14.51.36231) with the vcpkg commit 36677bbd and the four +pinned packages installed under the worktree artifacts root: + +- CTest with the CI filter: 36 of 36 pass. sccache recorded 9 compile requests and no non-cacheable compilation. +- `dotnet restore DesktopPlatform.slnx --locked-mode`, NativeAbiLayout 3 of 3, NativeAbi functional 15 of 15, and + a zero-warning build of ArcForges.Native.Abstractions, ArcForges.Native.Image, the Image runtime project and the + NativeAbi test project. `dotnet format DesktopPlatform.slnx --verify-no-changes` passes. +- The functional managed tests load a manifest-checked `ArcImageNative.dll` beside the test assembly. Locally that + pair is the cmake install output with a manifest written from the same bytes. It is a test prerequisite, not + staged provenance. +- Toolchain drift from the CI owned build: the local CMake is 4.4.3 (owned 4.3.3; vcpkg uses 4.4.0) and the local + Ninja is 1.13.2 (owned 1.13.1). `eng/packaging/native.py stage` refuses the owned generator with "Unreviewed owned + CMake build generator", so the staged packages, the managed Runtime pack and the package-guard tests are not run + locally (13 of 25 package-guard tests error on the missing `artifacts/packages` tree). The hosted native workflow + is the authority for them. The other 12 package tests and the dependency-policy, desktop-RID, licence-boundary and + provenance checks pass locally. +- Two clean builds of the same commit gave different `ArcImageNative.dll` hashes. The cause was not investigated. +- linux-x64 is still not run (decision D8). From 9061e47d95b496795e0cecfc6ea8925ae4e1a342 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 04:45:47 +0100 Subject: [PATCH 10/15] [NAT.11] Keep native coverage and the managed outcome in agreement Fix the two material findings from the independent review at 7ac75362. ImageReader.ReadRegionAsync: once the native read has returned OK, native coverage is committed. The managed read discarded its pixels and did not count the region when a cancellation arrived after that point, so the region could not be retried (native refuses the overlap) and CompleteAsync never succeeded. The managed count now follows the native success unconditionally, and the doc comment states the contract exactly: a region native code refuses or reports as cancelled changes no coverage; a region that native code returned OK for is counted and returned. arc_image_open: the metadata buffer was written before the handle was registered, so a refused 65th open (RESOURCE_LIMIT) wrote metadata while returning failure, against Annex 06 section 1. The handle is now registered first, into a local token. The metadata is copied only after that, and a short metadata buffer closes the new handle again, so a failed call writes no output and leaves no handle open. Tests: the lifecycle test checks that a short buffer is not written, that it leaves no handle open, and that a refused 65th open leaves a sentinel metadata buffer and its required field untouched. The managed suite gains CancellationAtEveryReadOrdinalKeepsCoverageConsistentWithOutcome, which sweeps the cancellation trigger across every source read of a region. The window after native success is not reachable deterministically through the source callback, so the sweep is the bounded approximation. Co-Authored-By: Claude Haiku 5.5 --- .../arcimage-abi/src/arcslate_image_abi.cpp | 18 ++++-- .../tests/arcslate_image_codec_tests.cpp | 32 +++++++++- .../ArcForges.Native.Image/ImageReader.cs | 13 ++-- tests/NativeAbiTests/ImageReaderTests.cs | 62 ++++++++++++++++++- 4 files changed, 112 insertions(+), 13 deletions(-) diff --git a/native/arcimage-abi/src/arcslate_image_abi.cpp b/native/arcimage-abi/src/arcslate_image_abi.cpp index 5fd2eb59..9e069037 100644 --- a/native/arcimage-abi/src/arcslate_image_abi.cpp +++ b/native/arcimage-abi/src/arcslate_image_abi.cpp @@ -115,13 +115,23 @@ arc_status_t ARC_ABI_CALL arc_image_open(const arc_io_v1* io, const arc_image_op if (opened != ARC_OK) { return opened; } - // The metadata is copied before the handle exists, so a short buffer consumes no state. - const arc_status_t written = write_text(session->metadata(), metadata); + // Annex 06 section 1: a failed call writes no output. The handle is registered first, into a local + // token, so a refused create (RESOURCE_LIMIT) leaves the metadata buffer untouched. Only then is the + // metadata copied. A short metadata buffer closes the new handle again, so the failed call leaves no + // open handle behind. + std::shared_ptr shared(std::move(session)); + arc_handle_t created = 0; + const arc_status_t registered = image_handles().create(image_handle_kind, shared, &created); + if (registered != ARC_OK) { + return registered; + } + const arc_status_t written = write_text(shared->metadata(), metadata); if (written != ARC_OK) { + (void)image_handles().close(created, image_handle_kind); return written; } - std::shared_ptr shared(std::move(session)); - return image_handles().create(image_handle_kind, shared, image); + *image = created; + return ARC_OK; } catch (const std::bad_alloc&) { return arc::abi::fail(ARC_OUT_OF_MEMORY, "Image open allocation failed", image_error_domain); } catch (...) { diff --git a/native/arcimage-abi/tests/arcslate_image_codec_tests.cpp b/native/arcimage-abi/tests/arcslate_image_codec_tests.cpp index 8d6957cb..fca8bfa1 100644 --- a/native/arcimage-abi/tests/arcslate_image_codec_tests.cpp +++ b/native/arcimage-abi/tests/arcslate_image_codec_tests.cpp @@ -783,8 +783,25 @@ void test_handle_lifecycle_and_limit() source state{}; state.bytes = &png; const arc_io_v1 io = make_io(state); + const arc_image_options_v1 options = make_options(ARC_FORMAT_RGBA8); std::vector handles; std::string metadata; + + // A short metadata buffer reports the required size, writes no metadata byte and leaves no handle open + // (the handle is rolled back), so the 64-handle limit below is still fully available afterwards. + { + char tiny[4] = {'S', 'E', 'N', 'T'}; + arc_mut_buffer_t output{}; + output.data = tiny; + output.capacity = sizeof(tiny); + arc_handle_t short_handle = 0; + const arc_status_t status = arc_image_open(&io, &options, &short_handle, &output, nullptr); + expect(status == ARC_BUFFER_TOO_SMALL && short_handle == 0 && output.required > sizeof(tiny), + "a short metadata buffer reports BUFFER_TOO_SMALL with the required size and no handle"); + expect(tiny[0] == 'S' && tiny[1] == 'E' && tiny[2] == 'N' && tiny[3] == 'T', + "a short metadata buffer is not written"); + } + for (int index = 0; index < 64; ++index) { arc_handle_t handle = 0; if (open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, handle, metadata) != ARC_OK) { @@ -793,10 +810,21 @@ void test_handle_lifecycle_and_limit() handles.push_back(handle); } expect(handles.size() == 64, "64 image handles open per library context"); + + // Annex 06 section 1: a refused open writes no output at all, so the metadata buffer keeps its sentinel + // bytes and its required field is untouched. + std::vector sentinel(65536, '\x5A'); + arc_mut_buffer_t sentinel_output{}; + sentinel_output.data = sentinel.data(); + sentinel_output.capacity = sentinel.size(); + sentinel_output.required = 0xFEEDFACEULL; arc_handle_t overflow = 0; - expect(open_image(io, make_options(ARC_FORMAT_RGBA8), nullptr, overflow, metadata) == ARC_RESOURCE_LIMIT && - overflow == 0, + const arc_status_t refused = arc_image_open(&io, &options, &overflow, &sentinel_output, nullptr); + expect(refused == ARC_RESOURCE_LIMIT && overflow == 0, "a 65th handle is refused with RESOURCE_LIMIT"); + expect(std::all_of(sentinel.begin(), sentinel.end(), [](char byte) { return byte == '\x5A'; }) && + sentinel_output.required == 0xFEEDFACEULL, + "a refused 65th open writes no metadata bytes and no required size"); for (const arc_handle_t handle : handles) { expect(arc_image_close(handle) == ARC_OK, "each handle closes once"); } diff --git a/src/Native/ArcForges.Native.Image/ImageReader.cs b/src/Native/ArcForges.Native.Image/ImageReader.cs index 8dee01d3..5b071c89 100644 --- a/src/Native/ArcForges.Native.Image/ImageReader.cs +++ b/src/Native/ArcForges.Native.Image/ImageReader.cs @@ -65,8 +65,10 @@ public static async Task ProbeAsync(ImageByteSource source, Image } } - // Reads one rectangle. Regions must be disjoint and arrive in raster order (decision D3). A refused or - // cancelled region changes no coverage, so the caller may retry it. + // Reads one rectangle. Regions must be disjoint and arrive in raster order (decision D3). A region that + // native code refuses, or reports as cancelled, changes no coverage, so the caller may retry it. Once native + // code has returned success, the region is counted and its pixels are returned even if cancellation was + // requested afterwards, because native coverage has already been committed and the two must agree. public async Task ReadRegionAsync(ImageRegion region, CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeStarted) != 0, this); @@ -83,11 +85,12 @@ public async Task ReadRegionAsync(ImageRegion region, Cancella { ObjectDisposedException.ThrowIf(_disposed, this); byte[] pixels = GC.AllocateUninitializedArray(checked((int)bytes)); + // Reaching this line means ReadNative returned without throwing, so native code returned OK and has + // already committed the coverage. The managed count follows it unconditionally. A cancellation that + // arrives now must not discard the pixels, or the image could never complete (the region would be + // refused on retry as an overlap). await Task.Run(() => ReadNative(region, pixels, linked.Token), linked.Token).ConfigureAwait(false); - // A read that finished after cancellation was requested is discarded, so it consumes no coverage and - // the caller can retry the same region. - linked.Token.ThrowIfCancellationRequested(); _coveredPixels += (ulong)region.Width * region.Height; _regionCount++; return new ImagePixelRegion(region, Metadata.OutputFormat, Metadata.BytesPerPixel, pixels); diff --git a/tests/NativeAbiTests/ImageReaderTests.cs b/tests/NativeAbiTests/ImageReaderTests.cs index 8f53b15c..e9e0c6e1 100644 --- a/tests/NativeAbiTests/ImageReaderTests.cs +++ b/tests/NativeAbiTests/ImageReaderTests.cs @@ -231,6 +231,57 @@ public async Task CancellationDuringNativeReadKeepsCoverageConsistentWithOutcome Assert.Equal((ulong)(Width * Height), completion.CoveredPixels); } + // Sweeps the cancellation trigger across every source read of one region, so that a cancellation which lands + // after native code has returned success is exercised, not only one which lands inside the native read. The + // outcome decides the coverage: pixels returned means the region counts and completion succeeds, and a + // cancelled read means nothing was counted and the same region can be read again. + [Fact] + public async Task CancellationAtEveryReadOrdinalKeepsCoverageConsistentWithOutcome() + { + byte[] file = ImageFixtures.PngRgba8(Width, Height, StraightRgba); + ImageRegion whole = new(0, 0, Width, Height); + const int MaxReadOrdinals = 256; + for (int ordinal = 1; ordinal <= MaxReadOrdinals; ordinal++) + { + using var stream = new MemoryStream(file); + using var cancellation = new CancellationTokenSource(); + var source = new ArmableSource(ImageByteSource.FromStream(stream), () => cancellation.Cancel()); + await using ImageReader reader = await ImageReader.OpenAsync(source); + + source.Arm(ordinal); + ImagePixelRegion? first = null; + try + { + first = await reader.ReadRegionAsync(whole, cancellation.Token); + } + catch (OperationCanceledException) + { + // The outcome is cancellation: no coverage may have been counted. + } + + bool fired = source.Fired; + source.Disarm(); + if (first is null) + { + ImageCoverageIncompleteException missing = + await Assert.ThrowsAsync(() => reader.CompleteAsync()); + Assert.Equal(0UL, missing.CoveredPixels); + first = await reader.ReadRegionAsync(whole); + } + + Assert.Equal(StraightRgba, first.Pixels.ToArray()); + ImageCompletion completion = await reader.CompleteAsync(); + Assert.Equal((ulong)(Width * Height), completion.CoveredPixels); + Assert.Equal(1u, completion.RegionCount); + + // The read finished before reaching this read ordinal, so no later ordinal is reachable. + if (!fired) + { + break; + } + } + } + [Fact] public async Task PreCancelledOpenDoesNotReachNative() { @@ -298,20 +349,27 @@ private sealed class ArmableSource(ImageByteSource inner, Action onArmedRead) : { private volatile bool _armed; private bool _fired; + private int _armedReads; + private int _triggerOrdinal = 1; public override long Length => inner.Length; - public void Arm() + // Fires onArmedRead on the triggerOrdinal-th read after arming (1 is the first read). + public void Arm(int triggerOrdinal = 1) { _fired = false; + _armedReads = 0; + _triggerOrdinal = triggerOrdinal; _armed = true; } public void Disarm() => _armed = false; + public bool Fired => _fired; + public override int ReadAt(long offset, Span destination) { - if (_armed && !_fired) + if (_armed && !_fired && ++_armedReads == _triggerOrdinal) { _fired = true; onArmedRead(); From e8dff231011d9b1a4b88e0ac8ddf8837dcc068c0 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 06:41:02 +0100 Subject: [PATCH 11/15] [NAT.11] Apply clang-format 22.1.8 to the codec tests The repository-hooks job (pre-commit clang-format v22.1.8) on PR #175 reported one hunk in arcslate_image_codec_tests.cpp: a call that fits on one line. The diff is applied exactly as the hook printed it; no other file changes. Co-Authored-By: Claude Opus 5.5 --- native/arcimage-abi/tests/arcslate_image_codec_tests.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/native/arcimage-abi/tests/arcslate_image_codec_tests.cpp b/native/arcimage-abi/tests/arcslate_image_codec_tests.cpp index fca8bfa1..4a2f5128 100644 --- a/native/arcimage-abi/tests/arcslate_image_codec_tests.cpp +++ b/native/arcimage-abi/tests/arcslate_image_codec_tests.cpp @@ -820,8 +820,7 @@ void test_handle_lifecycle_and_limit() sentinel_output.required = 0xFEEDFACEULL; arc_handle_t overflow = 0; const arc_status_t refused = arc_image_open(&io, &options, &overflow, &sentinel_output, nullptr); - expect(refused == ARC_RESOURCE_LIMIT && overflow == 0, - "a 65th handle is refused with RESOURCE_LIMIT"); + expect(refused == ARC_RESOURCE_LIMIT && overflow == 0, "a 65th handle is refused with RESOURCE_LIMIT"); expect(std::all_of(sentinel.begin(), sentinel.end(), [](char byte) { return byte == '\x5A'; }) && sentinel_output.required == 0xFEEDFACEULL, "a refused 65th open writes no metadata bytes and no required size"); From df3cf780ee819b16699a5345069ab62fce9a63ee Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 06:57:52 +0100 Subject: [PATCH 12/15] [NAT.11] Pin the OpenImageIO size guard to the shim's bounds OpenImageIO refuses a header whose uncompressed size exceeds the process global limits:imagesize_MB, whose default is min(32 GiB, physical memory). The shim's answer for a 65535 x 65535 RGBA8 header therefore depended on host memory: RESOURCE_LIMIT on a large workstation, an OpenImageIO refusal at open on the smaller hosted runner. The shim now pins limits:imagesize_MB once, before the first OpenImageIO open, to 131072 MiB: hard_max_pixels x max_channels x 8 bytes, the largest uncompressed image the hard profile admits. limits:channels keeps the host-independent OpenImageIO default of 1024, which is above the shim's 64-channel bound. The codec test executable links its own OpenImageIO copy, so it cannot read the shim's global. oiio_size_pin checks the pin through arc_image_open with a 128 GiB, 64-channel double TIFF header, which must open. The bomb test is unchanged. The README records the pin, its reason and the test counts. Co-Authored-By: Claude Haiku 5.5 --- native/arcimage-abi/CMakeLists.txt | 3 +- native/arcimage-abi/README.md | 27 ++++++-- native/arcimage-abi/src/image_session.cpp | 26 ++++++++ .../tests/arcslate_image_codec_tests.cpp | 64 +++++++++++++++++++ 4 files changed, 115 insertions(+), 5 deletions(-) diff --git a/native/arcimage-abi/CMakeLists.txt b/native/arcimage-abi/CMakeLists.txt index f7d2cce9..a36253c1 100644 --- a/native/arcimage-abi/CMakeLists.txt +++ b/native/arcimage-abi/CMakeLists.txt @@ -88,7 +88,8 @@ if(ARCFORGES_BUILD_TESTS) callback_failures handle_lifecycle exr_multipart - exr_mips) + exr_mips + oiio_size_pin) foreach(codec_test IN LISTS ARCFORGES_IMAGE_CODEC_TESTS) add_test(NAME arcslate_image_abi.codec.${codec_test} COMMAND arcslate_image_codec_tests ${codec_test}) endforeach() diff --git a/native/arcimage-abi/README.md b/native/arcimage-abi/README.md index 4cc0290f..5c534408 100644 --- a/native/arcimage-abi/README.md +++ b/native/arcimage-abi/README.md @@ -9,6 +9,22 @@ ownership crosses the boundary. The library reports ABI 1.1 (functional minor, d `read_at` callback through an OpenImageIO `IOProxy` adapter. - Bounds: dimensions, pixel count, channel count, tile memory and scanline memory are checked before any decode allocation. Region output is capped at 64 MiB, and at most 64 handles are open per library. +- Size authority: the shim's own bounds decide image size, not the host. OpenImageIO refuses a header whose + uncompressed size exceeds its process global `limits:imagesize_MB`, whose default is min(32 GiB, physical + memory). The shim pins that global once, before the first OpenImageIO open (`image_session.cpp`, + `pin_openimageio_size_limit`), to 131072 MiB (128 GiB). That value is `hard_max_pixels` (2^28) x + `max_channels` (64) x 8 bytes (double, the widest sample type), the largest uncompressed image the hard + profile admits. OpenImageIO therefore never refuses an image the shim admits, and the shim's RESOURCE_LIMIT + answers everything it bounds. Before the pin, a 65535 x 65535 RGBA8 header (about 16 GiB) was refused by + the shim on a host with large physical memory, but by OpenImageIO at open on a smaller host, so the status + depended on memory. A header larger than 128 GiB by OpenImageIO's count, such as 65535 x 65535 with 64 + double channels, still gets OpenImageIO's CORRUPT answer rather than the shim's RESOURCE_LIMIT. + `limits:channels` keeps OpenImageIO's default of 1024. That constant is above the shim's 64-channel bound, + so it never refuses a count the shim admits, and it does not depend on the host, so it is not pinned. + The shim links OpenImageIO statically (`x64-windows-static-md`), so the global is private to + ArcImageNative. The codec test executable has its own OpenImageIO copy and cannot read that global, so + `arcslate_image_abi.codec.oiio_size_pin` checks the pin through `arc_image_open` instead: a 16384 x 16384 + header of 64 double channels (128 GiB) must open on every host. - Coverage: a handle accepts regions only in raster order without overlap (decision D3). A refused region changes no state. - Conversion: `rgba8` (straight alpha, no transfer change), `rgba32fLinearPremultiplied` (sRGB to linear @@ -30,7 +46,7 @@ ownership crosses the boundary. The library reports ABI 1.1 (functional minor, d Probe, build and error exports keep their shipped signatures. The shim's own tests are the CTest target `arcslate_image_abi.hello` (probe, `arcslate_image_abi_tests`) and one CTest target per functional case, -`arcslate_image_abi.codec.` (`arcslate_image_codec_tests`, 33 cases, 368 checks on win-x64). The +`arcslate_image_abi.codec.` (`arcslate_image_codec_tests`, 34 cases, 374 checks on win-x64). The codec tests generate their fixtures in test code and ship none (decision D1). Production containment of untrusted decode is the WP11 helper (NAT.31), not this library. @@ -42,7 +58,8 @@ Codec test coverage: Each pixel is covered once in raster order. - Limits: pixel-count and tile or scanline memory limits are refused before decode. A read whose row band exceeds the memory limit is refused before any row decodes. A read that passes its deadline between tiles is - RESOURCE_LIMIT. + RESOURCE_LIMIT. A 65535 x 65535 PNG header is refused from the header alone (`limits_bomb`). A 128 GiB header + at the admitted maximum opens, which shows the OpenImageIO size pin is in effect (`oiio_size_pin`). - Corrupt input: truncated, bad-header-CRC and damaged-pixel PNG, truncated TIFF and EXR, and an invalid EXR version are CORRUPT. Each writes no pixels. - Failed codec: a callback failure during decode is IO, writes no pixels and consumes no coverage. @@ -66,8 +83,10 @@ Local win-x64 validation (NAT.11 unit 5; decision D12). The clean shim-static bu Visual Studio 2026 Community (MSVC 19.51.36257, toolset 14.51.36231) with the vcpkg commit 36677bbd and the four pinned packages installed under the worktree artifacts root: -- CTest with the CI filter: 36 of 36 pass. sccache recorded 9 compile requests and no non-cacheable compilation. -- `dotnet restore DesktopPlatform.slnx --locked-mode`, NativeAbiLayout 3 of 3, NativeAbi functional 15 of 15, and +- CTest with the CI filter: 37 of 37 pass (NAT.11 unit 6, after the OpenImageIO size pin). The codec test + executable reports 374 checks with no failures. A mutation check that lowers the pin to the 32 GiB default + fails `oiio_size_pin` (2 of 3 checks), which shows the test depends on the pin. +- `dotnet restore DesktopPlatform.slnx --locked-mode`, NativeAbiLayout 3 of 3, NativeAbi functional 16 of 16, and a zero-warning build of ArcForges.Native.Abstractions, ArcForges.Native.Image, the Image runtime project and the NativeAbi test project. `dotnet format DesktopPlatform.slnx --verify-no-changes` passes. - The functional managed tests load a manifest-checked `ArcImageNative.dll` beside the test assembly. Locally that diff --git a/native/arcimage-abi/src/image_session.cpp b/native/arcimage-abi/src/image_session.cpp index ef5f5f6e..f3a6b9ca 100644 --- a/native/arcimage-abi/src/image_session.cpp +++ b/native/arcimage-abi/src/image_session.cpp @@ -220,6 +220,28 @@ float srgb_to_linear(float encoded) noexcept return std::pow((encoded + 0.055F) / 1.055F, 2.4F); } +// OpenImageIO refuses a header whose uncompressed size exceeds limits:imagesize_MB, a process global whose +// default is min(32 GiB, physical memory). That default made the shim's answer depend on the host. The shim's +// own bounds are the only size authority, so the guard is pinned once, before the first open, to the largest +// image the hard profile admits: hard_max_pixels pixels of max_channels channels at 8 bytes per sample, the +// widest type (double). The attribute is counted in MiB. Inside that size OpenImageIO never refuses, and the +// shim's RESOURCE_LIMIT stays the answer for everything it bounds itself. +constexpr uint64_t widest_sample_bytes = 8; +constexpr uint64_t mebibyte = UINT64_C(1) << 20; +constexpr uint64_t admitted_image_bytes = hard_max_pixels * max_channels * widest_sample_bytes; +constexpr uint64_t admitted_image_mib = admitted_image_bytes / mebibyte; +static_assert(admitted_image_bytes % mebibyte == 0, "the admitted image size is a whole number of MiB"); +static_assert(admitted_image_mib <= 0x7FFFFFFFU, "limits:imagesize_MB is a 32-bit signed attribute"); + +// limits:channels keeps OpenImageIO's default of 1024. That value is a constant above the shim's channel bound, +// so it never refuses a count the shim admits, and the shim's own RESOURCE_LIMIT answers the rest. +bool pin_openimageio_size_limit() +{ + // A function-local static is initialised exactly once, and every later open sees the same result. + static const bool pinned = OIIO::attribute("limits:imagesize_MB", static_cast(admitted_image_mib)); + return pinned; +} + } // namespace // Binds the per-call cancellation token and deadline to the session and to the callback adapter, and @@ -301,6 +323,10 @@ arc_status_t image_session::open(const arc_io_v1& io, const arc_image_options_v1 if (io.length > limits.max_input_bytes) { return fail_image(ARC_RESOURCE_LIMIT, "Image input exceeds the input byte limit"); } + // Before the first OpenImageIO open, so no header is ever checked against a host-sized size guard. + if (!pin_openimageio_size_limit()) { + return fail_image(ARC_INTERNAL, "OpenImageIO size limit could not be pinned"); + } std::unique_ptr session(new image_session()); session->limits_ = limits; diff --git a/native/arcimage-abi/tests/arcslate_image_codec_tests.cpp b/native/arcimage-abi/tests/arcslate_image_codec_tests.cpp index 4a2f5128..6fe38020 100644 --- a/native/arcimage-abi/tests/arcslate_image_codec_tests.cpp +++ b/native/arcimage-abi/tests/arcslate_image_codec_tests.cpp @@ -270,6 +270,45 @@ std::vector decompression_bomb_png() return png_header_only(65535, 65535); } +// Rewrites the width, height and rows-per-strip entries of a one-row TIFF written by OpenImageIO, so the header +// declares a larger image and its strip table still matches the rows. Only the header is read by the open call. +std::vector tiff_declaring_size(std::vector bytes, uint32_t width, uint32_t height) +{ + const auto read16 = [&bytes](size_t at) { return static_cast(bytes[at] | (bytes[at + 1] << 8)); }; + const auto read32 = [&read16](size_t at) { return read16(at) | (read16(at + 2) << 16); }; + const auto write = [&bytes](size_t at, uint32_t value, size_t byte_count) { + for (size_t index = 0; index < byte_count; ++index) { + bytes[at + index] = static_cast((value >> (8 * index)) & 0xFFU); + } + }; + if (bytes.size() < 8 || bytes[0] != 'I' || bytes[1] != 'I') { + return {}; + } + const size_t ifd = read32(4); + if (ifd + 2 > bytes.size() || ifd + 2 + static_cast(read16(ifd)) * 12 > bytes.size()) { + return {}; + } + const size_t entries = read16(ifd); + for (size_t index = 0; index < entries; ++index) { + const size_t entry = ifd + 2 + index * 12; + const uint32_t tag = read16(entry); + const uint32_t type = read16(entry + 2); + if (tag != 256 && tag != 257 && tag != 278) { + continue; + } + const uint32_t value = tag == 256 ? width : height; + // TIFF SHORT (3) and LONG (4) hold the value left justified in the four-byte value field. + if (type == 3) { + write(entry + 8, value, 2); + } else if (type == 4) { + write(entry + 8, value, 4); + } else { + return {}; + } + } + return bytes; +} + // ---- Tests ------------------------------------------------------------------------------------- void test_probe_and_build_info() @@ -1683,6 +1722,30 @@ struct test_case final { void (*run)(); }; +void test_openimageio_size_limit_is_pinned() +{ + // OpenImageIO's own size guard defaults to min(32 GiB, physical memory), and a process global in the shim's + // OpenImageIO copy holds it. The shim pins that guard to the largest image its hard profile admits, which is + // 128 GiB (hard_max_pixels x max_channels x 8-byte doubles). This test executable links its own OpenImageIO, so + // it cannot read the shim's global. It checks the pin through the shim instead: a 16384 x 16384 header of 64 + // double channels is exactly the admitted size, above any default guard, so it must open on every host. + OIIO::ImageSpec spec(1, 1, 64, OIIO::TypeDesc::DOUBLE); + const std::vector pixel(64, 0.5); + const auto fixture = encode("fixture.tif", spec, OIIO::TypeDesc::DOUBLE, pixel.data()); + const auto header = tiff_declaring_size(fixture, 16384, 16384); + expect(!header.empty(), "a one-pixel 64-channel double TIFF fixture encodes and declares 16384 x 16384"); + + source state{}; + state.bytes = &header; + const arc_io_v1 io = make_io(state); + arc_handle_t handle = 0; + std::string metadata; + const arc_status_t status = open_image(io, make_options(ARC_FORMAT_FLOAT32_INTERLEAVED), nullptr, handle, metadata); + expect(status == ARC_OK && handle != 0, + "a 128 GiB header at the admitted maximum opens on every host, so the size guard is pinned"); + expect(arc_image_close(handle) == ARC_OK, "the admitted-maximum handle closes"); +} + constexpr test_case test_cases[] = { {"probe", test_probe_and_build_info}, {"png_rgba8", test_png_rgba8_round_trip}, @@ -1717,6 +1780,7 @@ constexpr test_case test_cases[] = { {"handle_lifecycle", test_handle_lifecycle_and_limit}, {"exr_multipart", test_exr_multipart_subimages_within_bounds}, {"exr_mips", test_exr_mip_levels_within_bounds}, + {"oiio_size_pin", test_openimageio_size_limit_is_pinned}, }; } // namespace From 0097442f95344f2eca00d9cd1906779416b313ed Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 07:12:34 +0100 Subject: [PATCH 13/15] [NAT.11] Re-pin the arcimage-abi CMakeLists.txt blob in the active inventory df3cf780 added the oiio_size_pin case to native/arcimage-abi/CMakeLists.txt, so the GOV.18 active-project pin drifted, and hosted reconciliation-policy failed with "pinned project blob drift". The row now holds the blob of that file at this commit (git rev-parse HEAD:native/arcimage-abi/CMakeLists.txt). No other row changes. Validation: eng/reconciliation.py and eng/dependency_policy.py pass; the reconciliation, dependency-policy and desktop-RID unit suites pass. Co-Authored-By: Claude Opus 5.5 --- eng/policy/reconciliation/active-projects.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/policy/reconciliation/active-projects.json b/eng/policy/reconciliation/active-projects.json index b4c79fd4..b88ad78c 100644 --- a/eng/policy/reconciliation/active-projects.json +++ b/eng/policy/reconciliation/active-projects.json @@ -24,7 +24,7 @@ }, { "path": "native/arcimage-abi/CMakeLists.txt", - "blob": "f7d2cce91d635e0f8b8f48b51dbdeae0022a8114" + "blob": "a36253c14cbc199b47f4ab01731ae32075c4a13c" }, { "path": "native/arcimage-abi/windows/arcslate_image_abi.vcxproj", From 71a2aec8397a56ae6752fe1a234bda005fb635dc Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 07:25:43 +0100 Subject: [PATCH 14/15] [NAT.11] Admit the ABI 1.1 functional exports for ArcImageNative in packaging The hosted native-win-x64 job failed at native.py stage with "Owned native export set differs from the admitted ABI." ArcImageNative.dll now exports arc_image_open, arc_image_read and arc_image_close (ABI 1.1, Annex 06) alongside the three support probes, but the packaging checks admitted only the probes. The same expectation was in packages.py verify ("Owned ABI exports mismatch."). The admitted set is now one definition in native.py: every library keeps the three probes, and ArcImageNative additionally admits _open, _read and _close. native.require_owned_exports is used by both the stage check and the package verify check, so the two cannot drift. A missing or extra export still fails. No other library's expectation changes. test_packages.py gains OwnedExportGuards: the exact six-export set is admitted, each missing export and an extra export are rejected, probe-only is no longer admitted for ArcImageNative, other libraries keep the probe-only set, and the admitted set must equal the exports in native/arcimage-abi/exports/windows.def. native_consumer.py already carries the ABI 1.1 expectation and has no export set check, so it is unchanged. ADP-07 supporting-file binding: eng/packaging/native.py and eng/packaging/packages.py are outside NAT.11's recorded writes. They are bound here for CI packaging of the delivered functional ABI. The matching unit suite is eng/packaging/test_packages.py. Validation: python -I -m unittest discover -s eng/packaging -p test_*.py runs the 31 tests; OwnedExportGuards and ExternalDependencyGuards pass. The PackageGuards fixtures need artifacts/packages from pack and are validated in the follow-up gate run. Co-Authored-By: Claude Haiku 5.5 --- eng/packaging/native.py | 14 ++++++++++-- eng/packaging/packages.py | 3 +-- eng/packaging/test_packages.py | 40 ++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/eng/packaging/native.py b/eng/packaging/native.py index fc5c54cd..1de5ccf0 100644 --- a/eng/packaging/native.py +++ b/eng/packaging/native.py @@ -16,6 +16,9 @@ import native_provenance import build_identity VCPKG_COMMIT = "36677bbd0b3bf11da7376e62e14bffcc54d2eaeb" +# Every owned library exports the three probes. ArcImageNative (ABI 1.1, Annex 06) adds its functional surface. +PROBE_EXPORTS = ("_get_abi_version", "_get_build_info", "_get_last_error") +FUNCTIONAL_EXPORTS = {"ArcImageNative": ("_open", "_read", "_close")} def require(condition, message): @@ -23,6 +26,14 @@ def require(condition, message): raise ValueError(message) +def admitted_exports(entry): + return {entry["prefix"] + suffix for suffix in PROBE_EXPORTS + FUNCTIONAL_EXPORTS.get(entry["library"], ())} + + +def require_owned_exports(entry, exports): + require(set(exports) == admitted_exports(entry), "Owned native export set differs from the admitted ABI.") + + def digest(path): with path.open("rb") as stream: return hashlib.file_digest(stream, "sha256").hexdigest() @@ -279,8 +290,7 @@ def stage(directory, vcpkg, installed_root): if not system_dependency(dependency): pending.append(dependency) owned = selected[entry["library"].lower() + ".dll"] - require(set(owned["exports"]) == {entry["prefix"] + suffix for suffix in ["_get_abi_version", "_get_build_info", "_get_last_error"]}, - "Owned native export set differs from the admitted ABI.") + require_owned_exports(entry, owned["exports"]) for original, relative in [(ROOT / entry["header"], "include/arc/" + Path(entry["header"]).name), (ROOT / "native/shared/include/arc/arc_native_abi.h", "include/arc/arc_native_abi.h"), (binary_root / "lib" / (entry["library"] + ".lib"), "sdk/win-x64/lib/" + entry["library"] + ".lib")]: diff --git a/eng/packaging/packages.py b/eng/packaging/packages.py index e5eb8cf3..a2326ac5 100644 --- a/eng/packaging/packages.py +++ b/eng/packaging/packages.py @@ -157,8 +157,7 @@ def inspect(path, entry, expected_version, commit): require(all(name in files or native.system_dependency(name) for name in details["imports"]), "Missing non-system native dependency.") owned = files[entry["library"].lower() + ".dll"] - require(set(owned["exports"]) == {entry["prefix"] + suffix for suffix in - ["_get_abi_version", "_get_build_info", "_get_last_error"]}, "Owned ABI exports mismatch.") + native.require_owned_exports(entry, owned["exports"]) sbom = json.loads(archive.read("sbom.json")) require(sbom["sourceCommit"] == commit and sbom["binaryFiles"] == document["files"], "Native SBOM identity mismatch.") for dependency in sbom["buildDependencies"]: diff --git a/eng/packaging/test_packages.py b/eng/packaging/test_packages.py index d2c58056..92d9519b 100644 --- a/eng/packaging/test_packages.py +++ b/eng/packaging/test_packages.py @@ -11,6 +11,7 @@ import xml.etree.ElementTree as ET import zipfile +import native import packages @@ -303,5 +304,44 @@ def tamper(files, _): packages.verify(target, manifest['version'], manifest['sourceCommit']) +class OwnedExportGuards(unittest.TestCase): + image = {"id": "ArcForges.Native.Image.Runtime.win-x64", "kind": "native", "library": "ArcImageNative", "prefix": "arc_image"} + probes = ["arc_image_get_abi_version", "arc_image_get_build_info", "arc_image_get_last_error"] + functional = ["arc_image_open", "arc_image_read", "arc_image_close"] + + def test_image_admits_exactly_the_abi_1_1_surface(self): + native.require_owned_exports(self.image, self.probes + self.functional) + + def test_image_missing_export_fails(self): + for name in self.probes + self.functional: + with self.subTest(missing=name), self.assertRaisesRegex(ValueError, "admitted ABI"): + native.require_owned_exports(self.image, [item for item in self.probes + self.functional if item != name]) + + def test_image_extra_export_fails(self): + for extra in ["arc_image_finish", "arc_image_open_ex", "arc_pdf_open"]: + with self.subTest(extra=extra), self.assertRaisesRegex(ValueError, "admitted ABI"): + native.require_owned_exports(self.image, self.probes + self.functional + [extra]) + + def test_image_probe_only_surface_is_no_longer_admitted(self): + with self.assertRaisesRegex(ValueError, "admitted ABI"): + native.require_owned_exports(self.image, self.probes) + + def test_other_libraries_keep_the_probe_only_surface(self): + other = {"id": "ArcForges.Other.Runtime.win-x64", "kind": "native", "library": "ArcOtherNative", "prefix": "arc_other"} + probes = [name.replace("arc_image", "arc_other") for name in self.probes] + native.require_owned_exports(other, probes) + with self.assertRaisesRegex(ValueError, "admitted ABI"): + native.require_owned_exports(other, probes + ["arc_other_open"]) + + def test_admitted_set_matches_the_windows_def_file(self): + document = json.loads((packages.ROOT / "eng/packaging/packages.json").read_text()) + entry = next(item for item in document["packages"] if item.get("library") == "ArcImageNative") + lines = (packages.ROOT / "native/arcimage-abi/exports/windows.def").read_text().splitlines() + start = next(index for index, line in enumerate(lines) if line.strip() == "EXPORTS") + 1 + defined = {line.strip() for line in lines[start:] if line.strip()} + self.assertEqual(defined, native.admitted_exports(entry)) + self.assertEqual(defined, set(self.probes + self.functional)) + + if __name__ == "__main__": unittest.main() From a68092e2c9def5d2ddb4a69bf607f3421a4bf815 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 08:17:48 +0100 Subject: [PATCH 15/15] [NAT.11] Close the image binding and RP-10 gaps on the hosted architecture gate The hosted managed-packages run on 71a2aec8 failed the Platform validation step on two architecture rules for the ArcImageNative family. - RepositoryPolicyTests.ProductionNativeBindingsHaveOneCapabilityOwner listed ArcImageNative:arc_image_open as an unadmitted production binding. The closed map admitted only the three support probes. It now admits arc_image_open, arc_image_read and arc_image_close, each owned by src/Native/ArcForges.Native.Image with the flags ImageAbi.Functional.cs declares (no UTF-16 marshalling, no last-error capture). The rule itself is unchanged and no exemption is added. - EvaluatedPolicyTests reported RP-10 for the public members ImageByteSource.ReadAt, ImageByteSource.FromStream, ImageReader.OpenAsync, ProbeAsync, ReadRegionAsync, CompleteAsync and DisposeAsync. Each now maps in architecture-contract-tests.json to an existing [Fact] in tests/NativeAbiTests ImageReaderTests that drives that member through the native reader. No stub test is added. ADP-07 supporting-file binding: tests/ArchitectureTests/RepositoryPolicyTests.cs and eng/policy/architecture-contract-tests.json are outside NAT.11's recorded writes. They are bound here because the closed binding map and the public-API to contract-test correspondence are the policy data the managed-packages gate reads for the ABI 1.1 functional exports admitted in 71a2aec8. Local validation on this tree (Windows, pinned SDK 10.0.400, build slot): ArchitectureTests 134/135; the single failure is EvaluatedPolicyTests with only RP-01, RP-08, RP-09 and AT-09, which need the hosted naming, secret and native configure receipts. RP-10 and the RepositoryPolicyTests failure are gone. Co-Authored-By: Claude Haiku 5.5 --- eng/policy/architecture-contract-tests.json | 35 +++++++++++++++++++ .../RepositoryPolicyTests.cs | 3 ++ 2 files changed, 38 insertions(+) diff --git a/eng/policy/architecture-contract-tests.json b/eng/policy/architecture-contract-tests.json index 82904a60..22fcfae1 100644 --- a/eng/policy/architecture-contract-tests.json +++ b/eng/policy/architecture-contract-tests.json @@ -689,6 +689,41 @@ "testProject": "tests/ArchitectureTests/ArcForges.Tests.ArchitectureTests.csproj", "testMethod": "ArcForges.Tests.ArchitectureTests.PublicBoundaryContractTests.ImagePublicContractHasOnlyTypedManagedResults()" }, + { + "apiSymbol": "ArcForges.Native.Image.ImageByteSource.ReadAt(long, System.Span)", + "testProject": "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj", + "testMethod": "ArcForges.Tests.NativeAbiTests.ImageReaderTests.CancellationDuringNativeReadKeepsCoverageConsistentWithOutcome()" + }, + { + "apiSymbol": "ArcForges.Native.Image.ImageByteSource.FromStream(System.IO.Stream)", + "testProject": "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj", + "testMethod": "ArcForges.Tests.NativeAbiTests.ImageReaderTests.PngRgba8StraightAlphaRoundTripsByteExactAndCompletes()" + }, + { + "apiSymbol": "ArcForges.Native.Image.ImageReader.OpenAsync(ArcForges.Native.Image.ImageByteSource, ArcForges.Native.Image.ImageOpenOptions?, System.Threading.CancellationToken)", + "testProject": "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj", + "testMethod": "ArcForges.Tests.NativeAbiTests.ImageReaderTests.PngRgba8StraightAlphaRoundTripsByteExactAndCompletes()" + }, + { + "apiSymbol": "ArcForges.Native.Image.ImageReader.ProbeAsync(ArcForges.Native.Image.ImageByteSource, ArcForges.Native.Image.ImageOpenOptions?, System.Threading.CancellationToken)", + "testProject": "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj", + "testMethod": "ArcForges.Tests.NativeAbiTests.ImageReaderTests.TiffRgba8UnassociatedAlphaRoundTripsByteExact()" + }, + { + "apiSymbol": "ArcForges.Native.Image.ImageReader.ReadRegionAsync(ArcForges.Native.Image.ImageRegion, System.Threading.CancellationToken)", + "testProject": "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj", + "testMethod": "ArcForges.Tests.NativeAbiTests.ImageReaderTests.PngRgba8StraightAlphaRoundTripsByteExactAndCompletes()" + }, + { + "apiSymbol": "ArcForges.Native.Image.ImageReader.CompleteAsync(System.Threading.CancellationToken)", + "testProject": "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj", + "testMethod": "ArcForges.Tests.NativeAbiTests.ImageReaderTests.RegionsCoverEveryPixelOnlyWhenEveryBandIsRead()" + }, + { + "apiSymbol": "ArcForges.Native.Image.ImageReader.DisposeAsync()", + "testProject": "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj", + "testMethod": "ArcForges.Tests.NativeAbiTests.ImageReaderTests.DisposeAsyncClosesTheHandleAndRefusesFurtherUse()" + }, { "apiSymbol": "ArcForges.Security.ActorOperation.Forward(TNext)", "testProject": "src/BuildingBlocks/ArcForges.Security/Tests/ArcForges.Security.Tests.csproj", diff --git a/tests/ArchitectureTests/RepositoryPolicyTests.cs b/tests/ArchitectureTests/RepositoryPolicyTests.cs index ac60c99f..c4fef600 100644 --- a/tests/ArchitectureTests/RepositoryPolicyTests.cs +++ b/tests/ArchitectureTests/RepositoryPolicyTests.cs @@ -66,6 +66,9 @@ public void ProductionNativeBindingsHaveOneCapabilityOwner() ["ArcImageNative:arc_image_get_abi_version"] = (image, false, false), ["ArcImageNative:arc_image_get_build_info"] = (image, false, false), ["ArcImageNative:arc_image_get_last_error"] = (image, false, false), + ["ArcImageNative:arc_image_open"] = (image, false, false), + ["ArcImageNative:arc_image_read"] = (image, false, false), + ["ArcImageNative:arc_image_close"] = (image, false, false), ["Advapi32.dll:EqualSid"] = (broker, false, false), ["Advapi32.dll:FreeSid"] = (broker, false, false), ["Advapi32.dll:GetTokenInformation"] = (broker, false, true),