From e93bf3abe91b603b7189a6cfda4c94a22b88e820 Mon Sep 17 00:00:00 2001 From: Gopalakrishna Sharma Date: Tue, 27 Oct 2026 20:42:37 +0530 Subject: [PATCH 1/2] feat!: migrate bindings to current Rust C APIs Remove approved deprecated embeddable and global-settings APIs. Preserve default constructors through Context initialization and adapt stream wrappers to opaque native handles. Migrate callers, ownership cleanup, tests, and compatibility documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/context-settings.md | 4 +- docs/embeddable-api.md | 13 +- docs/intents.md | 3 +- docs/release-notes.md | 20 +- include/c2pa.hpp | 52 +-- src/c2pa_builder.cpp | 43 +-- src/c2pa_core.cpp | 14 - src/c2pa_reader.cpp | 29 +- tests/builder.test.cpp | 235 ++++---------- tests/c-app-test/file_stream.h | 31 +- tests/c-app-test/test.c | 46 +-- tests/embeddable.test.cpp | 561 +++++++-------------------------- tests/include/test_utils.hpp | 17 + 13 files changed, 265 insertions(+), 803 deletions(-) diff --git a/docs/context-settings.md b/docs/context-settings.md index 5f12515..94518c2 100644 --- a/docs/context-settings.md +++ b/docs/context-settings.md @@ -883,7 +883,7 @@ c2pa::Context context(R"({ ## Migrating from deprecated APIs -The SDK introduced Context-based APIs to replace constructors and functions that relied on thread-local state. The older APIs still compile but produce deprecation warnings. This section covers each deprecation that involves passing a `Context`, and explains the `Builder::sign` overloads. +The SDK introduced Context-based APIs to replace constructors and functions that relied on thread-local state. `load_settings` has been removed; the older Reader and Builder constructors remain deprecated and now use default Contexts. This section covers migration to an explicit `Context`, and explains the `Builder::sign` overloads. ### Quick reference @@ -898,7 +898,7 @@ The SDK introduced Context-based APIs to replace constructors and functions that ### Replacing load_settings -`c2pa::load_settings(data, format)` sets thread-local settings that `Reader` and `Builder` pick up implicitly. Replace it with a `Context` that you pass explicitly. +The removed `c2pa::load_settings(data, format)` function set thread-local settings that `Reader` and `Builder` picked up implicitly. Replace it with a `Context` that you pass explicitly. | Aspect | load_settings (legacy) | Context | |--------|------------------------|---------| diff --git a/docs/embeddable-api.md b/docs/embeddable-api.md index 572af2f..4b60d4c 100644 --- a/docs/embeddable-api.md +++ b/docs/embeddable-api.md @@ -189,11 +189,12 @@ These methods perform the signing workflow: placeholder creation, hashing, and s | `Builder::update_hash_from_stream(format, stream)` | Reads the asset stream and computes the hard-binding hash. Automatically selects the appropriate path based on format: `BmffHash` for BMFF (skips manifest box), `BoxHash` for chunk-based formats (creates assertion if needed), or `DataHash` (skips exclusion ranges). Takes a `std::istream&`. | | `Builder::sign_embeddable(format)` | Signs the manifest and returns bytes ready to embed. For placeholder workflows, it pads the output to match the placeholder size for in-place patching. For BoxHash workflows, the output is the actual signed manifest size (not padded), suitable for appending as a new chunk. | -### Deprecated methods +### Removed methods -The c2pa-rs 0.92.0 removes C FFI functions behind some `Builder` methods. These methods will be removed when c2pa-c moves to c2pa-rs 0.92.0. +The following deprecated methods have been removed to support c2pa-rs 0.92.0, +which removes their underlying C FFI functions. -| Deprecated method | Replacement | +| Removed method | Replacement | |-------------------|-------------| | `data_hashed_placeholder(reserved_size, format)` | `needs_placeholder(format)`, then `placeholder(format)`. The reserve size comes from the signer on the Context. | | `sign_data_hashed_embeddable(signer, data_hash, format, asset)` | `set_data_hash_exclusions(exclusions)`, `update_hash_from_stream(format, stream)`, then `sign_embeddable(format)`. The signer comes from the Context. | @@ -201,6 +202,12 @@ The c2pa-rs 0.92.0 removes C FFI functions behind some `Builder` methods. These For the full replacement flow, see [Using the DataHash placeholder](#using-the-datahash-placeholder). +Attach the signer to the `Context` before constructing the `Builder`; the +placeholder's reserve size and signing operation both use that signer. +For a precomputed hash, include a `c2pa.hash.data` assertion in the manifest +definition, then call `sign_embeddable(format)` directly without creating a +placeholder or hashing an asset stream. + ## Using the DataHash placeholder Use this workflow for JPEG, PNG, and other non-BMFF formats. diff --git a/docs/intents.md b/docs/intents.md index 4e9eae8..856a1d3 100644 --- a/docs/intents.md +++ b/docs/intents.md @@ -52,7 +52,7 @@ You can set the intent on a `Builder` instance by: - [Using Context](#using-context) - [Using `set_intent` on the `Builder`](#using-set_intent-on-builder) -Don't set intent using the deprecated `load_settings()` function. For existing code, see [Context and Settings - Migrating from deprecated APIs](context-settings.md#migrating-from-deprecated-apis). +The deprecated `load_settings()` function has been removed. For existing code, see [Context and Settings - Migrating from deprecated APIs](context-settings.md#migrating-from-deprecated-apis). ### Using Context @@ -485,4 +485,3 @@ c2pa::Builder builder(context, R"({})"); builder.set_intent(Update); builder.sign("signed_asset.jpg", "updated_asset.jpg", signer); ``` - diff --git a/docs/release-notes.md b/docs/release-notes.md index fbb7b42..ef5ff71 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,24 @@ # Release notes +## Unreleased + +### Breaking changes + +- Removed deprecated `Builder::data_hashed_placeholder`, + `Builder::sign_data_hashed_embeddable`, and `Builder::format_embeddable` + for compatibility with c2pa-rs 0.92.0. Use the context-signer workflow in + [Embeddable manifests](embeddable-api.md#removed-methods). +- Removed deprecated `c2pa::load_settings`. Configure each `Context` using + `Settings` or `Context::ContextBuilder` instead; settings are no longer + thread-global. +- Stream wrappers now hold opaque `C2paStream` handles rather than inheriting + from the C struct. Use their existing `c_stream` member when calling the C + API, and rebuild C++ consumers against the updated headers. + +Non-deprecated method names and signatures are unchanged. Development uses a +local Rust source build; the default prebuilt-library version has not yet +been updated. + ## 3 Mar 2026 This release includes changes relating to trust validation and handling (made in the Rust library [v0.68.0](https://github.com/contentauth/c2pa-rs/releases/tag/c2pa-v0.68.0), see also [trust lists](https://opensource.contentauthenticity.org/docs/conformance/trust-lists) for more details on trust lists). @@ -10,4 +29,3 @@ Additionally, this release adds support for: - Using `Builder` and `Reader` together to [selectively construct a manifest by filtering actions and ingredients](selective-manifests.md). For answers to frequently asked questions about these features, see the [FAQs](faqs.md). - diff --git a/include/c2pa.hpp b/include/c2pa.hpp index 22855b2..e0a6b89 100644 --- a/include/c2pa.hpp +++ b/include/c2pa.hpp @@ -533,16 +533,6 @@ namespace c2pa /// @return Version string. std::string C2PA_CPP_API version(); - /// @brief Load C2PA settings from a string in a given format. - /// @param data The configuration data to load. - /// @param format The mimetype of the string. - /// @throws C2paException for errors encountered by the C2PA library. - /// @deprecated Use Context constructors or Context::ContextBuilder instead for better thread safety. - /// Will be removed when c2pa-c moves to c2pa-rs 0.92.0. - [[deprecated("Use Context(json), Context(settings), or Context::ContextBuilder instead. " - "Will be removed when c2pa-c moves to c2pa-rs 0.92.0.")]] - void C2PA_CPP_API load_settings(const std::string& data, const std::string& format); - /// @defgroup StreamWrappers Stream wrappers for C2PA C API /// @brief C++ stream types that adapt stream types to C2paStream. /// @@ -555,7 +545,7 @@ namespace c2pa /// @brief Input stream IStream wrapper for C2paStream. /// @details This class is used to wrap an input stream for use with the C2PA library. - class C2PA_CPP_API CppIStream : public C2paStream + class C2PA_CPP_API CppIStream { public: /// @brief Pointer to the underlying C2paStream. @@ -618,7 +608,7 @@ namespace c2pa /// @brief Output stream OStream wrapper for C2paStream. /// @details This class is used to wrap an output stream for use with the C2PA library. - class C2PA_CPP_API CppOStream : public C2paStream + class C2PA_CPP_API CppOStream { public: /// @brief Pointer to the underlying C2paStream. @@ -678,7 +668,7 @@ namespace c2pa /// @brief IOStream Class wrapper for C2paStream. /// @details This class is used to wrap an input/output stream for use with the C2PA library. - class C2PA_CPP_API CppIOStream : public C2paStream + class C2PA_CPP_API CppIOStream { public: /// @brief Pointer to the underlying C2paStream. @@ -1376,42 +1366,6 @@ namespace c2pa /// @throws C2paException for errors encountered by the C2PA library. void add_ingredient_from_archive(std::istream &archive); - /// @brief Create a hashed placeholder from the builder. - /// @param reserved_size The size required for a signature from the intended signer (in bytes). - /// @param format The mime format or extension of the asset. - /// @return A vector containing the hashed placeholder bytes. - /// @throws C2paException for errors encountered by the C2PA library. - /// @deprecated Use needs_placeholder() and placeholder(format), with the signer configured - /// on the Builder's Context. Will be removed when c2pa-c moves to c2pa-rs 0.92.0. - [[deprecated("Use needs_placeholder() and placeholder(format), with the signer configured " - "on the Builder's Context. Will be removed when c2pa-c moves to c2pa-rs 0.92.0.")]] - std::vector data_hashed_placeholder(uintptr_t reserved_size, const std::string &format); - - /// @brief Sign a Builder using the specified signer and data hash. - /// @param signer The signer to use for signing. - /// @param data_hash The data hash ranges to sign (must contain hashes unless an asset is provided). - /// @param format The mime format for embedding. Use "c2pa" for an unformatted result. - /// @param asset Optional asset to hash according to the data_hash information. - /// @return A vector containing the signed embeddable data. - /// @throws C2paException for errors encountered by the C2PA library. - /// @deprecated Use set_data_hash_exclusions(), update_hash_from_stream(), then - /// sign_embeddable(format); the signer comes from the Builder's Context. - /// Will be removed when c2pa-c moves to c2pa-rs 0.92.0. - [[deprecated("Use set_data_hash_exclusions(), update_hash_from_stream(), then sign_embeddable(format); " - "the signer comes from the Builder's Context. " - "Will be removed when c2pa-c moves to c2pa-rs 0.92.0.")]] - std::vector sign_data_hashed_embeddable(Signer &signer, const std::string &data_hash, const std::string &format, std::istream *asset = nullptr); - - /// @brief Convert unformatted manifest data to an embeddable format. - /// @param format The format for embedding. - /// @param data Unformatted manifest data from sign_data_hashed_embeddable using "c2pa" format. - /// @return A formatted copy of the data. - /// @deprecated Use sign_embeddable(format), which returns manifest bytes already formatted - /// for embedding. Will be removed when c2pa-c moves to c2pa-rs 0.92.0. - [[deprecated("Use sign_embeddable(format), which returns manifest bytes already formatted " - "for embedding. Will be removed when c2pa-c moves to c2pa-rs 0.92.0.")]] - static std::vector format_embeddable(const std::string &format, std::vector &data); - /// @brief Check if the given format requires a placeholder embedding step. /// @details Returns false for BoxHash-capable formats when prefer_box_hash is enabled in /// the context settings (no placeholder needed — hash covers the full asset). diff --git a/src/c2pa_builder.cpp b/src/c2pa_builder.cpp index 2b8eec7..8320045 100644 --- a/src/c2pa_builder.cpp +++ b/src/c2pa_builder.cpp @@ -90,11 +90,8 @@ namespace c2pa Builder::Builder(const std::string &manifest_json) : builder(nullptr) { - builder = c2pa_builder_from_json(manifest_json.c_str()); - if (builder == nullptr) - { - throw C2paException(); - } + Context context; + init_from_context(context, manifest_json); } /// @brief Create a Builder from an archive. @@ -394,33 +391,6 @@ namespace c2pa } } - std::vector Builder::data_hashed_placeholder(uintptr_t reserve_size, const std::string &format) - { - const std::string normalized_format = detail::normalize_format(format); - - const unsigned char *c2pa_manifest_bytes = nullptr; - auto result = c2pa_builder_data_hashed_placeholder(builder, reserve_size, normalized_format.c_str(), &c2pa_manifest_bytes); - return detail::to_byte_vector(c2pa_manifest_bytes, result); - } - - std::vector Builder::sign_data_hashed_embeddable(Signer &signer, const std::string &data_hash, const std::string &format, std::istream *asset) - { - const std::string normalized_format = detail::normalize_format(format); - - int64_t result; - const unsigned char *c2pa_manifest_bytes = nullptr; - if (asset) - { - CppIStream c_asset(*asset); - result = c2pa_builder_sign_data_hashed_embeddable(builder, signer.c2pa_signer(), data_hash.c_str(), normalized_format.c_str(), c_asset.c_stream, &c2pa_manifest_bytes); - } - else - { - result = c2pa_builder_sign_data_hashed_embeddable(builder, signer.c2pa_signer(), data_hash.c_str(), normalized_format.c_str(), nullptr, &c2pa_manifest_bytes); - } - return detail::to_byte_vector(c2pa_manifest_bytes, result); - } - bool Builder::needs_placeholder(const std::string &format) { const std::string resolved_format = detail::resolve_format(format); @@ -482,15 +452,6 @@ namespace c2pa return detail::to_byte_vector(c2pa_manifest_bytes, result); } - std::vector Builder::format_embeddable(const std::string &format, std::vector &data) - { - const std::string normalized_format = detail::normalize_format(format); - - const unsigned char *c2pa_manifest_bytes = nullptr; - auto result = c2pa_format_embeddable(normalized_format.c_str(), data.data(), data.size(), &c2pa_manifest_bytes); - return detail::to_byte_vector(c2pa_manifest_bytes, result); - } - std::vector Builder::supported_mime_types() { uintptr_t count = 0; auto ptr = c2pa_builder_supported_mime_types(&count); diff --git a/src/c2pa_core.cpp b/src/c2pa_core.cpp index a3dadca..ab67b3a 100644 --- a/src/c2pa_core.cpp +++ b/src/c2pa_core.cpp @@ -49,18 +49,4 @@ namespace c2pa return detail::c_string_to_string(c2pa_version()); } - /// Loads C2PA settings from a std::string in a given format. - /// @param data the std::string to load. - /// @param format the mime format of the string. - /// @throws a C2pa::C2paException for errors encountered by the C2PA library. - [[deprecated("Use Context(json), Context(settings), or Context::ContextBuilder instead. Will be removed when c2pa-c moves to c2pa-rs 0.92.0.")]] - void load_settings(const std::string &data, const std::string &format) - { - auto result = c2pa_load_settings(data.c_str(), format.c_str()); - if (result != 0) - { - throw c2pa::C2paException(); - } - } - } // namespace c2pa diff --git a/src/c2pa_reader.cpp b/src/c2pa_reader.cpp index 3faff77..e10b73c 100644 --- a/src/c2pa_reader.cpp +++ b/src/c2pa_reader.cpp @@ -220,34 +220,17 @@ namespace c2pa } Reader::Reader(const std::string &format, std::istream &stream) + : c2pa_reader(nullptr) { - const std::string normalized_format = detail::normalize_format(format); - - cpp_stream = std::make_unique(stream); - c2pa_reader = c2pa_reader_from_stream(normalized_format.c_str(), cpp_stream->c_stream); - if (c2pa_reader == nullptr) - { - throw C2paException(); - } + Context context; + init_from_context(context, format, stream); } Reader::Reader(const std::filesystem::path &source_path) + : c2pa_reader(nullptr) { - // Create owned stream that will live as long as the Reader - owned_stream = std::make_unique(source_path, std::ios::binary); - if (!owned_stream->is_open()) { - throw std::system_error(errno, std::system_category(), "Failed to open file: " + source_path.string()); - } - - std::string extension = detail::normalize_format(detail::extract_file_extension(source_path)); - - // CppIStream stores reference to owned_stream, which lives as long as Reader - cpp_stream = std::make_unique(*owned_stream); - c2pa_reader = c2pa_reader_from_stream(extension.c_str(), cpp_stream->c_stream); - if (c2pa_reader == nullptr) - { - throw C2paException(); - } + Context context; + init_from_context(context, source_path); } Reader::~Reader() diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index e5bd4c3..6339520 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -197,7 +197,10 @@ TEST_F(BuilderTest, wraps_c_ffi_builder) { fs::path manifest_path = current_dir / "../tests/fixtures/training.json"; auto manifest = c2pa_test::read_text_file(manifest_path); - C2paBuilder* ffi_builder = c2pa_builder_from_json(manifest.c_str()); + c2pa::Context context; + C2paBuilder* ffi_builder = c2pa_builder_from_context(context.c_context()); + ASSERT_NE(ffi_builder, nullptr); + ffi_builder = c2pa_builder_with_definition(ffi_builder, manifest.c_str()); ASSERT_NE(ffi_builder, nullptr); auto builder = c2pa::Builder(ffi_builder); @@ -880,9 +883,9 @@ TEST_F(BuilderTest, SignImageFileOnly) ASSERT_TRUE(std::filesystem::exists(output_path)); }; -TEST_F(BuilderTest, SignImageFileNoThumbnailAutoGenThreadLocalSettings) +TEST_F(BuilderTest, SignImageFileNoThumbnailAutoGenContextInThread) { - // Run in separate thread for complete test isolation (thread-local settings won't leak) + // Exercise context-scoped settings on another thread. fs::path temp_output = get_temp_path("training_image_only_thread_local.jpg"); std::thread test_thread([temp_output]() { fs::path current_dir = fs::path(__FILE__).parent_path(); @@ -898,13 +901,12 @@ TEST_F(BuilderTest, SignImageFileNoThumbnailAutoGenThreadLocalSettings) auto certs = c2pa_test::read_text_file(certs_path); auto p_key = c2pa_test::read_text_file(current_dir / "../tests/fixtures/es256_private.key"); - // set settings to not generate thumbnails (thread-local) - c2pa::load_settings("{\"builder\": { \"thumbnail\": {\"enabled\": false}}}", "json"); + auto context = c2pa::Context("{\"builder\": { \"thumbnail\": {\"enabled\": false}}}"); // create a signer c2pa::Signer signer = c2pa::Signer("Es256", certs, p_key, "http://timestamp.digicert.com"); - auto builder = c2pa::Builder(manifest); + auto builder = c2pa::Builder(context, manifest); // the Builder returns manifest bytes std::vector manifest_data; ASSERT_NO_THROW(manifest_data = builder.sign(signed_image_path, output_path, signer)); @@ -914,7 +916,6 @@ TEST_F(BuilderTest, SignImageFileNoThumbnailAutoGenThreadLocalSettings) auto reader = c2pa::Reader(output_path); ASSERT_NO_THROW(reader.json()); - // No need to reset settings - thread-local settings destroyed with thread }); // Wait for thread to complete @@ -1751,172 +1752,56 @@ TEST_F(BuilderTest, SignStreamCloudUrl) TEST_F(BuilderTest, SignDataHashedEmbedded) { - fs::path current_dir = fs::path(__FILE__).parent_path(); - - // Construct the paths relative to the current directory - fs::path manifest_path = current_dir / "../tests/fixtures/training.json"; - fs::path certs_path = current_dir / "../tests/fixtures/es256_certs.pem"; - // fs::path signed_image_path = current_dir / "../tests/fixtures/A.jpg"; - - auto manifest = c2pa_test::read_text_file(manifest_path); - auto certs = c2pa_test::read_text_file(certs_path); - auto p_key = c2pa_test::read_text_file(current_dir / "../tests/fixtures/es256_private.key"); - - // create a signer - c2pa::Signer signer = c2pa::Signer("Es256", certs, p_key, "http://timestamp.digicert.com"); - - auto builder = c2pa::Builder(manifest); - - auto placeholder = builder.data_hashed_placeholder(signer.reserve_size(), "image/jpeg"); - - std::string data_hash = R"({ - "exclusions": [ - { - "start": 20, - "length": 45884 - } - ], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "gWZNEOMHQNiULfA/tO5HD2awOwYMA3tnfUPApIr9csk=", - "pad": " " - })"; + auto settings = c2pa_test::create_test_settings_with_signer(); + auto context = c2pa::Context(settings); + auto builder = c2pa::Builder(context, c2pa_test::prehashed_training_manifest()); std::vector manifest_data; - ASSERT_NO_THROW(manifest_data = builder.sign_data_hashed_embeddable(signer, data_hash, "image/jpeg")); + ASSERT_NO_THROW(manifest_data = builder.sign_embeddable("image/jpeg")); + EXPECT_FALSE(manifest_data.empty()); } TEST_F(BuilderTest, SignDataHashedEmbeddedUsingContext) { - fs::path current_dir = fs::path(__FILE__).parent_path(); - - // Construct the paths relative to the current directory - fs::path manifest_path = current_dir / "../tests/fixtures/training.json"; - fs::path certs_path = current_dir / "../tests/fixtures/es256_certs.pem"; - // fs::path signed_image_path = current_dir / "../tests/fixtures/A.jpg"; - - auto manifest = c2pa_test::read_text_file(manifest_path); - auto certs = c2pa_test::read_text_file(certs_path); - auto p_key = c2pa_test::read_text_file(current_dir / "../tests/fixtures/es256_private.key"); - - // create a signer - c2pa::Signer signer = c2pa::Signer("Es256", certs, p_key, "http://timestamp.digicert.com"); - - // Create a Context and pass it to the Builder - auto context = c2pa::Context(); - auto builder = c2pa::Builder(context, manifest); - - auto placeholder = builder.data_hashed_placeholder(signer.reserve_size(), "image/jpeg"); - - std::string data_hash = R"({ - "exclusions": [ - { - "start": 20, - "length": 45884 - } - ], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "gWZNEOMHQNiULfA/tO5HD2awOwYMA3tnfUPApIr9csk=", - "pad": " " - })"; + auto context = c2pa::Context::ContextBuilder() + .with_signer(c2pa_test::create_test_signer()) + .create_context(); + auto builder = c2pa::Builder(context, c2pa_test::prehashed_training_manifest()); std::vector manifest_data; - ASSERT_NO_THROW(manifest_data = builder.sign_data_hashed_embeddable(signer, data_hash, "image/jpeg")); + ASSERT_NO_THROW(manifest_data = builder.sign_embeddable("image/jpeg")); + EXPECT_FALSE(manifest_data.empty()); } TEST_F(BuilderTest, SignDataHashedEmbeddedWithAsset) { - fs::path current_dir = fs::path(__FILE__).parent_path(); - - // Construct the paths relative to the current directory - fs::path manifest_path = current_dir / "../tests/fixtures/training.json"; - fs::path certs_path = current_dir / "../tests/fixtures/es256_certs.pem"; - fs::path image_path = current_dir / "../tests/fixtures/A.jpg"; - - auto manifest = c2pa_test::read_text_file(manifest_path); - auto certs = c2pa_test::read_text_file(certs_path); - auto p_key = c2pa_test::read_text_file(current_dir / "../tests/fixtures/es256_private.key"); - - // create a signer - c2pa::Signer signer = c2pa::Signer("Es256", certs, p_key, "http://timestamp.digicert.com"); - - auto builder = c2pa::Builder(manifest); - - auto placeholder = builder.data_hashed_placeholder(signer.reserve_size(), "image/jpeg"); - - std::string data_hash = R"({ - "exclusions": [ - { - "start": 20, - "length": 45884 - } - ], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "", - "pad": " " - })"; - - std::ifstream asset(image_path, std::ios::binary); - if (!asset) - { - FAIL() << "Failed to open file: " << image_path << std::endl; - } - + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto settings = c2pa_test::create_test_settings_with_signer(); + auto context = c2pa::Context(settings); + auto builder = c2pa::Builder(context, manifest); + auto placeholder = builder.placeholder("image/jpeg"); + builder.set_data_hash_exclusions({{20, placeholder.size()}}); + std::ifstream asset(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(asset.is_open()); + builder.update_hash_from_stream("image/jpeg", asset); std::vector manifest_data; - ASSERT_NO_THROW(manifest_data = builder.sign_data_hashed_embeddable(signer, data_hash, "application/c2pa", &asset)); - - auto embeddable_data = c2pa::Builder::format_embeddable("image/jpeg", manifest_data); - - ASSERT_TRUE(embeddable_data.size() > manifest_data.size()); + ASSERT_NO_THROW(manifest_data = builder.sign_embeddable("image/jpeg")); + EXPECT_EQ(manifest_data.size(), placeholder.size()); } TEST_F(BuilderTest, SignDataHashedEmbeddedWithAssetUsingContext) { - fs::path current_dir = fs::path(__FILE__).parent_path(); - - // Construct the paths relative to the current directory - fs::path manifest_path = current_dir / "../tests/fixtures/training.json"; - fs::path certs_path = current_dir / "../tests/fixtures/es256_certs.pem"; - fs::path image_path = current_dir / "../tests/fixtures/A.jpg"; - - auto manifest = c2pa_test::read_text_file(manifest_path); - auto certs = c2pa_test::read_text_file(certs_path); - auto p_key = c2pa_test::read_text_file(current_dir / "../tests/fixtures/es256_private.key"); - - // create a signer - c2pa::Signer signer = c2pa::Signer("Es256", certs, p_key, "http://timestamp.digicert.com"); - - // Create a Context and pass it to the Builder - auto context = c2pa::Context(); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto context = c2pa::Context::ContextBuilder() + .with_signer(c2pa_test::create_test_signer()) + .create_context(); auto builder = c2pa::Builder(context, manifest); - - auto placeholder = builder.data_hashed_placeholder(signer.reserve_size(), "image/jpeg"); - - std::string data_hash = R"({ - "exclusions": [ - { - "start": 20, - "length": 45884 - } - ], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "", - "pad": " " - })"; - - std::ifstream asset(image_path, std::ios::binary); - if (!asset) - { - FAIL() << "Failed to open file: " << image_path << std::endl; - } - + auto placeholder = builder.placeholder("image/jpeg"); + builder.set_data_hash_exclusions({{20, placeholder.size()}}); + std::ifstream asset(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(asset.is_open()); + builder.update_hash_from_stream("image/jpeg", asset); std::vector manifest_data; - ASSERT_NO_THROW(manifest_data = builder.sign_data_hashed_embeddable(signer, data_hash, "application/c2pa", &asset)); - - auto embeddable_data = c2pa::Builder::format_embeddable("image/jpeg", manifest_data); - - ASSERT_TRUE(embeddable_data.size() > manifest_data.size()); + ASSERT_NO_THROW(manifest_data = builder.sign_embeddable("image/jpeg")); + EXPECT_EQ(manifest_data.size(), placeholder.size()); } TEST_F(BuilderTest, SignWithInvalidStream) @@ -2302,9 +2187,9 @@ TEST_F(BuilderTest, AddIngredientToBuilderUsingBasePath) ASSERT_NO_THROW(reader.json()); } -TEST_F(BuilderTest, AddIngredientToBuilderUsingBasePathPlacedActionThreadLocalSettings) +TEST_F(BuilderTest, AddIngredientToBuilderUsingBasePathPlacedActionContextInThread) { - // Run in separate thread for complete test isolation (thread-local settings won't leak) + // Exercise context-scoped settings on another thread. std::thread test_thread([this]() { fs::path current_dir = fs::path(__FILE__).parent_path(); @@ -2325,8 +2210,7 @@ TEST_F(BuilderTest, AddIngredientToBuilderUsingBasePathPlacedActionThreadLocalSe } fs::create_directories(temp_dir); - // set settings to not auto-add a placed action (thread-local) - c2pa::load_settings("{\"builder\": { \"actions\": {\"auto_placed_action\": {\"enabled\": false}}}}", "json"); + auto context = c2pa::Context("{\"builder\": { \"actions\": {\"auto_placed_action\": {\"enabled\": false}}}}"); // The instance_id we assign to the ingredient and reference from the placed action. std::string instance_id = "test:iid:939a4c48-0dff-44ec-8f95-61f52b11618f"; @@ -2371,7 +2255,7 @@ TEST_F(BuilderTest, AddIngredientToBuilderUsingBasePathPlacedActionThreadLocalSe }; // Now we can create a Builder with the manifest - auto builder = c2pa::Builder(manifest_json.dump()); + auto builder = c2pa::Builder(context, manifest_json.dump()); // Add the ingredient with the matching instance_id so the placed action links to it. // The Builder reads the source file and embeds the ingredient resources directly. @@ -2402,7 +2286,6 @@ TEST_F(BuilderTest, AddIngredientToBuilderUsingBasePathPlacedActionThreadLocalSe } } - // No need to reset settings - thread-local settings destroyed with thread }); // Wait for thread to complete @@ -3530,7 +3413,8 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchiveLegacy) { const std::string& base_manifest_json) -> c2pa::Builder { // Create a Reader from the archive - c2pa::Reader reader("application/c2pa", archive_stream); + c2pa::Context context(R"({"verify": {"verify_after_reading": false}})"); + c2pa::Reader reader(context, "application/c2pa", archive_stream); auto json_result = reader.json(); // Parse the archive JSON to extract ingredient information @@ -3595,9 +3479,6 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchiveLegacy) { builder.to_archive(archive_stream); }); - // Skip verification since archives have placeholder signatures - c2pa::load_settings(R"({"verify": {"verify_after_reading": false}})", "json"); - // Use the helper function to create a builder with ingredients from the archive archive_stream.seekg(0); auto merged_builder = create_builder_with_ingredients_from_archive(archive_stream, manifest); @@ -3623,8 +3504,6 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchiveLegacy) { auto merged_ingredients = merged_parsed["manifests"][merged_active]["ingredients"]; EXPECT_EQ(merged_ingredients.size(), 3) << "Merged builder should have all 3 ingredients"; - // Reset settings - c2pa::load_settings(R"({"verify": {"verify_after_reading": true}})", "json"); } TEST_F(BuilderTest, ExtractIngredientsFromArchiveToBuilderLegacy) { @@ -3641,13 +3520,14 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchiveToBuilderLegacy) { builder.to_archive(builder_archive); builder_archive.seekg(0); - c2pa::Reader builder_reader("application/c2pa", builder_archive); + c2pa::Context context(R"({"verify": {"verify_after_reading": false}})"); + c2pa::Reader builder_reader(context, "application/c2pa", builder_archive); auto builder_parsed = json::parse(builder_reader.json()); std::string builder_active = builder_parsed["active_manifest"]; json current_ingredients = builder_parsed["manifests"][builder_active].value("ingredients", json::array()); // Read the incoming archive to get its ingredients - c2pa::Reader archive_reader("application/c2pa", archive_stream); + c2pa::Reader archive_reader(context, "application/c2pa", archive_stream); auto archive_parsed = json::parse(archive_reader.json()); std::string archive_active = archive_parsed["active_manifest"]; auto new_ingredients = archive_parsed["manifests"][archive_active]["ingredients"]; @@ -3773,9 +3653,6 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchiveToBuilderLegacy) { builder2.to_archive(archive2_stream); }); - // Skip verification since archives have placeholder signatures - c2pa::load_settings(R"({"verify": {"verify_after_reading": false}})", "json"); - auto merged_builder = c2pa::Builder(manifest); // First call: add ingredients from archive 1 (A.jpg, C.jpg) @@ -3807,8 +3684,6 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchiveToBuilderLegacy) { auto merged_ingredients = merged_parsed["manifests"][merged_active]["ingredients"]; EXPECT_EQ(merged_ingredients.size(), 3) << "Merged builder should have all 3 ingredients from both archives"; - // Reset settings - c2pa::load_settings(R"({"verify": {"verify_after_reading": true}})", "json"); } TEST_F(BuilderTest, ExtractIngredientsFromArchivesLegacy) { @@ -3816,6 +3691,7 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchivesLegacy) { std::vector>& archive_streams, const std::string& base_manifest_json) -> c2pa::Builder { + c2pa::Context context(R"({"verify": {"verify_after_reading": false}})"); // Collect ingredients and their associated readers from all archives. // Each entry pairs an ingredient JSON (possibly with remapped identifiers) // with a pointer to the Reader it came from, so we can copy resources later. @@ -3857,7 +3733,7 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchivesLegacy) { // Process each archive: extract ingredients, remap conflicting IDs for (auto& stream_ref : archive_streams) { - auto reader = std::make_unique("application/c2pa", stream_ref.get()); + auto reader = std::make_unique(context, "application/c2pa", stream_ref.get()); auto parsed = json::parse(reader->json()); std::string active = parsed["active_manifest"]; auto ingredients = parsed["manifests"][active]["ingredients"]; @@ -3944,9 +3820,6 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchivesLegacy) { builder2.to_archive(archive2_stream); }); - // Skip verification since archives have placeholder signatures - c2pa::load_settings(R"({"verify": {"verify_after_reading": false}})", "json"); - // Use the helper to create a builder from both archives at once archive1_stream.seekg(0); archive2_stream.seekg(0); @@ -3974,8 +3847,6 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchivesLegacy) { auto merged_ingredients = merged_parsed["manifests"][merged_active]["ingredients"]; EXPECT_EQ(merged_ingredients.size(), 3) << "Merged builder should have all 3 ingredients from both archives"; - // Reset settings - c2pa::load_settings(R"({"verify": {"verify_after_reading": true}})", "json"); } TEST_F(BuilderTest, AddMultipleActions) @@ -5932,7 +5803,7 @@ TEST_F(BuilderTest, SignStreamWithCallbackSignerInContext) { throw std::runtime_error("c2pa_ed25519_sign failed"); } std::vector signature(sig_ptr, sig_ptr + 64); - c2pa_signature_free(sig_ptr); + c2pa_free(sig_ptr); return signature; }; diff --git a/tests/c-app-test/file_stream.h b/tests/c-app-test/file_stream.h index 8e5daed..009946b 100644 --- a/tests/c-app-test/file_stream.h +++ b/tests/c-app-test/file_stream.h @@ -95,25 +95,32 @@ void release_stream(C2paStream *stream) c2pa_release_stream(stream); } -C2paStream *open_file_stream(const char *path, const char *mode) +typedef struct FileStream { + FILE *file; + C2paStream *stream; +} FileStream; + +FileStream open_file_stream(const char *path, const char *mode) { - FILE *file = fopen(path, mode); - if (file != NULL) - { - // printf("file open = %0lx\n", (unsigned long)file); - return create_file_stream(file); + FileStream result = {NULL, NULL}; + result.file = fopen(path, mode); + if (result.file != NULL) { + result.stream = create_file_stream(result.file); + if (result.stream == NULL) { + fclose(result.file); + result.file = NULL; + } } - return NULL; + return result; } -int close_file_stream(C2paStream *stream) +int close_file_stream(FileStream stream) { - if (stream == NULL) { + if (stream.stream == NULL) { return -1; } - FILE *file = (FILE *)stream->context; - int result = fclose(file); - c2pa_release_stream(stream); + c2pa_release_stream(stream.stream); + int result = fclose(stream.file); return result; } diff --git a/tests/c-app-test/test.c b/tests/c-app-test/test.c index e9edbd5..c3b08be 100644 --- a/tests/c-app-test/test.c +++ b/tests/c-app-test/test.c @@ -23,11 +23,11 @@ int main(void) char *version = c2pa_version(); assert_contains("version", version, "c2pa-c-ffi/0."); - C2paStream *input_stream = open_file_stream("tests/fixtures/C.jpg", "rb"); - assert_not_null("open_file_stream", input_stream); + FileStream input_stream = open_file_stream("tests/fixtures/C.jpg", "rb"); + assert_not_null("open_file_stream", input_stream.stream); - C2paReader *reader = c2pa_reader_from_stream("image/jpeg", input_stream); - assert_not_null("c2pa_reader_from_stream", reader); + C2paReader *reader = c2pa_reader_with_stream(c2pa_reader_new(), "image/jpeg", input_stream.stream); + assert_not_null("c2pa_reader_with_stream", reader); close_file_stream(input_stream); @@ -44,11 +44,12 @@ int main(void) } // Open a file to write the thumbnail into - C2paStream *thumb_stream = open_file_stream("build/thumb_c.jpg", "wb"); - assert_not_null("open_file_stream thumbnail", thumb_stream); + FileStream thumb_stream = open_file_stream("build/thumb_c.jpg", "wb"); + assert_not_null("open_file_stream thumbnail", thumb_stream.stream); // write the thumbnail resource to the stream - int res = c2pa_reader_resource_to_stream(reader, uri, thumb_stream); + int res = c2pa_reader_resource_to_stream(reader, uri, thumb_stream.stream); + close_file_stream(thumb_stream); free(uri); if (json != NULL) c2pa_free(json); @@ -62,34 +63,41 @@ int main(void) char *manifest = load_file("tests/fixtures/training.json"); - C2paBuilder *builder = c2pa_builder_from_json(manifest); - assert_not_null("c2pa_builder_from_json", builder); + C2paContext *context = c2pa_context_new(); + assert_not_null("c2pa_context_new", context); + C2paBuilder *builder = c2pa_builder_with_definition(c2pa_builder_from_context(context), manifest); + assert_not_null("c2pa_builder_with_definition", builder); - C2paStream *archive = open_file_stream("build/archive.zip", "wb"); - int arch_result = c2pa_builder_to_archive(builder, archive); + FileStream archive = open_file_stream("build/archive.zip", "wb"); + assert_not_null("open_file_stream archive", archive.stream); + int arch_result = c2pa_builder_to_archive(builder, archive.stream); assert_int("c2pa_builder_to_archive", arch_result); close_file_stream(archive); - C2paStream *archive2 = open_file_stream("build/archive.zip", "rb"); - C2paBuilder *builder2 = c2pa_builder_from_archive(archive2); - assert_not_null("c2pa_builder_from_archive", builder2); + FileStream archive2 = open_file_stream("build/archive.zip", "rb"); + assert_not_null("open_file_stream archive2", archive2.stream); + C2paBuilder *builder2 = c2pa_builder_with_archive(c2pa_builder_from_context(context), archive2.stream); + assert_not_null("c2pa_builder_with_archive", builder2); + c2pa_free(context); close_file_stream(archive2); C2paSigner *signer = c2pa_signer_create((const void *)"testing context", &signer_callback, Es256, certs, "http://timestamp.digicert.com"); assert_not_null("c2pa_signer_create", signer); - C2paStream *source = open_file_stream("tests/fixtures/C.jpg", "rb"); + FileStream source = open_file_stream("tests/fixtures/C.jpg", "rb"); + assert_not_null("open_file_stream source", source.stream); remove("build/earth4.jpg"); // stream needs to be w+b because we'll write, rewind, read - C2paStream *dest = open_file_stream("build/earth4.jpg", "w+b"); + FileStream dest = open_file_stream("build/earth4.jpg", "w+b"); + assert_not_null("open_file_stream dest", dest.stream); const unsigned char *manifest_bytes = NULL; // todo: test passing NULL instead of a pointer - int64_t result2 = c2pa_builder_sign(builder2, "image/jpeg", source, dest, signer, &manifest_bytes); + int64_t result2 = c2pa_builder_sign(builder2, "image/jpeg", source.stream, dest.stream, signer, &manifest_bytes); assert_int("c2pa_builder_sign", result2); const unsigned char *formatted_bytes = NULL; - int64_t result3 = c2pa_format_embeddable("image/jpeg", manifest_bytes, result2, (const unsigned char **)&formatted_bytes); - assert_int("c2pa_format_embeddable", result3); + int64_t result3 = c2pa_builder_compose_manifest(builder2, "image/jpeg", manifest_bytes, result2, &formatted_bytes); + assert_int("c2pa_builder_compose_manifest", result3); if (manifest_bytes != NULL) c2pa_free((void *)manifest_bytes); if (formatted_bytes != NULL) diff --git a/tests/embeddable.test.cpp b/tests/embeddable.test.cpp index 3205b08..ac0e6ba 100644 --- a/tests/embeddable.test.cpp +++ b/tests/embeddable.test.cpp @@ -4,7 +4,6 @@ // or the MIT license (http://opensource.org/licenses/MIT), // at your option. // Unless required by applicable law or agreed to in writing, -// this software is distributed on an "AS IS" BASIS, WITHOUT // WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or // implied. See the LICENSE-MIT and LICENSE-APACHE files for the // specific language governing permissions and limitations under @@ -25,12 +24,10 @@ namespace fs = std::filesystem; using nlohmann::json; -// Test fixture for embeddable tests with automatic cleanup class EmbeddableTest : public ::testing::Test { protected: std::vector temp_files; - // Get path for temp embeddable test files in build directory fs::path get_temp_path(const std::string& name) { fs::path current_dir = fs::path(__FILE__).parent_path(); fs::path build_dir = current_dir.parent_path() / "build"; @@ -51,464 +48,138 @@ class EmbeddableTest : public ::testing::Test { temp_files.clear(); } - // Helper: Creates a Builder with a test manifest. c2pa::Builder make_builder() { auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - return c2pa::Builder(manifest); + auto context = c2pa::Context::ContextBuilder() + .with_signer(c2pa_test::create_test_signer()) + .create_context(); + return c2pa::Builder(context, manifest); } + }; -// e2e workflow with A.jpg (has no existing C2PA) TEST_F(EmbeddableTest, FullWorkflowWithAJpg) { - // This test demonstrates the complete data-hashed embeddable workflow: - // 1. Create placeholder - // 2. Sign with auto-calculated hash - // 3. Format for embedding - // 4. Verify sizes match (critical invariant for in-place patching) - // - // Note: We don't actually embed and read back because manual byte-level - // embedding requires format-specific knowledge of JPEG structure. The SDK's - // normal sign() method handles this internally. This test verifies the - // embeddable APIs work correctly. - - auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - auto certs = c2pa_test::read_text_file(c2pa_test::get_fixture_path("es256_certs.pem")); - auto private_key = c2pa_test::read_text_file(c2pa_test::get_fixture_path("es256_private.key")); - auto source_asset = c2pa_test::get_fixture_path("A.jpg"); - - // Create signing infra - auto context = c2pa::Context(); - auto builder = c2pa::Builder(context, manifest_json); - c2pa::Signer signer("Es256", certs, private_key, "http://timestamp.digicert.com"); - - // 1: Get placeholder manifest - // The placeholder is the exact size of the final signed manifest. - // We specify "image/jpeg" to get JPEG-formatted placeholder bytes. - auto placeholder = builder.data_hashed_placeholder(signer.reserve_size(), "image/jpeg"); - ASSERT_GT(placeholder.size(), 0) << "Placeholder should not be empty"; - size_t placeholder_size = placeholder.size(); - - // 2: Build DataHash JSON - // In production, you would embed the placeholder and note its location. - // The exclusion range would match where you embedded it. - size_t embed_offset = 20; // Example offset - std::string data_hash = R"({ - "exclusions": [{ - "start": )" + std::to_string(embed_offset) + R"(, - "length": )" + std::to_string(placeholder.size()) + R"( - }], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "", - "pad": " " - })"; - - // 3: Sign with auto-calculated hash - // The SDK reads the asset stream, skips the exclusion range, - // calculates SHA-256 hash, and signs the manifest. - std::ifstream asset_stream(source_asset, std::ios::binary); - ASSERT_TRUE(asset_stream.is_open()); - - auto raw_manifest = builder.sign_data_hashed_embeddable( - signer, data_hash, "application/c2pa", &asset_stream); - asset_stream.close(); - ASSERT_GT(raw_manifest.size(), 0); - - // 4: Format for JPEG embedding - // Convert raw JUMBF to JPEG APP11 segments - auto jpeg_embeddable = c2pa::Builder::format_embeddable("image/jpeg", raw_manifest); - ASSERT_GT(jpeg_embeddable.size(), 0); - - // 5: Verify invariant (the placement size) - // The formatted manifest must be exactly the same size as the placeholder. - // This enables in-place patching in production workflows. - EXPECT_EQ(jpeg_embeddable.size(), placeholder_size) - << "Signed manifest size must match placeholder size for in-place patching"; + auto builder = make_builder(); + auto placeholder = builder.placeholder("image/jpeg"); + ASSERT_FALSE(placeholder.empty()); + builder.set_data_hash_exclusions({{20, placeholder.size()}}); + std::ifstream asset(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(asset.is_open()); + builder.update_hash_from_stream("image/jpeg", asset); + auto manifest = builder.sign_embeddable("image/jpeg"); + EXPECT_EQ(manifest.size(), placeholder.size()); } -// e2e workflow with A.jpg using new context-signer embeddable APIs TEST_F(EmbeddableTest, FullWorkflowWithAJpgContextSigner) { - // Demonstrates the new embeddable API where the signer lives on the context - // rather than being passed explicitly to placeholder/sign methods. - // - // Workflow: - // 1. needs_placeholder() — check whether a placeholder embed step is required - // 2. placeholder() — get the correctly-sized placeholder bytes to embed - // 3. set_data_hash_exclusions() — register where the placeholder was embedded - // 4. update_hash_from_stream() — hash the asset (SDK skips exclusion ranges) - // 5. sign_embeddable() — sign; output size is exactly placeholder.size() - auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - auto source_asset = c2pa_test::get_fixture_path("A.jpg"); - - // Create a context with the signer attached programmatically auto context = c2pa::Context::ContextBuilder() .with_signer(c2pa_test::create_test_signer()) .create_context(); - auto builder = c2pa::Builder(context, manifest_json); - - // 1: Check if a placeholder is required for JPEG - ASSERT_TRUE(builder.needs_placeholder("image/jpeg")) - << "JPEG always requires a placeholder"; - - // 2: Get the placeholder bytes (size is committed internally for later validation) - auto placeholder_bytes = builder.placeholder("image/jpeg"); - ASSERT_GT(placeholder_bytes.size(), 0) << "Placeholder must not be empty"; - size_t placeholder_size = placeholder_bytes.size(); - - // 3: Register where we would embed the placeholder. - // In production this offset is where you physically write placeholder_bytes. - size_t embed_offset = 20; - builder.set_data_hash_exclusions({{embed_offset, placeholder_bytes.size()}}); - - // 4: Hash the asset stream, skipping the exclusion range - std::ifstream asset_stream(source_asset, std::ios::binary); - ASSERT_TRUE(asset_stream.is_open()); - builder.update_hash_from_stream("image/jpeg", asset_stream); - asset_stream.close(); - - // 5: Sign — output must match the placeholder size (enables in-place patching) - auto signed_manifest = builder.sign_embeddable("image/jpeg"); - ASSERT_GT(signed_manifest.size(), 0); - EXPECT_EQ(signed_manifest.size(), placeholder_size) - << "Signed manifest must be exactly the same size as the placeholder"; + ASSERT_TRUE(builder.needs_placeholder("image/jpeg")); + auto placeholder = builder.placeholder("image/jpeg"); + ASSERT_FALSE(placeholder.empty()); + builder.set_data_hash_exclusions({{20, placeholder.size()}}); + std::ifstream asset(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(asset.is_open()); + builder.update_hash_from_stream("image/jpeg", asset); + auto manifest = builder.sign_embeddable("image/jpeg"); + EXPECT_EQ(manifest.size(), placeholder.size()); } -// e2e workflow with C.jpg (has existing C2PA metadata) TEST_F(EmbeddableTest, FullWorkflowWithCJpg) { - auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - auto signer = c2pa_test::create_test_signer(); - auto source_asset = c2pa_test::get_fixture_path("C.jpg"); - - auto context = c2pa::Context(); - auto builder = c2pa::Builder(context, manifest_json); - - // Get placeholder - auto placeholder = builder.data_hashed_placeholder(signer.reserve_size(), "image/jpeg"); - ASSERT_GT(placeholder.size(), 0); - - // Build data hash JSON - size_t embed_offset = 20; - std::string data_hash = R"({ - "exclusions": [{ - "start": )" + std::to_string(embed_offset) + R"(, - "length": )" + std::to_string(placeholder.size()) + R"( - }], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "", - "pad": " " - })"; - - // Sign with the asset that has existing C2PA - std::ifstream asset_stream(source_asset, std::ios::binary); - auto raw_manifest = builder.sign_data_hashed_embeddable( - signer, data_hash, "application/c2pa", &asset_stream); - asset_stream.close(); - ASSERT_GT(raw_manifest.size(), 0); - - // Format for jpeg - auto jpeg_embeddable = c2pa::Builder::format_embeddable("image/jpeg", raw_manifest); - ASSERT_GT(jpeg_embeddable.size(), 0); - - // Verify invariant: size matches so we could place - EXPECT_EQ(jpeg_embeddable.size(), placeholder.size()); + auto builder = make_builder(); + auto placeholder = builder.placeholder("image/jpeg"); + ASSERT_FALSE(placeholder.empty()); + builder.set_data_hash_exclusions({{20, placeholder.size()}}); + std::ifstream asset(c2pa_test::get_fixture_path("C.jpg"), std::ios::binary); + ASSERT_TRUE(asset.is_open()); + builder.update_hash_from_stream("image/jpeg", asset); + auto manifest = builder.sign_embeddable("image/jpeg"); + EXPECT_EQ(manifest.size(), placeholder.size()); } -// Pre-calculated hash TEST_F(EmbeddableTest, PreCalculatedHash) { - // This test demonstrates signing with a pre-calculated hash. - // In production, you might calculate the hash on a different machine or at a different time than signing. - - auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - auto signer = c2pa_test::create_test_signer(); - - auto context = c2pa::Context(); - auto builder = c2pa::Builder(context, manifest_json); - - // Get placeholder - auto placeholder = builder.data_hashed_placeholder(signer.reserve_size(), "image/jpeg"); - - // Build data hash JSON with a KNOWN hash value - // In this test, we use a dummy hash since we're not actually embedding. - // In production, this would be a real SHA-256 hash of your asset. - std::string data_hash = R"({ - "exclusions": [{ - "start": 20, - "length": )" + std::to_string(placeholder.size()) + R"( - }], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "gWZNEOMHQNiULfA/tO5HD2awOwYMA3tnfUPApIr9csk=", - "pad": " " - })"; - - // Sign without providing an asset stream - // The SDK uses the hash value from the data_hash JSON directly - auto manifest = builder.sign_data_hashed_embeddable( - signer, data_hash, "image/jpeg", nullptr); - - EXPECT_GT(manifest.size(), 0) << "Should produce signed manifest bytes"; - EXPECT_EQ(manifest.size(), placeholder.size()) << "Size should match placeholder"; + auto context = c2pa::Context::ContextBuilder() + .with_signer(c2pa_test::create_test_signer()) + .create_context(); + auto builder = c2pa::Builder(context, c2pa_test::prehashed_training_manifest()); + // A precomputed hard binding uses direct mode, with no asset stream. + auto manifest = builder.sign_embeddable("image/jpeg"); + EXPECT_FALSE(manifest.empty()); } -// Auto-calculated hash TEST_F(EmbeddableTest, AutoCalculatedHash) { - // This test demonstrates the SDK calculating the hash automatically from an asset stream. - // The hash field is left empty (""), and the SDK will: - // 1. Read the asset stream - // 2. Skip the exclusion ranges - // 3. Calculate the SHA-256 hash of the remaining bytes - // - // Usually (not in a test), you would have already embedded the placeholder at the exclusion offset. - // Here we just verify the API accepts an asset stream and auto-calculates the hash. - - auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - auto signer = c2pa_test::create_test_signer(); - auto source_asset = c2pa_test::get_fixture_path("A.jpg"); - - auto context = c2pa::Context(); - auto builder = c2pa::Builder(context, manifest_json); - - // Get placeholder - auto placeholder = builder.data_hashed_placeholder(signer.reserve_size(), "image/jpeg"); - - // Build data hash JSON with empty hash field - // The exclusion range would match where you embedded the placeholder - std::string data_hash = R"({ - "exclusions": [{ - "start": 20, - "length": )" + std::to_string(placeholder.size()) + R"( - }], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "", - "pad": " " - })"; - - // Sign with asset stream: SDK will auto-calculate hash - // This stream would be your asset with the placeholder already embedded - std::ifstream asset_stream(source_asset, std::ios::binary); - auto manifest = builder.sign_data_hashed_embeddable( - signer, data_hash, "application/c2pa", &asset_stream); - asset_stream.close(); - - EXPECT_GT(manifest.size(), 0) << "Should produce signed manifest bytes"; + auto builder = make_builder(); + auto placeholder = builder.placeholder("image/jpeg"); + builder.set_data_hash_exclusions({{20, placeholder.size()}}); + std::ifstream asset(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(asset.is_open()); + builder.update_hash_from_stream("image/jpeg", asset); + auto manifest = builder.sign_embeddable("application/c2pa"); + EXPECT_FALSE(manifest.empty()); } -// Embedding roundtrip TEST_F(EmbeddableTest, FormatEmbeddableRoundTrip) { - // This test demonstrates the two-step format conversion: - // 1. Sign with format="application/c2pa" to get raw JUMBF bytes - // 2. Use format_embeddable() to convert to JPEG-embeddable format - // - // This is useful when you want to sign once and embed in multiple formats. - - auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - auto signer = c2pa_test::create_test_signer(); - auto source_asset = c2pa_test::get_fixture_path("A.jpg"); - - auto context = c2pa::Context(); - auto builder = c2pa::Builder(context, manifest_json); - - auto placeholder = builder.data_hashed_placeholder(signer.reserve_size(), "image/jpeg"); - - std::string data_hash = R"({ - "exclusions": [{ - "start": 20, - "length": )" + std::to_string(placeholder.size()) + R"( - }], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "", - "pad": " " - })"; - - // 1: Sign with "application/c2pa" to get raw JUMBF - std::ifstream asset_stream(source_asset, std::ios::binary); - auto raw_jumbf = builder.sign_data_hashed_embeddable( - signer, data_hash, "application/c2pa", &asset_stream); - asset_stream.close(); - - // 2: Convert raw JUMBF to JPEG-embeddable format - auto jpeg_formatted = c2pa::Builder::format_embeddable("image/jpeg", raw_jumbf); - - // The JPEG format adds container overhead (APP11 markers, headers) - // so the formatted output should be larger than raw JUMBF due to this - EXPECT_GT(jpeg_formatted.size(), raw_jumbf.size()) - << "JPEG format should add container overhead"; - - // But still matches placeholder size (which was already JPEG-formatted) + auto builder = make_builder(); + auto placeholder = builder.placeholder("image/jpeg"); + builder.set_data_hash_exclusions({{20, placeholder.size()}}); + std::ifstream asset(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(asset.is_open()); + builder.update_hash_from_stream("image/jpeg", asset); + auto raw_jumbf = builder.sign_embeddable("application/c2pa"); + auto jpeg_formatted = builder.sign_embeddable("image/jpeg"); + EXPECT_GT(jpeg_formatted.size(), raw_jumbf.size()); EXPECT_EQ(jpeg_formatted.size(), placeholder.size()); } -// Verify placeholder size matches final size (asset has no existing C2PA) TEST_F(EmbeddableTest, PlaceholderSizeMatchesFinalInvariant) { - // This test verifies the critical invariant: - // placeholder.size() == final_signed_manifest.size() - // This is what enables in-place patching in workflows. - auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - auto signer = c2pa_test::create_test_signer(); - auto source_asset = c2pa_test::get_fixture_path("A.jpg"); - - auto context = c2pa::Context(); - auto builder = c2pa::Builder(context, manifest_json); - - // Get placeholder and record its size - auto placeholder = builder.data_hashed_placeholder(signer.reserve_size(), "image/jpeg"); - size_t placeholder_size = placeholder.size(); - - // Complete the signing flow - size_t embed_offset = 20; - std::string data_hash = R"({ - "exclusions": [{ - "start": )" + std::to_string(embed_offset) + R"(, - "length": )" + std::to_string(placeholder.size()) + R"( - }], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "", - "pad": " " - })"; - - std::ifstream asset_stream(source_asset, std::ios::binary); - auto raw_manifest = builder.sign_data_hashed_embeddable( - signer, data_hash, "application/c2pa", &asset_stream); - asset_stream.close(); - - auto jpeg_embeddable = c2pa::Builder::format_embeddable("image/jpeg", raw_manifest); - - // The final manifest is exactly the same size as placeholder - EXPECT_EQ(jpeg_embeddable.size(), placeholder_size) - << "Final signed manifest MUST be same size as placeholder"; + auto builder = make_builder(); + auto placeholder = builder.placeholder("image/jpeg"); + builder.set_data_hash_exclusions({{20, placeholder.size()}}); + std::ifstream asset(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(asset.is_open()); + builder.update_hash_from_stream("image/jpeg", asset); + auto manifest = builder.sign_embeddable("image/jpeg"); + EXPECT_EQ(manifest.size(), placeholder.size()); } -// Verify placeholder size matches final size (asset has existing C2PA metadata) TEST_F(EmbeddableTest, PlaceholderSizeMatchesFinalInvariantWithMetadata) { - auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - auto signer = c2pa_test::create_test_signer(); - auto source_asset = c2pa_test::get_fixture_path("C.jpg"); - - auto context = c2pa::Context(); - auto builder = c2pa::Builder(context, manifest_json); - - auto placeholder = builder.data_hashed_placeholder(signer.reserve_size(), "image/jpeg"); - size_t placeholder_size = placeholder.size(); - - size_t embed_offset = 20; - std::string data_hash = R"({ - "exclusions": [{ - "start": )" + std::to_string(embed_offset) + R"(, - "length": )" + std::to_string(placeholder.size()) + R"( - }], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "", - "pad": " " - })"; - - std::ifstream asset_stream(source_asset, std::ios::binary); - auto signed_manifest = builder.sign_data_hashed_embeddable( - signer, data_hash, "image/jpeg", &asset_stream); - asset_stream.close(); - - EXPECT_EQ(signed_manifest.size(), placeholder_size); + auto builder = make_builder(); + auto placeholder = builder.placeholder("image/jpeg"); + builder.set_data_hash_exclusions({{20, placeholder.size()}}); + std::ifstream asset(c2pa_test::get_fixture_path("C.jpg"), std::ios::binary); + ASSERT_TRUE(asset.is_open()); + builder.update_hash_from_stream("image/jpeg", asset); + auto manifest = builder.sign_embeddable("image/jpeg"); + EXPECT_EQ(manifest.size(), placeholder.size()); } TEST_F(EmbeddableTest, ContextSettingsPropagation) { + auto context = c2pa::Context::ContextBuilder() + .with_signer(c2pa_test::create_test_signer()) + .with_json(R"({"builder":{"thumbnail":{"enabled":false}}})") + .create_context(); auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - auto signer = c2pa_test::create_test_signer(); - auto source_asset = c2pa_test::get_fixture_path("A.jpg"); - - // Create context with custom settings - auto context = c2pa::Context(R"({ - "builder": { - "thumbnail": { - "enabled": false - } - } - })"); - - // Use context for builder auto builder = c2pa::Builder(context, manifest_json); - - // Get placeholder - auto placeholder = builder.data_hashed_placeholder(signer.reserve_size(), "image/jpeg"); - ASSERT_GT(placeholder.size(), 0); - - // Sign using the same context - std::string data_hash = R"({ - "exclusions": [{ - "start": 20, - "length": )" + std::to_string(placeholder.size()) + R"( - }], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "", - "pad": " " - })"; - - std::ifstream asset_stream(source_asset, std::ios::binary); - auto manifest = builder.sign_data_hashed_embeddable( - signer, data_hash, "application/c2pa", &asset_stream); - asset_stream.close(); - - EXPECT_GT(manifest.size(), 0) << "Context propagated successfully through workflow"; + auto placeholder = builder.placeholder("image/jpeg"); + ASSERT_FALSE(placeholder.empty()); + builder.set_data_hash_exclusions({{20, placeholder.size()}}); + std::ifstream asset(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(asset.is_open()); + builder.update_hash_from_stream("image/jpeg", asset); + EXPECT_FALSE(builder.sign_embeddable("image/jpeg").empty()); } -// Verify different formats TEST_F(EmbeddableTest, MultipleFormats) { - auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - auto signer = c2pa_test::create_test_signer(); - - auto context = c2pa::Context(); - - // jpeg - { - auto builder = c2pa::Builder(context, manifest_json); - auto placeholder = builder.data_hashed_placeholder( - signer.reserve_size(), "image/jpeg"); - EXPECT_GT(placeholder.size(), 0) << "JPEG placeholder should not be empty"; - - // Build simple data hash with dummy values - std::string data_hash = R"({ - "exclusions": [{ - "start": 20, - "length": )" + std::to_string(placeholder.size()) + R"( - }], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "gWZNEOMHQNiULfA/tO5HD2awOwYMA3tnfUPApIr9csk=", - "pad": " " - })"; - - auto manifest = builder.sign_data_hashed_embeddable( - signer, data_hash, "image/jpeg", nullptr); - EXPECT_GT(manifest.size(), 0) << "JPEG signed manifest should not be empty"; - } - - // png - { - auto builder = c2pa::Builder(context, manifest_json); - auto placeholder = builder.data_hashed_placeholder( - signer.reserve_size(), "image/png"); - EXPECT_GT(placeholder.size(), 0) << "PNG placeholder should not be empty"; - - std::string data_hash = R"({ - "exclusions": [{ - "start": 33, - "length": )" + std::to_string(placeholder.size()) + R"( - }], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "gWZNEOMHQNiULfA/tO5HD2awOwYMA3tnfUPApIr9csk=", - "pad": " " - })"; - - auto manifest = builder.sign_data_hashed_embeddable( - signer, data_hash, "image/png", nullptr); - EXPECT_GT(manifest.size(), 0) << "PNG signed manifest should not be empty"; + auto context = c2pa::Context::ContextBuilder() + .with_signer(c2pa_test::create_test_signer()) + .create_context(); + for (const auto& format : {"image/jpeg", "image/png"}) { + auto placeholder_builder = make_builder(); + EXPECT_FALSE(placeholder_builder.placeholder(format).empty()); + auto builder = c2pa::Builder(context, c2pa_test::prehashed_training_manifest()); + EXPECT_FALSE(builder.sign_embeddable(format).empty()); } } @@ -613,44 +284,24 @@ TEST_F(EmbeddableTest, BoxHashBmffAlwaysRequiresPlaceholder) { << "MP4 must always require a placeholder regardless of prefer_box_hash"; } -// Direct embedding when giving format TEST_F(EmbeddableTest, DirectEmbeddingWithFormat) { - // This test demonstrates that when you sign with format="image/jpeg", - // the output is already embeddable: no need for format_embeddable()! - // Whereas... - // Signing with "application/c2pa" requires a separate format_embeddable() call. - - auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - auto signer = c2pa_test::create_test_signer(); - auto source_asset = c2pa_test::get_fixture_path("A.jpg"); + auto builder = make_builder(); + auto placeholder = builder.placeholder("image/jpeg"); + builder.set_data_hash_exclusions({{20, placeholder.size()}}); + std::ifstream asset(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(asset.is_open()); + builder.update_hash_from_stream("image/jpeg", asset); + auto manifest = builder.sign_embeddable("image/jpeg"); + ASSERT_GT(manifest.size(), 2u); + EXPECT_EQ(manifest[0], 0xff); + EXPECT_EQ(manifest[1], 0xeb); + EXPECT_EQ(manifest.size(), placeholder.size()); +} +TEST_F(EmbeddableTest, PlaceholderRequiresContextSigner) { auto context = c2pa::Context(); - auto builder = c2pa::Builder(context, manifest_json); - - auto placeholder = builder.data_hashed_placeholder(signer.reserve_size(), "image/jpeg"); - - std::string data_hash = R"({ - "exclusions": [{ - "start": 20, - "length": )" + std::to_string(placeholder.size()) + R"( - }], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "", - "pad": " " - })"; - - std::ifstream asset_stream(source_asset, std::ios::binary); - - // Sign directly with "image/jpeg" format - // This output is already formatted: no format_embeddable() needed - auto jpeg_manifest = builder.sign_data_hashed_embeddable( - signer, data_hash, "image/jpeg", &asset_stream); - asset_stream.close(); - - EXPECT_GT(jpeg_manifest.size(), 0); - EXPECT_EQ(jpeg_manifest.size(), placeholder.size()) - << "Direct JPEG format output matches placeholder size"; + auto builder = c2pa::Builder(context, "{}"); + EXPECT_THROW(builder.placeholder("image/jpeg"), c2pa::C2paException); } class EmbeddableBlankFormatTest : public EmbeddableTest, public ::testing::WithParamInterface {}; diff --git a/tests/include/test_utils.hpp b/tests/include/test_utils.hpp index 1608981..d4cb74d 100644 --- a/tests/include/test_utils.hpp +++ b/tests/include/test_utils.hpp @@ -21,6 +21,7 @@ #include #include #include +#include #include "c2pa.hpp" @@ -78,6 +79,22 @@ inline c2pa::Settings create_test_settings_with_signer() return c2pa::Settings(settings_json, "json"); } +inline std::string prehashed_training_manifest() +{ + auto manifest = nlohmann::json::parse(read_text_file(get_fixture_path("training.json"))); + manifest["assertions"].push_back({ + {"label", "c2pa.hash.data"}, + {"data", { + {"exclusions", {{{"start", 20}, {"length", 100}}}}, + {"name", "jumbf manifest"}, + {"alg", "sha256"}, + {"hash", "gWZNEOMHQNiULfA/tO5HD2awOwYMA3tnfUPApIr9csk="}, + {"pad", " "} + }} + }); + return manifest.dump(); +} + } // namespace c2pa_test #endif // C2PA_TEST_UTILS_HPP From 3705e304319203dd64c57746178ded19f0cab376 Mon Sep 17 00:00:00 2001 From: Gopalakrishna Sharma Date: Tue, 27 Oct 2026 20:57:58 +0530 Subject: [PATCH 2/2] feat: add zero-copy byte buffers and in-place signing callbacks Add move-only ByteBuffer and buffer-returning siblings for every signing overload, placeholders, and embeddable manifests. Keep vector APIs compatible through owning conversions. Retain capturing signer callbacks across moves and Context ownership transfers, document stream and HTTP borrows, and validate pointer identity, lifetime, cleanup, and signing round trips. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/context-settings.md | 24 +- docs/release-notes.md | 13 ++ docs/usage.md | 62 +++++ include/c2pa.hpp | 118 +++++++++- src/c2pa_builder.cpp | 57 ++++- src/c2pa_context.cpp | 16 +- src/c2pa_internal.hpp | 42 +++- src/c2pa_signer.cpp | 14 ++ tests/zero_copy.test.cpp | 475 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 785 insertions(+), 36 deletions(-) create mode 100644 tests/zero_copy.test.cpp diff --git a/docs/context-settings.md b/docs/context-settings.md index 94518c2..d16a457 100644 --- a/docs/context-settings.md +++ b/docs/context-settings.md @@ -165,7 +165,7 @@ auto context = c2pa::Context::ContextBuilder() | `with_signer(signer)` | Store a `Signer` in the context (consumed; used by `Builder::sign` with no explicit signer) | | `with_progress_callback(callback)` | Register a progress/cancel callback (see [Progress callbacks and cancellation](#progress-callbacks-and-cancellation)) | | `create_context()` | Build and return the `Context` (consumes the builder) | -| `release()` | Release the raw `C2paContextBuilder*` together with its progress-callback heap owner (see also [Releasing to a custom IContextProvider](#releasing-to-a-custom-icontextprovider)) | +| `release()` | Release the raw `C2paContextBuilder*` together with its progress and in-place signer callback owners (see also [Releasing to a custom IContextProvider](#releasing-to-a-custom-icontextprovider)) | ## Progress callbacks and cancellation @@ -986,14 +986,28 @@ External libraries can implement `IContextProvider` to supply their own context #### Releasing to a custom IContextProvider -`Context::ContextBuilder::release()` hands off both the native `C2paContextBuilder*` and the heap-owned progress callback (if any) to the caller. It is the entry point when application code needs to build the native context directly or adopt it into a custom `IContextProvider` implementation. +`Context::ContextBuilder::release()` hands off the native `C2paContextBuilder*` +and both heap-owned callbacks (if any) to the caller. It is the entry point +when application code needs to build the native context directly or adopt it +into a custom `IContextProvider` implementation. -`ReleasedBuilder` exposes two fields: +`ReleasedBuilder` exposes three fields: - `builder`: the raw `C2paContextBuilder*`. It must be passed to `c2pa_context_builder_build` exactly once. - `callback_owner`: `std::unique_ptr`; `nullptr` when no progress callback was set. When non-null, it must remain alive at least as long as the native context built from `builder`. If it is destroyed earlier, the library will dereference freed memory on the next progress tick. - -When the built-in `Context` is sufficient, `create_context()` is preferred. It performs the same handoff internally and keeps the native handle and callback owner bound together. +- `signer_callback_owner`: `std::unique_ptr`; `nullptr` + unless an in-place callback signer was set. Keep it alive until all native + objects using the signer, including contexts and derived Builders, are + freed. Free native objects before destroying either callback owner. + +To adopt the result into the built-in Context, pass both owners to +`Context(native_context, std::move(released.callback_owner), +std::move(released.signer_callback_owner))` after successfully building the +native context. Do not drop the third field when adapting an older provider. + +When the built-in `Context` is sufficient, `create_context()` is preferred. It +performs the same handoff internally and keeps the native handle and both +callback owners bound together. ### Builder::sign overloads diff --git a/docs/release-notes.md b/docs/release-notes.md index ef5ff71..58fc061 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -2,6 +2,19 @@ ## Unreleased +### Zero-copy C++ APIs + +- Added move-only `ByteBuffer`, read-only and mutable byte views, and + `sign_to_buffer` siblings for all five signing overloads, plus + `placeholder_to_buffer` and `sign_embeddable_to_buffer`. Existing vector + APIs retain their signatures and copy explicitly through the owning buffer. +- Added capturing `SignerIntoFunc` callbacks that borrow native input/output + buffers directly. Heap-owned callback state survives moves and transfers + into Contexts; `ReleasedBuilder` now carries a `signer_callback_owner` + field that custom providers must retain. See [usage and ownership](usage.md). +- Documented direct stream-buffer forwarding and deferred native release of + HTTP response bodies. + ### Breaking changes - Removed deprecated `Builder::data_hashed_placeholder`, diff --git a/docs/usage.md b/docs/usage.md index 9a99fee..3edc973 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -6,6 +6,68 @@ To use this library, include the header file in your code as follows: #include "c2pa.hpp" ``` +## Native byte buffers without a copy + +Every `Builder::sign` overload has a `sign_to_buffer` sibling with the same +arguments. `placeholder_to_buffer(format)` and +`sign_embeddable_to_buffer(format)` similarly mirror the embeddable APIs. +These return a move-only `c2pa::ByteBuffer` that owns the native allocation +without copying it into a C++ vector: + +```cpp +c2pa::ByteBuffer manifest = builder.sign_to_buffer("image/jpeg", source, dest); +c2pa::ConstBytes view = manifest.view(); +// Consume view.data[0..view.size) while manifest owns the allocation. +auto copied = manifest.to_vector(); // Explicit, optional copy. +``` + +`data()`, `size()`, `empty()`, `begin()`, and `end()` provide read-only access. +Moving a buffer preserves its allocation address and empties the source. +Destruction or move-assignment over the owner invalidates its views and frees +the allocation exactly once with `c2pa_free`. Do not modify, `free`, `realloc`, +or call `c2pa_free` on `data()` yourself. The buffer may outlive its Builder. +An empty buffer may have a null `data()` pointer; do not dereference it. +Existing vector-returning methods keep their signatures and make an explicit +copy from the same owning buffer internally. + +## In-place signing callbacks + +The `Signer(SignerIntoFunc, alg, certs, tsa_uri)` overload accepts capturing +callables. Its `ConstBytes` input views the native payload and its +`MutableBytes` output views the native signature destination. An application's +crypto or HSM backend can write directly into that destination, eliminating +the vector-based callback's input and output copies. + +The callable must write no more than `signature.size` bytes, return the actual +number written, or return a negative value on failure. The wrapper rejects +oversized counts and converts exceptions to an I/O error before returning to +the C API. A short or invalid cryptographic signature fails signing; returning +a small count is not a way to truncate a signature. Never retain either view +after the callback returns. Captured state must be safe for concurrent calls. + +The callable is heap-owned at a stable address across Signer moves. Passing +the signer to `ContextBuilder::with_signer` transfers the callable along with +the native signer to the resulting Context. Prefer a `shared_ptr` +when constructing Builders so that their callback owner remains alive. +Custom providers using `ContextBuilder::release()` must keep its +`signer_callback_owner` alive until every native object using that signer is +freed; see [Context ownership](context-settings.md#releasing-to-a-custom-icontextprovider). +The existing vector-based `SignerFunc` constructor remains available. + +## Stream and HTTP callback ownership + +`CppIStream`, `CppOStream`, and `CppIOStream` pass the native callback's buffer +directly to the stream's read/write operation. There is no intermediate C++ +payload vector. Custom `std::streambuf` implementations receive those same +pointers in `xsgetn`/`xsputn`; any buffering performed inside a streambuf is +outside this adapter guarantee. The caller's streams must outlive the native +operation, and callback buffers must not be retained. + +An HTTP resolver response body must still be allocated with `malloc`. +The native library reads directly from it without an intermediate body copy, +then calls `free` when the response is dropped, including error paths. After +returning the response, the resolver must not reuse or release its body. + ## Read and validate an istream Use the `Reader` constructor to read C2PA data from a stream. This constructor examines the specified stream for C2PA data in the given format, and its return value is a `Reader` that you can use to extract more information. The constructor throws exceptions on errors. diff --git a/include/c2pa.hpp b/include/c2pa.hpp index e0a6b89..9a972e8 100644 --- a/include/c2pa.hpp +++ b/include/c2pa.hpp @@ -50,6 +50,65 @@ namespace c2pa { + /// Borrowed views, valid only for the lifetime of their owner or callback. + struct ConstBytes { + const unsigned char* data; + size_t size; + }; + + struct MutableBytes { + unsigned char* data; + size_t size; + }; + + /// Write a signature in place and return its byte count, or a negative error. + /// Views must not be retained. Captured state must support concurrent calls. + using SignerIntoFunc = std::function; + + class ByteBuffer; + namespace detail { + ByteBuffer to_byte_buffer(const unsigned char* data, int64_t size); + } + + /// Owns read-only native bytes without copying. Never free data() yourself. + /// Moves transfer the allocation without changing its address. Destruction or + /// replacement of the owning buffer invalidates views and releases via c2pa_free. + class C2PA_CPP_API ByteBuffer { + public: + ByteBuffer() noexcept = default; + ByteBuffer(const ByteBuffer&) = delete; + ByteBuffer& operator=(const ByteBuffer&) = delete; + ByteBuffer(ByteBuffer&& other) noexcept + : data_(std::exchange(other.data_, nullptr)), + size_(std::exchange(other.size_, 0)) {} + ByteBuffer& operator=(ByteBuffer&& other) noexcept { + if (this != &other) { + c2pa_free(data_); + data_ = std::exchange(other.data_, nullptr); + size_ = std::exchange(other.size_, 0); + } + return *this; + } + ~ByteBuffer() { c2pa_free(data_); } + + const unsigned char* data() const noexcept { return data_; } + size_t size() const noexcept { return size_; } + bool empty() const noexcept { return size_ == 0; } + const unsigned char* begin() const noexcept { return data_; } + const unsigned char* end() const noexcept { return size_ == 0 ? data_ : data_ + size_; } + ConstBytes view() const noexcept { return {data_, size_}; } + std::vector to_vector() const { + return empty() ? std::vector{} + : std::vector(begin(), end()); + } + + private: + friend ByteBuffer detail::to_byte_buffer(const unsigned char*, int64_t); + ByteBuffer(const unsigned char* data, size_t size) noexcept : data_(data), size_(size) {} + const unsigned char* data_ = nullptr; + size_t size_ = 0; + }; + /// @typedef SignerInfo /// @brief Type alias for C2paSignerInfo from the C API. typedef C2paSignerInfo SignerInfo; @@ -154,6 +213,10 @@ namespace c2pa /// - A consistent validation pattern exists across all context-using classes /// - Errors are caught early at construction time rather than during operations /// + /// Providers adopting ContextBuilder::release() must also retain its + /// signer_callback_owner until all native objects using that signer are + /// freed. Free native handles before destroying either callback owner. + /// /// @par Standard Context Implementation /// The built-in Context class always returns true from is_valid() after /// successful construction, as it validates the context pointer in its constructor. @@ -401,7 +464,9 @@ namespace c2pa /// @details The callback is invoked synchronously whenever the SDK needs to /// make an HTTP request (remote manifest fetch, OCSP, timestamp, etc.). /// The callback receives a C2paHttpRequest and must fill in a C2paHttpResponse. - /// The response body must be allocated with malloc(); the underlying native library will call free(). + /// The response body must be allocated with malloc(); the native library + /// reads it without an intermediate copy and calls free() when the + /// response is dropped, including error paths. Do not retain or free it. /// @param user_data Opaque user pointer passed to every callback invocation (may be nullptr). /// @param callback Function pointer matching C2paHttpResolverCallback. /// @return Reference to this ContextBuilder for method chaining. @@ -416,7 +481,9 @@ namespace c2pa /// @brief Result of ContextBuilder::release(): the raw native builder handle /// paired with the heap-owned progress callback (if any). - /// @details The caller takes joint ownership. If `callback_owner` is non-null, + /// @details The caller takes joint ownership of all three fields. + /// signer_callback_owner must outlive every native object using + /// the signer, including contexts and derived builders. If `callback_owner` is non-null, /// the native side stores a raw pointer into it and will invoke the /// callback until the native context built from `builder` is freed. /// Therefore `callback_owner` must remain alive at least until the @@ -426,6 +493,8 @@ namespace c2pa struct ReleasedBuilder { C2paContextBuilder* builder; std::unique_ptr callback_owner; + /// Keep alive until all native contexts/builders using the signer are freed. + std::unique_ptr signer_callback_owner = nullptr; }; /// @brief Release ownership of the underlying C2paContextBuilder handle and @@ -438,8 +507,8 @@ namespace c2pa /// Callers must keep ReleasedBuilder::callback_owner alive for as /// long as the native context built from ReleasedBuilder::builder /// exists. - /// @return ReleasedBuilder holding both the native builder and (optionally) - /// the owning unique_ptr for the progress callback. Fields are null + /// @return ReleasedBuilder holding the native builder and (optionally) + /// both callback owners. Fields are null /// when moved-from. [[nodiscard]] ReleasedBuilder release() noexcept; @@ -448,6 +517,7 @@ namespace c2pa // so destruction (reverse of declaration) frees context_builder first, while // the callback heap block is still live, then releases pending_callback_. std::unique_ptr pending_callback_; + std::unique_ptr pending_signer_callback_; C2paContextBuilder* context_builder; }; @@ -506,6 +576,10 @@ namespace c2pa Context(C2paContext* ctx, std::unique_ptr callback) noexcept; + /// Adopt a native context together with both callback owners from release(). + Context(C2paContext* ctx, std::unique_ptr callback, + std::unique_ptr signer_callback) noexcept; + /// @brief Request cancellation of any in-progress operation on this context. /// /// @details Safe to call from another thread while this Context remains valid @@ -527,6 +601,7 @@ namespace c2pa /// with a progress callback via ContextBuilder::with_progress_callback(). /// Destroyed after `context`. std::unique_ptr callback_owner_; + std::unique_ptr signer_callback_owner_; }; /// @brief Get the version of the C2PA library. @@ -1037,14 +1112,16 @@ namespace c2pa friend class Context::ContextBuilder; private: - C2paSigner *signer; + C2paSigner *signer = nullptr; + std::unique_ptr callback_owner_; /// @brief Transfers ownership of the underlying C2paSigner pointer out /// of this wrapper, without freeing it. /// @details Used by ContextBuilder::with_signer() to pass the raw pointer - /// to c2pa_context_builder_set_signer(), which takes ownership on - /// the Rust side via Box::from_raw. After this call the Signer - /// wrapper holds nullptr and its destructor is a no-op. + /// to c2pa_context_builder_set_signer(), which consumes it on + /// the Rust side. After this call the Signer + /// wrapper holds nullptr. ContextBuilder separately transfers + /// callback_owner_ after the native call succeeds. /// This is not the same as c2pa_signer_free(), which destroys /// the signer. Similar to std::unique_ptr::release(). /// @return Raw C2paSigner pointer, or nullptr if already released. @@ -1071,6 +1148,14 @@ namespace c2pa /// @throws C2paException if signer creation fails. Signer(SignerFunc *callback, C2paSigningAlg alg, const std::string &sign_cert, const std::string &tsa_uri); + /// Zero-copy signing with a capturing callable in stable heap storage. + /// Writes at most signature.size bytes and returns their count, or a negative + /// error. Exceptions become I/O errors; oversized results are rejected. + /// Concurrent signing requires thread-safe captured state. Callback views + /// are borrowed only until the callback returns. + Signer(SignerIntoFunc callback, C2paSigningAlg alg, + const std::string &sign_cert, const std::string &tsa_uri); + /// @brief Create a signer from a Signer pointer and take ownership of that pointer. /// @param c_signer The C2paSigner pointer (must be non-null). Signer(C2paSigner *c_signer) : signer(c_signer) { @@ -1093,13 +1178,16 @@ namespace c2pa /// @brief Move constructor. /// @param other Signer to move from. - Signer(Signer&& other) noexcept : signer(std::exchange(other.signer, nullptr)) { + Signer(Signer&& other) noexcept + : signer(std::exchange(other.signer, nullptr)), + callback_owner_(std::move(other.callback_owner_)) { } Signer& operator=(Signer&& other) noexcept { if (this != &other) { c2pa_free(signer); signer = std::exchange(other.signer, nullptr); + callback_owner_ = std::move(other.callback_owner_); } return *this; } @@ -1320,6 +1408,14 @@ namespace c2pa /// @throws C2paException if the context has no signer or on other errors. std::vector sign(const std::filesystem::path &source_path, const std::filesystem::path &dest_path); + /// Zero-copy siblings of sign(). Returned read-only bytes own their native + /// allocation independently of the Builder. Call to_vector() for an explicit copy. + ByteBuffer sign_to_buffer(const std::string &format, std::istream &source, std::ostream &dest, Signer &signer); + ByteBuffer sign_to_buffer(const std::string &format, std::istream &source, std::iostream &dest, Signer &signer); + ByteBuffer sign_to_buffer(const std::filesystem::path &source_path, const std::filesystem::path &dest_path, Signer &signer); + ByteBuffer sign_to_buffer(const std::string &format, std::istream &source, std::iostream &dest); + ByteBuffer sign_to_buffer(const std::filesystem::path &source_path, const std::filesystem::path &dest_path); + /// @brief Create a Builder from an archived Builder stream. /// @param archive The input stream to read the archive from. /// @return A new Builder instance loaded from the archive. @@ -1386,6 +1482,8 @@ namespace c2pa /// @return Composed placeholder bytes ready to embed into the asset. /// @throws C2paException on error. std::vector placeholder(const std::string &format); + /// Zero-copy sibling of placeholder(); same workflow and errors. + ByteBuffer placeholder_to_buffer(const std::string &format); /// @brief Register the byte ranges where the placeholder was embedded (DataHash workflow). /// @details Call this after embedding the placeholder bytes into the asset and before @@ -1418,6 +1516,8 @@ namespace c2pa /// @return Signed manifest bytes ready to embed into the asset. /// @throws C2paException on error. std::vector sign_embeddable(const std::string &format); + /// Zero-copy sibling of sign_embeddable(); same workflow and errors. + ByteBuffer sign_embeddable_to_buffer(const std::string &format); /// @brief Get a list of mime types that the Builder supports. /// @return Vector of supported MIME type strings. diff --git a/src/c2pa_builder.cpp b/src/c2pa_builder.cpp index 8320045..f56859f 100644 --- a/src/c2pa_builder.cpp +++ b/src/c2pa_builder.cpp @@ -218,6 +218,31 @@ namespace c2pa } std::vector Builder::sign(const std::string &format, std::istream &source, std::ostream &dest, Signer &signer) + { + return sign_to_buffer(format, source, dest, signer).to_vector(); + } + + std::vector Builder::sign(const std::string &format, std::istream &source, std::iostream &dest, Signer &signer) + { + return sign_to_buffer(format, source, dest, signer).to_vector(); + } + + std::vector Builder::sign(const std::filesystem::path &source_path, const std::filesystem::path &dest_path, Signer &signer) + { + return sign_to_buffer(source_path, dest_path, signer).to_vector(); + } + + std::vector Builder::sign(const std::string &format, std::istream &source, std::iostream &dest) + { + return sign_to_buffer(format, source, dest).to_vector(); + } + + std::vector Builder::sign(const std::filesystem::path &source_path, const std::filesystem::path &dest_path) + { + return sign_to_buffer(source_path, dest_path).to_vector(); + } + + ByteBuffer Builder::sign_to_buffer(const std::string &format, std::istream &source, std::ostream &dest, Signer &signer) { const std::string normalized_format = detail::normalize_format(format); @@ -229,10 +254,10 @@ namespace c2pa // c2pa_builder_sign() uses streams synchronously and completes before returning auto result = c2pa_builder_sign(builder, normalized_format.c_str(), c_source.c_stream, c_dest.c_stream, signer.c2pa_signer(), &c2pa_manifest_bytes); - return detail::to_byte_vector(c2pa_manifest_bytes, result); + return detail::to_byte_buffer(c2pa_manifest_bytes, result); } - std::vector Builder::sign(const std::string &format, std::istream &source, std::iostream &dest, Signer &signer) + ByteBuffer Builder::sign_to_buffer(const std::string &format, std::istream &source, std::iostream &dest, Signer &signer) { const std::string normalized_format = detail::normalize_format(format); @@ -244,7 +269,7 @@ namespace c2pa // c2pa_builder_sign() uses streams synchronously and completes before returning auto result = c2pa_builder_sign(builder, normalized_format.c_str(), c_source.c_stream, c_dest.c_stream, signer.c2pa_signer(), &c2pa_manifest_bytes); - return detail::to_byte_vector(c2pa_manifest_bytes, result); + return detail::to_byte_buffer(c2pa_manifest_bytes, result); } /// @brief Sign a file and write the signed data to an output file. @@ -253,7 +278,7 @@ namespace c2pa /// @param signer A signer object to use when signing. /// @return A vector containing the signed manifest bytes. /// @throws C2pa::C2paException for errors encountered by the C2PA library. - std::vector Builder::sign(const std::filesystem::path &source_path, const std::filesystem::path &dest_path, Signer &signer) + ByteBuffer Builder::sign_to_buffer(const std::filesystem::path &source_path, const std::filesystem::path &dest_path, Signer &signer) { const std::string format = detail::resolve_format(detail::extract_file_extension(dest_path)); @@ -274,11 +299,11 @@ namespace c2pa { throw std::runtime_error("Failed to open destination file: " + dest_path.string()); } - auto result = sign(format, *source, dest, signer); + auto result = sign_to_buffer(format, *source, dest, signer); return result; } - std::vector Builder::sign(const std::string &format, std::istream &source, std::iostream &dest) + ByteBuffer Builder::sign_to_buffer(const std::string &format, std::istream &source, std::iostream &dest) { const std::string normalized_format = detail::normalize_format(format); @@ -287,10 +312,10 @@ namespace c2pa const unsigned char *c2pa_manifest_bytes = nullptr; auto result = c2pa_builder_sign_context(builder, normalized_format.c_str(), c_source.c_stream, c_dest.c_stream, &c2pa_manifest_bytes); - return detail::to_byte_vector(c2pa_manifest_bytes, result); + return detail::to_byte_buffer(c2pa_manifest_bytes, result); } - std::vector Builder::sign(const std::filesystem::path &source_path, const std::filesystem::path &dest_path) + ByteBuffer Builder::sign_to_buffer(const std::filesystem::path &source_path, const std::filesystem::path &dest_path) { const std::string format = detail::resolve_format(detail::extract_file_extension(dest_path)); @@ -310,7 +335,7 @@ namespace c2pa { throw std::runtime_error("Failed to open destination file: " + dest_path.string()); } - auto result = sign(format, *source, dest); + auto result = sign_to_buffer(format, *source, dest); return result; } @@ -404,13 +429,18 @@ namespace c2pa } std::vector Builder::placeholder(const std::string &format) + { + return placeholder_to_buffer(format).to_vector(); + } + + ByteBuffer Builder::placeholder_to_buffer(const std::string &format) { // The format selects the hash assertion written into the builder. const std::string normalized_format = detail::normalize_format(format); const unsigned char *c2pa_manifest_bytes = nullptr; auto result = c2pa_builder_placeholder(builder, normalized_format.c_str(), &c2pa_manifest_bytes); - return detail::to_byte_vector(c2pa_manifest_bytes, result); + return detail::to_byte_buffer(c2pa_manifest_bytes, result); } void Builder::set_data_hash_exclusions(const std::vector> &exclusions) @@ -444,12 +474,17 @@ namespace c2pa } std::vector Builder::sign_embeddable(const std::string &format) + { + return sign_embeddable_to_buffer(format).to_vector(); + } + + ByteBuffer Builder::sign_embeddable_to_buffer(const std::string &format) { const std::string normalized_format = detail::normalize_format(format); const unsigned char *c2pa_manifest_bytes = nullptr; auto result = c2pa_builder_sign_embeddable(builder, normalized_format.c_str(), &c2pa_manifest_bytes); - return detail::to_byte_vector(c2pa_manifest_bytes, result); + return detail::to_byte_buffer(c2pa_manifest_bytes, result); } std::vector Builder::supported_mime_types() { diff --git a/src/c2pa_context.cpp b/src/c2pa_context.cpp index c763520..e3d8a2d 100644 --- a/src/c2pa_context.cpp +++ b/src/c2pa_context.cpp @@ -40,7 +40,8 @@ namespace c2pa Context::Context(Context&& other) noexcept : context(std::exchange(other.context, nullptr)), - callback_owner_(std::move(other.callback_owner_)) { + callback_owner_(std::move(other.callback_owner_)), + signer_callback_owner_(std::move(other.signer_callback_owner_)) { } Context& Context::operator=(Context&& other) noexcept { @@ -52,6 +53,7 @@ namespace c2pa } context = std::exchange(other.context, nullptr); callback_owner_ = std::move(other.callback_owner_); + signer_callback_owner_ = std::move(other.signer_callback_owner_); } return *this; } @@ -68,6 +70,12 @@ namespace c2pa : context(ctx), callback_owner_(std::move(callback)) { } + Context::Context(C2paContext* ctx, std::unique_ptr callback, + std::unique_ptr signer_callback) noexcept + : context(ctx), callback_owner_(std::move(callback)), + signer_callback_owner_(std::move(signer_callback)) { + } + void Context::cancel() noexcept { if (context) { c2pa_context_cancel(context); @@ -102,6 +110,7 @@ namespace c2pa Context::ContextBuilder::ContextBuilder(ContextBuilder&& other) noexcept : pending_callback_(std::move(other.pending_callback_)), + pending_signer_callback_(std::move(other.pending_signer_callback_)), context_builder(std::exchange(other.context_builder, nullptr)) { } @@ -115,6 +124,7 @@ namespace c2pa } context_builder = std::exchange(other.context_builder, nullptr); pending_callback_ = std::move(other.pending_callback_); + pending_signer_callback_ = std::move(other.pending_signer_callback_); } return *this; } @@ -187,6 +197,7 @@ namespace c2pa if (c2pa_context_builder_set_signer(context_builder, raw) != 0) { throw C2paException(); } + pending_signer_callback_ = std::move(signer.callback_owner_); return *this; } @@ -267,6 +278,7 @@ namespace c2pa return ReleasedBuilder{ std::exchange(context_builder, nullptr), std::move(pending_callback_), + std::move(pending_signer_callback_), }; } @@ -282,6 +294,6 @@ namespace c2pa throw C2paException("Failed to build context"); } - return Context(ctx, std::move(pending_callback_)); + return Context(ctx, std::move(pending_callback_), std::move(pending_signer_callback_)); } } // namespace c2pa diff --git a/src/c2pa_internal.hpp b/src/c2pa_internal.hpp index ee50d32..71bf7a2 100644 --- a/src/c2pa_internal.hpp +++ b/src/c2pa_internal.hpp @@ -27,6 +27,7 @@ #include #include #include +#include #include "c2pa.h" #include "c2pa.hpp" @@ -295,17 +296,17 @@ inline std::string c_string_to_string(T* c_result) { return str; } -/// @brief Convert C byte array result to C++ vector +/// @brief Adopt a C byte-array result without copying. /// @param data Raw byte array from C API /// @param size Size of the byte array (result from C API call) -/// @return Vector containing the bytes (throws if null or negative size) -/// @details This helper extracts the pattern of checking C API results, -/// copying to a vector, and freeing the C-allocated memory. +/// @return Owning buffer (throws on invalid pointer/size combinations). +/// @details Validation failures release any returned allocation. /// The C API contract is: if result < 0, the operation failed. A null /// data pointer with size == 0 is a valid empty result (the C API /// returns null for empty byte arrays). -inline std::vector to_byte_vector(const unsigned char* data, int64_t size) { - if (size < 0 || (data == nullptr && size > 0)) { +inline ByteBuffer to_byte_buffer(const unsigned char* data, int64_t size) { + if (size < 0 || (data == nullptr && size > 0) || + static_cast(size) > std::numeric_limits::max()) { c2pa_free(data); // May be null or allocated, c2pa_free handles both throw C2paException(); } @@ -314,9 +315,32 @@ inline std::vector to_byte_vector(const unsigned char* data, int6 return {}; } - auto result = std::vector(data, data + size); - c2pa_free(data); - return result; + return ByteBuffer(data, static_cast(size)); +} + +inline std::vector to_byte_vector(const unsigned char* data, int64_t size) { + return to_byte_buffer(data, size).to_vector(); +} + +inline intptr_t signer_into_passthrough(const void* context, const unsigned char* data, + uintptr_t len, unsigned char* signature, + uintptr_t capacity) noexcept { + if (!context || !data || !signature) { + return stream_error_return(StreamError::InvalidArgument); + } + try { + const auto& callback = *static_cast(context); + const auto written = callback({data, len}, {signature, capacity}); + if (written < 0) { + return stream_error_return(StreamError::IoError); + } + if (static_cast(written) > capacity) { + return stream_error_return(StreamError::NoBufferSpace); + } + return written; + } catch (...) { + return stream_error_return(StreamError::IoError); + } } } // namespace detail diff --git a/src/c2pa_signer.cpp b/src/c2pa_signer.cpp index 9e6954e..5e1ece0 100644 --- a/src/c2pa_signer.cpp +++ b/src/c2pa_signer.cpp @@ -77,6 +77,20 @@ namespace c2pa } } + Signer::Signer(SignerIntoFunc callback, C2paSigningAlg alg, + const std::string &sign_cert, const std::string &tsa_uri) + : callback_owner_(std::make_unique(std::move(callback))) + { + if (!*callback_owner_) { + throw C2paException("Signer callback must not be empty"); + } + signer = c2pa_signer_create(callback_owner_.get(), detail::signer_into_passthrough, + alg, sign_cert.c_str(), validate_tsa_uri(tsa_uri)); + if (!signer) { + throw C2paException(); + } + } + Signer::Signer(const std::string &alg, const std::string &sign_cert, const std::string &private_key, const std::optional &tsa_uri) { auto info = C2paSignerInfo { alg.c_str(), sign_cert.c_str(), private_key.c_str(), validate_tsa_uri(tsa_uri) }; diff --git a/tests/zero_copy.test.cpp b/tests/zero_copy.test.cpp new file mode 100644 index 0000000..0c722cc --- /dev/null +++ b/tests/zero_copy.test.cpp @@ -0,0 +1,475 @@ +// Copyright 2026 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +#include +#include +#include +#include +#include + +#include "include/test_utils.hpp" +#include "../src/c2pa_internal.hpp" + +namespace { + +std::string manifest_definition() +{ + return c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); +} + +std::string signing_certs() +{ + return c2pa_test::read_text_file(c2pa_test::get_fixture_path("ed25519.pub")); +} + +const unsigned char* native_bytes() +{ + const auto key = c2pa_test::read_text_file(c2pa_test::get_fixture_path("ed25519.pem")); + const unsigned char message[] = {1, 2, 3}; + auto* bytes = c2pa_ed25519_sign(message, sizeof(message), key.c_str()); + if (!bytes) { + throw c2pa::C2paException(); + } + return bytes; +} + +c2pa::Signer direct_signer() +{ + return c2pa::Signer("Ed25519", signing_certs(), + c2pa_test::read_text_file(c2pa_test::get_fixture_path("ed25519.pem"))); +} + +c2pa::Signer callback_signer(std::shared_ptr calls) +{ + auto key = c2pa_test::read_text_file(c2pa_test::get_fixture_path("ed25519.pem")); + return c2pa::Signer( + [key, calls](c2pa::ConstBytes data, c2pa::MutableBytes dest) -> intptr_t { + ++*calls; + auto signature = c2pa::detail::to_byte_buffer( + c2pa_ed25519_sign(data.data, data.size, key.c_str()), 64); + if (dest.size < signature.size()) { + return -1; + } + std::copy(signature.begin(), signature.end(), dest.data); + return static_cast(signature.size()); + }, C2paSigningAlg::Ed25519, signing_certs(), ""); +} + +std::stringstream asset() +{ + return std::stringstream(c2pa_test::read_text_file(c2pa_test::get_fixture_path("A.jpg")), + std::ios::in | std::ios::out | std::ios::binary); +} + +void expect_valid(std::iostream& signed_asset) +{ + signed_asset.clear(); + signed_asset.seekg(0); + auto context = std::make_shared(); + auto reader = c2pa::Reader(context, "image/jpeg", signed_asset); + const auto report = nlohmann::json::parse(reader.json()); + const auto state = report.at("validation_state"); + EXPECT_TRUE(state == "Valid" || state == "Trusted") << report.dump(2); +} + +static_assert(!std::is_copy_constructible::value); +static_assert(!std::is_copy_assignable::value); +static_assert(std::is_nothrow_move_constructible::value); +static_assert(std::is_nothrow_move_assignable::value); + +TEST(ByteBuffer, EmptyAndInvalidResults) +{ + c2pa::ByteBuffer empty; + EXPECT_EQ(empty.data(), nullptr); + EXPECT_EQ(empty.begin(), empty.end()); + EXPECT_EQ(empty.size(), 0u); + EXPECT_TRUE(empty.empty()); + EXPECT_TRUE(empty.to_vector().empty()); + EXPECT_TRUE(c2pa::detail::to_byte_buffer(nullptr, 0).empty()); + EXPECT_THROW(c2pa::detail::to_byte_buffer(nullptr, 1), c2pa::C2paException); + EXPECT_THROW(c2pa::detail::to_byte_buffer(nullptr, -1), c2pa::C2paException); + + auto* bytes = native_bytes(); + EXPECT_THROW(c2pa::detail::to_byte_buffer(bytes, -1), c2pa::C2paException); + EXPECT_NE(c2pa_free(bytes), 0); + bytes = native_bytes(); + EXPECT_TRUE(c2pa::detail::to_byte_buffer(bytes, 0).empty()); + EXPECT_NE(c2pa_free(bytes), 0); +} + +TEST(ByteBuffer, IdentityMovesAndExactlyOnceRelease) +{ + auto* bytes = native_bytes(); + const std::vector expected(bytes, bytes + 64); + { + auto original = c2pa::detail::to_byte_buffer(bytes, 64); + EXPECT_EQ(original.data(), bytes); + EXPECT_EQ(original.view().data, bytes); + EXPECT_EQ(original.view().size, 64u); + EXPECT_EQ(original.to_vector(), expected); + EXPECT_EQ(c2pa::detail::to_byte_vector(native_bytes(), 64), expected); + EXPECT_EQ(std::vector(original.begin(), original.end()), expected); + c2pa::ByteBuffer moved(std::move(original)); + EXPECT_TRUE(original.empty()); + EXPECT_EQ(original.data(), nullptr); + EXPECT_EQ(moved.data(), bytes); + auto* replaced = native_bytes(); + auto assigned = c2pa::detail::to_byte_buffer(replaced, 64); + assigned = std::move(moved); + EXPECT_NE(c2pa_free(replaced), 0); + EXPECT_TRUE(moved.empty()); + auto& self = assigned; + assigned = std::move(self); + EXPECT_EQ(assigned.data(), bytes); + EXPECT_EQ(assigned.to_vector(), expected); + } + EXPECT_NE(c2pa_free(bytes), 0); +} + +TEST(ZeroCopyBuilder, AllStreamOverloadsRoundTrip) +{ + auto context = std::make_shared( + c2pa::Context::ContextBuilder().with_signer(direct_signer()).create_context()); + for (int mode = 0; mode < 3; ++mode) { + auto source = asset(); + std::stringstream dest(std::ios::in | std::ios::out | std::ios::binary); + auto signer = direct_signer(); + c2pa::ByteBuffer bytes; + { + c2pa::Builder builder(context, manifest_definition()); + if (mode == 0) { + bytes = builder.sign_to_buffer("image/jpeg", source, dest, signer); + } else if (mode == 1) { + std::ostream& output = dest; + bytes = builder.sign_to_buffer("image/jpeg", source, output, signer); + } else { + bytes = builder.sign_to_buffer("image/jpeg", source, dest); + } + } + ASSERT_FALSE(bytes.empty()); + EXPECT_EQ(bytes.to_vector(), std::vector(bytes.begin(), bytes.end())); + expect_valid(dest); + } +} + +TEST(ZeroCopyBuilder, FileOverloadsRoundTrip) +{ + auto context = std::make_shared( + c2pa::Context::ContextBuilder().with_signer(direct_signer()).create_context()); + for (bool use_context : {false, true}) { + auto path = std::filesystem::temp_directory_path() / + ("c2pa-zero-copy-" + std::to_string( + reinterpret_cast(context.get())) + + (use_context ? "-context.jpg" : "-explicit.jpg")); + struct Cleanup { + std::filesystem::path path; + ~Cleanup() { std::error_code ec; std::filesystem::remove(path, ec); } + } cleanup{path}; + c2pa::Builder builder(context, manifest_definition()); + auto signer = direct_signer(); + auto bytes = use_context + ? builder.sign_to_buffer(c2pa_test::get_fixture_path("A.jpg"), path) + : builder.sign_to_buffer(c2pa_test::get_fixture_path("A.jpg"), path, signer); + EXPECT_FALSE(bytes.empty()); + std::fstream result(path, std::ios::in | std::ios::out | std::ios::binary); + expect_valid(result); + } +} + +TEST(ZeroCopyBuilder, EmbeddableAndVectorEquivalence) +{ + auto context = std::make_shared( + c2pa::Context::ContextBuilder().with_signer(direct_signer()).create_context()); + c2pa::Builder builder(context, c2pa_test::prehashed_training_manifest()); + auto bytes = builder.sign_embeddable_to_buffer("application/c2pa"); + auto vector = builder.sign_embeddable("application/c2pa"); + auto reading_context = std::make_shared( + R"({"verify":{"verify_after_reading":false}})"); + auto assertions = [&](const unsigned char* data, size_t size) { + std::istringstream input(std::string(reinterpret_cast(data), size)); + c2pa::Reader reader(reading_context, "application/c2pa", input); + auto report = nlohmann::json::parse(reader.json()); + return report["manifests"][report["active_manifest"].get()]["assertions"]; + }; + // Independent signing calls generate fresh labels and assertion salts. + // Compare decoded assertions here; the adapter test checks exact byte equality. + EXPECT_EQ(assertions(bytes.data(), bytes.size()), assertions(vector.data(), vector.size())); + EXPECT_THROW(builder.sign_embeddable_to_buffer(""), c2pa::C2paException); + + for (const auto* format : {"image/jpeg", "image/png"}) { + c2pa::Builder buffered(context, manifest_definition()); + c2pa::Builder copied(context, manifest_definition()); + auto placeholder = buffered.placeholder_to_buffer(format); + auto vector_placeholder = copied.placeholder(format); + EXPECT_EQ(placeholder.size(), vector_placeholder.size()); + ASSERT_GT(placeholder.size(), 8u); + EXPECT_TRUE(std::equal(placeholder.begin(), placeholder.begin() + 8, + vector_placeholder.begin())); + buffered.set_data_hash_exclusions({{20, placeholder.size()}}); + auto source = asset(); + buffered.update_hash_from_stream(format, source); + auto final = buffered.sign_embeddable_to_buffer(format); + EXPECT_EQ(final.size(), placeholder.size()); + } + c2pa::Builder no_signer(std::make_shared(), manifest_definition()); + EXPECT_THROW(no_signer.placeholder_to_buffer("image/jpeg"), c2pa::C2paException); + auto source = asset(); + std::stringstream dest; + EXPECT_THROW(no_signer.sign_to_buffer("image/jpeg", source, dest), c2pa::C2paException); +} + +TEST(SignerInto, AdapterForwardsExactPointersAndLengths) +{ + std::array payload{1, 2, 3, 4, 5}; + std::array signature{}; + c2pa::SignerIntoFunc callback = [&](c2pa::ConstBytes data, c2pa::MutableBytes dest) { + EXPECT_EQ(data.data, payload.data() + 1); + EXPECT_EQ(data.size, 3u); + EXPECT_EQ(dest.data, signature.data() + 2); + EXPECT_EQ(dest.size, 6u); + dest.data[0] = 42; + return 1; + }; + EXPECT_EQ(c2pa::detail::signer_into_passthrough( + &callback, payload.data() + 1, 3, signature.data() + 2, 6), 1); + EXPECT_EQ(signature[2], 42); +} + +TEST(SignerInto, AdapterRejectsOversizeExceptionsAndInvalidArguments) +{ + unsigned char byte = 0; + c2pa::SignerIntoFunc callback = [](c2pa::ConstBytes, c2pa::MutableBytes dest) { + return static_cast(dest.size + 1); + }; + EXPECT_EQ(c2pa::detail::signer_into_passthrough(&callback, &byte, 1, &byte, 1), -1); + EXPECT_EQ(errno, ENOBUFS); + callback = [](c2pa::ConstBytes, c2pa::MutableBytes) -> intptr_t { throw 42; }; + EXPECT_EQ(c2pa::detail::signer_into_passthrough(&callback, &byte, 1, &byte, 1), -1); + EXPECT_EQ(errno, EIO); + callback = [](c2pa::ConstBytes, c2pa::MutableBytes) { return -2; }; + EXPECT_EQ(c2pa::detail::signer_into_passthrough(&callback, &byte, 1, &byte, 1), -1); + EXPECT_EQ(errno, EIO); + EXPECT_EQ(c2pa::detail::signer_into_passthrough(nullptr, &byte, 1, &byte, 1), -1); + EXPECT_EQ(errno, EINVAL); +} + +TEST(SignerInto, MovesContextTransferAndSharedBuilderLifetime) +{ + auto calls = std::make_shared(0); + std::weak_ptr lifetime = calls; + auto signer = callback_signer(calls); + c2pa::Signer moved(std::move(signer)); + auto assigned = direct_signer(); + assigned = std::move(moved); + auto& self = assigned; + assigned = std::move(self); + ASSERT_EQ(signer.c2pa_signer(), nullptr); + ASSERT_EQ(moved.c2pa_signer(), nullptr); + c2pa::Context::ContextBuilder config; + config.with_signer(std::move(assigned)); + auto moved_config = std::move(config); + c2pa::Context::ContextBuilder final_config; + final_config = std::move(moved_config); + auto context = final_config.create_context(); + auto moved_context = std::move(context); + c2pa::Context final_context; + final_context = std::move(moved_context); + auto shared = std::make_shared(std::move(final_context)); + { + c2pa::Builder builder(shared, manifest_definition()); + shared.reset(); + auto source = asset(); + std::stringstream dest; + EXPECT_FALSE(builder.sign_to_buffer("image/jpeg", source, dest).empty()); + expect_valid(dest); + EXPECT_GT(*calls, 0); + calls.reset(); + EXPECT_FALSE(lifetime.expired()); + } + EXPECT_TRUE(lifetime.expired()); +} + +TEST(SignerInto, ReleaseCarriesStableCallbackOwnerAndReplacementCleansUp) +{ + auto calls = std::make_shared(0); + std::weak_ptr lifetime = calls; + { + c2pa::Context::ContextBuilder builder; + builder.with_signer(callback_signer(calls)); + calls.reset(); + auto released = builder.release(); + ASSERT_NE(released.signer_callback_owner, nullptr); + auto* address = released.signer_callback_owner.get(); + auto moved = std::move(released); + EXPECT_EQ(moved.signer_callback_owner.get(), address); + auto* native = c2pa_context_builder_build(moved.builder); + ASSERT_NE(native, nullptr); + c2pa::Context context(native, std::move(moved.callback_owner), + std::move(moved.signer_callback_owner)); + auto source = asset(); + std::stringstream dest; + c2pa::Builder manifest(context, manifest_definition()); + EXPECT_FALSE(manifest.sign_to_buffer("image/jpeg", source, dest).empty()); + expect_valid(dest); + } + EXPECT_TRUE(lifetime.expired()); + + calls = std::make_shared(0); + lifetime = calls; + c2pa::Context::ContextBuilder builder; + builder.with_signer(callback_signer(calls)); + calls.reset(); + builder.with_signer(direct_signer()); + EXPECT_TRUE(lifetime.expired()); +} + +TEST(SignerInto, FailuresReleaseCapturesAndPropagate) +{ + EXPECT_THROW(c2pa::Signer(c2pa::SignerIntoFunc{}, C2paSigningAlg::Ed25519, + signing_certs(), ""), c2pa::C2paException); + for (int failure = 0; failure < 4; ++failure) { + auto marker = std::make_shared(0); + std::weak_ptr lifetime = marker; + { + c2pa::Signer signer([marker, failure](c2pa::ConstBytes, c2pa::MutableBytes dest) -> intptr_t { + ++*marker; + if (failure == 0) { return static_cast(dest.size + 1); } + if (failure == 1) { throw std::runtime_error("signing failed"); } + if (failure == 2) { return -1; } + dest.data[0] = 0; + return 1; + }, C2paSigningAlg::Ed25519, signing_certs(), ""); + auto context = std::make_shared(); + c2pa::Builder builder(context, manifest_definition()); + auto source = asset(); + std::stringstream dest; + EXPECT_THROW(builder.sign_to_buffer("image/jpeg", source, dest, signer), c2pa::C2paException); + EXPECT_GT(*marker, 0); + marker.reset(); + } + EXPECT_TRUE(lifetime.expired()); + } +} + +TEST(SignerInto, StandaloneMoveReplacementAndConfigurationFailureReleaseCaptures) +{ + auto old_calls = std::make_shared(0); + auto new_calls = std::make_shared(0); + std::weak_ptr old_lifetime = old_calls; + std::weak_ptr new_lifetime = new_calls; + { + auto signer = callback_signer(old_calls); + auto replacement = callback_signer(new_calls); + old_calls.reset(); + signer = std::move(replacement); + EXPECT_TRUE(old_lifetime.expired()); + c2pa::Builder builder(std::make_shared(), manifest_definition()); + auto source = asset(); + std::stringstream dest; + EXPECT_FALSE(builder.sign_to_buffer("image/jpeg", source, dest, signer).empty()); + expect_valid(dest); + EXPECT_GT(*new_calls, 0); + new_calls.reset(); + } + EXPECT_TRUE(new_lifetime.expired()); + + auto calls = std::make_shared(0); + std::weak_ptr lifetime = calls; + auto signer = callback_signer(calls); + calls.reset(); + EXPECT_THROW(c2pa::Context::ContextBuilder() + .with_signer(std::move(signer)).with_json("not-json"), c2pa::C2paException); + EXPECT_TRUE(lifetime.expired()); +} + +TEST(SignerInto, ThrowingCallableCopyDoesNotLeakDuringConstruction) +{ + struct Captured { + int* destructions; + ~Captured() { ++*destructions; } + }; + struct Callable { + std::shared_ptr capture; + std::shared_ptr reject_copy; + Callable(std::shared_ptr state, std::shared_ptr reject) + : capture(std::move(state)), reject_copy(std::move(reject)) {} + Callable(const Callable& other) + : capture(other.capture), reject_copy(other.reject_copy) { + if (*reject_copy) { + throw std::runtime_error("callable copy failed"); + } + } + intptr_t operator()(c2pa::ConstBytes, c2pa::MutableBytes) const { return -1; } + }; + int destructions = 0; + auto capture = std::make_shared(); + capture->destructions = &destructions; + auto reject = std::make_shared(false); + c2pa::SignerIntoFunc callback = Callable(capture, reject); + capture.reset(); + *reject = true; + EXPECT_THROW(c2pa::Signer(callback, C2paSigningAlg::Ed25519, signing_certs(), ""), + std::runtime_error); + EXPECT_EQ(destructions, 0); + callback = nullptr; + EXPECT_EQ(destructions, 1); +} + +class RecordingStreambuf : public std::streambuf { +public: + char* read_pointer = nullptr; + const char* write_pointer = nullptr; + std::streamsize read_size = 0; + std::streamsize write_size = 0; +protected: + std::streamsize xsgetn(char* data, std::streamsize size) override { + read_pointer = data; + read_size = size; + std::memset(data, 7, static_cast(size)); + return size; + } + std::streamsize xsputn(const char* data, std::streamsize size) override { + write_pointer = data; + write_size = size; + return size; + } +}; + +TEST(ZeroCopyStreams, SharedAdaptersPreserveCallbackPointers) +{ + RecordingStreambuf buffer; + std::array bytes{}; + std::istream input(&buffer); + std::ostream output(&buffer); + std::iostream io(&buffer); + auto read = [&](auto& stream) { + using Stream = std::decay_t; + EXPECT_EQ(c2pa::detail::stream_reader( + reinterpret_cast(&stream), bytes.data() + 1, 5), 5); + EXPECT_EQ(buffer.read_pointer, reinterpret_cast(bytes.data() + 1)); + EXPECT_EQ(buffer.read_size, 5); + EXPECT_EQ(bytes[1], 7); + }; + auto write = [&](auto& stream) { + using Stream = std::decay_t; + EXPECT_EQ(c2pa::detail::stream_writer( + reinterpret_cast(&stream), bytes.data() + 2, 3), 3); + EXPECT_EQ(buffer.write_pointer, reinterpret_cast(bytes.data() + 2)); + EXPECT_EQ(buffer.write_size, 3); + }; + read(input); + write(output); + read(io); + write(io); +} + +} // namespace