From e7b7a0b59848e6d144c05fe6f34b204eb8983794 Mon Sep 17 00:00:00 2001 From: Gavin Peacock Date: Tue, 24 Mar 2026 15:52:56 -0700 Subject: [PATCH 1/3] feat: add C++ wrappers for progress/cancel C FFI APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the progress/cancel feature added to c2pa-rs in PR #1927 (merged to main 2026-03-24, not yet on crates.io). Public API additions (c2pa.hpp): - `c2pa::ProgressCallbackFunc` — std::function alias for the callback. Signature: `bool(C2paProgressPhase phase, uint32_t step, uint32_t total)`. Return false to request cancellation. - `Context::ContextBuilder::with_progress_callback(ProgressCallbackFunc)` Attaches a progress callback to the context being built. Chainable with with_settings(), with_json(), and with_signer(). The heap-allocated std::function is owned by the resulting Context and freed on destruction. - `Context::cancel() noexcept` Requests cancellation of the current in-progress operation. Safe to call from another thread. Implementation (c2pa_context.cpp): - `progress_callback_trampoline` — static C-compatible trampoline that forwards (user_data, phase, step, total) to the stored std::function. - `ContextBuilder::pending_callback_` — unique_ptr holding the heap-allocated callback between with_progress_callback() and create_context(). - `Context::callback_owner_` — void* that takes ownership of the raw pointer on create_context(); deleted in ~Context() and forwarded by move ops. Tests (context.test.cpp): - ProgressCallback_InvokedDuringSigning / _InvokedDuringReading - ProgressCallback_StepAndTotalValues - ProgressCallback_ReturnFalseCancels - ProgressCallback_CancelMethodAbortsOperation - CancelWithoutCallback_IsNoOp - ProgressCallback_ChainWithSettings - ProgressCallback_SurvivesContextMove / _SurvivesBuilderMove Requires: c2pa-rs >= 0.79.0 built from source (C2PA_BUILD_FROM_SOURCE=ON) pointing to a c2pa-rs checkout containing the progress/cancel feature. Made-with: Cursor --- include/c2pa.hpp | 56 ++++++++++++ src/c2pa_context.cpp | 48 +++++++++- tests/context.test.cpp | 195 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+), 3 deletions(-) diff --git a/include/c2pa.hpp b/include/c2pa.hpp index 52537bc..8a43573 100644 --- a/include/c2pa.hpp +++ b/include/c2pa.hpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -237,6 +238,21 @@ namespace c2pa C2paSettings* settings_ptr; }; + /// @brief Type alias for the progress callback passed to ContextBuilder::with_progress_callback(). + /// + /// @details The callback is invoked at each major phase of signing and reading operations. + /// Returning false from the callback aborts the operation with an + /// OperationCancelled error (equivalent to calling Context::cancel()). + /// + /// @param phase Current operation phase (C2paProgressPhase value from c2pa.h). + /// @param step 1-based step index within the phase; 0 when indeterminate. + /// @param total Total steps in the phase; 0 when indeterminate. + /// @return true to continue the operation, false to request cancellation. + /// + /// @note This feature requires c2pa-rs >= 0.79.0. Build with C2PA_BUILD_FROM_SOURCE + /// pointing to a c2pa-rs checkout that includes the progress/cancel feature. + using ProgressCallbackFunc = std::function; + /// @brief C2PA context implementing IContextProvider. /// @details Context objects manage C2PA SDK configuration and state. /// Contexts can be created via direct construction or the ContextBuilder: @@ -311,6 +327,31 @@ namespace c2pa /// @throws C2paException if the builder or signer is invalid. ContextBuilder& with_signer(Signer&& signer); + /// @brief Attach a progress callback to the context being built. + /// + /// @details The callback is invoked at each major phase of signing and + /// reading operations performed with the resulting context. + /// Return false from the callback to abort the current operation + /// with an OperationCancelled error. + /// + /// Phases emitted during a typical sign cycle (in order): + /// VerifyingIngredient → VerifyingManifest → VerifyingSignature → + /// VerifyingAssetHash → Thumbnail → Hashing → Signing → Embedding → + /// (if verify_after_sign) VerifyingManifest → … → VerifyingIngredient + /// + /// Phases emitted during reading: + /// Reading → VerifyingManifest → VerifyingSignature → + /// VerifyingAssetHash → VerifyingIngredient + /// + /// @param callback A callable matching ProgressCallbackFunc. The callback is + /// heap-allocated and owned by the resulting Context. If called more than + /// once the previous callback is replaced. + /// @return Reference to this ContextBuilder for method chaining. + /// @throws C2paException if the builder is invalid or the C API call fails. + /// + /// @note Requires c2pa-rs >= 0.79.0 (progress/cancel feature). + ContextBuilder& with_progress_callback(ProgressCallbackFunc callback); + /// @brief Create a Context from the current builder configuration. /// @return A new Context instance. /// @throws C2paException if context creation fails. @@ -319,6 +360,7 @@ namespace c2pa private: C2paContextBuilder* context_builder; + std::unique_ptr pending_callback_; }; // Direct construction @@ -369,8 +411,22 @@ namespace c2pa /// @throws C2paException if ctx is nullptr. explicit Context(C2paContext* ctx); + /// @brief Request cancellation of any in-progress operation on this context. + /// + /// @details May be called safely from another thread while a signing or reading + /// operation is running. The operation is aborted with an + /// OperationCancelled error at the next progress checkpoint. + /// Has no effect if no operation is currently in progress. + /// + /// @note Requires c2pa-rs >= 0.79.0 (progress/cancel feature). + void cancel() noexcept; + private: C2paContext* context; + + /// Heap-owned ProgressCallbackFunc; non-null only when set via + /// ContextBuilder::with_progress_callback(). Deleted in the destructor. + void* callback_owner_ = nullptr; }; /// @brief Get the version of the C2PA library. diff --git a/src/c2pa_context.cpp b/src/c2pa_context.cpp index 5d81791..510fc7b 100644 --- a/src/c2pa_context.cpp +++ b/src/c2pa_context.cpp @@ -38,19 +38,29 @@ namespace c2pa } Context::Context(Context&& other) noexcept - : context(std::exchange(other.context, nullptr)) { + : context(std::exchange(other.context, nullptr)), + callback_owner_(std::exchange(other.callback_owner_, nullptr)) { } Context& Context::operator=(Context&& other) noexcept { if (this != &other) { c2pa_free(context); + delete static_cast(callback_owner_); context = std::exchange(other.context, nullptr); + callback_owner_ = std::exchange(other.callback_owner_, nullptr); } return *this; } Context::~Context() noexcept { c2pa_free(context); + delete static_cast(callback_owner_); + } + + void Context::cancel() noexcept { + if (context) { + c2pa_context_cancel(context); + } } C2paContext* Context::c_context() const noexcept { @@ -80,13 +90,15 @@ namespace c2pa } Context::ContextBuilder::ContextBuilder(ContextBuilder&& other) noexcept - : context_builder(std::exchange(other.context_builder, nullptr)) { + : context_builder(std::exchange(other.context_builder, nullptr)), + pending_callback_(std::move(other.pending_callback_)) { } Context::ContextBuilder& Context::ContextBuilder::operator=(ContextBuilder&& other) noexcept { if (this != &other) { c2pa_free(context_builder); context_builder = std::exchange(other.context_builder, nullptr); + pending_callback_ = std::move(other.pending_callback_); } return *this; } @@ -154,6 +166,32 @@ namespace c2pa return *this; } + // C trampoline: delegates to the ProgressCallbackFunc stored in user_data. + static bool progress_callback_trampoline(void* user_data, + C2paProgressPhase phase, + uint32_t step, + uint32_t total) { + auto* cb = static_cast(user_data); + return (*cb)(phase, step, total); + } + + Context::ContextBuilder& Context::ContextBuilder::with_progress_callback(ProgressCallbackFunc callback) { + if (!is_valid()) { + throw C2paException("ContextBuilder is invalid (moved from)"); + } + // Heap-allocate the std::function so we can pass a stable pointer to the C API. + // The resulting Context takes ownership of this allocation. + pending_callback_ = std::make_unique(std::move(callback)); + if (c2pa_context_builder_set_progress_callback( + context_builder, + pending_callback_.get(), + progress_callback_trampoline) != 0) { + pending_callback_.reset(); + throw C2paException(); + } + return *this; + } + Context Context::ContextBuilder::create_context() { if (!is_valid()) { throw C2paException("ContextBuilder is invalid (moved from)"); @@ -168,6 +206,10 @@ namespace c2pa // Builder is consumed by the C API context_builder = nullptr; - return Context(ctx); + Context result(ctx); + // Transfer progress callback heap ownership to the Context so it is freed + // when the Context is destroyed (the C side holds a raw pointer to it). + result.callback_owner_ = pending_callback_.release(); + return result; } } // namespace c2pa diff --git a/tests/context.test.cpp b/tests/context.test.cpp index 8a4af20..d0ea4ab 100644 --- a/tests/context.test.cpp +++ b/tests/context.test.cpp @@ -12,9 +12,11 @@ #include #include +#include #include #include #include +#include #include #include "c2pa.hpp" @@ -489,3 +491,196 @@ TEST(Context, ContextBuilderWithSettingsAndSigner) { .create_context(); EXPECT_TRUE(context.is_valid()); } + +// --- Progress / Cancel API tests --- +// These tests require c2pa-rs >= 0.79.0 built from source with the progress/cancel feature. + +// Helper: sign a file and return the signed path, using a context with a progress callback. +static fs::path sign_with_progress_context(c2pa::IContextProvider& context, const fs::path& dest) { + auto manifest = 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 asset_path = c2pa_test::get_fixture_path("A.jpg"); + + c2pa::Builder builder(context, manifest); + c2pa::Signer signer("es256", certs, private_key); + builder.sign(asset_path, dest, signer); + return dest; +} + +// Callback is invoked at least once during a sign operation. +TEST_F(ContextTest, ProgressCallback_InvokedDuringSigning) { + std::atomic call_count{0}; + + auto context = c2pa::Context::ContextBuilder() + .with_progress_callback([&](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + ++call_count; + return true; + }) + .create_context(); + + ASSERT_TRUE(context.is_valid()); + EXPECT_NO_THROW(sign_with_progress_context(context, get_temp_path("progress_signing.jpg"))); + EXPECT_GT(call_count.load(), 0); +} + +// Callback is invoked at least once during a read operation. +TEST_F(ContextTest, ProgressCallback_InvokedDuringReading) { + // First sign a file without a callback so we have something to read. + { + c2pa::Context sign_ctx; + sign_with_progress_context(sign_ctx, get_temp_path("progress_read_src.jpg")); + } + + std::atomic call_count{0}; + + auto context = c2pa::Context::ContextBuilder() + .with_progress_callback([&](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + ++call_count; + return true; + }) + .create_context(); + + ASSERT_TRUE(context.is_valid()); + EXPECT_NO_THROW({ + c2pa::Reader reader(context, get_temp_path("progress_read_src.jpg")); + (void)reader.json(); + }); + EXPECT_GT(call_count.load(), 0); +} + +// Callback receives non-negative step and total values. +TEST_F(ContextTest, ProgressCallback_StepAndTotalValues) { + bool saw_valid_step = false; + + auto context = c2pa::Context::ContextBuilder() + .with_progress_callback([&](C2paProgressPhase /*phase*/, uint32_t step, uint32_t total) { + // step is 1-based when total > 0; both may be 0 for indeterminate phases. + if (total > 0) { + EXPECT_GE(step, 1u); + EXPECT_LE(step, total); + saw_valid_step = true; + } + return true; + }) + .create_context(); + + ASSERT_TRUE(context.is_valid()); + EXPECT_NO_THROW(sign_with_progress_context(context, get_temp_path("progress_step_total.jpg"))); + EXPECT_TRUE(saw_valid_step); +} + +// Returning false from the callback causes the operation to be cancelled. +TEST_F(ContextTest, ProgressCallback_ReturnFalseCancels) { + // Cancel on the very first callback invocation. + auto context = c2pa::Context::ContextBuilder() + .with_progress_callback([](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + return false; // request cancellation + }) + .create_context(); + + ASSERT_TRUE(context.is_valid()); + EXPECT_THROW( + sign_with_progress_context(context, get_temp_path("progress_cancel_false.jpg")), + c2pa::C2paException + ); +} + +// Context::cancel() called before an operation prevents that operation from completing. +TEST_F(ContextTest, ProgressCallback_CancelMethodAbortsOperation) { + auto context = c2pa::Context::ContextBuilder() + .with_progress_callback([](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + return true; + }) + .create_context(); + + ASSERT_TRUE(context.is_valid()); + + // Cancel is called from within the callback (simulates a cross-thread cancel). + c2pa::Context* ctx_ptr = &context; + bool cancel_called = false; + auto context2 = c2pa::Context::ContextBuilder() + .with_progress_callback([&](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + if (!cancel_called) { + cancel_called = true; + ctx_ptr->cancel(); + } + return true; // continue returning true; cancellation is handled via cancel() + }) + .create_context(); + + ASSERT_TRUE(context2.is_valid()); + ctx_ptr = &context2; + EXPECT_THROW( + sign_with_progress_context(context2, get_temp_path("progress_cancel_method.jpg")), + c2pa::C2paException + ); +} + +// Context::cancel() is safe to call on a context without a callback. +TEST(Context, CancelWithoutCallback_IsNoOp) { + c2pa::Context context; + ASSERT_TRUE(context.is_valid()); + EXPECT_NO_THROW(context.cancel()); +} + +// with_progress_callback can be chained with with_settings. +TEST_F(ContextTest, ProgressCallback_ChainWithSettings) { + std::atomic call_count{0}; + + c2pa::Settings settings; + settings.set("builder.thumbnail.enabled", "false"); + + auto context = c2pa::Context::ContextBuilder() + .with_settings(settings) + .with_progress_callback([&](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + ++call_count; + return true; + }) + .create_context(); + + ASSERT_TRUE(context.is_valid()); + auto manifest_json = sign_with_context(context, get_temp_path("progress_chain_settings.jpg")); + EXPECT_GT(call_count.load(), 0); + EXPECT_FALSE(has_thumbnail(manifest_json)); +} + +// Context with a progress callback can be move-constructed; callback is still valid. +TEST_F(ContextTest, ProgressCallback_SurvivesContextMove) { + std::atomic call_count{0}; + + auto original = c2pa::Context::ContextBuilder() + .with_progress_callback([&](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + ++call_count; + return true; + }) + .create_context(); + + c2pa::Context moved_to(std::move(original)); + EXPECT_FALSE(original.is_valid()); + ASSERT_TRUE(moved_to.is_valid()); + + EXPECT_NO_THROW(sign_with_progress_context(moved_to, get_temp_path("progress_move.jpg"))); + EXPECT_GT(call_count.load(), 0); +} + +// ContextBuilder with a progress callback can be move-constructed; callback is transferred. +TEST_F(ContextTest, ProgressCallback_SurvivesBuilderMove) { + std::atomic call_count{0}; + + auto b1 = c2pa::Context::ContextBuilder() + .with_progress_callback([&](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + ++call_count; + return true; + }); + + auto b2 = std::move(b1); + EXPECT_FALSE(b1.is_valid()); + ASSERT_TRUE(b2.is_valid()); + + auto context = b2.create_context(); + ASSERT_TRUE(context.is_valid()); + + EXPECT_NO_THROW(sign_with_progress_context(context, get_temp_path("progress_builder_move.jpg"))); + EXPECT_GT(call_count.load(), 0); +} From fedbaccbf7973a08086b499aefb3ebe7261bda06 Mon Sep 17 00:00:00 2001 From: Gavin Peacock Date: Fri, 27 Mar 2026 18:00:20 -0700 Subject: [PATCH 2/3] feat: Update progress/cancel API to published c2pa-rs 0.78.7 Bump c2pa-rs dependency from pre-release 0.78.4 to published 0.78.7; bump project to 0.18.3 Replace C2paProgressPhase (C unscoped enum) with c2pa::ProgressPhase scoped enum class in the public API; add static_asserts to catch any future C/C++ enum divergence at compile time Fix trampoline signature to match published ABI (const void*, int return, enum-typed phase) Update tests and remove stale @note Requires c2pa-rs >= 0.79.0 comments Add progress/cancel section to context-settings.md --- .cursor/rules/git-workflow.mdc | 12 ++++ CMakeLists.txt | 4 +- docs/context-settings.md | 122 +++++++++++++++++++++++++++++++++ include/c2pa.hpp | 48 ++++++++++--- src/c2pa_context.cpp | 32 +++++++-- tests/context.test.cpp | 28 ++++---- 6 files changed, 213 insertions(+), 33 deletions(-) create mode 100644 .cursor/rules/git-workflow.mdc diff --git a/.cursor/rules/git-workflow.mdc b/.cursor/rules/git-workflow.mdc new file mode 100644 index 0000000..0eb44fd --- /dev/null +++ b/.cursor/rules/git-workflow.mdc @@ -0,0 +1,12 @@ +--- +description: Git commit and branch workflow preferences +alwaysApply: true +--- + +# Git Workflow + +- **Never commit without explicit approval.** After making changes, show a summary and ask the user if they want to commit before running any `git commit` command. +- **Run the project's unit tests and confirm they pass before proposing a commit.** If tests fail, fix them first. For this repo that is `make test`. +- Never push to remote unless explicitly asked. +- Never force-push. +- Always show `git diff --stat` or a plain-language summary of changes before proposing a commit message. diff --git a/CMakeLists.txt b/CMakeLists.txt index 35bf6e7..52d4d3d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,10 +14,10 @@ cmake_minimum_required(VERSION 3.27) # This is the current version of this C++ project -project(c2pa-c VERSION 0.18.2) +project(c2pa-c VERSION 0.18.3) # Set the version of the c2pa_rs library used -set(C2PA_VERSION "0.78.4") +set(C2PA_VERSION "0.78.7") set(CMAKE_POLICY_DEFAULT_CMP0135 NEW) set(CMAKE_C_STANDARD 17) diff --git a/docs/context-settings.md b/docs/context-settings.md index 241485e..d5a0a19 100644 --- a/docs/context-settings.md +++ b/docs/context-settings.md @@ -162,8 +162,130 @@ auto context = c2pa::Context::ContextBuilder() | `with_settings(settings)` | Apply a `Settings` object | | `with_json(json_string)` | Apply settings from a JSON string | | `with_json_settings_file(path)` | Load and apply settings from a JSON file | +| `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) | +## Progress callbacks and cancellation + +You can register a callback on a `Context` to receive progress notifications during signing and reading operations, and to cancel an operation in flight. + +### Registering a callback + +Use `ContextBuilder::with_progress_callback` to attach a callback before building the context: + +```cpp +#include + +std::atomic phase_count{0}; + +auto context = c2pa::Context::ContextBuilder() + .with_progress_callback([&](c2pa::ProgressPhase phase, uint32_t step, uint32_t total) { + ++phase_count; + // Return true to continue, false to cancel. + return true; + }) + .create_context(); + +// Use the context normally — the callback fires automatically. +c2pa::Builder builder(context, manifest_json); +builder.sign("source.jpg", "output.jpg", signer); +``` + +The callback signature is: + +```cpp +bool callback(c2pa::ProgressPhase phase, uint32_t step, uint32_t total); +``` + +- **`phase`** — which stage the SDK is in (see [`ProgressPhase` values](#progressphase-values) below). +- **`step`** — monotonically increasing counter within the current phase, starting at `1`. Resets to `1` at the start of each new phase. Use as a liveness signal: a rising `step` means the SDK is making forward progress. +- **`total`** — `0` = indeterminate (show a spinner); `1` = single-shot phase; `> 1` = determinate (`step / total` gives a completion fraction). +- **Return value** — return `true` to continue, `false` to request cancellation (same effect as calling `context.cancel()`). + +### Cancelling from another thread + +Call `Context::cancel()` from any thread to abort the current operation. The SDK returns a `C2paException` with an `OperationCancelled` error at the next progress checkpoint: + +```cpp +#include + +auto context = c2pa::Context::ContextBuilder() + .with_progress_callback([](c2pa::ProgressPhase, uint32_t, uint32_t) { + return true; // Don't cancel from the callback — use cancel() instead. + }) + .create_context(); + +// Kick off a cancel after 500 ms from a background thread. +std::thread cancel_thread([&context]() { + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + context.cancel(); +}); + +try { + c2pa::Builder builder(context, manifest_json); + builder.sign("large_file.jpg", "output.jpg", signer); +} catch (const c2pa::C2paException& e) { + // "OperationCancelled" if cancel() fired before signing completed. +} + +cancel_thread.join(); +``` + +`cancel()` is safe to call even if no operation is in progress — it is a no-op in that case. + +### `ProgressPhase` values + +| Phase | When emitted | +|-------|-------------| +| `Reading` | Start of a read/verification pass | +| `VerifyingManifest` | Manifest structure is being validated | +| `VerifyingSignature` | COSE signature is being verified | +| `VerifyingIngredient` | An ingredient manifest is being verified | +| `VerifyingAssetHash` | Asset hash is being computed and checked | +| `AddingIngredient` | An ingredient is being embedded | +| `Thumbnail` | A thumbnail is being generated | +| `Hashing` | Asset data is being hashed for signing | +| `Signing` | Claim is being signed | +| `Embedding` | Signed manifest is being embedded into the asset | +| `FetchingRemoteManifest` | A remote manifest URL is being fetched | +| `Writing` | Output is being written | +| `FetchingOCSP` | OCSP certificate status is being fetched | +| `FetchingTimestamp` | A timestamp is being fetched from a TSA | + +**Typical phase sequence during signing:** + +``` +AddingIngredient → Thumbnail → Hashing → Signing → Embedding +``` + +If `verify_after_sign` is enabled, verification phases follow: + +``` +→ VerifyingManifest → VerifyingSignature → VerifyingAssetHash → VerifyingIngredient +``` + +**Typical phase sequence during reading:** + +``` +Reading → VerifyingManifest → VerifyingSignature → VerifyingAssetHash → VerifyingIngredient +``` + +### Combining with other settings + +`with_progress_callback` chains with other `ContextBuilder` methods: + +```cpp +auto context = c2pa::Context::ContextBuilder() + .with_settings(settings) + .with_signer(std::move(signer)) + .with_progress_callback([](c2pa::ProgressPhase phase, uint32_t step, uint32_t total) { + // Update a UI progress bar, log phases, etc. + return true; + }) + .create_context(); +``` + ## Common configuration patterns Common configurations include: diff --git a/include/c2pa.hpp b/include/c2pa.hpp index 8a43573..63a2a02 100644 --- a/include/c2pa.hpp +++ b/include/c2pa.hpp @@ -238,20 +238,50 @@ namespace c2pa C2paSettings* settings_ptr; }; + /// @brief Phase values reported to the ProgressCallbackFunc. + /// + /// @details A scoped C++ mirror of `C2paProgressPhase` from c2pa.h. + /// Values are verified at compile time to match the C enum, so any + /// future divergence in c2pa-rs will be caught as a build error. + /// + /// Phases emitted during a typical sign cycle (in order): + /// AddingIngredient → Thumbnail → Hashing → Signing → Embedding → + /// (if verify_after_sign) VerifyingManifest → VerifyingSignature → + /// VerifyingAssetHash → VerifyingIngredient + /// + /// Phases emitted during reading: + /// Reading → VerifyingManifest → VerifyingSignature → + /// VerifyingAssetHash → VerifyingIngredient + enum class ProgressPhase : uint8_t { + Reading = 0, + VerifyingManifest = 1, + VerifyingSignature = 2, + VerifyingIngredient = 3, + VerifyingAssetHash = 4, + AddingIngredient = 5, + Thumbnail = 6, + Hashing = 7, + Signing = 8, + Embedding = 9, + FetchingRemoteManifest = 10, + Writing = 11, + FetchingOCSP = 12, + FetchingTimestamp = 13, + }; + /// @brief Type alias for the progress callback passed to ContextBuilder::with_progress_callback(). /// /// @details The callback is invoked at each major phase of signing and reading operations. /// Returning false from the callback aborts the operation with an /// OperationCancelled error (equivalent to calling Context::cancel()). /// - /// @param phase Current operation phase (C2paProgressPhase value from c2pa.h). - /// @param step 1-based step index within the phase; 0 when indeterminate. - /// @param total Total steps in the phase; 0 when indeterminate. + /// @param phase Current operation phase. + /// @param step 1-based step index within the phase. + /// 0 = indeterminate (use as liveness signal); resets to 1 at each new phase. + /// @param total 0 = indeterminate; 1 = single-shot; >1 = determinate (step/total = fraction). /// @return true to continue the operation, false to request cancellation. /// - /// @note This feature requires c2pa-rs >= 0.79.0. Build with C2PA_BUILD_FROM_SOURCE - /// pointing to a c2pa-rs checkout that includes the progress/cancel feature. - using ProgressCallbackFunc = std::function; + using ProgressCallbackFunc = std::function; /// @brief C2PA context implementing IContextProvider. /// @details Context objects manage C2PA SDK configuration and state. @@ -344,12 +374,11 @@ namespace c2pa /// VerifyingAssetHash → VerifyingIngredient /// /// @param callback A callable matching ProgressCallbackFunc. The callback is - /// heap-allocated and owned by the resulting Context. If called more than - /// once the previous callback is replaced. + /// heap-allocated and owned by the resulting Context. Calling this method + /// more than once on the same builder replaces the previous callback. /// @return Reference to this ContextBuilder for method chaining. /// @throws C2paException if the builder is invalid or the C API call fails. /// - /// @note Requires c2pa-rs >= 0.79.0 (progress/cancel feature). ContextBuilder& with_progress_callback(ProgressCallbackFunc callback); /// @brief Create a Context from the current builder configuration. @@ -418,7 +447,6 @@ namespace c2pa /// OperationCancelled error at the next progress checkpoint. /// Has no effect if no operation is currently in progress. /// - /// @note Requires c2pa-rs >= 0.79.0 (progress/cancel feature). void cancel() noexcept; private: diff --git a/src/c2pa_context.cpp b/src/c2pa_context.cpp index 510fc7b..26464db 100644 --- a/src/c2pa_context.cpp +++ b/src/c2pa_context.cpp @@ -166,13 +166,31 @@ namespace c2pa return *this; } - // C trampoline: delegates to the ProgressCallbackFunc stored in user_data. - static bool progress_callback_trampoline(void* user_data, - C2paProgressPhase phase, - uint32_t step, - uint32_t total) { - auto* cb = static_cast(user_data); - return (*cb)(phase, step, total); + // Verify our C++ enum class stays in sync with the C enum from c2pa.h. + // If c2pa-rs adds or reorders variants, these will catch it at compile time. + static_assert(static_cast(ProgressPhase::Reading) == Reading, "ProgressPhase::Reading mismatch"); + static_assert(static_cast(ProgressPhase::VerifyingManifest) == VerifyingManifest, "ProgressPhase::VerifyingManifest mismatch"); + static_assert(static_cast(ProgressPhase::VerifyingSignature) == VerifyingSignature, "ProgressPhase::VerifyingSignature mismatch"); + static_assert(static_cast(ProgressPhase::VerifyingIngredient) == VerifyingIngredient, "ProgressPhase::VerifyingIngredient mismatch"); + static_assert(static_cast(ProgressPhase::VerifyingAssetHash) == VerifyingAssetHash, "ProgressPhase::VerifyingAssetHash mismatch"); + static_assert(static_cast(ProgressPhase::AddingIngredient) == AddingIngredient, "ProgressPhase::AddingIngredient mismatch"); + static_assert(static_cast(ProgressPhase::Thumbnail) == Thumbnail, "ProgressPhase::Thumbnail mismatch"); + static_assert(static_cast(ProgressPhase::Hashing) == Hashing, "ProgressPhase::Hashing mismatch"); + static_assert(static_cast(ProgressPhase::Signing) == Signing, "ProgressPhase::Signing mismatch"); + static_assert(static_cast(ProgressPhase::Embedding) == Embedding, "ProgressPhase::Embedding mismatch"); + static_assert(static_cast(ProgressPhase::FetchingRemoteManifest)== FetchingRemoteManifest,"ProgressPhase::FetchingRemoteManifest mismatch"); + static_assert(static_cast(ProgressPhase::Writing) == Writing, "ProgressPhase::Writing mismatch"); + static_assert(static_cast(ProgressPhase::FetchingOCSP) == FetchingOCSP, "ProgressPhase::FetchingOCSP mismatch"); + static_assert(static_cast(ProgressPhase::FetchingTimestamp) == FetchingTimestamp, "ProgressPhase::FetchingTimestamp mismatch"); + + // C trampoline: bridges the C callback ABI to the stored std::function. + // Returns non-zero to continue, zero to cancel (matching ProgressCCallback convention). + static int progress_callback_trampoline(const void* user_data, + C2paProgressPhase phase, + uint32_t step, + uint32_t total) { + const auto* cb = static_cast(user_data); + return (*cb)(static_cast(phase), step, total) ? 1 : 0; } Context::ContextBuilder& Context::ContextBuilder::with_progress_callback(ProgressCallbackFunc callback) { diff --git a/tests/context.test.cpp b/tests/context.test.cpp index d0ea4ab..bb20dc3 100644 --- a/tests/context.test.cpp +++ b/tests/context.test.cpp @@ -493,7 +493,7 @@ TEST(Context, ContextBuilderWithSettingsAndSigner) { } // --- Progress / Cancel API tests --- -// These tests require c2pa-rs >= 0.79.0 built from source with the progress/cancel feature. +// Progress/cancel tests — require c2pa-rs >= 0.78.7. // Helper: sign a file and return the signed path, using a context with a progress callback. static fs::path sign_with_progress_context(c2pa::IContextProvider& context, const fs::path& dest) { @@ -513,7 +513,7 @@ TEST_F(ContextTest, ProgressCallback_InvokedDuringSigning) { std::atomic call_count{0}; auto context = c2pa::Context::ContextBuilder() - .with_progress_callback([&](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + .with_progress_callback([&](c2pa::ProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { ++call_count; return true; }) @@ -535,7 +535,7 @@ TEST_F(ContextTest, ProgressCallback_InvokedDuringReading) { std::atomic call_count{0}; auto context = c2pa::Context::ContextBuilder() - .with_progress_callback([&](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + .with_progress_callback([&](c2pa::ProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { ++call_count; return true; }) @@ -554,7 +554,7 @@ TEST_F(ContextTest, ProgressCallback_StepAndTotalValues) { bool saw_valid_step = false; auto context = c2pa::Context::ContextBuilder() - .with_progress_callback([&](C2paProgressPhase /*phase*/, uint32_t step, uint32_t total) { + .with_progress_callback([&](c2pa::ProgressPhase /*phase*/, uint32_t step, uint32_t total) { // step is 1-based when total > 0; both may be 0 for indeterminate phases. if (total > 0) { EXPECT_GE(step, 1u); @@ -574,7 +574,7 @@ TEST_F(ContextTest, ProgressCallback_StepAndTotalValues) { TEST_F(ContextTest, ProgressCallback_ReturnFalseCancels) { // Cancel on the very first callback invocation. auto context = c2pa::Context::ContextBuilder() - .with_progress_callback([](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + .with_progress_callback([](c2pa::ProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { return false; // request cancellation }) .create_context(); @@ -589,7 +589,7 @@ TEST_F(ContextTest, ProgressCallback_ReturnFalseCancels) { // Context::cancel() called before an operation prevents that operation from completing. TEST_F(ContextTest, ProgressCallback_CancelMethodAbortsOperation) { auto context = c2pa::Context::ContextBuilder() - .with_progress_callback([](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + .with_progress_callback([](c2pa::ProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { return true; }) .create_context(); @@ -600,7 +600,7 @@ TEST_F(ContextTest, ProgressCallback_CancelMethodAbortsOperation) { c2pa::Context* ctx_ptr = &context; bool cancel_called = false; auto context2 = c2pa::Context::ContextBuilder() - .with_progress_callback([&](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + .with_progress_callback([&](c2pa::ProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { if (!cancel_called) { cancel_called = true; ctx_ptr->cancel(); @@ -633,7 +633,7 @@ TEST_F(ContextTest, ProgressCallback_ChainWithSettings) { auto context = c2pa::Context::ContextBuilder() .with_settings(settings) - .with_progress_callback([&](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + .with_progress_callback([&](c2pa::ProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { ++call_count; return true; }) @@ -650,7 +650,7 @@ TEST_F(ContextTest, ProgressCallback_SurvivesContextMove) { std::atomic call_count{0}; auto original = c2pa::Context::ContextBuilder() - .with_progress_callback([&](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + .with_progress_callback([&](c2pa::ProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { ++call_count; return true; }) @@ -668,11 +668,11 @@ TEST_F(ContextTest, ProgressCallback_SurvivesContextMove) { TEST_F(ContextTest, ProgressCallback_SurvivesBuilderMove) { std::atomic call_count{0}; - auto b1 = c2pa::Context::ContextBuilder() - .with_progress_callback([&](C2paProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { - ++call_count; - return true; - }); + c2pa::Context::ContextBuilder b1; + b1.with_progress_callback([&](c2pa::ProgressPhase /*phase*/, uint32_t /*step*/, uint32_t /*total*/) { + ++call_count; + return true; + }); auto b2 = std::move(b1); EXPECT_FALSE(b1.is_valid()); From 940ccb38dbcd2f0bccfc68003098e624fb49e95b Mon Sep 17 00:00:00 2001 From: Gavin Peacock Date: Mon, 30 Mar 2026 09:53:44 -0700 Subject: [PATCH 3/3] chore: added a try_catch in the callback trampolne to handle throws updated documenation --- docs/context-settings.md | 6 ++++-- include/c2pa.hpp | 16 ++++++++++++---- src/c2pa_context.cpp | 15 ++++++++++++--- tests/context.test.cpp | 2 +- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/docs/context-settings.md b/docs/context-settings.md index d5a0a19..9a889ed 100644 --- a/docs/context-settings.md +++ b/docs/context-settings.md @@ -203,9 +203,11 @@ bool callback(c2pa::ProgressPhase phase, uint32_t step, uint32_t total); - **`total`** — `0` = indeterminate (show a spinner); `1` = single-shot phase; `> 1` = determinate (`step / total` gives a completion fraction). - **Return value** — return `true` to continue, `false` to request cancellation (same effect as calling `context.cancel()`). +**Do not throw** from the progress callback. Exceptions cannot cross the C/Rust boundary safely; if your callback throws, the wrapper catches it and the operation is aborted as a cancellation (you do not get your exception back at the call site). Use `return false`, `context.cancel()`, or application-side state instead. + ### Cancelling from another thread -Call `Context::cancel()` from any thread to abort the current operation. The SDK returns a `C2paException` with an `OperationCancelled` error at the next progress checkpoint: +You may call `Context::cancel()` from another thread while the same `Context` remains valid and is not being destroyed or moved concurrently with that call. The SDK returns a `C2paException` with an `OperationCancelled` error at the next progress checkpoint: ```cpp #include @@ -232,7 +234,7 @@ try { cancel_thread.join(); ``` -`cancel()` is safe to call even if no operation is in progress — it is a no-op in that case. +`cancel()` is safe to call when no operation is in progress — it is a no-op in that case (and a no-op if the `Context` is moved-from). ### `ProgressPhase` values diff --git a/include/c2pa.hpp b/include/c2pa.hpp index 63a2a02..bd249fc 100644 --- a/include/c2pa.hpp +++ b/include/c2pa.hpp @@ -281,6 +281,11 @@ namespace c2pa /// @param total 0 = indeterminate; 1 = single-shot; >1 = determinate (step/total = fraction). /// @return true to continue the operation, false to request cancellation. /// + /// @note The callback must not throw. If it throws, the implementation catches the + /// exception and reports cancellation to the underlying library (same as returning + /// false); the original exception is not propagated. Prefer returning false or + /// using Context::cancel() instead of throwing. + /// using ProgressCallbackFunc = std::function; /// @brief C2PA context implementing IContextProvider. @@ -376,6 +381,7 @@ namespace c2pa /// @param callback A callable matching ProgressCallbackFunc. The callback is /// heap-allocated and owned by the resulting Context. Calling this method /// more than once on the same builder replaces the previous callback. + /// The callable must not throw when invoked (see ProgressCallbackFunc). /// @return Reference to this ContextBuilder for method chaining. /// @throws C2paException if the builder is invalid or the C API call fails. /// @@ -442,10 +448,12 @@ namespace c2pa /// @brief Request cancellation of any in-progress operation on this context. /// - /// @details May be called safely from another thread while a signing or reading - /// operation is running. The operation is aborted with an - /// OperationCancelled error at the next progress checkpoint. - /// Has no effect if no operation is currently in progress. + /// @details Safe to call from another thread while this Context remains valid + /// and is not being destroyed or moved concurrently with this call. + /// While a signing or reading operation is running on a valid Context, + /// the operation is aborted with an OperationCancelled error at the + /// next progress checkpoint. Has no effect if no operation is currently + /// in progress, or if this object is moved-from (is_valid() is false). /// void cancel() noexcept; diff --git a/src/c2pa_context.cpp b/src/c2pa_context.cpp index 26464db..859ffb8 100644 --- a/src/c2pa_context.cpp +++ b/src/c2pa_context.cpp @@ -13,8 +13,9 @@ /// @file c2pa_context.cpp /// @brief Context and ContextBuilder implementation. -#include +#include #include +#include #include "c2pa.hpp" #include "c2pa_internal.hpp" @@ -185,12 +186,20 @@ namespace c2pa // C trampoline: bridges the C callback ABI to the stored std::function. // Returns non-zero to continue, zero to cancel (matching ProgressCCallback convention). + // Exceptions must not unwind into Rust/C: treat any throw like cancellation (return 0). + // Callers should not throw from the callback; a future c2pa-rs API may surface errors explicitly. static int progress_callback_trampoline(const void* user_data, C2paProgressPhase phase, uint32_t step, uint32_t total) { - const auto* cb = static_cast(user_data); - return (*cb)(static_cast(phase), step, total) ? 1 : 0; + try { + const auto* cb = static_cast(user_data); + return (*cb)(static_cast(phase), step, total) ? 1 : 0; + } catch (const std::exception&) { + return 0; + } catch (...) { + return 0; + } } Context::ContextBuilder& Context::ContextBuilder::with_progress_callback(ProgressCallbackFunc callback) { diff --git a/tests/context.test.cpp b/tests/context.test.cpp index bb20dc3..5ba0762 100644 --- a/tests/context.test.cpp +++ b/tests/context.test.cpp @@ -493,7 +493,7 @@ TEST(Context, ContextBuilderWithSettingsAndSigner) { } // --- Progress / Cancel API tests --- -// Progress/cancel tests — require c2pa-rs >= 0.78.7. +// Progress/cancel tests, available since c2pa-rs == 0.78.7. // Helper: sign a file and return the signed path, using a context with a progress callback. static fs::path sign_with_progress_context(c2pa::IContextProvider& context, const fs::path& dest) {