From 64a114eb24dced02dab626cadfe3bd568740d59d Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 9 Jul 2026 06:25:28 -0700 Subject: [PATCH 1/4] fix: New tag --- CMakeLists.txt | 4 ++-- compile_commands.json | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) create mode 120000 compile_commands.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 59e9ab38..399c7aba 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.23.15) +project(c2pa-c VERSION 0.23.16) # Set the version of the c2pa_rs library used -set(C2PA_VERSION "0.86.1") +set(C2PA_VERSION "0.87.0") set(CMAKE_POLICY_DEFAULT_CMP0135 NEW) set(CMAKE_C_STANDARD 17) diff --git a/compile_commands.json b/compile_commands.json new file mode 120000 index 00000000..7c1ac711 --- /dev/null +++ b/compile_commands.json @@ -0,0 +1 @@ +build/debug/compile_commands.json \ No newline at end of file From 51ce24c417f308c3bceb2e8b1b29cff657de1597 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:18:18 -0700 Subject: [PATCH 2/4] feat: Bindings for dedicated ingredient archives APIs (#223) * fix: WIP * fix: Huge docs update * fix: Cleanup build helper * fix: Docs * chore: Bump project version to 0.23.11 and C2PA version to 0.85.0 * fix: All the docs uopdates * fix: All the docs uopdates * fix: All the docs uopdates * fix: Build * fix: Update tests * fix: Update tests * fix: Merge syntax error * Bump project version to 0.24.1 --- Makefile | 26 +- docs/selective-manifests.md | 303 ++++++++- docs/working-stores.md | 426 +++++++++++- include/c2pa.hpp | 14 + src/c2pa_builder.cpp | 20 + tests/builder.test.cpp | 1243 ++++++++++++++++++++++++++++++++++- 6 files changed, 1968 insertions(+), 64 deletions(-) diff --git a/Makefile b/Makefile index 038d0ef4..cf1f4fd3 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,23 @@ ifdef C2PA_RS_PATH CMAKE_OPTS += -DC2PA_RS_PATH="$(C2PA_RS_PATH)" endif +# Sanitizer builds need a compiler-rt that matches the running OS. On macOS 26+ +# the Apple clang (Xcode 16) AddressSanitizer runtime can abort at process startup +# with "sanitizer_malloc_mac.inc CHECK failed: ((!asan_init_is_running))" +# before main() runs. If a Homebrew LLVM clang is actually installed, use it for +# sanitizer builds; otherwise fall back to the default toolchain (fine on older +# macOS and on Linux). Override with SAN_CC / SAN_CXX. +# Note: `brew --prefix llvm` prints a path even when llvm is NOT installed, so the +# binary's existence must be verified (-x) before using it. +SAN_CMAKE_OPTS := +ifeq ($(OS),Darwin) +SAN_CC ?= $(shell c="$$(brew --prefix llvm 2>/dev/null)/bin/clang"; [ -x "$$c" ] && echo "$$c") +SAN_CXX ?= $(shell c="$$(brew --prefix llvm 2>/dev/null)/bin/clang++"; [ -x "$$c" ] && echo "$$c") +ifneq ($(SAN_CC),) +SAN_CMAKE_OPTS += -DCMAKE_C_COMPILER=$(SAN_CC) -DCMAKE_CXX_COMPILER=$(SAN_CXX) +endif +endif + # Default target all: clean test examples @@ -43,7 +60,14 @@ test-release: clean release # Test with sanitizers (ASAN + UBSAN) test-san: clean - cmake -S . -B $(DEBUG_BUILD_DIR) -G "Ninja" -DCMAKE_BUILD_TYPE=Debug -DENABLE_SANITIZERS=ON $(CMAKE_OPTS) + @if [ -n "$(SAN_CC)" ]; then \ + echo "Sanitizer build using $(SAN_CC)"; \ + elif [ "$(OS)" = "Darwin" ]; then \ + echo "NOTE: using the default toolchain for sanitizers. If tests abort at startup with"; \ + echo " 'asan_init_is_running' (Apple clang on macOS 26+), run 'brew install llvm'"; \ + echo " or set SAN_CC/SAN_CXX to a clang whose compiler-rt supports this OS."; \ + fi + cmake -S . -B $(DEBUG_BUILD_DIR) -G "Ninja" -DCMAKE_BUILD_TYPE=Debug -DENABLE_SANITIZERS=ON $(CMAKE_OPTS) $(SAN_CMAKE_OPTS) cmake --build $(DEBUG_BUILD_DIR) cd $(DEBUG_BUILD_DIR) && ctest --output-on-failure diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index ff674b8b..5ece2767 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -419,29 +419,39 @@ for (auto& assertion : manifest["assertions"]) { | --- | --- | --- | | **Who controls it** | Caller (any string) | Caller (any string, or from XMP metadata) | | **Priority for linking** | Primary: checked first | Fallback: used when label is absent/empty | -| **When to use** | JSON-defined manifests where the caller controls the ingredient definition | Programmatic workflows using `read_ingredient_file()` or XMP-based IDs | +| **When to use** | JSON-defined manifests where the caller controls the ingredient definition | Programmatic workflows where a stable identifier persisting unchanged across rebuilds is needed (`read_ingredient_file()` is deprecated) | | **Survives signing** | SDK may reassign the actual assertion label | Unchanged | | **Stable across rebuilds** | The caller controls the build-time value; the post-signing label may change | Yes, always the same set value | -**Use `label`** when defining manifests in JSON. -**Use `instance_id`** when working programmatically with ingredients whose identity comes from other sources, or when a stable identifier that persists unchanged across rebuilds is needed. +Use `label` when defining manifests in JSON. Use `instance_id` when working programmatically with ingredients whose identity comes from other sources, or when a stable identifier that persists unchanged across rebuilds is needed. ## Working with archives A `Builder` represents a **working store**: a manifest that is being assembled but has not yet been signed. Archives serialize this working store (definition + resources) to a `.c2pa` binary format, allowing to save, transfer, or resume the work later. For more background on working stores and archives, see [Working stores](https://opensource.contentauthenticity.org/docs/rust-sdk/docs/working-stores). -There are two distinct types of archives, sharing the same binary format but being conceptually different: builder archives (working store archives) and ingredient archives. +There are two distinct types of archives, sharing the same binary format but being conceptually different: builder archives and ingredient archives. ### Builder archives vs. ingredient archives -A **builder archive** (also called a working store archive) is a serialized snapshot of a `Builder`. It contains the manifest definition, all resources, and any ingredients that were added. It is created by `builder.to_archive()` and restored with `Builder::from_archive()` or `builder.with_archive()`. +A **builder archive** is a serialized snapshot of a `Builder` (i.e. of a working store). The term *working store* refers to the unsigned `Builder` itself; the *builder archive* is its serialized `.c2pa` form. The archive contains the manifest definition, all resources, and any ingredients that were added. It is created by `builder.to_archive()` and restored with `Builder::from_archive()` or `builder.with_archive()`. An **ingredient archive** contains the manifest store from an asset that was added as an ingredient. The key difference: a builder archive is a work-in-progress (unsigned). An ingredient archive carries the provenance history of a source asset for reuse as an ingredient in other working stores. -### The ingredients catalog pattern +### Two ways to produce an ingredient archive + +The SDK supports two approaches for producing an ingredient archive. They share the same `.c2pa` binary format and are interchangeable from the consumer side. + +| Approach | Entry point | Status | +| --- | --- | --- | +| Dedicated ingredient archive APIs | `add_ingredient` then `write_ingredient_archive(id, stream)` | **Recommended** | +| Read-filter-rebuild pattern | `Builder` + `add_ingredient` + `to_archive`, then `Reader` + manual JSON | **Older pattern** (see [catalog migration guide](#migration-guide-catalog-pattern) and [extraction migration guide](#migration-guide-ingredient-extraction)) | + +For the full contract (id resolution, error cases, examples), see [Single-ingredient archive APIs](./working-stores.md#single-ingredient-archive-apis) in the working stores guide. + +## The ingredients catalog pattern An **ingredients catalog** is a collection of archived ingredients that can be selected when constructing a final manifest. Each archive holds ingredients; at build time the caller selects only the ones needed. @@ -467,7 +477,93 @@ flowchart TD style X fill:#f99,stroke:#c00 ``` +The catalog can be implemented two ways. The dedicated (ingredient) archives API uses one archive per ingredient. + +Alternatively, a single builder archive can hold many ingredients (a multi-ingredient builder archive is still just a builder archive, not a deprecated format), and the read-filter-rebuild *pattern* slices out a subset of ingredients (and resources) from it. The pattern is the older approach; the archive itself is not legacy. + +### Dedicated archives API: one ingredient per archive + +The producer registers each ingredient on a builder and writes one archive per ingredient, keyed by `instance_id`. The consumer assembles a final builder by loading only the archives it needs via `add_ingredient_from_archive`. + +The first argument to `write_ingredient_archive` is the *archive key*: it locates the ingredient on the producer (matched against either `label` or `instance_id`) and becomes the `ingredientIds` value to use on the signing builder. See [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking) for the full rules. + +> [!NOTE] +> `"relationship": "componentOf"` is shown explicitly below, but `componentOf` is the default the SDK applies when `relationship` is omitted. + +Producer side, build the catalog: + +```cpp +auto settings = c2pa::Settings(); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto catalog_builder = c2pa::Builder(context, manifest_json); +catalog_builder.add_ingredient( + R"({"title": "photo-A.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-A"})", + "photo-A.jpg"); +catalog_builder.add_ingredient( + R"({"title": "photo-B.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-B"})", + "photo-B.jpg"); +catalog_builder.add_ingredient( + R"({"title": "photo-C.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-C"})", + "photo-C.jpg"); + +// One archive per ingredient, keyed by the instance_id used at registration. +std::stringstream archive_a(std::ios::in | std::ios::out | std::ios::binary); +std::stringstream archive_b(std::ios::in | std::ios::out | std::ios::binary); +std::stringstream archive_c(std::ios::in | std::ios::out | std::ios::binary); +catalog_builder.write_ingredient_archive("catalog:ingredient-A", archive_a); +catalog_builder.write_ingredient_archive("catalog:ingredient-B", archive_b); +catalog_builder.write_ingredient_archive("catalog:ingredient-C", archive_c); +``` + +Consumer side, pick one archive and load it: + +```cpp +auto final_builder = c2pa::Builder(context, manifest_json); +archive_b.seekg(0); +final_builder.add_ingredient_from_archive(archive_b); + +final_builder.sign(source_path, output_path, signer); +``` + +The signed output contains exactly the picked ingredient (`photo-B.jpg` here). `archive_a` stays unused. + +A single action can link several ingredients loaded this way. With the three archives from the producer above (`catalog:ingredient-A`, `catalog:ingredient-B`, `catalog:ingredient-C`) loaded into one signing builder, a `c2pa.placed` action that lists all three ids in `ingredientIds` resolves to three distinct ingredient URLs after signing: + +```cpp +auto signing_builder = c2pa::Builder(context, R"({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [{ + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["catalog:ingredient-A", "catalog:ingredient-B", "catalog:ingredient-C"] + } + }] + } + }] +})"); + +archive_a.seekg(0); +archive_b.seekg(0); +archive_c.seekg(0); +signing_builder.add_ingredient_from_archive(archive_a); +signing_builder.add_ingredient_from_archive(archive_b); +signing_builder.add_ingredient_from_archive(archive_c); + +signing_builder.sign(source_path, output_path, signer); +``` + +### Legacy catalog: read-filter-rebuild APIs +> [!NOTE] +> **Legacy approach.** This pattern requires manual JSON parsing and `add_resource` loops to transfer binary data. See [Migration guide](#migration-guide-catalog-pattern) to use the [dedicated ingredient archive APIs](#dedicated-archives-api-one-ingredient-per-archive) instead. + +Use this approach when the catalog already exists as a single `.c2pa` builder archive containing many ingredients and you need to pick a subset by reading, filtering, and rebuilding. ```cpp // Read from a catalog of archived ingredients @@ -516,11 +612,86 @@ for (auto& ingredient : selected) { builder.sign(source_path, output_path, signer); ``` +#### Migration guide: catalog pattern + +Switch to the dedicated ingredient archive APIs: set `instance_id` per ingredient, call `write_ingredient_archive` once per ingredient on the producer, and `add_ingredient_from_archive` on the consumer. No JSON parsing or `add_resource` loops required. + +Producer side: + +```cpp +auto settings = c2pa::Settings(); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto catalog_builder = c2pa::Builder(context, manifest_json); +catalog_builder.add_ingredient( + R"({"title": "photo-A.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-A"})", + "photo-A.jpg"); +catalog_builder.add_ingredient( + R"({"title": "photo-B.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-B"})", + "photo-B.jpg"); + +std::stringstream archive_a(std::ios::in | std::ios::out | std::ios::binary); +std::stringstream archive_b(std::ios::in | std::ios::out | std::ios::binary); +catalog_builder.write_ingredient_archive("catalog:ingredient-A", archive_a); +catalog_builder.write_ingredient_archive("catalog:ingredient-B", archive_b); +``` + +Consumer side: + +```cpp +auto final_builder = c2pa::Builder(context, manifest_json); +archive_b.seekg(0); +final_builder.add_ingredient_from_archive(archive_b); +final_builder.sign(source_path, output_path, signer); +``` + +Action linking also changes between the two approaches. Legacy catalog code linked ingredients via `label` set on the signing builder's `add_ingredient` JSON; `instance_id` was not accepted. The dedicated archive API accepts the archive key passed to `write_ingredient_archive`, which can be either `label` or `instance_id`. See [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking). + +#### Choosing between approaches + +The legacy read-filter-rebuild APIs fit when the catalog already exists as one multi-ingredient builder archive and the consumer wants a subset of it. The dedicated ingredient archive APIs fit when ingredients are produced and consumed independently: each archive holds exactly one ingredient, and the call sites stay short. Both produce the same signed output. + ### Identifying ingredients in archives -When building an ingredient archive, you can set `instance_id` on the ingredient to give it a stable, caller-controlled identifier. This field survives archiving and signing unchanged, so it can be used to look up a specific ingredient from a catalog archive. The `description` and `informational_URI` fields also survive and can carry additional metadata about the ingredient's origin. +Setting `instance_id` on an ingredient gives it a stable, caller-controlled identifier. This field survives archiving and signing unchanged, so it can locate a specific ingredient in a catalog. The `description` and `informational_URI` fields also survive and can carry additional metadata about the ingredient's origin. -`instance_id` is only for identification and catalog lookups. It cannot be used as a linking key in `ingredientIds` when linking ingredient archives to actions — use `label` for that (see [Linking an archived ingredient to an action](#linking-an-archived-ingredient-to-an-action)). +For the dedicated archive methods, `instance_id` is the preferred key: it is the only one of these identifiers observable in the signed manifest. A `label` is a builder-only linking key and does not appear in the signed output (see [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking)). + +For the legacy load path (`add_ingredient(json, "application/c2pa", archive)`), `instance_id` cannot be used as a linking key in `ingredientIds`; use `label` instead (see [Linking an archived ingredient to an action](#linking-an-archived-ingredient-to-an-action)). For the dedicated `write_ingredient_archive` + `add_ingredient_from_archive` ingredient archive APIs, the archive key can be either `label` or `instance_id` and becomes the `ingredientIds` value (see [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking)). + +With the dedicated single-ingredient API, `instance_id` also serves as the lookup key passed to `write_ingredient_archive`. Set it on `add_ingredient`, then pass the same value to write the archive: + +> [!NOTE] +> A caller-set `instance_id` replaces the ingredient asset's own XMP `instance_id`. Use a value you control (as in `catalog:photo-A` below) when the archive key matters more than preserving the asset's original XMP id. + +```cpp +// Producer: register ingredient with instance_id, write its archive. +auto settings = c2pa::Settings(); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto builder = c2pa::Builder(context, manifest_str); +builder.add_ingredient( + R"({"title": "photo-A.jpg", "relationship": "componentOf", "instance_id": "catalog:photo-A"})", + source_path); + +std::stringstream archive_a(std::ios::in | std::ios::out | std::ios::binary); +builder.write_ingredient_archive("catalog:photo-A", archive_a); + +// Consumer: load this archive directly, no Reader loop required. +auto consumer = c2pa::Builder(context, manifest_json); +archive_a.seekg(0); +consumer.add_ingredient_from_archive(archive_a); +consumer.sign(source_path, output_path, signer); +``` + +#### Legacy: `to_archive` + Reader loop + +> [!NOTE] +> **Legacy approach.** The pattern below archives a multi-ingredient builder and uses a `Reader` loop to find ingredients by `instance_id`. ```cpp // Set instance_id when adding the ingredient to the archive builder. @@ -632,7 +803,46 @@ for (auto& action : actions) { ### Extracting ingredients from a working store -An example workflow is to build up a working store with multiple ingredients, archive it, and then later extract specific ingredients from that archive to use in a new working store. +A way to extract a specific ingredient from a working store is with the dedicated ingredient archive APIs: the producer writes one archive per ingredient with `write_ingredient_archive`, and the consumer loads only what it needs with `add_ingredient_from_archive`. The read-filter-rebuild APIs are the legacy approach. + +#### Dedicated ingredient archive APIs + +The producer registers each ingredient keyed by `instance_id`, writes one archive per ingredient, and the consumer loads only the needed one. + +```cpp +auto settings = c2pa::Settings(); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +// Producer: register two ingredients keyed by instance_id, archive each separately. +auto producer = c2pa::Builder(context, manifest_json); +producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-A"})", + "A.jpg"); +producer.add_ingredient( + R"({"title": "C.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-C"})", + "C.jpg"); + +std::stringstream archive_a(std::ios::in | std::ios::out | std::ios::binary); +std::stringstream archive_c(std::ios::in | std::ios::out | std::ios::binary); +producer.write_ingredient_archive("catalog:ingredient-A", archive_a); +producer.write_ingredient_archive("catalog:ingredient-C", archive_c); + +// Consumer: load only ingredient-A, no JSON parsing, no add_resource loop. +auto sink = c2pa::Builder(context, manifest_json); +archive_a.seekg(0); +sink.add_ingredient_from_archive(archive_a); + +sink.sign(source_path, output_path, signer); +``` + +The signed output contains exactly the loaded ingredient. + +#### Legacy: read-filter-rebuild APIs + +> [!NOTE] +> **Legacy approach.** This pattern archives the full working store, then reads it back with `Reader`, filters ingredients in JSON, and transfers binary resources manually. ```mermaid flowchart TD @@ -651,8 +861,6 @@ flowchart TD end ``` - - **Step 1:** Build a working store and archive it: ```cpp @@ -718,6 +926,18 @@ for (auto& ingredient : selected) { new_builder.sign(source_path, output_path, signer); ``` +##### Migration guide: ingredient extraction + +| Step | Legacy (manual) approach | Current dedicated ingredient archive APIs approach | +| --- | --- | --- | +| Archive | `builder.to_archive(stream)` (full builder) | `builder.write_ingredient_archive(id, stream)` (one ingredient) | +| Load | `Reader` + JSON parse + filter loop + `add_resource` per resource | `builder2.add_ingredient_from_archive(stream)` | +| Setting required | None | `builder.generate_c2pa_archive = "true"` on producer (current default) | + +The dedicated ingredient archive APIs require no JSON parsing and no `add_resource` calls. Each archive holds exactly one ingredient. + +Action linking differs between the two paths. With the legacy approach, the signing builder must re-assert `label` on its `add_ingredient` JSON to link to an action; `instance_id` is not accepted. With the dedicated ingredient archive APIs, the archive key passed to `write_ingredient_archive` (either `label` or `instance_id` from the producer ingredient) flows through and becomes the `ingredientIds` value. See [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking). + ### Reading ingredient details from an ingredient archive An ingredient archive is a serialized `Builder` containing exactly one and only one ingredient (see [Builder archives vs. ingredient archives](#builder-archives-vs-ingredient-archives)). Reading it with `Reader` allows the caller to inspect the ingredient before deciding whether to use it: its thumbnail, whether it carries provenance (e.g. an active manifest), validation status, relationship, etc. @@ -785,20 +1005,22 @@ if (ingredient.contains("thumbnail")) { #### Ingredient vs. ingredient archive -A plain ingredient is a source asset (image, video, document) the builder reads at `add_ingredient` time, with `label` (primary) or `instance_id` (fallback) usable as linking keys. An ingredient archive is a `.c2pa` file containing one already-formed ingredient. When passed to `add_ingredient`, the builder treats its contents as opaque provenance. The only linking key the action can resolve is the `label` set on the *current* `add_ingredient` call. +A plain ingredient is a source asset (image, video, document) the builder reads at `add_ingredient` time, with `label` (primary) or `instance_id` (fallback) usable as linking keys. An ingredient archive is a `.c2pa` file containing one already-formed ingredient. When the archive is loaded via the legacy `add_ingredient(json, "application/c2pa", archive)` path, the only linking key the action can resolve is the `label` set on the *current* `add_ingredient` call. When loaded via `add_ingredient_from_archive`, the linking key is the archive key passed to `write_ingredient_archive` (either `label` or `instance_id` on the producer). For a side-by-side comparison, see [Ingredient vs. ingredient archive](working-stores.md#ingredient-vs-ingredient-archive) in the working-stores doc. #### Linking an archived ingredient to an action -Linking an **archived** ingredient to an action is **label-driven**: archived ingredients can only be linked to actions using labels. +When the archived ingredient is loaded via the legacy `add_ingredient(json, "application/c2pa", archive)` path, linking is **label-driven**: archived ingredients can only be linked to actions using labels. For the dedicated `write_ingredient_archive` + `add_ingredient_from_archive` ingredient archive APIs, the archive key (either `label` or `instance_id`) drives linking. See [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking). To do so, set a `label` on the archived ingredient's JSON passed to `add_ingredient` on the builder, and use that same string in the action's `ingredientIds`. Reading the archive first is *not* required to link it. `Reader` is only useful when the caller wants to preview the ingredient (thumbnail, provenance, validation status) before deciding whether to use it (see [Reading ingredient details from an ingredient archive](#reading-ingredient-details-from-an-ingredient-archive)). +This section covers the **legacy** load path: producer calls `to_archive`, signing builder calls `add_ingredient(json, "application/c2pa", archive)`. For the dedicated `write_ingredient_archive` + `add_ingredient_from_archive` ingredient archive APIs, the archive key (either `label` or `instance_id`) flows through and becomes the `ingredientIds` value. See [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking). + > [!WARNING] -> **`instance_id` does not work as a linking key for ingredient archives.** Use `label` instead. +> **For the legacy load path, `instance_id` does not work as a linking key for ingredient archives.** Use `label` instead. > > **Labels baked into the archive ingredient at archive-creation time do not carry through as linking keys either.** The label must be re-asserted on the signing builder's `add_ingredient` call so action and archived ingredient properly link. @@ -892,6 +1114,56 @@ builder.add_ingredient( builder.sign(source_path, output_path, signer); ``` +##### Linking with the dedicated archive API + +The same linking flow works when the ingredient is loaded with `add_ingredient_from_archive`. The id used at write time on the producer (passed as the first argument to `write_ingredient_archive`) becomes the linking key on the signing builder. Reference that same id in `ingredientIds`. + +Producer side: + +```cpp +auto settings = c2pa::Settings(); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto archive_builder = c2pa::Builder(context, manifest_json); +archive_builder.add_ingredient( + R"({"title": "photo.jpg", "relationship": "parentOf", "label": "my-ingredient"})", + "photo.jpg"); + +std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); +archive_builder.write_ingredient_archive("my-ingredient", archive); +``` + +Signing side, link `my-ingredient` to `c2pa.opened`: + +```cpp +auto signing_builder = c2pa::Builder(context, R"({ + "claim_generator_info": [{"name": "an-application", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [{ + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": { "ingredientIds": ["my-ingredient"] } + }] + } + }] +})"); + +archive.seekg(0); +signing_builder.add_ingredient_from_archive(archive); + +signing_builder.sign(source_path, output_path, signer); +``` + +The same id can appear in `ingredientIds` of more than one action. A `c2pa.opened` and a `c2pa.placed` action that both list `my-ingredient` resolve to the same ingredient URL after signing. + +For `c2pa.placed`, the relationship on the producing builder is `componentOf` instead of `parentOf`. Otherwise the linking pattern is identical. + +A signing builder can mix the dedicated API with the existing `add_ingredient(json, source)` overloads in the same build. Linking by id works the same regardless of how each ingredient reached the builder. For example, an action that lists `via-add`, `via-stream`, `via-archive` in `ingredientIds` resolves to three distinct ingredient URLs when one ingredient is added by file, one by stream, and one by ingredient archive. + #### Troubleshooting linking errors A common signing-time error when linking ingredients is: @@ -918,6 +1190,8 @@ In some cases you may need to merge ingredients from multiple working stores (bu When merging from multiple sources, resource identifier URIs can collide. Rename identifiers with a unique suffix when needed. Use two passes: (1) collect ingredients with collision handling, build the manifest, create the builder; (2) re-read each archive and transfer resources (use original ID for `get_resource()`, renamed ID for `add_resource()` when collisions occurred). +When each source contributes one ingredient, the dedicated single-ingredient API sidesteps this resource-identifier collision case: each archive holds one and only one ingredient, and `add_ingredient_from_archive` registers it cleanly on the consuming builder. See [Single-ingredient archive APIs](./working-stores.md#single-ingredient-archive-apis) in the working stores guide. The two-pass approach below remains the right tool when sources hold multiple ingredients each and a full merge is required. + ```cpp std::set used_ids; int suffix_counter = 0; @@ -1334,4 +1608,3 @@ flowchart TD SIGN --> OUT[Output asset with new manifest containing only filtered content] end ``` - diff --git a/docs/working-stores.md b/docs/working-stores.md index c8211b21..c8446e5e 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -446,17 +446,36 @@ Ingredients represent source materials used to create an asset, preserving the p A **(plain) ingredient** is a source asset that the builder reads at `add_ingredient` time. The builder sees the asset's bytes, and stores live required ingredient data (including any caller-set `instance_id`) inside the new manifest. -An **ingredient archive** (in c2pa-archive-format) is a `.c2pa` file produced by `to_archive()` that already contains a fully-formed ingredient ("a ready to use ingredient"). When passed to `add_ingredient`, the builder treats the archive's contents as opaque provenance: the archive's internal fields are not exposed as live JSON the signing builder can introspect (or use for linking to actions). Only the JSON the caller supplies in the current `add_ingredient` call is visible to the builder in that round. +An **ingredient archive** (in c2pa-archive-format) is a `.c2pa` file that already contains a fully-formed ingredient. It can be produced with `write_ingredient_archive` (dedicated ingredient archive APIs) or with `to_archive()` on a builder holding one ingredient (legacy). When passed to `add_ingredient`, the builder treats the archive's contents as opaque provenance: the archive's internal fields are not exposed as live JSON the signing builder can introspect (or use for linking to actions). Only the JSON the caller supplies in the current `add_ingredient` call is visible to the builder in that round. -This difference governs how each can be linked to an action via `ingredientIds`: +Once an ingredient is archived, the original ingredient asset is no longer needed: the `.c2pa` ingredient archive stands in for it and carries the ingredient's provenance. -| Aspect | Ingredient | Ingredient archive | +> [!NOTE] +> The relationship is one-directional. For legacy support you can _read_ an ingredient out of a builder archive, but you should not try to restore a `Builder` from an ingredient archive — consume it as an ingredient with `add_ingredient_from_archive` (or the legacy `add_ingredient(json, "application/c2pa", archive)` path) instead. + +For the dedicated ingredient archive APIs, see [Single-ingredient archive APIs](#single-ingredient-archive-apis). + +This difference governs how each can be linked to an action via `ingredientIds`. The table below covers all three cases: plain ingredients, ingredient archives loaded via the dedicated APIs (recommended), and ingredient archives loaded via the legacy `add_ingredient` path: + +| Aspect | Ingredient | Ingredient archive (dedicated APIs: `write_ingredient_archive` + `add_ingredient_from_archive`) | Ingredient archive (legacy load via `add_ingredient(json, "application/c2pa", archive)`) | +| --- | --- | --- | --- | +| Source format passed to `add_ingredient` | Asset MIME type (`image/jpeg`, `video/mp4`, ...) or asset path | N/A — loaded via `add_ingredient_from_archive(stream)` | `application/c2pa` or path to a `.c2pa` ingredient archive file | +| What it is | "Live" asset | A serialized single-ingredient archive (opaque provenance) | A serialized manifest store (opaque provenance) | +| Linking via `label` | Primary linking key, set on the signing builder's `add_ingredient` JSON parameter | Pass `label` value as archive key to `write_ingredient_archive`; flows through as `ingredientIds` value | Only linking key that works, set on the signing builder's `add_ingredient` JSON | +| Linking via `instance_id` | Alternative to using `label` | Pass `instance_id` value as archive key to `write_ingredient_archive`; flows through as `ingredientIds` value | Does not link, signing-time error | +| Linking via a `label` baked in at archive-creation time | N/A (not an archive) | N/A — archive key is set explicitly at `write_ingredient_archive` call time | Does not carry through, must be re-asserted on the signing builder's `add_ingredient` JSON parameter | + +#### When to use `label` vs `instance_id` + +| Property | `label` | `instance_id` | | --- | --- | --- | -| Source format passed to `add_ingredient` | Asset MIME type (`image/jpeg`, `video/mp4`, ...) or asset path | `application/c2pa` or path to a `.c2pa` ingredient archive file | -| What it is | "Live" asset | A serialized manifest store (opaque provenance) | -| Linking via `label` | Primary linking key, set on the signing builder's `add_ingredient` JSON parameter | Only linking key that works, set on the signing builder's `add_ingredient` JSON | -| Linking via `instance_id` | Alternative to using `label` | Does not link, signing-time error | -| Linking via a `label` baked in at archive-creation time | N/A (not an archive) | Does not carry through, must be re-asserted on the signing builder, set on the signing builder's `add_ingredient` JSON parameter | +| Who controls it | Caller (any string) | Caller (any string, or from XMP metadata) | +| Priority for linking | Primary: checked first | Fallback: used when `label` is absent/empty | +| When to use | JSON-defined manifests where the caller controls the ingredient definition | Programmatic workflows where a stable identifier persisting unchanged across rebuilds is needed | +| Survives signing | SDK may reassign the actual assertion label in the signed manifest | Unchanged | +| Stable across rebuilds | The caller controls the build-time value; the post-signing label may change | Yes, always the same set value | + +Use `label` when defining manifests in JSON. Use `instance_id` when a stable identifier that persists unchanged across rebuilds is needed. The `label` used at build time may be reassigned by the SDK during signing and will not appear unchanged in `Reader::json()` output. ### Adding ingredients to a working store @@ -485,7 +504,7 @@ ingredient_stream.close(); // have an archived ingredient (1 ingredient per archive) at hand. // The JSON parameter would then override what was in the archive and would be used for // The ingredient added to the working store. -// builder.add_ingredient(ingredient_json, "applciation/c2pa", ingredient archive); +// builder.add_ingredient(ingredient_json, "application/c2pa", ingredient archive); // Sign: ingredients become part of the manifest store builder.sign("new_asset.jpg", "signed_asset.jpg", signer); @@ -493,22 +512,75 @@ builder.sign("new_asset.jpg", "signed_asset.jpg", signer); ### Linking an ingredient archive to an action +Two paths exist for loading an ingredient archive and linking it to an action. The dedicated ingredient archive APIs (`write_ingredient_archive` + `add_ingredient_from_archive`) are recommended. The legacy path (`add_ingredient(json, "application/c2pa", archive)`) is deprecated. + +#### Using the dedicated ingredient archive APIs + +The archive key passed to `write_ingredient_archive` (either a `label` or `instance_id` value) flows through automatically and becomes the `ingredientIds` value on the signing builder. No re-assertion is needed. + +Producer — register the ingredient and write the archive, keyed by `instance_id`: + +```cpp +auto settings = c2pa::Settings(); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto producer = c2pa::Builder(context, manifest_str); +producer.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf", + "instance_id": "my-ingredient"})", + "photo.jpg"); + +std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); +producer.write_ingredient_archive("my-ingredient", archive); +``` + +Signing builder — use the same archive key string in `ingredientIds`, then load the archive: + +```cpp +auto signing_manifest = R"({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [{ + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["my-ingredient"] + } + }] + } + }] +})"; + +auto consumer = c2pa::Builder(context, signing_manifest); +archive.seekg(0); +consumer.add_ingredient_from_archive(archive); +consumer.sign("source.jpg", "signed.jpg", signer); +``` + +For the full table of linking outcomes when `label`, `instance_id`, or both are set, see [Lookup keys and action linking](#lookup-keys-and-action-linking). + +When linking multiple ingredient archives, write one archive per ingredient with a distinct key and load each with `add_ingredient_from_archive`. List all keys in the appropriate `ingredientIds` arrays. + +#### Using the legacy load path (deprecated) + > [!IMPORTANT] -> **Linking an ingredient archive is `label`-driven only.** +> **For the legacy load path, linking an ingredient archive is `label`-driven only.** > -> - `instance_id` does not work as a linking key for ingredient archives, use `label` instead. +> - `instance_id` does not work as a linking key for ingredient archives loaded via `add_ingredient(json, "application/c2pa", archive)`. Use `label` instead. > - Labels baked into the archive at archive-creation time do not carry through. The label must be re-asserted in the signing builder's `add_ingredient` JSON. > - Both rules apply whether the archive is added by file path or by stream. > > Attempting to link via `instance_id`, or relying on a baked-in label alone, produces a sign-time error: `Action ingredientId not found: `. See [Troubleshooting linking errors](#troubleshooting-linking-errors). -To link an ingredient archive to an action via `ingredientIds`, set a `label` on the JSON passed to `add_ingredient` on the signing builder, and use the same string in the action's `ingredientIds` array. A label, as linking key, links ingredients and actions using it together: the label identifies the link. Labels are build-time linking keys only. The SDK may reassign the actual label in the signed manifest during signing. - +To link an ingredient archive to an action via `ingredientIds`, set a `label` on the JSON passed to `add_ingredient` on the signing builder, and use the same string in the action's `ingredientIds` array. Labels are build-time linking keys only. The SDK may reassign the actual label in the signed manifest during signing. ```cpp c2pa::Context context; -// Step 1: Create the ingredient archive. +// Step 1: Create the ingredient archive (legacy). auto manifest_str = read_file("training.json"); auto archive_builder = c2pa::Builder(context, manifest_str); archive_builder.add_ingredient( @@ -556,7 +628,7 @@ archive_stream.close(); builder.sign("source.jpg", "signed.jpg", signer); ``` -When linking multiple ingredient archives, give each a distinct label and reference it in the appropriate action's `ingredientIds` array. +When linking multiple ingredient archives with the legacy path, give each a distinct label and reference it in the appropriate action's `ingredientIds` array. If each ingredient has its own action (e.g., one `c2pa.opened` for the parent and one `c2pa.placed` for a composited element), set up two actions with separate `ingredientIds`: @@ -646,6 +718,8 @@ const std::string ingredient_json = R"({ builder.add_ingredient(ingredient_json, "base_layer.png"); ``` +For the dedicated single-ingredient archive APIs, see [Single-ingredient archive APIs](#single-ingredient-archive-apis) below. For the multi-archive catalog use case, see [The ingredients catalog pattern](./selective-manifests.md#the-ingredients-catalog-pattern) in the selective manifests guide. + ## Working with archives An *archive* (C2PA archive) is a serialized working store (`Builder` object) saved to a file or stream. @@ -659,6 +733,22 @@ Using archives provides these advantages: The default binary format of an archive is the **C2PA JUMBF binary format** (`application/c2pa`), which is the standard way to save and restore working stores. +### Which archive API to use + +Two archive API families share the same binary format but serve different purposes: + +| | Full-builder APIs | Single-ingredient APIs | +| --- | --- | --- | +| APIs | `to_archive`, `from_archive`, `with_archive` | `write_ingredient_archive`, `add_ingredient_from_archive` | +| What is archived | Entire builder: manifest definition + all ingredients + all resources | One ingredient only (other builder state is omitted) | +| Typical use | Checkpoint or transfer a manifest-in-progress between sessions or machines | Ingredient catalog; selectively load individual ingredients at sign time | +| Requires setting | None | `builder.generate_c2pa_archive = true` on the producing builder (this is the current default) | +| Linking key | N/A (full builder is restored as-is) | Archive key (`label` or `instance_id`) flows through automatically as `ingredientIds` value | + +Use `to_archive` / `from_archive` to pause and resume a signing workflow, or to hand off a complete manifest-in-progress to another process or machine. Use `write_ingredient_archive` / `add_ingredient_from_archive` to distribute or cache individual ingredients independently, or to assemble a manifest from a catalog of pre-archived ingredients at sign time. + +The subsections below cover the full-builder APIs. For single-ingredient archive workflows, see [Single-ingredient archive APIs](#single-ingredient-archive-apis). + ### Saving a working store to archive ```cpp @@ -767,6 +857,312 @@ void sign_asset() { } ``` +### Single-ingredient archive APIs + +> [!NOTE] +> These are the recommended dedicated ingredient archive APIs for ingredient archive workflows. Use `write_ingredient_archive` and `add_ingredient_from_archive` in preference to the legacy `to_archive` / `from_archive` pattern for ingredient use cases. + +The `Builder` class exposes two dedicated APIs for moving a single ingredient between builders without manual JSON manipulation: + +- `Builder::write_ingredient_archive(id, stream)` writes one already-registered ingredient out as a single-ingredient JUMBF archive. +- `Builder::add_ingredient_from_archive(stream)` loads one such archive into a builder. + +#### How `add_ingredient` and `write_ingredient_archive` interact + +`add_ingredient(json, source)` is the registration step. It hashes the source asset, builds the ingredient assertion, and stores the ingredient in the builder under an id read from the JSON. The id is the `label` field if present, otherwise `instance_id`. + +`write_ingredient_archive(id, stream)` is a lookup step rather than a factory. It finds an ingredient that was already registered under `id` and serializes that one ingredient as a JUMBF archive (tagged `ARCHIVE_TYPE_INGREDIENT`). Calling it without a prior `add_ingredient` for that id throws `c2pa::C2paException`. + +The exported archive is not a lossless slice of the parent. It contains one cloned ingredient and a fresh claim instance id. Any other ingredients on the parent builder are omitted. + +`add_ingredient_from_archive(stream)` adds the ingredient back to a consuming builder, keyed by the same id the producer used. + +#### Example 1: Write a single-ingredient archive + +```cpp +auto settings = c2pa::Settings(); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto builder = c2pa::Builder(context, manifest_json); + +// Register three ingredients. The `label` becomes each ingredient's id. +builder.add_ingredient( + R"({"title": "first.jpg", "relationship": "componentOf", "label": "first"})", + "first.jpg"); +builder.add_ingredient( + R"({"title": "second.jpg", "relationship": "componentOf", "label": "second"})", + "second.jpg"); +builder.add_ingredient( + R"({"title": "third.jpg", "relationship": "componentOf", "label": "third"})", + "third.jpg"); + +// Look up "second" and write only that one to the archive stream. +std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); +builder.write_ingredient_archive("second", archive); +``` + +The archive contains exactly one ingredient. Reading it back through `c2pa::Reader` with format `application/c2pa` shows a single-ingredient manifest. + +#### Example 2: Load an ingredient archive into a fresh builder + +```cpp +auto settings = c2pa::Settings(); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto consumer = c2pa::Builder(context, manifest_json); + +// `archive` is a stream produced by write_ingredient_archive on another builder. +archive.seekg(0); +consumer.add_ingredient_from_archive(archive); + +// The ingredient is now registered on `consumer`. Sign as usual. +consumer.sign("source.jpg", "output.jpg", signer); +``` + +#### Id resolution + +The id passed to `write_ingredient_archive` is matched against each registered ingredient's `label` and its `instance_id`. The first ingredient whose `label` or `instance_id` equals the id is selected (OR-match, no precedence). If both are set on the same ingredient, pass whichever value is to be used as the linking key. See [Lookup keys and action linking](#lookup-keys-and-action-linking) for the full table of linking outcomes. + +#### Errors + +`write_ingredient_archive` throws `c2pa::C2paException` when: + +- The producing `Builder` has no prior `add_ingredient` registration. The lookup table is empty, so no id can resolve. +- The id does not match any registered ingredient's `label` or `instance_id`. Registering ingredient `real-id` and then asking for `wrong-id` throws. + +```cpp +auto builder = c2pa::Builder(context, manifest_json); +builder.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf", "label": "real-id"})", + "photo.jpg"); + +std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); +// Throws c2pa::C2paException: "wrong-id" was never registered. +builder.write_ingredient_archive("wrong-id", stream); +``` + +For a multi-archive use case (one catalog, many ingredients picked at build time), see [The ingredients catalog pattern](./selective-manifests.md#the-ingredients-catalog-pattern) in the selective manifests guide. + +#### Migration guide: from `to_archive` / `from_archive` to single-ingredient APIs + +The legacy approach wrapped one ingredient in a full builder archive, then restored it with `from_archive`: + +```cpp +// Legacy: one ingredient archived as a full builder, restored with from_archive +auto builder = c2pa::Builder(context, manifest_json); +builder.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf"})", + "photo.jpg"); +builder.to_archive("ingredient.c2pa"); + +// Consumer: +auto restored = c2pa::Builder::from_archive("ingredient.c2pa"); +restored.sign("source.jpg", "output.jpg", signer); +``` + +With the dedicated ingredient archive APIs, the producer writes a single-ingredient archive directly, and the consumer loads it with `add_ingredient_from_archive`: + +```cpp +// Current API: one archive per ingredient via write_ingredient_archive +auto settings = c2pa::Settings(); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto builder = c2pa::Builder(context, manifest_json); +builder.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf", "instance_id": "my-photo"})", + "photo.jpg"); + +std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); +builder.write_ingredient_archive("my-photo", archive); + +// Consumer: +auto consumer = c2pa::Builder(context, manifest_json); +archive.seekg(0); +consumer.add_ingredient_from_archive(archive); +consumer.sign("source.jpg", "output.jpg", signer); +``` + +Key differences: no JSON parsing, no `add_resource` loops, each archive holds exactly one ingredient, and the consumer loads selectively without deserializing anything else. + +Action linking also changes between the two APIs. The legacy load path (`add_ingredient(json, "application/c2pa", archive)`) accepts only `label` as the linking key on the signing builder's `add_ingredient` JSON. See [Linking an ingredient archive to an action](#linking-an-ingredient-archive-to-an-action). The dedicated ingredient archive APIs (`write_ingredient_archive` + `add_ingredient_from_archive`) accept the archive key, which can be either `label` or `instance_id`. See [Lookup keys and action linking](#lookup-keys-and-action-linking). When migrating code that linked by label, pass that same label as the archive key to keep `ingredientIds` unchanged. + +## How `instance_id` survives archiving and signing + +### What is an instance_id? + +`instance_id` is a string field on an ingredient. It is optional in C2PA ingredient assertion starting versions 2, which the SDK currently writes by default. Version 1 required it. + +In priority order, this value comes from: + +1. The caller: if you set `instance_id` in the JSON passed to `add_ingredient`, that value is stored as-is. No normalization or transformation is applied. +2. XMP fallback: if no `instance_id` was provided and the source asset has `xmpMM:InstanceID` in its XMP metadata, the library reads that value and sets it on the ingredient. +3. Auto-generated default: if neither caller nor XMP provided a value, the library generates `xmp.iid:` automatically (required for V1 assertion compatibility). + +### Instance_id across operations + +`instance_id` is kept through every archiving and signing operation this library performs. The table below covers the common paths: + +| Operation | `instance_id` kept? | +| --- | --- | +| `add_ingredient`, `write_ingredient_archive`, `add_ingredient_from_archive`, then sign | Yes | +| `add_ingredient`, `to_archive`, then `Reader::json()` | Yes | +| `add_ingredient`, sign, then `Reader::json()` (no archive) | Yes | +| `add_ingredient_from_archive` (loaded from prior archive), sign, then `Reader::json()` | Yes | + +### Lookup keys and action linking + +The first argument to `write_ingredient_archive`, called the _archive key_, has two roles. It locates the ingredient on the producer builder by matching against either `label` or `instance_id`. It also becomes the `ingredientIds` value on the signing builder: `add_ingredient_from_archive` stores the archive key in the archive metadata and restores it as the ingredient's linking label. + +Whatever string you pass as the archive key is the string you must use in `ingredientIds`. + +| Producer sets | Archive key to pass | `ingredientIds` value | +| --- | --- | --- | +| `label` only | `label` value | same `label` value | +| `instance_id` only | `instance_id` value | same `instance_id` value | +| both `label` and `instance_id` | either value | same string you passed | + +The linking label is a builder-only concept. It does not appear in `Reader::json()` output after signing. Only `instance_id` is observable in the signed manifest. + +If the archive key matches neither `label` nor `instance_id` of any ingredient on the producer builder, `write_ingredient_archive` throws immediately with `C2paException`. + +#### Linking with `instance_id` only + +When no `label` is set, pass the `instance_id` value to `write_ingredient_archive`. Use that same string in `ingredientIds` on the signing builder. + +Producer: + +```cpp +// Producer: archive one ingredient identified by instance_id. +auto producer = c2pa::Builder(context, manifest_str); +producer.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf", + "instance_id": "catalog:photo-A"})", + source_path); + +std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); +producer.write_ingredient_archive("catalog:photo-A", archive); +``` + +Signing builder: + +```cpp +// Signing builder: load archive, then reference the same string in ingredientIds. +auto signing_manifest = R"({ + "claim_generator_info": [{"name": "app", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["catalog:photo-A"]}}]} + }] +})"; + +auto consumer = c2pa::Builder(context, signing_manifest); +archive.seekg(0); +consumer.add_ingredient_from_archive(archive); +consumer.sign(source_path, output_path, signer); +``` + +#### Linking with `label` only + +When only `label` is set, pass the `label` value to `write_ingredient_archive`. Use that same string in `ingredientIds`. + +This works even though `label` is not preserved as an ingredient field. The label string is not written into `instance_id`, and it does not appear in the signed manifest. `add_ingredient_from_archive` carries the archive key in the archive's metadata and restores it as a builder-only linking key, so the action resolves to the ingredient at signing time. See [Lookup keys and action linking](#lookup-keys-and-action-linking) for the full mechanism. + +Producer: + +```cpp +auto producer = c2pa::Builder(context, manifest_str); +producer.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf", + "label": "my-photo"})", + source_path); + +std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); +producer.write_ingredient_archive("my-photo", archive); +``` + +Signing builder: + +```cpp +auto signing_manifest = R"({ + "claim_generator_info": [{"name": "app", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["my-photo"]}}]} + }] +})"; + +auto consumer = c2pa::Builder(context, signing_manifest); +archive.seekg(0); +consumer.add_ingredient_from_archive(archive); +consumer.sign(source_path, output_path, signer); +``` + +#### Linking when both `label` and `instance_id` are set + +If both `label` and `instance_id` are set on an ingredient, pass whichever value is to be used as the linking key to `write_ingredient_archive`. That string, and only that string, is what `ingredientIds` must reference on the signing builder. + +Producer (passing `label` as the key): + +```cpp +auto producer = c2pa::Builder(context, manifest_str); +producer.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf", + "label": "my-photo", "instance_id": "iid:abc123"})", + source_path); + +std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); +// Pass "my-photo": this becomes the ingredientIds key. +// Passing "iid:abc123" instead would also work, but then ingredientIds +// must use "iid:abc123", not "my-photo". +producer.write_ingredient_archive("my-photo", archive); +``` + +Signing builder: + +```cpp +// ingredientIds uses "my-photo": the value passed to write_ingredient_archive. +auto signing_manifest = R"({ + "claim_generator_info": [{"name": "app", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["my-photo"]}}]} + }] +})"; + +auto consumer = c2pa::Builder(context, signing_manifest); +archive.seekg(0); +consumer.add_ingredient_from_archive(archive); +consumer.sign(source_path, output_path, signer); +``` + +### Catalog lookups with the read-filter-rebuild APIs + +With the legacy `to_archive` + `Reader` pattern, `instance_id` survives into the Reader output and can be used to find a specific ingredient by scanning `Reader::json()`: + +```cpp +auto reader = c2pa::Reader(context, archive_path); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto& ingredients = parsed["manifests"][active]["ingredients"]; + +for (auto& ing : ingredients) { + if (ing.contains("instance_id") && ing["instance_id"] == "catalog:photo-A") { + // Found the ingredient + } +} +``` + +Using the dedicated archive API, this loop is unnecessary: each archive holds exactly and explicitly one ingredient, so `add_ingredient_from_archive` loads precisely what was written. + ## Embedded vs external manifests By default, manifest stores are **embedded** directly into the asset file. You can also use **external** or **remote** manifest stores. diff --git a/include/c2pa.hpp b/include/c2pa.hpp index a79576de..a1e32d44 100644 --- a/include/c2pa.hpp +++ b/include/c2pa.hpp @@ -1296,6 +1296,20 @@ namespace c2pa /// @note Prefer using the streaming APIs if possible. void to_archive(const std::filesystem::path &dest_path); + /// @brief Write a single-ingredient archive for the named ingredient. + /// @param ingredient_id The ingredient's `label` if set, otherwise its `instance_id`, + /// as supplied to add_ingredient. + /// @param dest The output stream to write the ingredient archive to. + /// @note Requires the `generate_c2pa_archive` context setting to be enabled + /// (enabled by default). + /// @throws C2paException for errors encountered by the C2PA library. + void write_ingredient_archive(const std::string &ingredient_id, std::ostream &dest); + + /// @brief Add an ingredient to this builder from a per-ingredient archive stream. + /// @param archive The input stream containing the archive produced by write_ingredient_archive. + /// @throws C2paException for errors encountered by the C2PA library. + void add_ingredient_from_archive(std::istream &archive); + /// @brief Create a hashed placeholder from the builder. /// @param reserved_size The size required for a signature from the intended signer (in bytes). /// @param format The mime format or extension of the asset. diff --git a/src/c2pa_builder.cpp b/src/c2pa_builder.cpp index 358f7fe4..4e11c8bf 100644 --- a/src/c2pa_builder.cpp +++ b/src/c2pa_builder.cpp @@ -363,6 +363,26 @@ namespace c2pa to_archive(*dest); } + void Builder::write_ingredient_archive(const std::string &ingredient_id, std::ostream &dest) + { + CppOStream c_dest(dest); + int result = c2pa_builder_write_ingredient_archive(builder, ingredient_id.c_str(), c_dest.c_stream); + if (result < 0) + { + throw C2paException(); + } + } + + void Builder::add_ingredient_from_archive(std::istream &archive) + { + CppIStream c_archive(archive); + int result = c2pa_builder_add_ingredient_from_archive(builder, c_archive.c_stream); + if (result < 0) + { + throw C2paException(); + } + } + std::vector Builder::data_hashed_placeholder(uintptr_t reserve_size, const std::string &format) { const unsigned char *c2pa_manifest_bytes = nullptr; diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index de74d8b6..6b2b2057 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -3388,6 +3388,191 @@ TEST_F(BuilderTest, ArchiveToFilePath) { } TEST_F(BuilderTest, ExtractIngredientsFromArchive) { + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // Archive each ingredient individually using the dedicated ingredient archive API. + // Each ingredient is registered under a label, then written to its own archive stream. + auto archive_ingredient = [&](const std::string& ingredient_json, + const std::string& ingredient_id, + const fs::path& asset_path) -> std::stringstream { + auto b = c2pa::Builder(manifest); + b.add_ingredient(ingredient_json, asset_path); + std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); + b.write_ingredient_archive(ingredient_id, ss); + return ss; + }; + + auto archive1 = archive_ingredient( + R"({"title": "A.jpg", "relationship": "parentOf", "label": "ing-a"})", + "ing-a", + c2pa_test::get_fixture_path("A.jpg")); + auto archive2 = archive_ingredient( + R"({"title": "C.jpg", "relationship": "componentOf", "label": "ing-c"})", + "ing-c", + c2pa_test::get_fixture_path("C.jpg")); + auto archive3 = archive_ingredient( + R"({"title": "sample.gif", "relationship": "componentOf", "label": "ing-gif"})", + "ing-gif", + c2pa_test::get_fixture_path("sample1.gif")); + + // Add each archived ingredient to the new builder using the archive API. + auto merged_builder = c2pa::Builder(manifest); + archive1.seekg(0); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive1)); + archive2.seekg(0); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive2)); + archive3.seekg(0); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive3)); + + // Sign and verify + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + auto output_path = get_temp_path("merged_output.jpg"); + + std::vector manifest_data; + EXPECT_NO_THROW({ + manifest_data = merged_builder.sign(source_path, output_path, signer); + }); + ASSERT_FALSE(manifest_data.empty()); + + // Read and log the merged builder's manifest JSON + auto merged_reader = c2pa::Reader(output_path); + // Verify all 3 ingredients are present in the merged builder + auto merged_parsed = json::parse(merged_reader.json()); + std::string merged_active = merged_parsed["active_manifest"]; + auto merged_ingredients = merged_parsed["manifests"][merged_active]["ingredients"]; + EXPECT_EQ(merged_ingredients.size(), 3) << "Merged builder should have all 3 ingredients"; +} + +TEST_F(BuilderTest, ExtractIngredientsFromArchiveToBuilder) { + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // Helper: register a single ingredient under a label and write its dedicated + // ingredient archive to a stream. + auto make_ingredient_archive = [&](const std::string& ingredient_json, + const std::string& ingredient_id, + const fs::path& asset_path) -> std::stringstream { + auto b = c2pa::Builder(manifest); + b.add_ingredient(ingredient_json, asset_path); + std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); + b.write_ingredient_archive(ingredient_id, ss); + return ss; + }; + + // Archive 1 contains A.jpg and C.jpg (as separate per-ingredient archives) + auto archive_a = make_ingredient_archive( + R"({"title": "A.jpg", "relationship": "parentOf", "label": "ing-a"})", + "ing-a", + c2pa_test::get_fixture_path("A.jpg")); + auto archive_c = make_ingredient_archive( + R"({"title": "C.jpg", "relationship": "componentOf", "label": "ing-c"})", + "ing-c", + c2pa_test::get_fixture_path("C.jpg")); + + // Archive 2 contains sample.gif + auto archive_gif = make_ingredient_archive( + R"({"title": "sample.gif", "relationship": "componentOf", "label": "ing-gif"})", + "ing-gif", + c2pa_test::get_fixture_path("sample1.gif")); + + // Build merged builder by loading each archived ingredient via the archive API. + // Load twice for "archive 1" group, once for "archive 2" group. + auto merged_builder = c2pa::Builder(manifest); + + archive_a.seekg(0); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive_a)); + + archive_c.seekg(0); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive_c)); + + archive_gif.seekg(0); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive_gif)); + + // Sign and verify + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + auto output_path = get_temp_path("merged_output2.jpg"); + + std::vector manifest_data; + EXPECT_NO_THROW({ + manifest_data = merged_builder.sign(source_path, output_path, signer); + }); + ASSERT_FALSE(manifest_data.empty()); + + auto merged_reader = c2pa::Reader(output_path); + auto merged_parsed = json::parse(merged_reader.json()); + std::string merged_active = merged_parsed["active_manifest"]; + auto merged_ingredients = merged_parsed["manifests"][merged_active]["ingredients"]; + EXPECT_EQ(merged_ingredients.size(), 3) << "Merged builder should have all 3 ingredients from both archives"; +} + +TEST_F(BuilderTest, ExtractIngredientsFromArchives) { + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // Helper: register a single asset under a label and write its dedicated + // per-ingredient archive. + auto make_ingredient_archive = [&](const std::string& ingredient_json, + const std::string& ingredient_id, + const fs::path& asset_path) -> std::stringstream { + auto b = c2pa::Builder(manifest); + b.add_ingredient(ingredient_json, asset_path); + std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); + b.write_ingredient_archive(ingredient_id, ss); + return ss; + }; + + // "Archive group 1": A.jpg and C.jpg — each gets its own per-ingredient archive + auto archive_a = make_ingredient_archive( + R"({"title": "A.jpg", "relationship": "parentOf", "label": "ing-a"})", + "ing-a", + c2pa_test::get_fixture_path("A.jpg")); + auto archive_c = make_ingredient_archive( + R"({"title": "C.jpg", "relationship": "componentOf", "label": "ing-c"})", + "ing-c", + c2pa_test::get_fixture_path("C.jpg")); + + // "Archive group 2": sample1.gif — per-ingredient archive + auto archive_gif = make_ingredient_archive( + R"({"title": "sample.gif", "relationship": "componentOf", "label": "ing-gif"})", + "ing-gif", + c2pa_test::get_fixture_path("sample1.gif")); + + // Merge all three ingredients into one builder via the dedicated archive API + auto merged_builder = c2pa::Builder(manifest); + + archive_a.seekg(0); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive_a)); + + archive_c.seekg(0); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive_c)); + + archive_gif.seekg(0); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive_gif)); + + // Sign the merged builder + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + auto output_path = get_temp_path("merged_from_archives.jpg"); + + std::vector manifest_data; + EXPECT_NO_THROW({ + manifest_data = merged_builder.sign(source_path, output_path, signer); + }); + ASSERT_FALSE(manifest_data.empty()); + + // Read and verify the merged output + auto merged_reader = c2pa::Reader(output_path); + auto merged_json = merged_reader.json(); + + // Verify all 3 ingredients are present from both archive groups + auto merged_parsed = json::parse(merged_json); + std::string merged_active = merged_parsed["active_manifest"]; + auto merged_ingredients = merged_parsed["manifests"][merged_active]["ingredients"]; + EXPECT_EQ(merged_ingredients.size(), 3) << "Merged builder should have all 3 ingredients from both archives"; +} + + +TEST_F(BuilderTest, ExtractIngredientsFromArchiveLegacy) { // Helper function to transfer ingredients from an archive to a new builder auto create_builder_with_ingredients_from_archive = []( std::istream& archive_stream, @@ -3469,7 +3654,7 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchive) { // Sign the merged builder auto signer = c2pa_test::create_test_signer(); auto source_path = c2pa_test::get_fixture_path("A.jpg"); - auto output_path = get_temp_path("merged_output.jpg"); + auto output_path = get_temp_path("merged_output_legacy.jpg"); std::vector manifest_data; EXPECT_NO_THROW({ @@ -3491,7 +3676,7 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchive) { c2pa::load_settings(R"({"verify": {"verify_after_reading": true}})", "json"); } -TEST_F(BuilderTest, ExtractIngredientsFromArchiveToBuilder) { +TEST_F(BuilderTest, ExtractIngredientsFromArchiveToBuilderLegacy) { auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); // Helper function that adds ingredients from an archived builder into an existing builder. @@ -3619,13 +3804,10 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchiveToBuilder) { // Archive 1: A.jpg and C.jpg auto builder1 = c2pa::Builder(manifest); - - std::string ingredient1_json = R"({"title": "A.jpg", "relationship": "parentOf"})"; - builder1.add_ingredient(ingredient1_json, c2pa_test::get_fixture_path("A.jpg")); - - std::string ingredient2_json = R"({"title": "C.jpg", "relationship": "componentOf"})"; - builder1.add_ingredient(ingredient2_json, c2pa_test::get_fixture_path("C.jpg")); - + builder1.add_ingredient(R"({"title": "A.jpg", "relationship": "parentOf"})", + c2pa_test::get_fixture_path("A.jpg")); + builder1.add_ingredient(R"({"title": "C.jpg", "relationship": "componentOf"})", + c2pa_test::get_fixture_path("C.jpg")); std::stringstream archive1_stream(std::ios::in | std::ios::out | std::ios::binary); EXPECT_NO_THROW({ builder1.to_archive(archive1_stream); @@ -3633,10 +3815,8 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchiveToBuilder) { // Archive 2: sample1.gif auto builder2 = c2pa::Builder(manifest); - - std::string ingredient3_json = R"({"title": "sample.gif", "relationship": "componentOf"})"; - builder2.add_ingredient(ingredient3_json, c2pa_test::get_fixture_path("sample1.gif")); - + builder2.add_ingredient(R"({"title": "sample.gif", "relationship": "componentOf"})", + c2pa_test::get_fixture_path("sample1.gif")); std::stringstream archive2_stream(std::ios::in | std::ios::out | std::ios::binary); EXPECT_NO_THROW({ builder2.to_archive(archive2_stream); @@ -3658,7 +3838,7 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchiveToBuilder) { // Sign the merged builder auto signer = c2pa_test::create_test_signer(); auto source_path = c2pa_test::get_fixture_path("A.jpg"); - auto output_path = get_temp_path("merged_output2.jpg"); + auto output_path = get_temp_path("merged_output2_legacy.jpg"); std::vector manifest_data; EXPECT_NO_THROW({ @@ -3680,8 +3860,7 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchiveToBuilder) { c2pa::load_settings(R"({"verify": {"verify_after_reading": true}})", "json"); } -TEST_F(BuilderTest, ExtractIngredientsFromArchives) { - // Helper that creates a builder from multiple archives, merging all their ingredients. +TEST_F(BuilderTest, ExtractIngredientsFromArchivesLegacy) { auto create_builder_with_ingredients_from_archives = []( std::vector>& archive_streams, const std::string& base_manifest_json) -> c2pa::Builder { @@ -3801,24 +3980,14 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchives) { // Archive 1: A.jpg and C.jpg auto builder1 = c2pa::Builder(manifest); - - std::string ingredient1_json = R"({"title": "A.jpg", "relationship": "parentOf"})"; - builder1.add_ingredient(ingredient1_json, c2pa_test::get_fixture_path("A.jpg")); - - std::string ingredient2_json = R"({"title": "C.jpg", "relationship": "componentOf"})"; - builder1.add_ingredient(ingredient2_json, c2pa_test::get_fixture_path("C.jpg")); - + builder1.add_ingredient(R"({"title": "A.jpg", "relationship": "parentOf"})", c2pa_test::get_fixture_path("A.jpg")); + builder1.add_ingredient(R"({"title": "C.jpg", "relationship": "componentOf"})", c2pa_test::get_fixture_path("C.jpg")); std::stringstream archive1_stream(std::ios::in | std::ios::out | std::ios::binary); - EXPECT_NO_THROW({ - builder1.to_archive(archive1_stream); - }); + EXPECT_NO_THROW({ builder1.to_archive(archive1_stream); }); // Archive 2: sample1.gif auto builder2 = c2pa::Builder(manifest); - - std::string ingredient3_json = R"({"title": "sample.gif", "relationship": "componentOf"})"; - builder2.add_ingredient(ingredient3_json, c2pa_test::get_fixture_path("sample1.gif")); - + builder2.add_ingredient(R"({"title": "sample.gif", "relationship": "componentOf"})", c2pa_test::get_fixture_path("sample1.gif")); std::stringstream archive2_stream(std::ios::in | std::ios::out | std::ios::binary); EXPECT_NO_THROW({ builder2.to_archive(archive2_stream); @@ -3836,7 +4005,7 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchives) { // Sign the merged builder auto signer = c2pa_test::create_test_signer(); auto source_path = c2pa_test::get_fixture_path("A.jpg"); - auto output_path = get_temp_path("merged_from_archives.jpg"); + auto output_path = get_temp_path("merged_from_archives_legacy.jpg"); std::vector manifest_data; EXPECT_NO_THROW({ @@ -5504,7 +5673,6 @@ TEST_F(BuilderTest, MultiphaseRebuildFromArchiveWithUpdatedProperties2) // Verify everything from both phases made it through. auto signed_reader = c2pa::Reader(context, output_path); - std::cout << signed_reader.json() << std::endl; auto signed_parsed = json::parse(signed_reader.json()); std::string signed_active = signed_parsed["active_manifest"]; auto& signed_manifest = signed_parsed["manifests"][signed_active]; @@ -6141,7 +6309,6 @@ TEST_F(BuilderTest, ArchiveIngredientWithProvenanceRoundTripAndReuse) c2pa::Reader archive_reader(context, "application/c2pa", archive_in); std::string archive_json; ASSERT_NO_THROW(archive_json = archive_reader.json()); - std::cout << archive_json << std::endl; auto parsed = json::parse(archive_json); ASSERT_TRUE(parsed.contains("active_manifest")); @@ -6177,3 +6344,1013 @@ TEST_F(BuilderTest, ArchiveIngredientWithProvenanceRoundTripAndReuse) EXPECT_EQ(out_ingredients[0]["title"], "C.jpg"); EXPECT_EQ(out_ingredients[0]["relationship"], "componentOf"); } + +// Extract ingredient from archive, then reuse it. +// write_ingredient_archive per-ingredient -> selective add_ingredient_from_archive. +TEST_F(BuilderTest, ExtractIngredientsFromArchiveAndReuseUsingArchiveApi) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // Build a store with two ingredients, write each to its own ingredient archive. + auto builder = c2pa::Builder(context, manifest_str); + builder.add_ingredient( + json({{"title", "A.jpg"}, {"relationship", "componentOf"}, {"instance_id", "catalog:ingredient-A"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + builder.add_ingredient( + json({{"title", "C.jpg"}, {"relationship", "componentOf"}, {"instance_id", "catalog:ingredient-C"}}).dump(), + c2pa_test::get_fixture_path("C.jpg")); + + std::stringstream streamA(std::ios::in | std::ios::out | std::ios::binary); + std::stringstream streamC(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(builder.write_ingredient_archive("catalog:ingredient-A", streamA)); + ASSERT_NO_THROW(builder.write_ingredient_archive("catalog:ingredient-C", streamC)); + + auto builder2 = c2pa::Builder(context, manifest_str); + streamA.seekg(0); + ASSERT_NO_THROW(builder2.add_ingredient_from_archive(streamA)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("extract_reuse_new.jpg"); + ASSERT_NO_THROW(builder2.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u) << "Only ingredient-A should be present"; + EXPECT_EQ(ingredients[0]["title"], "A.jpg"); +} + +// Link a parentOf ingredient archive to an opened action. +// The ingredient_id passed to write_ingredient_archive survives the round-trip via the +// archive metadata's archive:ingredient_id field, so the signing builder references the +// loaded ingredient by that producer-side id. +TEST_F(BuilderTest, LinkIngredientArchiveParentOfOpenedUsingArchiveApi) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto archive_builder = c2pa::Builder(context, manifest_str); + archive_builder.add_ingredient( + json({{"title", "photo.jpg"}, {"relationship", "parentOf"}, {"label", "my-ingredient"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(archive_builder.write_ingredient_archive("my-ingredient", stream)); + + auto manifest_json = make_manifest_with_action("c2pa.opened", "my-ingredient", + "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"); + auto signing_builder = c2pa::Builder(context, manifest_json.dump()); + stream.seekg(0); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("link_ingredient_archive_parentof_opened.jpg"); + bool linked = verify_ingredient_linked(signing_builder, output_path, signer, "c2pa.opened"); + EXPECT_TRUE(linked); +} + +// Link a componentOf ingredient archive to a placed action. +TEST_F(BuilderTest, LinkIngredientArchiveComponentOfPlacedUsingArchiveApi) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto archive_builder = c2pa::Builder(context, manifest_str); + archive_builder.add_ingredient( + json({{"title", "photo.jpg"}, {"relationship", "componentOf"}, {"label", "my-ingredient"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(archive_builder.write_ingredient_archive("my-ingredient", stream)); + + auto manifest_json = make_manifest_with_action("c2pa.placed", "my-ingredient"); + auto signing_builder = c2pa::Builder(context, manifest_json.dump()); + stream.seekg(0); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("link_ingredient_archive_componentof_placed.jpg"); + bool linked = verify_ingredient_linked(signing_builder, output_path, signer, "c2pa.placed"); + EXPECT_TRUE(linked); +} + +// Link same ingredient to 2 different actions +TEST_F(BuilderTest, LinkIngredientArchiveToBothOpenedAndPlacedUsingArchiveApi) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto archive_builder = c2pa::Builder(context, manifest_str); + archive_builder.add_ingredient( + json({{"title", "photo.jpg"}, {"relationship", "parentOf"}, {"label", "shared-ingredient"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(archive_builder.write_ingredient_archive("shared-ingredient", stream)); + + json manifest_json = { + {"claim_generator_info", json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions.v2"}, + {"data", {{"actions", json::array({ + { + {"action", "c2pa.opened"}, + {"digitalSourceType", "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}, + {"parameters", {{"ingredientIds", json::array({"shared-ingredient"})}}} + }, + { + {"action", "c2pa.placed"}, + {"parameters", {{"ingredientIds", json::array({"shared-ingredient"})}}} + } + })}}} + } + })} + }; + + auto signing_builder = c2pa::Builder(context, manifest_json.dump()); + stream.seekg(0); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(stream)); + + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + auto output_path = get_temp_path("link_ingredient_archive_both.jpg"); + ASSERT_NO_THROW(signing_builder.sign(source_path, output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& manifest = parsed["manifests"][active]; + + json opened_action, placed_action; + bool found_opened = false, found_placed = false; + for (auto& assertion : manifest["assertions"]) { + if (assertion["label"] != "c2pa.actions.v2") continue; + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] == "c2pa.opened") { opened_action = action; found_opened = true; } + if (action["action"] == "c2pa.placed") { placed_action = action; found_placed = true; } + } + } + ASSERT_TRUE(found_opened) << "c2pa.opened action not found"; + ASSERT_TRUE(found_placed) << "c2pa.placed action not found"; + + ASSERT_TRUE(opened_action.contains("parameters")); + ASSERT_TRUE(opened_action["parameters"].contains("ingredients")); + ASSERT_EQ(opened_action["parameters"]["ingredients"].size(), 1u); + + ASSERT_TRUE(placed_action.contains("parameters")); + ASSERT_TRUE(placed_action["parameters"].contains("ingredients")); + ASSERT_EQ(placed_action["parameters"]["ingredients"].size(), 1u); + + std::string opened_url = opened_action["parameters"]["ingredients"][0]["url"]; + std::string placed_url = placed_action["parameters"]["ingredients"][0]["url"]; + EXPECT_EQ(opened_url, placed_url) << "Both actions should link the same ingredient archive"; + EXPECT_EQ(opened_url, "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"); +} + +// Catalog pattern: write per-ingredient archives indexed by instance_id, +// then assemble any subset directly via add_ingredient_from_archive. +TEST_F(BuilderTest, IngredientCatalogUsingArchiveApi) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // Build catalog: two ingredient archives indexed by instance_id. + auto catalog_builder = c2pa::Builder(context, manifest_str); + catalog_builder.add_ingredient( + json({{"title", "photo-A.jpg"}, {"relationship", "componentOf"}, {"instance_id", "catalog:ingredient-A"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + catalog_builder.add_ingredient( + json({{"title", "photo-B.jpg"}, {"relationship", "componentOf"}, {"instance_id", "catalog:ingredient-B"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream streamA(std::ios::in | std::ios::out | std::ios::binary); + std::stringstream streamB(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(catalog_builder.write_ingredient_archive("catalog:ingredient-A", streamA)); + ASSERT_NO_THROW(catalog_builder.write_ingredient_archive("catalog:ingredient-B", streamB)); + + // Assemble final builder using only ingredient-B from the catalog. + auto final_builder = c2pa::Builder(context, manifest_str); + streamB.seekg(0); + ASSERT_NO_THROW(final_builder.add_ingredient_from_archive(streamB)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("catalog_new.jpg"); + ASSERT_NO_THROW(final_builder.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u) << "Only ingredient-B should be present"; + EXPECT_EQ(ingredients[0]["title"], "photo-B.jpg"); + if (ingredients[0].contains("instance_id")) { + EXPECT_EQ(ingredients[0]["instance_id"], "catalog:ingredient-B"); + } +} + +// Three ingredient archives with distinct ids loaded into one signing builder, with a +// single action linking all three. +TEST_F(BuilderTest, LinkThreeIngredientArchivesDistinctIdsUsingArchiveApi) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto build_archive = [&](const std::string& id, const std::string& title, std::stringstream& stream) { + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + json({{"title", title}, {"relationship", "componentOf"}, {"label", id}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + producer.write_ingredient_archive(id, stream); + }; + + std::stringstream archive_a(std::ios::in | std::ios::out | std::ios::binary); + std::stringstream archive_b(std::ios::in | std::ios::out | std::ios::binary); + std::stringstream archive_c(std::ios::in | std::ios::out | std::ios::binary); + build_archive("ing-a", "Ingredient A", archive_a); + build_archive("ing-b", "Ingredient B", archive_b); + build_archive("ing-c", "Ingredient C", archive_c); + + json manifest_json = { + {"claim_generator_info", json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions.v2"}, + {"data", {{"actions", json::array({ + { + {"action", "c2pa.placed"}, + {"parameters", {{"ingredientIds", json::array({"ing-a", "ing-b", "ing-c"})}}} + } + })}}} + } + })} + }; + + auto signing_builder = c2pa::Builder(context, manifest_json.dump()); + archive_a.seekg(0); + archive_b.seekg(0); + archive_c.seekg(0); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(archive_a)); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(archive_b)); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(archive_c)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("link_three_ingredient_archives.jpg"); + ASSERT_NO_THROW(signing_builder.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& manifest = parsed["manifests"][active]; + + json placed_action; + bool found_placed = false; + for (auto& assertion : manifest["assertions"]) { + if (assertion["label"] != "c2pa.actions.v2") continue; + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] == "c2pa.placed") { + placed_action = action; + found_placed = true; + } + } + } + ASSERT_TRUE(found_placed); + ASSERT_EQ(placed_action["parameters"]["ingredients"].size(), 3u); + + std::set urls; + for (auto& ing : placed_action["parameters"]["ingredients"]) { + urls.insert(ing["url"].get()); + } + EXPECT_EQ(urls.size(), 3u) << "Three distinct ingredient URLs expected"; + EXPECT_TRUE(urls.count("self#jumbf=c2pa.assertions/c2pa.ingredient.v3")); + EXPECT_TRUE(urls.count("self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1")); + EXPECT_TRUE(urls.count("self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2")); +} + +// Mix add_ingredient overloads with the dedicated ingredient archive API in the same +// builder. Action links every ingredient by its caller-supplied id regardless +// of how it was added. +TEST_F(BuilderTest, MixIngredientApisLinkByLabel) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto archive_producer = c2pa::Builder(context, manifest_str); + archive_producer.add_ingredient( + json({{"title", "via-archive.jpg"}, {"relationship", "componentOf"}, {"label", "via-archive"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + archive_producer.write_ingredient_archive("via-archive", archive_stream); + + json manifest_json = { + {"claim_generator_info", json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions.v2"}, + {"data", {{"actions", json::array({ + { + {"action", "c2pa.placed"}, + {"parameters", {{"ingredientIds", json::array({"via-add", "via-stream", "via-archive"})}}} + } + })}}} + } + })} + }; + + auto signing_builder = c2pa::Builder(context, manifest_json.dump()); + + signing_builder.add_ingredient( + json({{"title", "via-add.jpg"}, {"relationship", "componentOf"}, {"label", "via-add"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::ifstream stream_src(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(stream_src.good()); + signing_builder.add_ingredient( + json({{"title", "via-stream.jpg"}, {"relationship", "componentOf"}, {"label", "via-stream"}}).dump(), + "image/jpeg", + stream_src); + stream_src.close(); + + archive_stream.seekg(0); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("mix_old_new_apis.jpg"); + ASSERT_NO_THROW(signing_builder.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& manifest = parsed["manifests"][active]; + + json placed_action; + for (auto& assertion : manifest["assertions"]) { + if (assertion["label"] != "c2pa.actions.v2") continue; + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] == "c2pa.placed") placed_action = action; + } + } + ASSERT_FALSE(placed_action.is_null()); + ASSERT_EQ(placed_action["parameters"]["ingredients"].size(), 3u) + << "All three ingredients should resolve via their caller-supplied ids"; +} + +TEST_F(BuilderTest, IngredientArchiveFallsBackToInstanceIdWhenNoLabel) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + json({{"title", "anon.jpg"}, + {"relationship", "componentOf"}, + {"instance_id", "xmp:iid:anon-fixture"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); + producer.write_ingredient_archive("xmp:iid:anon-fixture", archive); + + json manifest_json = { + {"claim_generator_info", json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions.v2"}, + {"data", {{"actions", json::array({ + { + {"action", "c2pa.placed"}, + {"parameters", {{"ingredientIds", json::array({"xmp:iid:anon-fixture"})}}} + } + })}}} + } + })} + }; + + auto signing_builder = c2pa::Builder(context, manifest_json.dump()); + archive.seekg(0); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(archive)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("ingredient_archive_no_label_fallback.jpg"); + EXPECT_TRUE(verify_ingredient_linked(signing_builder, output_path, signer, "c2pa.placed")) + << "instance_id passed as archive key should link the loaded ingredient to the action"; +} + +// Empty builder: write_ingredient_archive cannot fabricate an ingredient +// from the id alone. With no prior add_ingredient, the lookup fails. +TEST_F(BuilderTest, WriteIngredientArchiveWithoutAddIngredientThrows) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(context, manifest_str); + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + EXPECT_THROW(builder.write_ingredient_archive("never-added", stream), c2pa::C2paException); +} + +// Id mismatch: the id arg must match an id previously supplied via +// add_ingredient's JSON (label or instance_id). +TEST_F(BuilderTest, WriteIngredientArchiveWithUnknownIdThrows) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(context, manifest_str); + builder.add_ingredient( + json({{"title", "photo.jpg"}, {"relationship", "componentOf"}, {"label", "real-id"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + EXPECT_THROW(builder.write_ingredient_archive("wrong-id", stream), c2pa::C2paException); +} + +// When the builder has many ingredients, write_ingredient_archive +// puts only the requested one in the archive. +TEST_F(BuilderTest, WriteIngredientArchiveContainsOnlyTargetIngredient) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(context, manifest_str); + builder.add_ingredient( + json({{"title", "first.jpg"}, {"relationship", "componentOf"}, {"label", "first"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + builder.add_ingredient( + json({{"title", "second.jpg"}, {"relationship", "componentOf"}, {"label", "second"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + builder.add_ingredient( + json({{"title", "third.jpg"}, {"relationship", "componentOf"}, {"label", "third"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(builder.write_ingredient_archive("second", archive)); + + archive.seekg(0); + c2pa::Reader reader(context, "application/c2pa", archive); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u) << "Archive should contain only the requested ingredient"; + EXPECT_EQ(ingredients[0]["title"], "second.jpg"); +} + +// instance_id set on add_ingredient survives write_ingredient_archive → +// add_ingredient_from_archive → signing, and is readable via Reader::json(). +TEST_F(BuilderTest, InstanceIdSurvivesWriteIngredientArchiveRoundTripAndSigning) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + + auto manifest_str = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}] + })"; + + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", "instance_id": "iid:survival-test-001"})", + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(producer.write_ingredient_archive("iid:survival-test-001", archive_stream)); + + auto consumer = c2pa::Builder(context, manifest_str); + archive_stream.seekg(0); + ASSERT_NO_THROW(consumer.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("iid_survives_ingredient_archive.jpg"); + ASSERT_NO_THROW(consumer.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + ASSERT_TRUE(ingredients[0].contains("instance_id")) + << "instance_id should survive write_ingredient_archive + add_ingredient_from_archive + sign"; + EXPECT_EQ(ingredients[0]["instance_id"], "iid:survival-test-001"); +} + +// When neither caller nor XMP provides instance_id, the library auto-generates +// an xmp.iid: value so the ingredient assertion is valid. +TEST_F(BuilderTest, InstanceIdAutoGeneratedWhenNotProvided) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + + auto manifest_str = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}] + })"; + + // Add ingredient with NO instance_id in JSON. + // A.jpg has no XMP metadata, so the library must generate one. + auto builder = c2pa::Builder(context, manifest_str); + builder.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf"})", + c2pa_test::get_fixture_path("A.jpg")); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("iid_auto_generated.jpg"); + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + + // instance_id should be present and start with "xmp.iid:" + ASSERT_TRUE(ingredients[0].contains("instance_id")) + << "Library should auto-generate instance_id when none is provided"; + std::string iid = ingredients[0]["instance_id"]; + EXPECT_EQ(iid.substr(0, 8), "xmp.iid:") + << "Auto-generated instance_id should start with xmp.iid:, got: " << iid; +} + +// When add_ingredient JSON omits "relationship", the SDK applies "componentOf" as the default. +TEST_F(BuilderTest, RelationshipDefaultsToComponentOf) +{ + auto context = c2pa::Context::ContextBuilder().create_context(); + + auto manifest_str = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}] + })"; + + // Add ingredient with NO relationship in JSON. + auto builder = c2pa::Builder(context, manifest_str); + builder.add_ingredient( + R"({"title": "A.jpg"})", + c2pa_test::get_fixture_path("A.jpg")); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("relationship_defaults_component_of.jpg"); + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + ASSERT_TRUE(ingredients[0].contains("relationship")); + EXPECT_EQ(ingredients[0]["relationship"], "componentOf") + << "Omitting relationship should default to componentOf"; +} + +// instance_id set on add_ingredient survives to_archive and is +// readable via Reader::json() on the archive. +TEST_F(BuilderTest, InstanceIdSurvivesToArchiveAndReader) +{ + auto context = c2pa::Context::ContextBuilder().create_context(); + + auto manifest_str = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}] + })"; + + auto builder = c2pa::Builder(context, manifest_str); + builder.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", "instance_id": "iid:legacy-survival-001"})", + c2pa_test::get_fixture_path("A.jpg")); + + auto archive_path = get_temp_path("iid_survives_to_archive.c2pa"); + ASSERT_NO_THROW(builder.to_archive(archive_path)); + + auto reader = c2pa::Reader(context, archive_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_FALSE(ingredients.empty()); + + bool found = false; + for (auto& ing : ingredients) { + if (ing.contains("instance_id") && ing["instance_id"] == "iid:legacy-survival-001") { + found = true; + EXPECT_EQ(ing["title"], "A.jpg"); + } + } + ASSERT_TRUE(found) << "instance_id should survive to_archive and be readable via Reader"; +} + +// The ingredient_id passed to write_ingredient_archive is restored as the label +// on the loaded ingredient in the signing builder. This means ingredientIds in +// actions must use the same value that was passed to write_ingredient_archive, +// regardless of whether that value was the label or instance_id at archive-creation time. +TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredPrefersLabelInSigningBuilder) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + + auto manifest_str = R"({"claim_generator_info": [{"name": "test", "version": "1.0"}]})"; + + // Producer sets both label and instance_id on the ingredient. + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", + "label": "my-label", "instance_id": "iid:label-survival-test"})", + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + // write_ingredient_archive accepts label or instance_id as the lookup key. + // The value passed here becomes the restored label in the signing builder. + ASSERT_NO_THROW(producer.write_ingredient_archive("my-label", archive_stream)); + + // Signing builder loads the archive. + // ingredientIds must use "my-label": the value passed to write_ingredient_archive, + // because that value is restored as the ingredient's label in the signing builder. + auto signing_manifest = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["my-label"]}}]} + }] + })"; + + auto consumer = c2pa::Builder(context, signing_manifest); + archive_stream.seekg(0); + ASSERT_NO_THROW(consumer.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("archive_ingredient_id_restored_as_label.jpg"); + ASSERT_NO_THROW(consumer.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + + // instance_id survives the CBOR assertion round-trip. + ASSERT_TRUE(ingredients[0].contains("instance_id")); + EXPECT_EQ(ingredients[0]["instance_id"], "iid:label-survival-test"); +} + +// When the ingredient has only a label (no instance_id), the label is passed to +// write_ingredient_archive and is restored as the label in the signing builder. +// ingredientIds must use the label value. +TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelUsingLabelOnly) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + + auto manifest_str = R"({"claim_generator_info": [{"name": "test", "version": "1.0"}]})"; + + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", "label": "only-label"})", + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(producer.write_ingredient_archive("only-label", archive_stream)); + + auto signing_manifest = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["only-label"]}}]} + }] + })"; + + auto consumer = c2pa::Builder(context, signing_manifest); + archive_stream.seekg(0); + ASSERT_NO_THROW(consumer.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("archive_label_only_ingredient.jpg"); + ASSERT_NO_THROW(consumer.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + EXPECT_EQ(ingredients[0]["title"], "A.jpg"); +} + +// When the ingredient has only an instance_id (no label), the instance_id value is +// passed to write_ingredient_archive and is restored as the label in the signing +// builder. ingredientIds must use that instance_id value. +TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelUsingInstanceIdOnly) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + + auto manifest_str = R"({"claim_generator_info": [{"name": "test", "version": "1.0"}]})"; + + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", "instance_id": "iid:only-instance"})", + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + // No label set, so pass instance_id as the lookup key. + ASSERT_NO_THROW(producer.write_ingredient_archive("iid:only-instance", archive_stream)); + + // ingredientIds uses the instance_id value, same string passed to write_ingredient_archive. + auto signing_manifest = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["iid:only-instance"]}}]} + }] + })"; + + auto consumer = c2pa::Builder(context, signing_manifest); + archive_stream.seekg(0); + ASSERT_NO_THROW(consumer.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("archive_instance_id_only_ingredient.jpg"); + ASSERT_NO_THROW(consumer.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + + // instance_id survives in the CBOR assertion. + ASSERT_TRUE(ingredients[0].contains("instance_id")); + EXPECT_EQ(ingredients[0]["instance_id"], "iid:only-instance"); +} + +// Both label and instance_id set. write_ingredient_archive called with label. +// The label is the lookup key and becomes the restored label in signing builder. +// ingredientIds must use the label value. +TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelBothSetUseLabelForLinking) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + + auto manifest_str = R"({"claim_generator_info": [{"name": "test", "version": "1.0"}]})"; + + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", + "label": "lbl:both-set", "instance_id": "iid:both-set"})", + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(producer.write_ingredient_archive("lbl:both-set", archive_stream)); + + auto signing_manifest = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["lbl:both-set"]}}]} + }] + })"; + + auto consumer = c2pa::Builder(context, signing_manifest); + archive_stream.seekg(0); + ASSERT_NO_THROW(consumer.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("archive_both_set_pass_label.jpg"); + ASSERT_NO_THROW(consumer.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + + // instance_id from the CBOR assertion survives unchanged. + ASSERT_TRUE(ingredients[0].contains("instance_id")); + EXPECT_EQ(ingredients[0]["instance_id"], "iid:both-set"); +} + +// Both label and instance_id set. write_ingredient_archive called with instance_id. +// The instance_id string becomes the restored label in signing builder. +// ingredientIds must use the instance_id value, not the label. +TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelBothSetUseInstanceIdForLinking) +{ + auto settings = c2pa::Settings(); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + + auto manifest_str = R"({"claim_generator_info": [{"name": "test", "version": "1.0"}]})"; + + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", + "label": "lbl:both-set2", "instance_id": "iid:both-set2"})", + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + // Pass instance_id — lookup finds it via the instance_id branch of the OR-match. + ASSERT_NO_THROW(producer.write_ingredient_archive("iid:both-set2", archive_stream)); + + // ingredientIds must use "iid:both-set2", the value passed to write_ingredient_archive. + // Using "lbl:both-set2" here would fail because the restored label is "iid:both-set2". + auto signing_manifest = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["iid:both-set2"]}}]} + }] + })"; + + auto consumer = c2pa::Builder(context, signing_manifest); + archive_stream.seekg(0); + ASSERT_NO_THROW(consumer.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("archive_both_set_pass_iid.jpg"); + ASSERT_NO_THROW(consumer.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + ASSERT_TRUE(ingredients[0].contains("instance_id")); + EXPECT_EQ(ingredients[0]["instance_id"], "iid:both-set2"); +} + +// write_ingredient_archive requires the c2pa (JUMBF) working-store format. +TEST_F(BuilderTest, WriteIngredientArchiveThrowsWhenSettingDisabled) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "false"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(context, manifest_str); + builder.add_ingredient( + json({{"title", "photo.jpg"}, {"relationship", "componentOf"}, {"label", "my-ingredient"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + EXPECT_THROW(builder.write_ingredient_archive("my-ingredient", stream), c2pa::C2paException); +} + +TEST_F(BuilderTest, ProvenanceSurvivesIngredientArchiveRoundTrip) +{ + auto context = c2pa::Context::ContextBuilder().create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // C.jpg is C2PA-signed, so the ingredient records an active manifest. + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + json({{"title", "C.jpg"}, {"relationship", "componentOf"}, {"instance_id", "iid:provenance-roundtrip"}}).dump(), + c2pa_test::get_fixture_path("C.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(producer.write_ingredient_archive("iid:provenance-roundtrip", archive_stream)); + + auto consumer = c2pa::Builder(context, manifest_str); + archive_stream.seekg(0); + ASSERT_NO_THROW(consumer.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("provenance_survives_ingredient_archive.jpg"); + ASSERT_NO_THROW(consumer.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + EXPECT_EQ(ingredients[0]["title"], "C.jpg"); + ASSERT_TRUE(ingredients[0].contains("instance_id")); + EXPECT_EQ(ingredients[0]["instance_id"], "iid:provenance-roundtrip"); + EXPECT_TRUE(ingredients[0].contains("active_manifest")) + << "Ingredient provenance (active_manifest) should survive the archive round-trip"; +} + +TEST(SignerTest, InvalidCredentialsThrowFromConstructor) { + EXPECT_THROW( + c2pa::Signer("Es256", "not a certificate", "not a private key"), + c2pa::C2paException); +} + +TEST_F(BuilderTest, SignSourceStreamWithExceptions) { + auto image_path = c2pa_test::get_fixture_path("A.jpg"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto context = std::make_shared(); + auto builder = c2pa::Builder(context, manifest); + auto signer = c2pa_test::create_test_signer(); + + std::ifstream source(image_path, std::ios::binary); + ASSERT_TRUE(source.is_open()); + source.exceptions(std::ios::failbit | std::ios::badbit); + + std::stringstream memory_buffer(std::ios::in | std::ios::out | std::ios::binary); + std::iostream& dest = memory_buffer; + + try { + auto manifest_data = builder.sign("image/jpeg", source, dest, signer); + EXPECT_FALSE(manifest_data.empty()); + } catch (const c2pa::C2paException&) { + // An error result is acceptable; crossing the FFI with an exception is not. + } +} + +TEST_F(BuilderTest, ArchiveToFstreamBackedCppOStream) { + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto context = std::make_shared(); + auto builder = c2pa::Builder(context, manifest); + + auto archive_path = get_temp_path("archive_fstream_cppostream.bin"); + std::fstream dest(archive_path, + std::ios_base::binary | std::ios_base::trunc | + std::ios_base::in | std::ios_base::out); + ASSERT_TRUE(dest.is_open()); + + c2pa::CppOStream c_dest(dest); + ASSERT_EQ(c2pa_builder_to_archive(builder.c2pa_builder(), c_dest.c_stream), 0); + dest.flush(); + + EXPECT_GT(std::filesystem::file_size(archive_path), 0u); +} From 48124e7ed4a0d03d9331b66b121597b3b8797fbd Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:28:36 -0700 Subject: [PATCH 3/4] feat: Bindings for `c2pa_reader_with_manifest_data_and_stream` and `c2pa_reader_with_fragment` (#237) * feat: Bind the new API * fix: Clean up to keep only most relevant tests * fix: Verified lifetime note added to docs * feat: Add the video streaming API bingind too * fix: Refactor * fix: Memory handling * fix: Review comments --- include/c2pa.hpp | 56 +++++++++++++- src/c2pa_reader.cpp | 82 +++++++++++++++++++++ tests/fixtures/dash1.m4s | Bin 0 -> 71111 bytes tests/fixtures/dashinit.mp4 | Bin 0 -> 4765 bytes tests/reader.test.cpp | 142 ++++++++++++++++++++++++++++++++++++ 5 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/dash1.m4s create mode 100644 tests/fixtures/dashinit.mp4 diff --git a/include/c2pa.hpp b/include/c2pa.hpp index a1e32d44..b78eb229 100644 --- a/include/c2pa.hpp +++ b/include/c2pa.hpp @@ -778,6 +778,18 @@ namespace c2pa void init_from_context(IContextProvider& context, const std::string &format, std::istream &stream); void init_from_context(IContextProvider& context, const std::filesystem::path &source_path); + void init_from_manifest_data_and_stream(IContextProvider& context, + const std::string& format, + std::istream& image_stream, + const std::vector& manifest_jumbf); + + /// @brief Throw if the Reader holds no valid native handle. + void ensure_initialized() const { + if (c2pa_reader == nullptr) { + throw C2paException("Reader is not initialized"); + } + } + Reader() : c2pa_reader(nullptr) {} public: @@ -826,6 +838,25 @@ namespace c2pa /// @throws C2paException if context is null or context->is_valid() returns false. Reader(std::shared_ptr context, const std::filesystem::path &source_path); + /// @brief Create a Reader from a shared context, image stream, and external JUMBF manifest. + /// @details Performs full C2PA binding verification between @p image_stream and the provided + /// JUMBF manifest bytes without requiring the manifest to be embedded in the asset. + /// Typical use: sidecar .c2pa files, database-stored manifests, in-memory pipelines. + /// The Reader retains a shared reference to the context for its lifetime. + /// @param context Shared context provider (trust anchors, verification policy). + /// @param format MIME type of the image stream (e.g., "image/jpeg"). + /// @param image_stream Asset data. Must support seeking (used for exclusion-range hashing). + /// @param manifest_jumbf Raw JUMBF manifest bytes (e.g., contents of a .c2pa sidecar file). + /// @note @p image_stream, @p manifest_jumbf, and @p format need only remain valid for the + /// duration of the construction call. The Reader does not retain their references. + /// @throws C2paException if context is null, + /// context->is_valid() is false, + /// manifest_jumbf is empty, or the C2PA library reports an error. + Reader(std::shared_ptr context, + const std::string& format, + std::istream& image_stream, + const std::vector& manifest_jumbf); + /// @brief Create a Reader from a stream (will use global settings if any loaded). /// @details The validation_status field in the JSON contains validation results. /// @param format The mime format of the stream. @@ -907,8 +938,11 @@ namespace c2pa /// @brief Check if the reader was created from an embedded manifest. /// @return true if the manifest was embedded in the asset, false if external. - /// @throws C2paException for errors encountered by the C2PA library. + /// @throws C2paException if the Reader holds no valid handle (e.g. it was moved + /// from, or a prior with_fragment() failed and consumed it), or for other + /// errors encountered by the C2PA library. [[nodiscard]] inline bool is_embedded() const { + ensure_initialized(); return c2pa_reader_is_embedded(c2pa_reader); } @@ -918,6 +952,26 @@ namespace c2pa /// @throws C2paException for errors encountered by the C2PA library. [[nodiscard]] std::optional remote_url() const; + /// @brief Process a BMFF fragment stream with this Reader instance. + /// @details Used for fragmented BMFF media (DASH/HLS streaming) where content is split + /// into an init/main segment and separate fragment files. The Reader is + /// created from the init segment (e.g. Reader(context, "video/mp4", init)), + /// then goes through one fragment at a time via this method. + /// The underlying native SDK consumes the current reader and + /// returns a new one configured with the fragment. + /// This method swaps the handle in place and returns *this for chaining + /// across fragments as the reading steps go through fragments. + /// @param format MIME type of the media (e.g., "video/mp4"). + /// @param stream The main/init segment. + /// @param fragment The current fragment to process. + /// @return *this, for chaining across multiple fragments. + /// @note @p stream and @p fragment need only remain valid for the duration of this call. + /// The Reader does not retain a reference to either after returning. + /// @throws C2paException if the C2PA library reports an error. + /// On failure the underlying reader handle is consumed and + /// this Reader must not be used further. + Reader& with_fragment(const std::string& format, std::istream& stream, std::istream& fragment); + /// @brief Get the manifest as a JSON string. /// @return The manifest as a JSON string. /// @throws C2paException for errors encountered by the C2PA library. diff --git a/src/c2pa_reader.cpp b/src/c2pa_reader.cpp index 909e61e7..f1e56499 100644 --- a/src/c2pa_reader.cpp +++ b/src/c2pa_reader.cpp @@ -96,6 +96,43 @@ namespace c2pa c2pa_reader = updated; } + void Reader::init_from_manifest_data_and_stream( + IContextProvider& context, + const std::string& format, + std::istream& image_stream, + const std::vector& manifest_jumbf) + { + if (!context.is_valid()) { + throw C2paException("Invalid Context provider IContextProvider"); + } + if (manifest_jumbf.empty()) { + throw C2paException("manifest_jumbf must not be empty"); + } + + cpp_stream = std::make_unique(image_stream); + + c2pa_reader = c2pa_reader_from_context(context.c_context()); + if (c2pa_reader == nullptr) { + throw C2paException("Failed to create reader from context"); + } + + // c2pa_reader_with_manifest_data_and_stream always consumes c2pa_reader. + C2paReader* updated = c2pa_reader_with_manifest_data_and_stream( + c2pa_reader, + format.c_str(), + cpp_stream->c_stream, + manifest_jumbf.data(), + manifest_jumbf.size()); + c2pa_reader = nullptr; + if (updated == nullptr) { + throw C2paException(); + } + c2pa_reader = updated; + + // Stream not retained by C FFI + cpp_stream.reset(); + } + Reader::Reader(IContextProvider& context, const std::string &format, std::istream &stream) : c2pa_reader(nullptr) { @@ -111,6 +148,9 @@ namespace c2pa Reader::Reader(std::shared_ptr context, const std::string &format, std::istream &stream) : c2pa_reader(nullptr) { + if (!context) { + throw C2paException("context must not be null"); + } init_from_context(*context, format, stream); context_ref = std::move(context); } @@ -118,10 +158,49 @@ namespace c2pa Reader::Reader(std::shared_ptr context, const std::filesystem::path &source_path) : c2pa_reader(nullptr) { + if (!context) { + throw C2paException("context must not be null"); + } init_from_context(*context, source_path); context_ref = std::move(context); } + Reader::Reader(std::shared_ptr context, + const std::string& format, + std::istream& image_stream, + const std::vector& manifest_jumbf) + : c2pa_reader(nullptr) + { + if (!context) { + throw C2paException("context must not be null"); + } + init_from_manifest_data_and_stream(*context, format, image_stream, manifest_jumbf); + context_ref = std::move(context); + } + + Reader& Reader::with_fragment(const std::string& format, std::istream& stream, std::istream& fragment) + { + ensure_initialized(); + + CppIStream main_wrapper(stream); + CppIStream fragment_wrapper(fragment); + + // c2pa_reader_with_fragment consumes the existing reader and returns a new one. + // *this is returned for chaining so reading can go through all segments. + C2paReader* updated = c2pa_reader_with_fragment( + c2pa_reader, + format.c_str(), + main_wrapper.c_stream, + fragment_wrapper.c_stream); + c2pa_reader = nullptr; + if (updated == nullptr) { + throw C2paException(); + } + c2pa_reader = updated; + + return *this; + } + Reader::Reader(const std::string &format, std::istream &stream) { cpp_stream = std::make_unique(stream); @@ -159,16 +238,19 @@ namespace c2pa std::string Reader::json() const { + ensure_initialized(); return detail::c_string_to_string(c2pa_reader_json(c2pa_reader)); } std::string Reader::detailed_json() const { + ensure_initialized(); return detail::c_string_to_string(c2pa_reader_detailed_json(c2pa_reader)); } std::string Reader::crjson() const { + ensure_initialized(); return detail::c_string_to_string(c2pa_reader_crjson(c2pa_reader)); } diff --git a/tests/fixtures/dash1.m4s b/tests/fixtures/dash1.m4s new file mode 100644 index 0000000000000000000000000000000000000000..1a9a99644b833105a4ee54e958f4a762442ca7b1 GIT binary patch literal 71111 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∋D0KiSPO4Ys;jH*$ELgMQjX-+?)IdIfGAXNI=; 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 literal 0 HcmV?d00001 diff --git a/tests/reader.test.cpp b/tests/reader.test.cpp index 68827ee4..1323df5a 100644 --- a/tests/reader.test.cpp +++ b/tests/reader.test.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include "include/test_utils.hpp" @@ -718,3 +719,144 @@ TEST_F(ReaderTest, ReadCrJsonSpecialChars) auto crjson = reader.crjson(); EXPECT_FALSE(crjson.empty()); } + +TEST(Reader, StreamWithExceptions) { + fs::path current_dir = fs::path(__FILE__).parent_path(); + fs::path test_file = current_dir / "../tests/fixtures/C.jpg"; + + std::ifstream stream(test_file, std::ios::binary); + ASSERT_TRUE(stream.is_open()); + stream.exceptions(std::ios::failbit | std::ios::badbit); + + auto context = std::make_shared(); + try { + auto reader = c2pa::Reader(context, "image/jpeg", stream); + auto json_result = reader.json(); + EXPECT_FALSE(json_result.empty()); + } catch (const c2pa::C2paException&) { + // An error result is acceptable; crossing the FFI with an exception is not. + } +} + +TEST_F(ReaderTest, ReaderFromFragmentDashFixtures) { + auto ctx = std::make_shared(); + + std::ifstream init(c2pa_test::get_fixture_path("dashinit.mp4"), std::ios::binary); + ASSERT_TRUE(init.is_open()); + c2pa::Reader reader(ctx, "video/mp4", init); + + std::ifstream main_seg(c2pa_test::get_fixture_path("dashinit.mp4"), std::ios::binary); + std::ifstream fragment(c2pa_test::get_fixture_path("dash1.m4s"), std::ios::binary); + ASSERT_TRUE(main_seg.is_open()); + ASSERT_TRUE(fragment.is_open()); + + auto& same = reader.with_fragment("video/mp4", main_seg, fragment); + EXPECT_EQ(&same, &reader); + EXPECT_FALSE(reader.json().empty()); +} + +TEST_F(ReaderTest, ReaderFromFragmentReaderCanMove) { + auto ctx = std::make_shared(); + std::ifstream init(c2pa_test::get_fixture_path("dashinit.mp4"), std::ios::binary); + ASSERT_TRUE(init.is_open()); + c2pa::Reader reader(ctx, "video/mp4", init); + + { + std::ifstream main_seg(c2pa_test::get_fixture_path("dashinit.mp4"), std::ios::binary); + std::ifstream fragment(c2pa_test::get_fixture_path("dash1.m4s"), std::ios::binary); + reader.with_fragment("video/mp4", main_seg, fragment); + } + + c2pa::Reader moved = std::move(reader); + EXPECT_FALSE(moved.json().empty()); +} + +class ReaderSidecarTest : public ReaderTest { +public: + // A sidecar manifest plus the asset bytes it is bound to. + struct TestSidecar { + std::vector manifest; // external JUMBF, to pass as manifest_jumbf + std::vector asset_bytes; // the asset the manifest's dataHash covers + }; + + // Create manifest bytes for an asset. + static TestSidecar make_test_sidecar_bytes(const fs::path& asset, + const std::string& format) { + auto signer = c2pa_test::create_test_signer(); + auto context = c2pa::Context(); + auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto builder = c2pa::Builder(context, manifest_json); + builder.set_no_embed(); + std::ifstream source(asset, std::ios::binary); + std::stringstream dest(std::ios::in | std::ios::out | std::ios::binary); + auto manifest_bytes = builder.sign(format, source, dest, signer); + + std::string signed_str = dest.str(); + return TestSidecar{ + std::vector(manifest_bytes.begin(), manifest_bytes.end()), + std::vector(signed_str.begin(), signed_str.end())}; + } + + // True if any validation_status entry's code contains "dataHash". + static bool has_data_hash_failure(const std::string& reader_json) { + auto obj = nlohmann::json::parse(reader_json); + for (auto& status : obj.value("validation_status", nlohmann::json::array())) { + if (status.value("code", std::string()).find("dataHash") != std::string::npos) { + return true; + } + } + return false; + } + + // Open the signed asset bytes as a seekable binary stream. + static std::unique_ptr open_asset_stream(const std::vector& bytes) { + return std::make_unique( + std::string(bytes.begin(), bytes.end()), std::ios::binary); + } +}; + +TEST_F(ReaderSidecarTest, ReaderCanReadSidecar) { + auto sc = make_test_sidecar_bytes(c2pa_test::get_fixture_path("C.jpg"), "image/jpeg"); + auto img = open_asset_stream(sc.asset_bytes); + auto ctx = std::make_shared(); + c2pa::Reader r(ctx, "image/jpeg", *img, sc.manifest); + EXPECT_FALSE(r.json().empty()); + EXPECT_FALSE(r.is_embedded()); + EXPECT_TRUE(nlohmann::json::parse(r.json()).contains("manifests")); + EXPECT_FALSE(has_data_hash_failure(r.json())); +} + +TEST_F(ReaderSidecarTest, ReaderCanReadSidecarSpecialChars) { +#ifdef _WIN32 + auto asset = c2pa_test::get_fixture_path(L"CÖÄ_.jpg"); +#else + auto asset = c2pa_test::get_fixture_path("CÖÄ_.jpg"); +#endif + auto sc = make_test_sidecar_bytes(asset, "image/jpeg"); + auto img = open_asset_stream(sc.asset_bytes); + auto ctx = std::make_shared(); + c2pa::Reader r(ctx, "image/jpeg", *img, sc.manifest); + EXPECT_FALSE(r.json().empty()); + EXPECT_FALSE(r.is_embedded()); + EXPECT_FALSE(has_data_hash_failure(r.json())); +} + +TEST_F(ReaderSidecarTest, SidecarReaderCanMove) { + auto sc = make_test_sidecar_bytes(c2pa_test::get_fixture_path("C.jpg"), "image/jpeg"); + auto img = open_asset_stream(sc.asset_bytes); + auto ctx = std::make_shared(); + c2pa::Reader r1(ctx, "image/jpeg", *img, sc.manifest); + c2pa::Reader r2 = std::move(r1); + EXPECT_FALSE(r2.json().empty()); +} + +TEST_F(ReaderSidecarTest, SidecarReaderResetsStreamPosition) { + auto sc = make_test_sidecar_bytes(c2pa_test::get_fixture_path("C.jpg"), "image/jpeg"); + auto img = open_asset_stream(sc.asset_bytes); + img->seekg(249); + auto ctx = std::make_shared(); + + // Reader can read even if stream img is at another pos than 0 + c2pa::Reader r(ctx, "image/jpeg", *img, sc.manifest); + EXPECT_FALSE(r.json().empty()); +} From a75ad02ae2975e3b04abe6df851a4dda567376a1 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 9 Jul 2026 06:56:08 -0700 Subject: [PATCH 4/4] fix: Wrap up cherry picking of all fixes except deprecations --- src/c2pa_internal.hpp | 92 ++++++++++++++++++++++++++----------------- src/c2pa_signer.cpp | 8 ++++ 2 files changed, 63 insertions(+), 37 deletions(-) diff --git a/src/c2pa_internal.hpp b/src/c2pa_internal.hpp index e1fe8ab2..63300a54 100644 --- a/src/c2pa_internal.hpp +++ b/src/c2pa_internal.hpp @@ -107,29 +107,37 @@ struct StreamSeekTraits { }; /// Seeker impl. +/// Exceptions must not unwind into Rust/C, so any throw +/// is converted to an IoError return. template intptr_t stream_seeker(StreamContext* context, intptr_t offset, C2paSeekMode whence) { - auto* stream = reinterpret_cast(context); - if (!is_stream_usable(stream)) { - return stream_error_return(StreamError::IoError); - } - const std::ios_base::seekdir dir = whence_to_seekdir(whence); - stream->clear(); - StreamSeekTraits::seek(stream, offset, dir); - if (stream->fail()) { - return stream_error_return(StreamError::InvalidArgument); - } - if (stream->bad()) { - return stream_error_return(StreamError::IoError); - } - const int64_t pos = StreamSeekTraits::tell(stream); - if (pos < 0) { + try { + auto* stream = reinterpret_cast(context); + if (!is_stream_usable(stream)) { + return stream_error_return(StreamError::IoError); + } + const std::ios_base::seekdir dir = whence_to_seekdir(whence); + stream->clear(); + StreamSeekTraits::seek(stream, offset, dir); + if (stream->fail()) { + return stream_error_return(StreamError::InvalidArgument); + } + if (stream->bad()) { + return stream_error_return(StreamError::IoError); + } + const int64_t pos = StreamSeekTraits::tell(stream); + if (pos < 0) { + return stream_error_return(StreamError::IoError); + } + return static_cast(pos); + } catch (...) { return stream_error_return(StreamError::IoError); } - return static_cast(pos); } /// Reader impl. +/// Exceptions must not unwind into Rust/C, so any throw +/// is converted to an IoError return. template intptr_t stream_reader(StreamContext* context, uint8_t* buffer, intptr_t size) { if (!context || !buffer) { @@ -141,37 +149,47 @@ intptr_t stream_reader(StreamContext* context, uint8_t* buffer, intptr_t size) { if (size == 0) { return 0; } - auto* stream = reinterpret_cast(context); - if (!is_stream_usable(stream)) { - return stream_error_return(StreamError::IoError); - } - stream->read(reinterpret_cast(buffer), size); - if (stream->fail()) { - if (!stream->eof()) { - return stream_error_return(StreamError::InvalidArgument); + try { + auto* stream = reinterpret_cast(context); + if (!is_stream_usable(stream)) { + return stream_error_return(StreamError::IoError); } - } - if (stream->bad()) { + stream->read(reinterpret_cast(buffer), size); + if (stream->fail()) { + if (!stream->eof()) { + return stream_error_return(StreamError::InvalidArgument); + } + } + if (stream->bad()) { + return stream_error_return(StreamError::IoError); + } + return static_cast(stream->gcount()); + } catch (...) { return stream_error_return(StreamError::IoError); } - return static_cast(stream->gcount()); } /// Get stream from context, used by writer and flusher. +/// Exceptions must not unwind into Rust/C, so any throw +/// is converted to an IoError return. template intptr_t stream_op(StreamContext* context, Op op) { - auto* stream = reinterpret_cast(context); - if (!is_stream_usable(stream)) { - return stream_error_return(StreamError::IoError); - } - const intptr_t result = op(stream); - if (stream->fail()) { - return stream_error_return(StreamError::InvalidArgument); - } - if (stream->bad()) { + try { + auto* stream = reinterpret_cast(context); + if (!is_stream_usable(stream)) { + return stream_error_return(StreamError::IoError); + } + const intptr_t result = op(stream); + if (stream->fail()) { + return stream_error_return(StreamError::InvalidArgument); + } + if (stream->bad()) { + return stream_error_return(StreamError::IoError); + } + return result; + } catch (...) { return stream_error_return(StreamError::IoError); } - return result; } /// Writer impl. diff --git a/src/c2pa_signer.cpp b/src/c2pa_signer.cpp index 8172f537..9e6954e2 100644 --- a/src/c2pa_signer.cpp +++ b/src/c2pa_signer.cpp @@ -71,12 +71,20 @@ namespace c2pa { // Pass the C++ callback as a context to our static callback wrapper. signer = c2pa_signer_create((const void *)callback, &signer_passthrough, alg, sign_cert.c_str(), validate_tsa_uri(tsa_uri)); + if (signer == nullptr) + { + throw C2paException(); + } } Signer::Signer(const std::string &alg, const std::string &sign_cert, const std::string &private_key, const std::optional &tsa_uri) { auto info = C2paSignerInfo { alg.c_str(), sign_cert.c_str(), private_key.c_str(), validate_tsa_uri(tsa_uri) }; signer = c2pa_signer_from_info(&info); + if (signer == nullptr) + { + throw C2paException(); + } } Signer::~Signer()