diff --git a/docs/embeddable-api.md b/docs/embeddable-api.md index 956192d..5e91c1e 100644 --- a/docs/embeddable-api.md +++ b/docs/embeddable-api.md @@ -540,3 +540,372 @@ classDiagram ContextBuilder --> Context : creates via create_context() Context --> Builder : passed to constructor ``` + +## Embeddable pipeline/workflow API (`EmbeddablePipeline`) + +`EmbeddablePipeline` abstract the "flat" embeddable API `Builder` methods behind a mutable object with runtime state enforcement. The format string is captured once at construction, and calling a method in the wrong state throws `C2paException` with a message describing the required and current state. + +### State diagram + +```mermaid +stateDiagram-v2 + [*] --> init : EmbeddablePipeline(builder, format) + + init --> placeholder_created : create_placeholder + init --> hashed : hash_from_stream [BoxHash] + + placeholder_created --> exclusions_configured : set_exclusions [DataHash] + placeholder_created --> hashed : hash_from_stream [BmffHash] + + exclusions_configured --> hashed : hash_from_stream + + hashed --> pipeline_signed : sign + pipeline_signed --> [*] + + note right of init + Use hash_type() to determine which path applies. + end note +``` + +### Methods by state + +The following diagram groups methods by which state enables them. `EmbeddablePipeline` is a single class. The state groups show which methods are callable at runtime in each state, not separate C++ types. + +```mermaid +classDiagram + class EmbeddablePipeline { + -Builder builder_ + -string format_ + -State state_ + +EmbeddablePipeline(Builder&&, string format) + +format() const string& + +current_state() State + +state_name(State) const char*$ + +hash_type() HashType + +is_faulted() bool + +release_builder() Builder + +faulted_from() optional~State~ + } + + class init { + +create_placeholder() const vector~uint8~& + +hash_from_stream(stream) [BoxHash] + } + + class placeholder_created { + +set_exclusions(excl) + +hash_from_stream(stream) [BmffHash] + +placeholder_bytes() const vector~uint8~& + } + + class exclusions_configured { + +hash_from_stream(stream) [DataHash] + +placeholder_bytes() const vector~uint8~& + +exclusion_ranges() const vector& + } + + class hashed { + +sign() const vector~uint8~& + +placeholder_bytes() const vector~uint8~& + +exclusion_ranges() const vector& + } + + class pipeline_signed { + +signed_bytes() const vector~uint8~& + +placeholder_bytes() const vector~uint8~& + +exclusion_ranges() const vector& + } + + EmbeddablePipeline -- init : state = init + EmbeddablePipeline -- placeholder_created : state = placeholder_created + EmbeddablePipeline -- exclusions_configured : state = exclusions_configured + EmbeddablePipeline -- hashed : state = hashed + EmbeddablePipeline -- pipeline_signed : state = pipeline_signed +``` + +### When to use `EmbeddablePipeline` vs the flat API + +`EmbeddablePipeline` wraps the flat Builder embeddable methods (`placeholder`, `set_data_hash_exclusions`, `update_hash_from_stream`, `sign_embeddable`) with format capture, state enforcement, and polymorphic dispatch. + +Use the pipeline when the asset format is determined at runtime and the caller wants the factory to select the correct workflow type automatically. The pipeline stores the format string once at construction and passes it to every internal Builder call, which removes the risk of inconsistent format strings across the multi-step workflow. Each method validates the current state before proceeding and throws `C2paException` with a message naming both the required and current states, which is more useful than the errors that surface from the Rust FFI layer when flat Builder methods are called out of order. If any operation fails, the pipeline transitions to a terminal `faulted` state and rejects all subsequent calls (see [Faulted state and recovery](#faulted-state-and-recovery)). + +Use the flat Builder methods when the caller manages its own orchestration, needs to interleave other Builder operations (like `add_ingredient` or `add_resource`) between embeddable steps, or needs to archive the builder mid-workflow. The pipeline consumes the Builder at construction via move semantics, so these operations are not available after that point. See also [Archiving](#archiving). + +### Factory construction + +`EmbeddablePipeline::create(builder, format)` calls `Builder::hash_type(format)` to determine the hard-binding strategy. This method calls the C API function `c2pa_builder_hash_type()`, which returns a `C2paHashType` enum value (`DataHash = 0`, `BmffHash = 1`, or `BoxHash = 2`). The C++ wrapper maps these to `HashType::Data`, `HashType::Bmff`, and `HashType::Box`, and the factory constructs the matching subclass (`DataHashPipeline`, `BmffHashPipeline`, or `BoxHashPipeline`). The result is returned as a `std::unique_ptr`. + +Not every pipeline subclass supports every method. Calling an unsupported method throws `C2paUnsupportedOperationException`, a subclass of `C2paException`. Each optional step can be wrapped in its own `try`/`catch`: + +```cpp +auto pipeline = c2pa::EmbeddablePipeline::create(std::move(builder), format); + +try { + auto& placeholder = pipeline->create_placeholder(); + // embed placeholder into asset at offset ... +} catch (const c2pa::C2paUnsupportedOperationException&) { + // BoxHash does not use placeholders +} + +try { + pipeline->set_exclusions({{offset, placeholder_size}}); +} catch (const c2pa::C2paUnsupportedOperationException&) { + // BmffHash and BoxHash do not use exclusions +} + +std::ifstream stream(asset_path, std::ios::binary); +pipeline->hash_from_stream(stream); +stream.close(); + +auto& manifest = pipeline->sign(); +``` + +If the hash type is known at compile time, construct the concrete subclass directly to avoid the factory's runtime dispatch: + +```cpp +auto pipeline = c2pa::DataHashPipeline(std::move(builder), "image/jpeg"); +``` + +### Pipeline DataHash example + +```cpp +auto pipeline = c2pa::DataHashPipeline(std::move(builder), "image/jpeg"); + +auto& placeholder = pipeline.create_placeholder(); +uint64_t offset = 2; +auto size = placeholder.size(); +// embed placeholder into asset at offset + +pipeline.set_exclusions({{offset, size}}); + +std::ifstream stream("output.jpg", std::ios::binary); +pipeline.hash_from_stream(stream); +stream.close(); + +auto& manifest = pipeline.sign(); +// patch the placeholder in place +``` + +### Pipeline BmffHash example + +```cpp +auto pipeline = c2pa::BmffHashPipeline(std::move(builder), "video/mp4"); + +auto& placeholder = pipeline.create_placeholder(); +// embed into container + +std::ifstream stream("output.mp4", std::ios::binary); +pipeline.hash_from_stream(stream); +stream.close(); + +auto& manifest = pipeline.sign(); +``` + +### State gating + +Transition methods require an exact state. Calling any transition method on a `faulted` or `cancelled` pipeline throws `C2paException`. + +| Method | Allowed state(s) | +| --- | --- | +| `create_placeholder()` | `init` | +| `set_exclusions()` | `placeholder_created` | +| `hash_from_stream()` | `init` (BoxHash), `placeholder_created` (BmffHash), `exclusions_configured` (DataHash) | +| `sign()` | `hashed` | + +Accessors are available from the state where the data is produced onward. Calling an accessor on a pipeline path that never produced the data (e.g. `placeholder_bytes()` on a BoxHash pipeline) throws `C2paException`. + +| Accessor | Available from | +| --- | --- | +| `placeholder_bytes()` | `placeholder_created` and later | +| `exclusion_ranges()` | `exclusions_configured` and later | +| `signed_bytes()` | `pipeline_signed` | +| `release_builder()` | any state (throws if already released) | +| `faulted_from()` | any state (returns `std::nullopt` if not faulted) | + +Calling a method in the wrong state throws `C2paException`: + +```text +sign() requires state 'hashed' but current state is 'init' +``` + +### Faulted state and recovery + +If any pipeline operation throws, the pipeline transitions to the `faulted` state. `faulted` is part of the `State` enum and is returned by `current_state()`. The `is_faulted()` convenience method returns `true` when `current_state() == State::faulted`. A faulted pipeline rejects all subsequent workflow calls: + +```text +hash_from_stream() cannot be called: pipeline faulted during a prior operation +``` + +#### Builder safety after a fault + +A failed operation may leave the Builder in an inconsistent state. `faulted_from()` returns the state the pipeline was in when the fault occurred, which determines whether the recovered Builder is safe to reuse directly or should be restored from an archive. + +| `faulted_from()` | Failed operation | Builder safe to reuse? | +| --- | --- | --- | +| `init` | `create_placeholder()` or `hash_from_stream()` (BoxHash) | No | +| `placeholder_created` | `set_exclusions()` | Yes | +| `placeholder_created` | `hash_from_stream()` (BmffHash) | No | +| `exclusions_configured` | `hash_from_stream()` (DataHash) | No | +| `hashed` | `sign()` | Yes | + +#### Recovery via archive + +Archive the Builder before creating the pipeline. On fault, restore from the archive for a retry regardless of which operation failed. + +```cpp +std::ostringstream archive_stream; +builder.to_archive(archive_stream); +auto pipeline = c2pa::EmbeddablePipeline::create(std::move(builder), format); + +try { + pipeline->create_placeholder(); + // embed placeholder, set exclusions ... + pipeline->hash_from_stream(stream); + auto& manifest = pipeline->sign(); +} catch (const c2pa::C2paException& e) { + if (pipeline->is_faulted()) { + // Restore from archive with the same signer context + std::istringstream restore(archive_stream.str()); + auto clean_builder = c2pa::Builder(context); + clean_builder.with_archive(restore); + pipeline = c2pa::EmbeddablePipeline::create(std::move(clean_builder), format); + } +} +``` + +#### Recovery via release_builder() + +When archiving is not available, `release_builder()` recovers the Builder directly. Check `faulted_from()` to determine whether the Builder is safe to reuse. + +```cpp +if (pipeline->is_faulted()) { + auto from = pipeline->faulted_from(); + auto builder = pipeline->release_builder(); + + if (from == c2pa::EmbeddablePipeline::State::hashed) { + // sign() did not mutate the builder; retry with it directly + auto retry = c2pa::EmbeddablePipeline::create(std::move(builder), format); + } else { + // placeholder() or hash_from_stream() may have left inconsistent + // assertions in the builder; restore from archive instead + } +} +``` + +### Progress reporting and cancellation + +Progress callbacks and cancellation are configured on the Context (set on the Builder), not on the pipeline. The pipeline's Builder holds a reference to the Context, so a callback registered via `ContextBuilder::with_progress_callback` fires automatically during `hash_from_stream()` and `sign()`. See [Progress callbacks and cancellation](context-settings.md#progress-callbacks-and-cancellation) for details on progress callbacks. + +#### Reporting progress + +Register a callback on the Context before constructing the pipeline: + +```cpp +std::atomic saw_hashing{false}; + +auto context = c2pa::Context::ContextBuilder() + .with_signer(c2pa::Signer("Es256", certs, private_key, "http://timestamp.digicert.com")) + .with_progress_callback([&](c2pa::ProgressPhase phase, uint32_t step, uint32_t total) { + if (phase == c2pa::ProgressPhase::Hashing) { + saw_hashing.store(true); + } + return true; // continue + }) + .create_context(); + +auto builder = c2pa::Builder(context, manifest_json); +auto pipeline = c2pa::BmffHashPipeline(std::move(builder), "video/mp4"); + +auto& placeholder = pipeline.create_placeholder(); +// insert placeholder into container ... + +std::ifstream stream("output.mp4", std::ios::binary); +pipeline.hash_from_stream(stream); // Hashing progress events fire here +stream.close(); + +auto& manifest = pipeline.sign(); // Signing/Embedding events fire here +``` + +> [!IMPORTANT] +> The Context must remain valid for the lifetime of the pipeline. The progress callback is owned by the Context, and destroying the Context while the pipeline is still in use causes undefined behavior. + +#### Cancelling via callback + +Return `false` from the progress callback to cancel the current operation. The pipeline throws `C2paCancelledException` (a subclass of `C2paException`) and transitions to the `cancelled` state: + +```cpp +std::atomic should_cancel{false}; + +auto context = c2pa::Context::ContextBuilder() + .with_signer(std::move(signer)) + .with_progress_callback([&](c2pa::ProgressPhase, uint32_t, uint32_t) { + return !should_cancel.load(); + }) + .create_context(); + +auto builder = c2pa::Builder(context, manifest_json); +auto pipeline = c2pa::BmffHashPipeline(std::move(builder), "video/mp4"); +pipeline.create_placeholder(); +// insert placeholder ... + +should_cancel.store(true); // e.g. user clicked Cancel + +try { + std::ifstream stream("output.mp4", std::ios::binary); + pipeline.hash_from_stream(stream); +} catch (const c2pa::C2paCancelledException&) { + // pipeline.current_state() == State::cancelled + // pipeline.is_faulted() == false +} +``` + +#### Cancelling via `Context::cancel()` + +Call `Context::cancel()` from another thread to abort a running operation. The Context must remain valid and must not be destroyed or moved concurrently with this call: + +```cpp +// context must outlive the pipeline and remain valid during cancel() +std::thread cancel_thread([&context]() { + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + context.cancel(); +}); + +try { + std::ifstream stream("output.mp4", std::ios::binary); + pipeline.hash_from_stream(stream); + auto& manifest = pipeline.sign(); +} catch (const c2pa::C2paCancelledException&) { + // pipeline is now cancelled +} + +cancel_thread.join(); +``` + +Both cancellation paths produce the same result: the pipeline transitions to `cancelled`, throws `C2paCancelledException`, and rejects all subsequent workflow calls with `C2paCancelledException`. Recover the Builder with `release_builder()` or restore from an archive (see [Archiving](#archiving)). + +### Cancelled state + +The pipeline transitions to the `cancelled` state when: + +- `release_builder()` is called on a non-faulted pipeline, or +- a progress callback returns `false`, or +- `Context::cancel()` is called during an operation. + +This is distinct from `faulted`: `cancelled` means the caller chose to stop, not that an operation failed. Like `faulted`, a cancelled pipeline rejects all subsequent workflow calls, but it throws `C2paCancelledException` instead of `C2paException`. + +### Archiving + +The pipeline does not expose `to_archive()`. The pipeline's workflow state (current state, cached placeholder bytes, exclusion ranges) is not part of the Builder's archive format. Archive the Builder before constructing the pipeline if you need the ability to restore a Builder later (e.g. for retries on failure). + +```cpp +auto builder = c2pa::Builder(context, manifest_json); +builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_stream); + +// Archive before creating the pipeline +builder.to_archive(archive_stream); + +// Later: restore into a builder with the same context (signer included) +auto restored = c2pa::Builder(context); +restored.with_archive(archive_stream); +auto pipeline = c2pa::EmbeddablePipeline::create(std::move(restored), format); +``` diff --git a/include/c2pa.hpp b/include/c2pa.hpp index 46767a9..5f16565 100644 --- a/include/c2pa.hpp +++ b/include/c2pa.hpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -73,6 +74,13 @@ namespace c2pa NoBufferSpace = ENOBUFS }; + /// @brief Hash binding type for embeddable signing workflows. + enum class HashType { + Data, ///< Placeholder + exclusions + hash + sign (JPEG, PNG, etc.) + Bmff, ///< Placeholder + hash + sign (MP4, AVIF, HEIF/HEIC) + Box, ///< Hash + sign, no placeholder needed + }; + /// @brief Set errno from StreamError and return error sentinel. /// @param e The StreamError value to convert to errno. /// @return OperationResult::Error (-1) for use as C API error return. @@ -112,6 +120,26 @@ namespace c2pa std::string message_; }; + /// @brief Exception thrown when a pipeline method is not supported by the current hash type. + /// @details Subclass of C2paException. Thrown by EmbeddablePipeline base class defaults + /// (e.g. create_placeholder() on BoxHashPipeline). Allows callers to catch + /// unsupported operations separately from other C2PA errors. + class C2PA_CPP_API C2paUnsupportedOperationException : public C2paException { + public: + explicit C2paUnsupportedOperationException(std::string message); + ~C2paUnsupportedOperationException() override = default; + }; + + /// @brief Exception thrown when a C2PA operation is cancelled. + /// @details Subclass of C2paException. Thrown when a progress callback returns false, + /// Context::cancel() is called, or a method is invoked on a cancelled pipeline. + /// Allows callers to distinguish user-initiated cancellation from actual errors. + class C2PA_CPP_API C2paCancelledException : public C2paException { + public: + explicit C2paCancelledException(std::string message); + ~C2paCancelledException() override = default; + }; + /// @brief Interface for types that can provide C2PA context functionality. /// @details This interface can be implemented by external libraries to provide /// custom context implementations (e.g. AdobeContext wrappers). @@ -999,7 +1027,7 @@ namespace c2pa /// @details This class is used to create a manifest from a json std::string and add resources and ingredients to the manifest. class C2PA_CPP_API Builder { - private: + protected: C2paBuilder *builder; std::shared_ptr context_ref; @@ -1252,6 +1280,12 @@ namespace c2pa /// @return A formatted copy of the data. static std::vector format_embeddable(const std::string &format, std::vector &data); + /// @brief Query which hash binding type the builder will use for the given format. + /// @param format The MIME type or extension of the asset. + /// @return The HashType that will be used for embeddable signing. + /// @throws C2paException on error. + HashType hash_type(const std::string &format); + /// @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). @@ -1312,6 +1346,164 @@ namespace c2pa private: explicit Builder(std::istream &archive); }; + + + /// @brief Base class for embeddable signing pipelines. + /// + /// Holds shared state and infrastructure for the three embeddable signing + /// workflows. Not directly constructible, use one of the concrete subtypes: + /// - DataHashPipeline (JPEG, PNG, etc.) + /// - BmffHashPipeline (MP4, AVIF, HEIF/HEIC) + /// - BoxHashPipeline (when prefer_box_hash is enabled) + /// + /// Configure the Builder before constructing a pipeline. + /// The pipeline only handles the signing workflow. + class C2PA_CPP_API EmbeddablePipeline { + public: + /// @brief Pipeline states, ordered for comparison. + /// `faulted` and `cancelled` are placed before `init` so that + /// require_state_at_least(State::init, ...) naturally rejects both. + enum class State { faulted, cancelled, init, placeholder_created, exclusions_configured, hashed, pipeline_signed }; + + virtual ~EmbeddablePipeline() = default; + + /// @brief Factory: create the correct pipeline subclass for the given format. + /// @param builder Builder to consume (moved from). Configure it before calling. + /// @param format MIME type of the target asset (e.g. "image/jpeg", "video/mp4"). + /// @return A unique_ptr to the correct EmbeddablePipeline subclass. + /// @throws C2paException if the hash type query fails. + static std::unique_ptr create(Builder&& builder, const std::string& format); + + EmbeddablePipeline(EmbeddablePipeline&&) noexcept = default; + EmbeddablePipeline& operator=(EmbeddablePipeline&&) noexcept = default; + EmbeddablePipeline(const EmbeddablePipeline&) = delete; + EmbeddablePipeline& operator=(const EmbeddablePipeline&) = delete; + + /// @brief Hash the asset stream. + /// @throws C2paException if not in the expected state, or on library error. + virtual void hash_from_stream(std::istream& stream) = 0; + + /// @brief [hashed -> pipeline_signed] Sign and produce the signed manifest bytes. + /// @return Reference to the signed manifest bytes (valid for the lifetime of this object). + /// @throws C2paException if not in hashed state, or on library error. + const std::vector& sign(); + + /// @brief Returns the signed manifest bytes. + /// Available in pipeline_signed state only. + const std::vector& signed_bytes() const; + + /// @brief Returns the MIME format string. + const std::string& format() const noexcept; + + /// @brief Returns the current pipeline state. + State current_state() const noexcept; + + /// @brief Returns the current state name as a human-readable string. + static const char* state_name(State s) noexcept; + + /// @brief Check if the pipeline has faulted due to a failed operation. + /// @details Equivalent to `current_state() == State::faulted`. + /// Call release_builder() to recover the Builder, or restore from an archive. + bool is_faulted() const noexcept; + + /// @brief Move the Builder out of this pipeline. + /// @details Available from any state. Transitions to cancelled if not already + /// faulted. The pipeline rejects all subsequent workflow calls. + /// @warning After a fault, the recovered Builder may have partially modified + /// assertions. Restore from an archive for a clean retry. + /// See faulted_from() to check which operation failed. + /// @return The Builder that was consumed at construction. + /// @throws C2paException if the Builder has already been released. + Builder release_builder(); + + /// @brief Returns the state the pipeline was in when it faulted. + /// @return The pre-fault state, or std::nullopt if the pipeline has not faulted. + std::optional faulted_from() const noexcept; + + /// @brief Returns the hash binding type for this pipeline. + virtual HashType hash_type() const = 0; + + /// @brief [init -> placeholder_created] Create the placeholder manifest bytes. + /// @return Reference to the placeholder bytes (valid for the lifetime of this object). + /// @throws C2paException if not supported by this hash type, or not in init state. + virtual const std::vector& create_placeholder(); + + /// @brief [placeholder_created -> exclusions_configured] Register where the placeholder was embedded. + /// @param exclusions Vector of (offset, length) pairs. + /// @throws C2paException if not supported by this hash type, or not in placeholder_created state. + virtual void set_exclusions(const std::vector>& exclusions); + + /// @brief Returns the placeholder bytes. Available from placeholder_created state onward. + /// @throws C2paException if not supported by this hash type, or not in required state. + virtual const std::vector& placeholder_bytes() const; + + /// @brief Returns the exclusion ranges. Available from exclusions_configured state onward. + /// @throws C2paException if not supported by this hash type, or not in required state. + virtual const std::vector>& exclusion_ranges() const; + + protected: + /// @brief Construct the base pipeline from a Builder and a MIME format string. + EmbeddablePipeline(Builder&& builder, std::string format); + + /// @brief Shared hash implementation: calls Builder::update_hash_from_stream and transitions to hashed. + void do_hash(std::istream& stream); + + Builder builder_; + std::string format_; + State state_ = State::init; + std::vector placeholder_; + std::vector> exclusions_; + std::vector signed_manifest_; + State faulted_from_ = State::init; + bool builder_released_ = false; + + [[noreturn]] void throw_wrong_state(const char* method, const std::string& expected) const; + [[noreturn]] void throw_faulted(const char* method) const; + void require_state(State expected, const char* method) const; + void require_state_at_least(State minimum, const char* method) const; + void require_state_in(std::initializer_list allowed, const char* method) const; + }; + + + /// @brief DataHash embeddable pipeline for formats like e.g. JPEG, PNG. + /// Workflow: create_placeholder() -> set_exclusions() -> hash_from_stream() -> sign() + class C2PA_CPP_API DataHashPipeline : public EmbeddablePipeline { + public: + DataHashPipeline(Builder&& builder, std::string format); + + HashType hash_type() const override; + const std::vector& create_placeholder() override; + void set_exclusions(const std::vector>& exclusions) override; + void hash_from_stream(std::istream& stream) override; + const std::vector& placeholder_bytes() const override; + const std::vector>& exclusion_ranges() const override; + }; + + + /// @brief BmffHash embeddable pipeline for container formats like MP4, AVIF, HEIF/HEIC. + /// Workflow: create_placeholder() -> hash_from_stream() -> sign() + /// Exclusions are handled automatically by the BMFF assertion. + class C2PA_CPP_API BmffHashPipeline : public EmbeddablePipeline { + public: + BmffHashPipeline(Builder&& builder, std::string format); + + HashType hash_type() const override; + const std::vector& create_placeholder() override; + void hash_from_stream(std::istream& stream) override; + const std::vector& placeholder_bytes() const override; + }; + + + /// @brief BoxHash embeddable pipeline for when prefer_box_hash is enabled. + /// Workflow: hash_from_stream() -> sign() + /// No placeholder or exclusions needed. + class C2PA_CPP_API BoxHashPipeline : public EmbeddablePipeline { + public: + BoxHashPipeline(Builder&& builder, std::string format); + + HashType hash_type() const override; + void hash_from_stream(std::istream& stream) override; + }; } // Restore warnings diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 67c856a..551a837 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -183,6 +183,7 @@ set(C2PA_CPP_PARTS c2pa_core.cpp c2pa_context.cpp c2pa_builder.cpp + c2pa_embeddable_pipeline.cpp c2pa_reader.cpp c2pa_settings.cpp c2pa_signer.cpp diff --git a/src/c2pa_builder.cpp b/src/c2pa_builder.cpp index 8b1249a..841ff34 100644 --- a/src/c2pa_builder.cpp +++ b/src/c2pa_builder.cpp @@ -375,6 +375,22 @@ namespace c2pa return detail::to_byte_vector(c2pa_manifest_bytes, result); } + HashType Builder::hash_type(const std::string &format) + { + C2paHashType c_hash_type; + int result = c2pa_builder_hash_type(builder, format.c_str(), &c_hash_type); + if (result < 0) + { + throw C2paException(); + } + switch (c_hash_type) { + case DataHash: return HashType::Data; + case BmffHash: return HashType::Bmff; + case BoxHash: return HashType::Box; + default: throw C2paException("Unsupported hash type"); + } + } + bool Builder::needs_placeholder(const std::string &format) { int result = c2pa_builder_needs_placeholder(builder, format.c_str()); @@ -415,7 +431,7 @@ namespace c2pa int result = c2pa_builder_update_hash_from_stream(builder, format.c_str(), c_stream.c_stream); if (result < 0) { - throw C2paException(); + detail::throw_from_last_error(); } } @@ -438,4 +454,5 @@ namespace c2pa auto ptr = c2pa_builder_supported_mime_types(&count); return detail::c_mime_types_to_vector(ptr, count); } + } // namespace c2pa diff --git a/src/c2pa_core.cpp b/src/c2pa_core.cpp index e379c2a..6d87e0c 100644 --- a/src/c2pa_core.cpp +++ b/src/c2pa_core.cpp @@ -43,6 +43,16 @@ namespace c2pa return message_.c_str(); } + C2paUnsupportedOperationException::C2paUnsupportedOperationException(std::string message) + : C2paException(std::move(message)) + { + } + + C2paCancelledException::C2paCancelledException(std::string message) + : C2paException(std::move(message)) + { + } + /// Returns the version of the C2PA library. std::string version() { diff --git a/src/c2pa_embeddable_pipeline.cpp b/src/c2pa_embeddable_pipeline.cpp new file mode 100644 index 0000000..3c7f8be --- /dev/null +++ b/src/c2pa_embeddable_pipeline.cpp @@ -0,0 +1,303 @@ +// 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. + +/// @file c2pa_embeddable_pipeline.cpp +/// @brief EmbeddablePipeline and derived pipeline implementations. + +#include +#include + +#include "c2pa.hpp" + +namespace c2pa { + const char* EmbeddablePipeline::state_name(State s) noexcept { + switch (s) { + case State::faulted: return "faulted"; + case State::cancelled: return "cancelled"; + case State::init: return "init"; + case State::placeholder_created: return "placeholder_created"; + case State::exclusions_configured: return "exclusions_configured"; + case State::hashed: return "hashed"; + case State::pipeline_signed: return "pipeline_signed"; + } + return "unknown"; + } + + [[noreturn]] void EmbeddablePipeline::throw_wrong_state( + const char* method, const std::string& expected) const { + std::ostringstream msg; + msg << method << " requires state " << expected + << " but current state is '" << state_name(state_) << "'"; + throw C2paException(msg.str()); + } + + void EmbeddablePipeline::require_state(State expected, const char* method) const { + if (state_ == State::faulted || state_ == State::cancelled) throw_faulted(method); + if (state_ != expected) { + std::ostringstream expected_str; + expected_str << "'" << state_name(expected) << "'"; + throw_wrong_state(method, expected_str.str()); + } + } + + void EmbeddablePipeline::require_state_at_least(State minimum, const char* method) const { + if (state_ == State::faulted || state_ == State::cancelled) throw_faulted(method); + if (state_ < minimum) { + std::ostringstream expected; + expected << "'" << state_name(minimum) << "' or later"; + throw_wrong_state(method, expected.str()); + } + } + + void EmbeddablePipeline::require_state_in( + std::initializer_list allowed, const char* method) const { + if (state_ == State::faulted || state_ == State::cancelled) throw_faulted(method); + for (auto s : allowed) { + if (state_ == s) return; + } + std::ostringstream expected; + expected << "one of {"; + for (auto it = allowed.begin(); it != allowed.end(); ++it) { + if (it != allowed.begin()) expected << ", "; + expected << state_name(*it); + } + expected << "}"; + throw_wrong_state(method, expected.str()); + } + + [[noreturn]] void EmbeddablePipeline::throw_faulted(const char* method) const { + std::ostringstream msg; + if (state_ == State::cancelled) { + msg << method << " cannot be called: pipeline was cancelled"; + throw C2paCancelledException(msg.str()); + } + msg << method + << " cannot be called: pipeline faulted during a prior operation"; + throw C2paException(msg.str()); + } + + EmbeddablePipeline::EmbeddablePipeline(Builder&& builder, std::string format) + : builder_(std::move(builder)) + , format_(std::move(format)) + { + } + + // Workflow methods + + void EmbeddablePipeline::do_hash(std::istream& stream) { + try { + builder_.update_hash_from_stream(format_, stream); + } catch (const C2paCancelledException&) { + state_ = State::cancelled; + throw; + } catch (...) { + faulted_from_ = state_; + state_ = State::faulted; + throw; + } + state_ = State::hashed; + } + + const std::vector& EmbeddablePipeline::sign() { + require_state(State::hashed, "sign()"); + try { + signed_manifest_ = builder_.sign_embeddable(format_); + } catch (const C2paCancelledException&) { + state_ = State::cancelled; + throw; + } catch (...) { + faulted_from_ = state_; + state_ = State::faulted; + throw; + } + state_ = State::pipeline_signed; + return signed_manifest_; + } + + // Accessors + + const std::vector& EmbeddablePipeline::signed_bytes() const { + require_state(State::pipeline_signed, "signed_bytes()"); + return signed_manifest_; + } + + const std::string& EmbeddablePipeline::format() const noexcept { + return format_; + } + + EmbeddablePipeline::State EmbeddablePipeline::current_state() const noexcept { + return state_; + } + + bool EmbeddablePipeline::is_faulted() const noexcept { + return state_ == State::faulted; + } + + Builder EmbeddablePipeline::release_builder() { + if (builder_released_) { + throw C2paException("release_builder() cannot be called: Builder has already been released"); + } + builder_released_ = true; + Builder released = std::move(builder_); + if (state_ != State::faulted) { + state_ = State::cancelled; + } + return released; + } + + std::optional EmbeddablePipeline::faulted_from() const noexcept { + if (state_ != State::faulted) return std::nullopt; + return faulted_from_; + } + + // Base class default implementations (throw for unsupported hash types) + + const std::vector& EmbeddablePipeline::create_placeholder() { + throw C2paUnsupportedOperationException("create_placeholder() is not supported for this hash type"); + } + + void EmbeddablePipeline::set_exclusions(const std::vector>&) { + throw C2paUnsupportedOperationException("set_exclusions() is not supported for this hash type"); + } + + const std::vector& EmbeddablePipeline::placeholder_bytes() const { + throw C2paUnsupportedOperationException("placeholder_bytes() is not supported for this hash type"); + } + + const std::vector>& EmbeddablePipeline::exclusion_ranges() const { + throw C2paUnsupportedOperationException("exclusion_ranges() is not supported for this hash type"); + } + + // Specialized class: DataHashPipeline + + DataHashPipeline::DataHashPipeline(Builder&& builder, std::string format) + : EmbeddablePipeline(std::move(builder), std::move(format)) + { + } + + HashType DataHashPipeline::hash_type() const { return HashType::Data; } + + const std::vector& DataHashPipeline::create_placeholder() { + require_state(State::init, "create_placeholder()"); + try { + placeholder_ = builder_.placeholder(format_); + } catch (const C2paCancelledException&) { + state_ = State::cancelled; + throw; + } catch (...) { + faulted_from_ = state_; + state_ = State::faulted; + throw; + } + state_ = State::placeholder_created; + return placeholder_; + } + + void DataHashPipeline::set_exclusions( + const std::vector>& exclusions) { + require_state(State::placeholder_created, "set_exclusions()"); + try { + builder_.set_data_hash_exclusions(exclusions); + } catch (const C2paCancelledException&) { + state_ = State::cancelled; + throw; + } catch (...) { + faulted_from_ = state_; + state_ = State::faulted; + throw; + } + exclusions_ = exclusions; + state_ = State::exclusions_configured; + } + + void DataHashPipeline::hash_from_stream(std::istream& stream) { + require_state(State::exclusions_configured, "hash_from_stream()"); + do_hash(stream); + } + + const std::vector& DataHashPipeline::placeholder_bytes() const { + require_state_at_least(State::placeholder_created, "placeholder_bytes()"); + return placeholder_; + } + + const std::vector>& DataHashPipeline::exclusion_ranges() const { + require_state_at_least(State::exclusions_configured, "exclusion_ranges()"); + return exclusions_; + } + + // Specialized class: BmffHashPipeline + + BmffHashPipeline::BmffHashPipeline(Builder&& builder, std::string format) + : EmbeddablePipeline(std::move(builder), std::move(format)) + { + } + + HashType BmffHashPipeline::hash_type() const { return HashType::Bmff; } + + const std::vector& BmffHashPipeline::create_placeholder() { + require_state(State::init, "create_placeholder()"); + try { + placeholder_ = builder_.placeholder(format_); + } catch (const C2paCancelledException&) { + state_ = State::cancelled; + throw; + } catch (...) { + faulted_from_ = state_; + state_ = State::faulted; + throw; + } + state_ = State::placeholder_created; + return placeholder_; + } + + void BmffHashPipeline::hash_from_stream(std::istream& stream) { + require_state(State::placeholder_created, "hash_from_stream()"); + do_hash(stream); + } + + const std::vector& BmffHashPipeline::placeholder_bytes() const { + require_state_at_least(State::placeholder_created, "placeholder_bytes()"); + return placeholder_; + } + + // Specialized class: BoxHashPipeline + + BoxHashPipeline::BoxHashPipeline(Builder&& builder, std::string format) + : EmbeddablePipeline(std::move(builder), std::move(format)) + { + } + + HashType BoxHashPipeline::hash_type() const { return HashType::Box; } + + void BoxHashPipeline::hash_from_stream(std::istream& stream) { + require_state(State::init, "hash_from_stream()"); + do_hash(stream); + } + + // Factory + + std::unique_ptr EmbeddablePipeline::create( + Builder&& builder, const std::string& format) { + // Query hash type before moving builder + HashType ht = builder.hash_type(format); + switch (ht) { + case HashType::Data: + return std::make_unique(std::move(builder), format); + case HashType::Bmff: + return std::make_unique(std::move(builder), format); + case HashType::Box: + return std::make_unique(std::move(builder), format); + } + throw C2paException("Unknown hash type"); + } + +} // namespace c2pa diff --git a/src/c2pa_internal.hpp b/src/c2pa_internal.hpp index e1fe8ab..723319e 100644 --- a/src/c2pa_internal.hpp +++ b/src/c2pa_internal.hpp @@ -230,6 +230,19 @@ inline std::string c_string_to_string(T* c_result) { return str; } +/// @brief Read the last C API error and throw the appropriate exception type. +/// @details Throws C2paCancelledException if the error message contains +/// "operation cancelled", otherwise throws C2paException. +[[noreturn]] inline void throw_from_last_error() { + auto result = c2pa_error(); + std::string msg = result ? std::string(result) : std::string(); + c2pa_free(result); + if (msg.find("operation cancelled") != std::string::npos) { + throw C2paCancelledException(std::move(msg)); + } + throw C2paException(std::move(msg)); +} + /// @brief Convert C byte array result to C++ vector /// @param data Raw byte array from C API /// @param size Size of the byte array (result from C API call) @@ -240,7 +253,7 @@ inline std::string c_string_to_string(T* c_result) { inline std::vector to_byte_vector(const unsigned char* data, int64_t size) { if (size < 0 || data == nullptr) { c2pa_free(data); // May be null or allocated, c2pa_free handles both - throw C2paException(); + throw_from_last_error(); } auto result = std::vector(data, data + size); diff --git a/tests/embeddable.test.cpp b/tests/embeddable.test.cpp index 67e00bd..c928295 100644 --- a/tests/embeddable.test.cpp +++ b/tests/embeddable.test.cpp @@ -13,7 +13,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -556,8 +558,8 @@ TEST_F(EmbeddableTest, BoxHashNeedsPlaceholderReturnsFalse) { << "JPEG with prefer_box_hash should not require a placeholder"; } -// BoxHash: embeddable API workflow (update_hash_from_stream + sign_embeddable) -TEST_F(EmbeddableTest, BoxHashEmbeddableWorkflow) { +// BoxHash: embeddable API pipeline (update_hash_from_stream + sign_embeddable) +TEST_F(EmbeddableTest, BoxHashEmbeddablePipeline) { 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"); @@ -646,3 +648,731 @@ TEST_F(EmbeddableTest, DirectEmbeddingWithFormat) { EXPECT_EQ(jpeg_manifest.size(), placeholder.size()) << "Direct JPEG format output matches placeholder size"; } + +using PipelineState = c2pa::EmbeddablePipeline::State; + +class EmbeddablePipelineTest : public ::testing::Test { +protected: + std::vector temp_files; + + 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"; + if (!fs::exists(build_dir)) { + fs::create_directories(build_dir); + } + fs::path temp_path = build_dir / ("pipeline-" + name); + temp_files.push_back(temp_path); + return temp_path; + } + + void TearDown() override { + for (const auto& path : temp_files) { + if (fs::exists(path)) { + fs::remove(path); + } + } + temp_files.clear(); + } + + c2pa::Builder make_builder( + std::optional callback = std::nullopt) { + auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + c2pa::Context::ContextBuilder cb; + cb.with_signer(c2pa_test::create_test_signer()); + if (callback) { + cb.with_progress_callback(std::move(*callback)); + } + auto context = cb.create_context(); + return c2pa::Builder(context, manifest_json); + } + + struct BuilderWithContext { + c2pa::Context context; + c2pa::Builder builder; + }; + + BuilderWithContext make_cancellable_builder(c2pa::ProgressCallbackFunc callback) { + auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto context = c2pa::Context::ContextBuilder() + .with_signer(c2pa_test::create_test_signer()) + .with_progress_callback(std::move(callback)) + .create_context(); + auto builder = c2pa::Builder(context, manifest_json); + return {std::move(context), std::move(builder)}; + } + + c2pa::Builder make_boxhash_builder() { + auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto context = c2pa::Context::ContextBuilder() + .with_signer(c2pa_test::create_test_signer()) + .with_json(R"({ + "builder": { + "prefer_box_hash": true + } + })") + .create_context(); + return c2pa::Builder(context, manifest_json); + } +}; + +TEST_F(EmbeddablePipelineTest, DataHashFormatPreservedThroughStates) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "image/jpeg"); + EXPECT_EQ(pipeline.format(), "image/jpeg"); + + pipeline.create_placeholder(); + EXPECT_EQ(pipeline.format(), "image/jpeg"); + + pipeline.set_exclusions({{0, 100}}); + EXPECT_EQ(pipeline.format(), "image/jpeg"); +} + +TEST_F(EmbeddablePipelineTest, DataHashCurrentStateReporting) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "image/jpeg"); + EXPECT_EQ(pipeline.current_state(), PipelineState::init); + + pipeline.create_placeholder(); + EXPECT_EQ(pipeline.current_state(), PipelineState::placeholder_created); + + pipeline.set_exclusions({{0, 100}}); + EXPECT_EQ(pipeline.current_state(), PipelineState::exclusions_configured); + + auto source_asset = c2pa_test::get_fixture_path("A.jpg"); + std::ifstream stream(source_asset, std::ios::binary); + pipeline.hash_from_stream(stream); + stream.close(); + EXPECT_EQ(pipeline.current_state(), PipelineState::hashed); + + pipeline.sign(); + EXPECT_EQ(pipeline.current_state(), PipelineState::pipeline_signed); +} + +TEST_F(EmbeddablePipelineTest, DataHashNotFaultedOnSuccess) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "image/jpeg"); + auto source_asset = c2pa_test::get_fixture_path("A.jpg"); + + EXPECT_FALSE(pipeline.is_faulted()); + + pipeline.create_placeholder(); + EXPECT_FALSE(pipeline.is_faulted()); + + pipeline.set_exclusions({{20, pipeline.placeholder_bytes().size()}}); + EXPECT_FALSE(pipeline.is_faulted()); + + std::ifstream stream(source_asset, std::ios::binary); + pipeline.hash_from_stream(stream); + stream.close(); + EXPECT_FALSE(pipeline.is_faulted()); + + pipeline.sign(); + EXPECT_FALSE(pipeline.is_faulted()); +} + +TEST_F(EmbeddablePipelineTest, DataHashFaultedAfterFailedOperation) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "bogus/format"); + + EXPECT_FALSE(pipeline.is_faulted()); + EXPECT_THROW(pipeline.create_placeholder(), c2pa::C2paException); + EXPECT_TRUE(pipeline.is_faulted()); + EXPECT_EQ(pipeline.current_state(), PipelineState::faulted); +} + +TEST_F(EmbeddablePipelineTest, DataHashFaultedPipelineBlocksAllMethods) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "bogus/format"); + + EXPECT_THROW(pipeline.create_placeholder(), c2pa::C2paException); + ASSERT_TRUE(pipeline.is_faulted()); + + EXPECT_THROW(pipeline.create_placeholder(), c2pa::C2paException); + + std::istringstream dummy("data"); + EXPECT_THROW(pipeline.hash_from_stream(dummy), c2pa::C2paException); + EXPECT_THROW(pipeline.sign(), c2pa::C2paException); + + // Read-only accessors still work + EXPECT_EQ(pipeline.current_state(), PipelineState::faulted); + EXPECT_EQ(pipeline.format(), "bogus/format"); +} + +TEST_F(EmbeddablePipelineTest, BoxHashFormatPreservedThroughStates) { + auto pipeline = c2pa::BoxHashPipeline(make_boxhash_builder(), "image/jpeg"); + EXPECT_EQ(pipeline.format(), "image/jpeg"); + + auto source_asset = c2pa_test::get_fixture_path("A.jpg"); + std::ifstream stream(source_asset, std::ios::binary); + pipeline.hash_from_stream(stream); + stream.close(); + EXPECT_EQ(pipeline.format(), "image/jpeg"); +} + +TEST_F(EmbeddablePipelineTest, BoxHashCurrentStateReporting) { + auto pipeline = c2pa::BoxHashPipeline(make_boxhash_builder(), "image/jpeg"); + EXPECT_EQ(pipeline.current_state(), PipelineState::init); + + auto source_asset = c2pa_test::get_fixture_path("A.jpg"); + std::ifstream stream(source_asset, std::ios::binary); + pipeline.hash_from_stream(stream); + stream.close(); + EXPECT_EQ(pipeline.current_state(), PipelineState::hashed); + + pipeline.sign(); + EXPECT_EQ(pipeline.current_state(), PipelineState::pipeline_signed); +} + +TEST_F(EmbeddablePipelineTest, BoxHashNotFaultedOnSuccess) { + auto pipeline = c2pa::BoxHashPipeline(make_boxhash_builder(), "image/jpeg"); + auto source_asset = c2pa_test::get_fixture_path("A.jpg"); + + EXPECT_FALSE(pipeline.is_faulted()); + + std::ifstream stream(source_asset, std::ios::binary); + pipeline.hash_from_stream(stream); + stream.close(); + EXPECT_FALSE(pipeline.is_faulted()); + + pipeline.sign(); + EXPECT_FALSE(pipeline.is_faulted()); +} + +TEST_F(EmbeddablePipelineTest, BoxHashFaultedAfterFailedOperation) { + // BoxHash doesn't validate format during hash_from_stream — failure + // surfaces at sign() when the builder tries to produce a manifest. + auto pipeline = c2pa::BoxHashPipeline(make_boxhash_builder(), "bogus/format"); + + EXPECT_FALSE(pipeline.is_faulted()); + + std::istringstream dummy("data"); + pipeline.hash_from_stream(dummy); + EXPECT_FALSE(pipeline.is_faulted()); + + EXPECT_THROW(pipeline.sign(), c2pa::C2paException); + EXPECT_TRUE(pipeline.is_faulted()); + EXPECT_EQ(pipeline.current_state(), PipelineState::faulted); +} + +TEST_F(EmbeddablePipelineTest, BoxHashFaultedPipelineBlocksAllMethods) { + auto pipeline = c2pa::BoxHashPipeline(make_boxhash_builder(), "bogus/format"); + + // Drive to hashed state, then fault via sign() + std::istringstream dummy("data"); + pipeline.hash_from_stream(dummy); + EXPECT_THROW(pipeline.sign(), c2pa::C2paException); + ASSERT_TRUE(pipeline.is_faulted()); + + // All mutating methods now throw + std::istringstream dummy2("data"); + EXPECT_THROW(pipeline.hash_from_stream(dummy2), c2pa::C2paException); + EXPECT_THROW(pipeline.sign(), c2pa::C2paException); + + // Read-only accessors still work + EXPECT_EQ(pipeline.current_state(), PipelineState::faulted); + EXPECT_EQ(pipeline.format(), "bogus/format"); +} + +TEST_F(EmbeddablePipelineTest, BmffHashFormatPreservedThroughStates) { + auto pipeline = c2pa::BmffHashPipeline(make_builder(), "video/mp4"); + EXPECT_EQ(pipeline.format(), "video/mp4"); + + pipeline.create_placeholder(); + EXPECT_EQ(pipeline.format(), "video/mp4"); +} + +TEST_F(EmbeddablePipelineTest, BmffHashCurrentStateReporting) { + auto pipeline = c2pa::BmffHashPipeline(make_builder(), "video/mp4"); + EXPECT_EQ(pipeline.current_state(), PipelineState::init); + + pipeline.create_placeholder(); + EXPECT_EQ(pipeline.current_state(), PipelineState::placeholder_created); + + auto source_asset = c2pa_test::get_fixture_path("video1.mp4"); + std::ifstream stream(source_asset, std::ios::binary); + pipeline.hash_from_stream(stream); + stream.close(); + EXPECT_EQ(pipeline.current_state(), PipelineState::hashed); + + pipeline.sign(); + EXPECT_EQ(pipeline.current_state(), PipelineState::pipeline_signed); +} + +TEST_F(EmbeddablePipelineTest, BmffHashNotFaultedOnSuccess) { + auto pipeline = c2pa::BmffHashPipeline(make_builder(), "video/mp4"); + auto source_asset = c2pa_test::get_fixture_path("video1.mp4"); + + EXPECT_FALSE(pipeline.is_faulted()); + + pipeline.create_placeholder(); + EXPECT_FALSE(pipeline.is_faulted()); + + std::ifstream stream(source_asset, std::ios::binary); + pipeline.hash_from_stream(stream); + stream.close(); + EXPECT_FALSE(pipeline.is_faulted()); + + pipeline.sign(); + EXPECT_FALSE(pipeline.is_faulted()); +} + +TEST_F(EmbeddablePipelineTest, BmffHashFaultedAfterFailedOperation) { + auto pipeline = c2pa::BmffHashPipeline(make_builder(), "bogus/format"); + + EXPECT_FALSE(pipeline.is_faulted()); + EXPECT_THROW(pipeline.create_placeholder(), c2pa::C2paException); + EXPECT_TRUE(pipeline.is_faulted()); + EXPECT_EQ(pipeline.current_state(), PipelineState::faulted); +} + +TEST_F(EmbeddablePipelineTest, BmffHashFaultedPipelineBlocksAllMethods) { + auto pipeline = c2pa::BmffHashPipeline(make_builder(), "bogus/format"); + + EXPECT_THROW(pipeline.create_placeholder(), c2pa::C2paException); + ASSERT_TRUE(pipeline.is_faulted()); + + EXPECT_THROW(pipeline.create_placeholder(), c2pa::C2paException); + + std::istringstream dummy("data"); + EXPECT_THROW(pipeline.hash_from_stream(dummy), c2pa::C2paException); + EXPECT_THROW(pipeline.sign(), c2pa::C2paException); + + // Read-only accessors still work + EXPECT_EQ(pipeline.current_state(), PipelineState::faulted); + EXPECT_EQ(pipeline.format(), "bogus/format"); +} + +TEST_F(EmbeddablePipelineTest, BoxHashThrowsOnCreatePlaceholder) { + auto pipeline = c2pa::BoxHashPipeline(make_boxhash_builder(), "image/jpeg"); + EXPECT_THROW(pipeline.create_placeholder(), c2pa::C2paUnsupportedOperationException); +} + +TEST_F(EmbeddablePipelineTest, BoxHashThrowsOnSetExclusions) { + auto pipeline = c2pa::BoxHashPipeline(make_boxhash_builder(), "image/jpeg"); + EXPECT_THROW(pipeline.set_exclusions({{0, 100}}), c2pa::C2paUnsupportedOperationException); +} + +TEST_F(EmbeddablePipelineTest, BoxHashThrowsOnPlaceholderBytes) { + auto pipeline = c2pa::BoxHashPipeline(make_boxhash_builder(), "image/jpeg"); + EXPECT_THROW(pipeline.placeholder_bytes(), c2pa::C2paUnsupportedOperationException); +} + +TEST_F(EmbeddablePipelineTest, BoxHashThrowsOnExclusionRanges) { + auto pipeline = c2pa::BoxHashPipeline(make_boxhash_builder(), "image/jpeg"); + EXPECT_THROW(pipeline.exclusion_ranges(), c2pa::C2paUnsupportedOperationException); +} + +TEST_F(EmbeddablePipelineTest, BmffHashThrowsOnSetExclusions) { + auto pipeline = c2pa::BmffHashPipeline(make_builder(), "video/mp4"); + EXPECT_THROW(pipeline.set_exclusions({{0, 100}}), c2pa::C2paUnsupportedOperationException); +} + +TEST_F(EmbeddablePipelineTest, BmffHashThrowsOnExclusionRanges) { + auto pipeline = c2pa::BmffHashPipeline(make_builder(), "video/mp4"); + EXPECT_THROW(pipeline.exclusion_ranges(), c2pa::C2paUnsupportedOperationException); +} + +TEST_F(EmbeddablePipelineTest, DataHashFullWorkflow) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "image/jpeg"); + auto source_asset = c2pa_test::get_fixture_path("A.jpg"); + + EXPECT_EQ(pipeline.current_state(), PipelineState::init); + + auto& placeholder = pipeline.create_placeholder(); + EXPECT_EQ(pipeline.current_state(), PipelineState::placeholder_created); + ASSERT_GT(placeholder.size(), 0u); + size_t placeholder_size = placeholder.size(); + + uint64_t embed_offset = 20; + pipeline.set_exclusions({{embed_offset, placeholder_size}}); + EXPECT_EQ(pipeline.current_state(), PipelineState::exclusions_configured); + + std::ifstream asset_stream(source_asset, std::ios::binary); + ASSERT_TRUE(asset_stream.is_open()); + pipeline.hash_from_stream(asset_stream); + asset_stream.close(); + EXPECT_EQ(pipeline.current_state(), PipelineState::hashed); + + auto& manifest = pipeline.sign(); + EXPECT_EQ(pipeline.current_state(), PipelineState::pipeline_signed); + ASSERT_GT(manifest.size(), 0u); + EXPECT_EQ(manifest.size(), placeholder_size) + << "Signed manifest must match placeholder size for in-place patching"; +} + +TEST_F(EmbeddablePipelineTest, BmffHashFullWorkflow) { + auto pipeline = c2pa::BmffHashPipeline(make_builder(), "video/mp4"); + auto source_asset = c2pa_test::get_fixture_path("video1.mp4"); + + pipeline.create_placeholder(); + EXPECT_EQ(pipeline.current_state(), PipelineState::placeholder_created); + + std::ifstream asset_stream(source_asset, std::ios::binary); + ASSERT_TRUE(asset_stream.is_open()); + pipeline.hash_from_stream(asset_stream); + asset_stream.close(); + EXPECT_EQ(pipeline.current_state(), PipelineState::hashed); + + auto& manifest = pipeline.sign(); + EXPECT_EQ(pipeline.current_state(), PipelineState::pipeline_signed); + ASSERT_GT(manifest.size(), 0u); +} + +TEST_F(EmbeddablePipelineTest, BoxHashFullWorkflow) { + auto pipeline = c2pa::BoxHashPipeline(make_boxhash_builder(), "image/jpeg"); + auto source_asset = c2pa_test::get_fixture_path("A.jpg"); + + std::ifstream asset_stream(source_asset, std::ios::binary); + ASSERT_TRUE(asset_stream.is_open()); + pipeline.hash_from_stream(asset_stream); + asset_stream.close(); + EXPECT_EQ(pipeline.current_state(), PipelineState::hashed); + + auto& manifest = pipeline.sign(); + EXPECT_EQ(pipeline.current_state(), PipelineState::pipeline_signed); + ASSERT_GT(manifest.size(), 0u); +} + +TEST_F(EmbeddablePipelineTest, DataHashFullWorkflowViaFactory) { + auto pipeline = c2pa::EmbeddablePipeline::create(make_builder(), "image/jpeg"); + auto source_asset = c2pa_test::get_fixture_path("A.jpg"); + + EXPECT_EQ(pipeline->hash_type(), c2pa::HashType::Data); + EXPECT_EQ(pipeline->current_state(), PipelineState::init); + + auto& placeholder = pipeline->create_placeholder(); + EXPECT_EQ(pipeline->current_state(), PipelineState::placeholder_created); + ASSERT_GT(placeholder.size(), 0u); + size_t placeholder_size = placeholder.size(); + + uint64_t embed_offset = 20; + pipeline->set_exclusions({{embed_offset, placeholder_size}}); + EXPECT_EQ(pipeline->current_state(), PipelineState::exclusions_configured); + + std::ifstream asset_stream(source_asset, std::ios::binary); + ASSERT_TRUE(asset_stream.is_open()); + pipeline->hash_from_stream(asset_stream); + asset_stream.close(); + EXPECT_EQ(pipeline->current_state(), PipelineState::hashed); + + auto& manifest = pipeline->sign(); + EXPECT_EQ(pipeline->current_state(), PipelineState::pipeline_signed); + ASSERT_GT(manifest.size(), 0u); + EXPECT_EQ(manifest.size(), placeholder_size) + << "Signed manifest must match placeholder size for in-place patching"; +} + +TEST_F(EmbeddablePipelineTest, BmffHashFullWorkflowViaFactory) { + auto pipeline = c2pa::EmbeddablePipeline::create(make_builder(), "video/mp4"); + auto source_asset = c2pa_test::get_fixture_path("video1.mp4"); + + EXPECT_EQ(pipeline->hash_type(), c2pa::HashType::Bmff); + EXPECT_EQ(pipeline->current_state(), PipelineState::init); + + pipeline->create_placeholder(); + EXPECT_EQ(pipeline->current_state(), PipelineState::placeholder_created); + + std::ifstream asset_stream(source_asset, std::ios::binary); + ASSERT_TRUE(asset_stream.is_open()); + pipeline->hash_from_stream(asset_stream); + asset_stream.close(); + EXPECT_EQ(pipeline->current_state(), PipelineState::hashed); + + auto& manifest = pipeline->sign(); + EXPECT_EQ(pipeline->current_state(), PipelineState::pipeline_signed); + ASSERT_GT(manifest.size(), 0u); +} + +TEST_F(EmbeddablePipelineTest, BoxHashFullWorkflowViaFactory) { + auto pipeline = c2pa::EmbeddablePipeline::create(make_boxhash_builder(), "image/jpeg"); + auto source_asset = c2pa_test::get_fixture_path("A.jpg"); + + EXPECT_EQ(pipeline->hash_type(), c2pa::HashType::Box); + EXPECT_EQ(pipeline->current_state(), PipelineState::init); + + std::ifstream asset_stream(source_asset, std::ios::binary); + ASSERT_TRUE(asset_stream.is_open()); + pipeline->hash_from_stream(asset_stream); + asset_stream.close(); + EXPECT_EQ(pipeline->current_state(), PipelineState::hashed); + + auto& manifest = pipeline->sign(); + EXPECT_EQ(pipeline->current_state(), PipelineState::pipeline_signed); + ASSERT_GT(manifest.size(), 0u); +} + +TEST_F(EmbeddablePipelineTest, ReleaseBuilderFromInitState) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "image/jpeg"); + EXPECT_EQ(pipeline.current_state(), PipelineState::init); + + auto builder = pipeline.release_builder(); + EXPECT_EQ(pipeline.current_state(), PipelineState::cancelled); + EXPECT_FALSE(pipeline.is_faulted()); +} + +TEST_F(EmbeddablePipelineTest, ReleaseBuilderFromPlaceholderCreated) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "image/jpeg"); + pipeline.create_placeholder(); + EXPECT_EQ(pipeline.current_state(), PipelineState::placeholder_created); + + auto builder = pipeline.release_builder(); + EXPECT_EQ(pipeline.current_state(), PipelineState::cancelled); +} + +TEST_F(EmbeddablePipelineTest, ReleaseBuilderFromFaultedState) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "bogus/format"); + EXPECT_THROW(pipeline.create_placeholder(), c2pa::C2paException); + ASSERT_TRUE(pipeline.is_faulted()); + + auto builder = pipeline.release_builder(); + // State stays faulted (not cancelled) because the fault came from an operation + EXPECT_EQ(pipeline.current_state(), PipelineState::faulted); +} + +TEST_F(EmbeddablePipelineTest, ReleaseBuilderFromPipelineSigned) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "image/jpeg"); + auto source_asset = c2pa_test::get_fixture_path("A.jpg"); + + auto& placeholder = pipeline.create_placeholder(); + pipeline.set_exclusions({{20, placeholder.size()}}); + + std::ifstream stream(source_asset, std::ios::binary); + pipeline.hash_from_stream(stream); + stream.close(); + + pipeline.sign(); + EXPECT_EQ(pipeline.current_state(), PipelineState::pipeline_signed); + + auto builder = pipeline.release_builder(); + EXPECT_EQ(pipeline.current_state(), PipelineState::cancelled); +} + +TEST_F(EmbeddablePipelineTest, DoubleReleaseThrows) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "image/jpeg"); + auto builder = pipeline.release_builder(); + + EXPECT_THROW(pipeline.release_builder(), c2pa::C2paException); +} + +TEST_F(EmbeddablePipelineTest, PipelineOperationsThrowAfterRelease) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "image/jpeg"); + auto builder = pipeline.release_builder(); + EXPECT_EQ(pipeline.current_state(), PipelineState::cancelled); + + EXPECT_THROW(pipeline.create_placeholder(), c2pa::C2paException); + + std::istringstream dummy("data"); + EXPECT_THROW(pipeline.hash_from_stream(dummy), c2pa::C2paException); + EXPECT_THROW(pipeline.sign(), c2pa::C2paException); +} + +TEST_F(EmbeddablePipelineTest, FactoryPipelineRelease) { + auto pipeline = c2pa::EmbeddablePipeline::create(make_builder(), "image/jpeg"); + auto builder = pipeline->release_builder(); + EXPECT_EQ(pipeline->current_state(), PipelineState::cancelled); + + EXPECT_THROW(pipeline->create_placeholder(), c2pa::C2paException); +} + +TEST_F(EmbeddablePipelineTest, ArchiveRecoveryAfterFault) { + auto source_asset = c2pa_test::get_fixture_path("A.jpg"); + + // Archive the builder before creating the pipeline + std::ostringstream archive_stream; + { + auto builder = make_builder(); + builder.to_archive(archive_stream); + } + + // Create a pipeline that will fault + { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "bogus/format"); + EXPECT_THROW(pipeline.create_placeholder(), c2pa::C2paException); + ASSERT_TRUE(pipeline.is_faulted()); + } + + // Restore from archive into a builder that has a signer context + // (from_archive alone loses the signer; use with_archive on a context-bearing builder) + auto context = c2pa::Context::ContextBuilder() + .with_signer(c2pa_test::create_test_signer()) + .create_context(); + auto restored = c2pa::Builder(context); + std::istringstream restore(archive_stream.str()); + restored.with_archive(restore); + + auto pipeline = c2pa::DataHashPipeline(std::move(restored), "image/jpeg"); + + auto& placeholder = pipeline.create_placeholder(); + ASSERT_GT(placeholder.size(), 0u); + + pipeline.set_exclusions({{20, placeholder.size()}}); + + std::ifstream stream(source_asset, std::ios::binary); + pipeline.hash_from_stream(stream); + stream.close(); + + auto& manifest = pipeline.sign(); + ASSERT_GT(manifest.size(), 0u); + EXPECT_EQ(manifest.size(), placeholder.size()); +} + +TEST_F(EmbeddablePipelineTest, FaultedFromReturnsInitOnPlaceholderFault) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "bogus/format"); + EXPECT_THROW(pipeline.create_placeholder(), c2pa::C2paException); + ASSERT_TRUE(pipeline.is_faulted()); + + auto from = pipeline.faulted_from(); + ASSERT_TRUE(from.has_value()); + EXPECT_EQ(*from, PipelineState::init); +} + +TEST_F(EmbeddablePipelineTest, FaultedFromReturnsHashedOnSignFault) { + auto pipeline = c2pa::BoxHashPipeline(make_boxhash_builder(), "bogus/format"); + + std::istringstream dummy("data"); + pipeline.hash_from_stream(dummy); + EXPECT_EQ(pipeline.current_state(), PipelineState::hashed); + + EXPECT_THROW(pipeline.sign(), c2pa::C2paException); + ASSERT_TRUE(pipeline.is_faulted()); + + auto from = pipeline.faulted_from(); + ASSERT_TRUE(from.has_value()); + EXPECT_EQ(*from, PipelineState::hashed); +} + +TEST_F(EmbeddablePipelineTest, FaultedFromReturnsNulloptWhenNotFaulted) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "image/jpeg"); + EXPECT_FALSE(pipeline.faulted_from().has_value()); + + pipeline.create_placeholder(); + EXPECT_FALSE(pipeline.faulted_from().has_value()); + + pipeline.set_exclusions({{0, 100}}); + EXPECT_FALSE(pipeline.faulted_from().has_value()); +} + +TEST_F(EmbeddablePipelineTest, FaultedFromReturnsNulloptAfterCancellation) { + auto pipeline = c2pa::DataHashPipeline(make_builder(), "image/jpeg"); + auto builder = pipeline.release_builder(); + + EXPECT_EQ(pipeline.current_state(), PipelineState::cancelled); + EXPECT_FALSE(pipeline.faulted_from().has_value()); +} + +TEST_F(EmbeddablePipelineTest, BmffHashPipelineProgressCallbackFires) { + std::atomic saw_hashing{false}; + std::atomic saw_signing{false}; + + auto [context, builder] = make_cancellable_builder( + [&](c2pa::ProgressPhase phase, uint32_t, uint32_t) { + if (phase == c2pa::ProgressPhase::Hashing) saw_hashing.store(true); + if (phase == c2pa::ProgressPhase::Signing) saw_signing.store(true); + return true; + }); + + auto pipeline = c2pa::BmffHashPipeline(std::move(builder), "video/mp4"); + pipeline.create_placeholder(); + + auto source_asset = c2pa_test::get_fixture_path("video1.mp4"); + std::ifstream stream(source_asset, std::ios::binary); + pipeline.hash_from_stream(stream); + stream.close(); + + pipeline.sign(); + + EXPECT_TRUE(saw_hashing.load()); + EXPECT_TRUE(saw_signing.load()); + EXPECT_EQ(pipeline.current_state(), PipelineState::pipeline_signed); +} + +TEST_F(EmbeddablePipelineTest, BmffHashPipelineCancelViaCallbackReturnFalse) { + auto [context, builder] = make_cancellable_builder( + [](c2pa::ProgressPhase, uint32_t, uint32_t) { + return false; // cancel immediately + }); + + auto pipeline = c2pa::BmffHashPipeline(std::move(builder), "video/mp4"); + pipeline.create_placeholder(); + + auto source_asset = c2pa_test::get_fixture_path("video1.mp4"); + std::ifstream stream(source_asset, std::ios::binary); + + EXPECT_THROW(pipeline.hash_from_stream(stream), c2pa::C2paCancelledException); + stream.close(); + + EXPECT_EQ(pipeline.current_state(), PipelineState::cancelled); + EXPECT_FALSE(pipeline.is_faulted()); +} + +TEST_F(EmbeddablePipelineTest, BmffHashPipelineCancelViaContextCancel) { + auto [context, builder] = make_cancellable_builder( + [](c2pa::ProgressPhase, uint32_t, uint32_t) { return true; }); + + context.cancel(); + + auto pipeline = c2pa::BmffHashPipeline(std::move(builder), "video/mp4"); + pipeline.create_placeholder(); + + auto source_asset = c2pa_test::get_fixture_path("video1.mp4"); + std::ifstream stream(source_asset, std::ios::binary); + + EXPECT_THROW(pipeline.hash_from_stream(stream), c2pa::C2paCancelledException); + stream.close(); + + EXPECT_EQ(pipeline.current_state(), PipelineState::cancelled); + EXPECT_FALSE(pipeline.is_faulted()); +} + +TEST_F(EmbeddablePipelineTest, BmffHashPipelineCancelledPipelineRejectsSubsequentCalls) { + auto [context, builder] = make_cancellable_builder( + [](c2pa::ProgressPhase, uint32_t, uint32_t) { return false; }); + + auto pipeline = c2pa::BmffHashPipeline(std::move(builder), "video/mp4"); + pipeline.create_placeholder(); + + auto source_asset = c2pa_test::get_fixture_path("video1.mp4"); + std::ifstream stream(source_asset, std::ios::binary); + + EXPECT_THROW(pipeline.hash_from_stream(stream), c2pa::C2paCancelledException); + stream.close(); + + ASSERT_EQ(pipeline.current_state(), PipelineState::cancelled); + + // Subsequent calls on cancelled pipeline throw C2paCancelledException + std::ifstream stream2(source_asset, std::ios::binary); + EXPECT_THROW(pipeline.hash_from_stream(stream2), c2pa::C2paCancelledException); + stream2.close(); +} + +TEST_F(EmbeddablePipelineTest, BmffHashPipelineReleaseBuilderAfterProgressCancel) { + auto [context, builder] = make_cancellable_builder( + [](c2pa::ProgressPhase, uint32_t, uint32_t) { return false; }); + + auto pipeline = c2pa::BmffHashPipeline(std::move(builder), "video/mp4"); + pipeline.create_placeholder(); + + auto source_asset = c2pa_test::get_fixture_path("video1.mp4"); + std::ifstream stream(source_asset, std::ios::binary); + + EXPECT_THROW(pipeline.hash_from_stream(stream), c2pa::C2paCancelledException); + stream.close(); + + ASSERT_EQ(pipeline.current_state(), PipelineState::cancelled); + + // release_builder() works on a cancelled pipeline and state stays cancelled + auto recovered = pipeline.release_builder(); + EXPECT_EQ(pipeline.current_state(), PipelineState::cancelled); +} + +TEST_F(EmbeddablePipelineTest, BmffHashPipelineRealFaultStillFaults) { + auto pipeline = c2pa::BmffHashPipeline(make_builder(), "bogus/format"); + + try { + pipeline.create_placeholder(); + FAIL() << "Expected C2paException for bogus format"; + } catch (const c2pa::C2paCancelledException&) { + FAIL() << "Real fault should not throw C2paCancelledException"; + } catch (const c2pa::C2paException&) { + // Expected: real fault, not cancellation + } + + EXPECT_TRUE(pipeline.is_faulted()); + EXPECT_EQ(pipeline.current_state(), PipelineState::faulted); +}