diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 4997076c..3c0f8986 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -1,6 +1,6 @@ # Selective manifest construction with Builder and Reader -`Builder` and `Reader` can be used together to selectively construct manifests, keeping only the parts needed and leaving out the rest. This is usfeul in the case where not all ingredients on a working store should be added (e.g. in the case where ingredient assets are not visible). This process is best described as filtering, or rebuilding a working store: +`Builder` and `Reader` can be used together to selectively construct manifests, keeping only the parts needed and leaving out the rest. This is useful in the case where not all ingredients on a working store should be added (e.g. in the case where ingredient assets are not visible). This process is best described as filtering, or rebuilding a working store: 1. Read an existing manifest. 1. Choose which elements to retain. @@ -12,7 +12,7 @@ Since both `Reader` and `Builder` are **read-only by design** (there is no `remo > **Important**: This process always creates a new `Builder`. The original signed asset and its manifest are never modified, neither is the starting working store. The `Reader` extracts data without side effects, and the `Builder` constructs a new manifest based on extracted data. -## Core concept +## Core concepts ```mermaid flowchart LR @@ -69,7 +69,7 @@ reader.get_resource(thumbnail_id, fs::path("thumbnail.jpg")); Each example below creates a **new `Builder`** from filtered data. The original asset (and its manifest store) remains as is. -When rebuilding by transferring ingredients between a `Reader` and a **new** `Builder`, remember that both the JSON metadata and the associated binary resources (thumbnails, manifest data) must be transferred. The JSON contains identifiers that reference binary resources. Those identifiers must match what you register with `builder.add_resource()`. +When rebuilding by transferring ingredients between a `Reader` and a **new** `Builder`, remember that both the JSON metadata and the associated binary resources (thumbnails, manifest data) must be transferred. The JSON contains identifiers that reference binary resources. Those identifiers must match what is registered with `builder.add_resource()`. ### Example 1: Keep only specific ingredients @@ -132,7 +132,7 @@ for (auto& assertion : assertions) { } json new_manifest = json::parse(R"({ - "claim_generator_info": [{"name": "c2pa-cpp-docs", "version": "0.1.0"}] + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}] })"); new_manifest["assertions"] = kept_assertions; @@ -172,7 +172,7 @@ flowchart TD // Create a new Builder with a new definition c2pa::Builder builder(context); builder.with_definition(R"({ - "claim_generator_info": [{"name": "c2pa-cpp-docs", "version": "0.1.0"}], + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "assertions": [] })"); @@ -184,44 +184,255 @@ builder.add_ingredient(R"({"title": "original.jpg", "relationship": "parentOf"}) builder.sign(source_path, output_path, signer); ``` -## Working with archives +## Adding actions to a working store -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. +Actions record what was done to an asset (e.g. color adjustments, cropping, placing content). Use `builder.add_action()` to add actions to a working store. -There are two distinct types of archives, sharing the same binary format but being conceptually different: builder archives (working stores archives) and ingredient archives. +```cpp +builder.add_action(R"({ + "action": "c2pa.color_adjustments", + "parameters": { "name": "brightnesscontrast" } +})"); -### Builder archives vs. ingredient archives +builder.add_action(R"({ + "action": "c2pa.filtered", + "parameters": { "name": "A filter" }, + "description": "Filtering applied" +})"); +``` -```mermaid -flowchart TD - subgraph BA["Builder Archive"] - direction TB - B1["Serialized Builder state (manifest definition + all resources)"] - B2["Purpose: defer signing to another process or machine"] - B3["Not yet signed"] - end +### Action JSON fields + +| Field | Required | Description | +|-------|----------|-------------| +| `action` | Yes | Action identifier, e.g. `"c2pa.created"`, `"c2pa.opened"`, `"c2pa.placed"`, `"c2pa.color_adjustments"`, `"c2pa.filtered"` | +| `parameters` | No | Free-form object with action-specific data (including `ingredientIds` for linking ingredients, for instance) | +| `description` | No | Human-readable description of what happened | +| `digitalSourceType` | Sometimes, depending on action | URI describing the digital source type (typically for `c2pa.created`) | + +### Linking actions to ingredients + +When an action involves a specific ingredient, the ingredient and action need to be linked. This is done using `ingredientIds` in the action's parameters, which references a matching key on the ingredient. + +#### How `ingredientIds` resolution works + +The SDK matches each value in `ingredientIds` against ingredients using this priority: + +1. **`label`** on the ingredient (primary): if set and non-empty, this is used as the linking key +2. **`instance_id`** on the ingredient (fallback): used when `label` is absent or empty + +#### Linking with `label` + +The `label` field on an ingredient is the **primary** linking key. Set a `label` on the ingredient and reference it in the action's `ingredientIds`. The label can be any string: it acts as a linking key between the ingredient and the action. + +```cpp +c2pa::Context context; + +auto manifest_json = R"( +{ + "claim_generator_info": [{ "name": "an-application", "version": "1.0" }], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + }, + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["c2pa.ingredient.v3"] + } + } + ] + } + } + ] +} +)"; + +c2pa::Builder builder(context, manifest_json); + +// The label on the ingredient matches the value in ingredientIds +auto ingredient_json = R"( +{ + "title": "photo.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3" +} +)"; +builder.add_ingredient(ingredient_json, photo_path); + +builder.sign(source_path, output_path, signer); ``` -```mermaid -flowchart TD - subgraph IA["Ingredient Archive"] - direction TB - I1["Extracted manifest store from a signed asset"] - I2["Purpose: carry provenance of a source asset"] - I3["Contains real signatures from the original asset"] - end +##### Multiple ingredients need distinct identifying labels + +When linking multiple ingredients, each ingredient needs a unique label: + +```cpp +auto manifest_json = 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": ["c2pa.ingredient.v3_1"] + } + }, + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["c2pa.ingredient.v3_2"] + } + } + ] + } + } + ] +} +)"; + +c2pa::Builder builder(context, manifest_json); + +// parentOf ingredient linked to c2pa.opened +builder.add_ingredient(R"({ + "title": "original.jpg", + "format": "image/jpeg", + "relationship": "parentOf", + "label": "c2pa.ingredient.v3_1" +})", original_path); + +// componentOf ingredient linked to c2pa.placed +builder.add_ingredient(R"({ + "title": "overlay.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3_2" +})", overlay_path); + +builder.sign(source_path, output_path, signer); ``` -| | Builder archive | Ingredient archive | -|------------------------------------|----------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| -| **What it contains** | The full `Builder` state: manifest definition, resources, and ingredients (not yet signed) | The manifest store data from ingredients that were added to a `Builder` | -| **Purpose** | Persist a work-in-progress `Builder` so it can be resumed or signed later | Carry the provenance history of a source asset so it can be embedded as an ingredient in a new manifest | -| **Created by** | `builder.to_archive(stream)` | Extracted from a signed asset's `manifest_data` via `Reader` | -| **Read with** | `Builder::from_archive(stream)` or `builder.with_archive(stream)` | Passed to `builder.add_resource(id, stream)` alongside ingredient JSON | +> **Note:** The labels used for linking in the working store may not be the exact labels that appear in the signed manifest. They are indicators for the SDK to know which ingredient to link with which action. The SDK assigns final labels during signing. + +#### Linking with `instance_id` (fallback) + +When no `label` is set on an ingredient, the SDK matches `ingredientIds` against `instance_id`. + +```cpp +c2pa::Context context; + +// instance_id is used as identified for the ingredient to link, and needs to be unique +std::string instance_id = "xmp:iid:939a4c48-0dff-44ec-8f95-61f52b11618f"; + +json manifest_json = { + {"claim_generator_info", json::array({{{"name", "an-application"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions"}, + {"data", { + {"actions", json::array({ + { + {"action", "c2pa.opened"}, + {"parameters", { + {"ingredientIds", json::array({instance_id})} + }} + } + })} + }} + } + })} +}; + +c2pa::Builder builder(context, manifest_json.dump()); + +// No label set: instance_id is used as the linking key +json ingredient = { + {"title", "source_photo.jpg"}, + {"relationship", "parentOf"}, + {"instance_id", instance_id} +}; +builder.add_ingredient(ingredient.dump(), source_photo_path); + +builder.sign(source_path, output_path, signer); +``` + +> Note: The `instance_id` can be read back from the ingredient JSON after signing. + +#### After signing: reading linked ingredients back + +After signing, `ingredientIds` is gone. The action's `parameters.ingredients[]` contains hashed JUMBF URIs pointing to ingredient assertions. To match an action to its ingredient, extract the label from the URL: + +```cpp +auto reader = c2pa::Reader(context, signed_path); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto manifest = parsed["manifests"][active]; + +// Build a map: label -> ingredient +std::map label_to_ingredient; +for (auto& ing : manifest["ingredients"]) { + label_to_ingredient[ing["label"]] = ing; +} + +// Match each action to its ingredients by extracting labels from URLs +for (auto& assertion : manifest["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + for (auto& action : assertion["data"]["actions"]) { + if (action.contains("parameters") && + action["parameters"].contains("ingredients")) { + for (auto& ref : action["parameters"]["ingredients"]) { + std::string url = ref["url"]; + std::string label = url.substr(url.rfind('/') + 1); + auto& matched = label_to_ingredient[label]; + // Now the ingredient is available + } + } + } + } +} +``` + +#### `label` vs `instance_id`: when to use which? + +| Property | `label` | `instance_id` | +| -------- | ------- | ------------- | +| **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 | +| **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. + +## 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. + +### 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()`. + +An **ingredient archive** contains the manifest store data 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 to reuse as ingredient in other working stores. ### The ingredients catalog pattern -A usage example of ingredient archives is building an **ingredients catalog**: a collection of archived ingredients (with 1 ingredient per archive) that can be picked and chosen from when constructing a final manifest. Each archive in the catalog holds ingredients, and at build time select only the ones you need. +A usage example of ingredient archives is building an **ingredients catalog**: a collection of archived ingredients (with 1 ingredient per archive) that can be picked and chosen from when constructing a final manifest. Each archive in the catalog holds ingredients, and at build time the caller selects only the ones needed. ```mermaid flowchart TD @@ -249,7 +460,7 @@ flowchart TD // Read from a catalog of archived ingredients // verify_after_reading is not needed for newer versions of the SDK // c2pa::Context archive_ctx(R"({"verify": {"verify_after_reading": false}})"); -c2pa::Context archive_ctx(R"<>"); +c2pa::Context archive_ctx(R"<>"); // Open one archive from the catalog archive_stream.seekg(0); @@ -258,7 +469,7 @@ auto parsed = json::parse(reader.json()); std::string active = parsed["active_manifest"]; auto available_ingredients = parsed["manifests"][active]["ingredients"]; -// Pick only the ingredients you need +// Pick only the needed ingredients json selected = json::array(); for (auto& ingredient : available_ingredients) { if (ingredient["title"] == "photo_1.jpg" || ingredient["title"] == "logo.png") { @@ -268,7 +479,7 @@ for (auto& ingredient : available_ingredients) { // Create a new Builder with selected ingredients json manifest = json::parse(R"({ - "claim_generator_info": [{"name": "c2pa-cpp-docs", "version": "0.1.0"}] + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}] })"); manifest["ingredients"] = selected; c2pa::Builder builder(context, manifest.dump()); @@ -294,76 +505,177 @@ for (auto& ingredient : selected) { builder.sign(source_path, output_path, signer); ``` -### Extracting ingredients from a builder archive +### Overriding ingredient properties when adding from an archive + +When adding an ingredient (from an archive or from a file), the JSON passed to `add_ingredient()` can override properties like `title` and `relationship`. This is useful when reusing archived ingredients in a different context: + +```cpp +// Override title, relationship, and set a custom instance_id for tracking +json ingredient_override = { + {"title", "my-custom-title.jpg"}, + {"relationship", "parentOf"}, + {"instance_id", "my-tracking-id:asset-example-id"} +}; +builder.add_ingredient(ingredient_override.dump(), signed_asset_path); +``` + +The `title`, `relationship`, and `instance_id` fields in the provided JSON take priority. The library fills in everything else (thumbnail, manifest_data, format) automatically from the source asset. This works with any source: a signed asset, a `.c2pa` archive, or an unsigned file. + +### Using custom vendor parameters in actions + +The C2PA specification allows **vendor-namespaced parameters** on actions using reverse domain notation. These custom parameters survive signing and can be read back, making them useful for tagging actions with IDs that help with filtering. + +```cpp +auto manifest_json = R"( +{ + "claim_generator_info": [{ "name": "an-application", "version": "1.0" }], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/compositeCapture", + "parameters": { + "com.mycompany.tool": "my-editor", + "com.mycompany.session_id": "session-abc-123" + } + }, + { + "action": "c2pa.placed", + "description": "Placed an image", + "parameters": { + "com.mycompany.layer_id": "layer-42", + "ingredientIds": ["c2pa.ingredient.v3"] + } + } + ] + } + } + ] +} +)"; +``` + +After signing, these custom parameters appear alongside the standard fields: + +```json +{ + "action": "c2pa.placed", + "parameters": { + "com.mycompany.layer_id": "layer-42", + "ingredients": [{"url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"}] + } +} +``` + +Custom vendor parameters can be used to filter actions. For example, to find all actions related to a specific layer: + +```cpp +for (auto& action : actions) { + if (action.contains("parameters") && + action["parameters"].contains("com.mycompany.layer_id") && + action["parameters"]["com.mycompany.layer_id"] == "layer-42") { + // This action is related to layer-42 + } +} +``` + +> **Naming convention:** Vendor parameters must use reverse domain notation with period-separated components following the pattern `[a-zA-Z0-9][a-zA-Z0-9_-]*` (e.g., `com.mycompany.tool`, `net.example.session_id`). Some namespaces (eg. `c2pa` or `cawg`) may be reserved. -A Builder archive (or working store archive) can be read with `Reader` to filter its contents without modifying the original archive and use the selected filtered content to create a **new** Builder. This produces a new `Builder`: +### Extracting ingredients from a working store into archives + +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. ```mermaid flowchart TD - B1[Builder unsigned] -->|to_archive| AR[Archive.c2pa] - AR -->|"Reader(application/c2pa, stream) filtering, not modifying the archive"| JSON[JSON + binary resources] - JSON -->|"filter + create new Builder"| B2[New Builder] - B2 -->|sign| OUT[Output Asset] + subgraph Step1["Step 1: Build a working store with ingredients"] + IA["add_ingredient(A.jpg)"] --> B1[Builder] + IB["add_ingredient(B.jpg)"] --> B1 + B1 -->|"to_archive()"| AR["archive.c2pa"] + end + subgraph Step2["Step 2: Extract ingredients from archive"] + AR -->|"Reader(application/c2pa)"| RD[JSON + resources] + RD -->|"pick ingredients"| SEL[Selected ingredients] + end + subgraph Step3["Step 3: Reuse in a new Builder"] + SEL -->|"new Builder + add_resource()"| B2[New Builder] + B2 -->|sign| OUT[Signed Output] + end +``` + +**Step 1:** Build a working store and archive it: + +```cpp +c2pa::Context context; +c2pa::Builder builder(context, manifest_json); + +// Add ingredients to the working store +builder.add_ingredient(R"({"title": "A.jpg", "relationship": "componentOf"})", + path_to_A); +builder.add_ingredient(R"({"title": "B.jpg", "relationship": "componentOf"})", + path_to_B); + +// Save the working store as an archive +std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); +builder.to_archive(archive_stream); ``` +**Step 2:** Read the archive and extract ingredients: + ```cpp -// Read the archive. This does not modify the archive +// Read the archive (does not modify it) archive_stream.seekg(0); -c2pa::Reader reader(archive_ctx, "application/c2pa", archive_stream); +c2pa::Reader reader(context, "application/c2pa", archive_stream); auto parsed = json::parse(reader.json()); std::string active = parsed["active_manifest"]; auto ingredients = parsed["manifests"][active]["ingredients"]; +``` -// Create a new Builder with extracted ingredients -json new_manifest = json::parse(base_manifest_json); -new_manifest["ingredients"] = ingredients; - -c2pa::Builder builder(context, new_manifest.dump()); +**Step 3:** Create a new Builder with the extracted ingredients: -// Transfer binary resources +```cpp +// Pick the desired ingredients +json selected = json::array(); for (auto& ingredient : ingredients) { + if (ingredient["title"] == "A.jpg") { + selected.push_back(ingredient); + } +} + +// Create a new Builder with only the selected ingredients +json new_manifest = json::parse(base_manifest_json); +new_manifest["ingredients"] = selected; +c2pa::Builder new_builder(context, new_manifest.dump()); + +// Transfer binary resources for the selected ingredients +for (auto& ingredient : selected) { if (ingredient.contains("thumbnail")) { std::string id = ingredient["thumbnail"]["identifier"]; std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); reader.get_resource(id, stream); stream.seekg(0); - builder.add_resource(id, stream); + new_builder.add_resource(id, stream); } if (ingredient.contains("manifest_data")) { std::string id = ingredient["manifest_data"]["identifier"]; std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); reader.get_resource(id, stream); stream.seekg(0); - builder.add_resource(id, stream); + new_builder.add_resource(id, stream); } } -builder.sign(source_path, output_path, signer); +new_builder.sign(source_path, output_path, signer); ``` ### Merging multiple working stores -In some cases you may need to merge ingredients from multiple working stores (builder archives) or multiple working stores into a single `Builder`. This should be a **fallback strategy** as the recommended practice is to maintain a **single** active working store and reuse it by adding ingredients incrementally (which is where having archived ingredients catalogs can be helpful). Merging is available as a backup when you end up with multiple working stores that need to be consolidated. +In some cases it may be necessary to merge ingredients from multiple working stores (builder archives) or multiple working stores into a single `Builder`. This should be a **fallback strategy** as the recommended practice is to maintain a **single** active working store and reuse it by adding ingredients incrementally (which is where having archived ingredients catalogs can be helpful). Merging is available as a backup when multiple working stores need to be consolidated. When merging from multiple sources, resource identifier URIs can collide. One way to avoid collisions is to rename identifiers with a unique suffix: -```mermaid -flowchart TD - subgraph WS1["Working Store A"] - IX[Ingredient X thumb: T1] - end - subgraph WS2["Working Store B"] - IY[Ingredient Y thumb: T1] - end - IX -->|"keep as T1"| NB[New Builder] - IY -->|"rename to T1__1"| NB - NB -->|sign| OUT[Signed Output Asset] - style IY fill:#ff9,stroke:#cc0 - - NOTE["Prefer maintaining a single working store. Merge only as a fallback."] - style NOTE fill:#ffd,stroke:#cc0,stroke-dasharray: 5 5 -``` - ```cpp // Track used resource IDs to detect collisions std::set used_ids; @@ -399,13 +711,303 @@ for (auto& archive_stream : archives) { // Create a single new Builder with all merged ingredients json manifest = json::parse(R"({ - "claim_generator_info": [{"name": "c2pa-cpp-docs", "version": "0.1.0"}] + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}] })"); manifest["ingredients"] = all_ingredients; c2pa::Builder builder(context, manifest.dump()); builder.sign(source_path, output_path, signer); ``` +## Retrieving actions from a working store + +Use `Reader` to read actions back from an asset, or an archived Builder. Actions are stored in the `c2pa.actions.v2` assertion inside the manifest. + +### Reading actions + +```cpp +c2pa::Context context; +c2pa::Reader reader(context, "image/jpeg", source_stream); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto assertions = parsed["manifests"][active]["assertions"]; + +// Find the actions assertion +for (auto& assertion : assertions) { + if (assertion["label"] == "c2pa.actions.v2") { + auto actions = assertion["data"]["actions"]; + for (auto& action : actions) { + std::cout << "Action: " << action["action"] << std::endl; + if (action.contains("description")) { + std::cout << " Description: " << action["description"] << std::endl; + } + } + } +} +``` + +### Reading actions from an archive + +The same approach works for archived Builders. The format parameter `"application/c2pa"` tells the `Reader` to read from an archive stream instead of an asset: + +```cpp +c2pa::Context context; + +// Read from an archive instead of an asset +std::ifstream archive_file("builder_archive.c2pa", std::ios::binary); +c2pa::Reader reader(context, "application/c2pa", archive_file); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto assertions = parsed["manifests"][active]["assertions"]; + +// Find the actions assertion -- same as reading from an asset +for (auto& assertion : assertions) { + if (assertion["label"] == "c2pa.actions.v2") { + auto actions = assertion["data"]["actions"]; + for (auto& action : actions) { + std::cout << "Action: " << action["action"] << std::endl; + if (action.contains("description")) { + std::cout << " Description: " << action["description"] << std::endl; + } + } + } +} +``` + +### Understanding the manifest tree + +The `Reader` returns a manifest store, which is a dictionary of manifests. But conceptually, it represents a tree (graph without cycles): each manifest may have ingredients and assertions, and each ingredient can carry its own manifest store (in its `manifest_data` field), which in turn can have its own ingredients, actions, assertions, and so on recursively. + +```mermaid +flowchart TD + subgraph Store["Manifest Store"] + M1["Active Manifest\n- assertions (including c2pa.actions.v2), ingredients"] + M2["Ingredient A's manifest with its own c2pa.actions.v2 and its own ingredients"] + M3["Ingredient B's manifest with its own c2pa.actions.v2"] + end + M1 -->|"ingredient A has manifest_data"| M2 + M1 -->|"ingredient B has manifest_data"| M3 + M2 -->|"may have its own ingredients..."| M4["...deeper in the tree"] +``` + +The `reader.json()` returns all manifests in a flattened `"manifests"` dictionary, keyed by their label (a URN like `contentauth:urn:uuid:...`). The `"active_manifest"` key indicates which one is the top of the tree. + +Each manifest in the tree has its assertions and ingredients. Walking the tree reveals the full provenance chain: what each actor did at each step, including what actions were performed and what ingredients were used. + +**To walk the tree and find actions at each level:** + +```cpp +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto active_manifest = parsed["manifests"][active]; + +// Read the active manifest's actions +for (auto& assertion : active_manifest["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + std::cout << "Active manifest actions:" << std::endl; + for (auto& action : assertion["data"]["actions"]) { + std::cout << " " << action["action"].get() << std::endl; + } + } +} + +// Walk into each ingredient's manifest +for (auto& ingredient : active_manifest["ingredients"]) { + std::cout << "Ingredient: " << ingredient["title"].get() << std::endl; + + // If this ingredient has its own manifest (it was a signed asset), + // its manifest label is in "active_manifest" + if (ingredient.contains("active_manifest")) { + std::string ing_manifest_label = ingredient["active_manifest"]; + if (parsed["manifests"].contains(ing_manifest_label)) { + auto ing_manifest = parsed["manifests"][ing_manifest_label]; + + // This ingredient's manifest has its own actions + for (auto& assertion : ing_manifest["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + std::cout << " Ingredient's actions:" << std::endl; + for (auto& action : assertion["data"]["actions"]) { + std::cout << " " << action["action"].get() << std::endl; + } + } + } + + // And its own ingredients (deeper in the tree)... + } + } else { + // This ingredient has no manifest of its own (it was an unsigned asset). + // It still has a title, format, and relationship, but no manifest_data, + // no actions, and no deeper provenance chain. + std::cout << " (no content credentials)" << std::endl; + } +} +``` + +## Filtering actions (keeping some, removing others) + +Since there is no `remove()` method, the way to remove actions is to **read, pick the ones to keep, create a new Builder**. + +```mermaid +flowchart TD + SA["Signed Asset with 3 actions: opened, placed, filtered"] -->|Reader| JSON[Parse JSON] + JSON -->|"Keep only opened + placed"| FILT[Filtered actions] + FILT -->|"New Builder with 2 actions"| NB[New Builder] + NB -->|sign| OUT["New with 2 actions only: opened, placed"] +``` + +### Basic action filtering + +When filtering, remember that the first action must remain `c2pa.created` or `c2pa.opened` for the manifest to be valid. If the first action is removed, a new one must be added. + +```cpp +c2pa::Context context; +c2pa::Reader reader(context, "image/jpeg", source_stream); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto manifest = parsed["manifests"][active]; + +// Filter actions: keep c2pa.created/c2pa.opened (mandatory) and c2pa.placed, drop the rest +json kept_actions = json::array(); +for (auto& assertion : manifest["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + for (auto& action : assertion["data"]["actions"]) { + std::string action_type = action["action"]; + if (action_type == "c2pa.created" || action_type == "c2pa.opened" || + action_type == "c2pa.placed") { + kept_actions.push_back(action); + } + // Skip c2pa.filtered, c2pa.color_adjustments, etc. + } + } +} + +// Build a new manifest with only the kept actions +json new_manifest = json::parse(R"({ + "claim_generator_info": [{"name": "an-application", "version": "1.0"}] +})"); + +if (!kept_actions.empty()) { + new_manifest["assertions"] = json::array({ + { + {"label", "c2pa.actions"}, + {"data", {{"actions", kept_actions}}} + } + }); +} + +c2pa::Builder builder(context, new_manifest.dump()); +builder.sign(source_path, output_path, signer); +``` + +### Filtering actions that reference ingredients + +Some actions reference ingredients (via `parameters.ingredients[].url` after signing). If keeping an action that references an ingredient, **the corresponding ingredient and its binary resources must also be kept**. If an ingredient is dropped, any actions that reference it must also be dropped (or updated). + +#### Understanding the `c2pa.opened` action + +The `c2pa.opened` action is special because it must be the first action and it references the asset that was opened (the `parentOf` ingredient). When filtering: + +- **Always keep `c2pa.opened` or `c2pa.created`**: it is required for a valid manifest +- **Keep the ingredient it references**: the `parentOf` ingredient linked via its `parameters.ingredients[].url` +- Removing the ingredient that `c2pa.opened` points to will make the manifest invalid + +#### Understanding the `c2pa.placed` action + +The `c2pa.placed` action references a `componentOf` ingredient that was composited into the asset. When filtering: + +- If keeping `c2pa.placed`, keep the ingredient it references +- If the ingredient is dropped, also drop the `c2pa.placed` action +- If `c2pa.placed` is not required: it can safely be removed (and the ingredient it references, if it is the only reference) + +#### Full example: filtering with linked ingredients + +```cpp +c2pa::Context context; +c2pa::Reader reader(context, "image/jpeg", source_stream); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto manifest = parsed["manifests"][active]; + +// Filter actions and track which ingredients are needed +json kept_actions = json::array(); +std::set needed_ingredient_labels; + +for (auto& assertion : manifest["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + for (auto& action : assertion["data"]["actions"]) { + std::string action_type = action["action"]; + + // Always keep c2pa.opened/c2pa.created (required for valid manifest) + // Keep c2pa.placed (optional -- kept here as an example) + // Drop everything else + bool keep = (action_type == "c2pa.opened" || + action_type == "c2pa.created" || + action_type == "c2pa.placed"); + + if (keep) { + kept_actions.push_back(action); + + // Track which ingredients this action needs + if (action.contains("parameters") && + action["parameters"].contains("ingredients")) { + for (auto& ing_ref : action["parameters"]["ingredients"]) { + std::string url = ing_ref["url"]; + std::string label = url.substr(url.rfind('/') + 1); + needed_ingredient_labels.insert(label); + } + } + } + } + } +} + +// Keep only the ingredients that are referenced by kept actions +json kept_ingredients = json::array(); +for (auto& ingredient : manifest["ingredients"]) { + if (ingredient.contains("label") && + needed_ingredient_labels.count(ingredient["label"])) { + kept_ingredients.push_back(ingredient); + } +} + +// Build the new manifest with filtered actions and matching ingredients +json new_manifest = json::parse(R"({ + "claim_generator_info": [{"name": "an-application", "version": "1.0"}] +})"); +new_manifest["ingredients"] = kept_ingredients; +if (!kept_actions.empty()) { + new_manifest["assertions"] = json::array({ + { + {"label", "c2pa.actions"}, + {"data", {{"actions", kept_actions}}} + } + }); +} + +c2pa::Builder builder(context, new_manifest.dump()); + +// Transfer binary resources for kept ingredients +for (auto& ingredient : kept_ingredients) { + if (ingredient.contains("thumbnail")) { + std::string id = ingredient["thumbnail"]["identifier"]; + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, stream); + stream.seekg(0); + builder.add_resource(id, stream); + } + if (ingredient.contains("manifest_data")) { + std::string id = ingredient["manifest_data"]["identifier"]; + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, stream); + stream.seekg(0); + builder.add_resource(id, stream); + } +} + +builder.sign(source_path, output_path, signer); +``` + +> **Note:** When copying ingredient JSON objects from a reader, they keep their `label` field. Since the action URLs reference ingredients by label, the links resolve correctly as long as ingredients are not renamed or reindexed. If ingredients are re-added via `add_ingredient()` (which generates new labels), the action URLs will also need to be updated. + ## Controlling manifest embedding By default, `sign()` embeds the manifest directly inside the output asset file. @@ -455,16 +1057,17 @@ flowchart TD subgraph Step2["Step 2: FILTER"] RD --> FI[Parse JSON] FI --> F1[Pick ingredients to keep] - FI --> F2[Pick assertions to keep] - FI --> F3[Extract resource IDs for kept items] - F1 & F2 & F3 --> FM[Build new manifest JSON with only filtered items] + FI --> F3[Pick actions to keep] + FI --> F4[Ensure kept actions' ingredients are also kept] + FI --> F5["Ensure c2pa.created/opened is still the first action"] + F1 & F3 & F4 & F5 --> FM[Build new manifest JSON with only filtered items] end subgraph Step3["Step 3: BUILD new Builder"] FM --> BLD["new Builder with context and filtered_json"] BLD --> AR[".add_resource for each kept binary resource"] - AR --> AI[".add_ingredient to add original as parent"] - AI --> AA[".add_action to record what was done"] + AR --> AI[".add_ingredient to add original as parent (optional)"] + AI --> AA[".add_action to record new actions (optional)"] end subgraph Step4["Step 4: SIGN"] @@ -477,9 +1080,9 @@ flowchart TD This section answers questions about when to use each API and how they work together. -### When should I use a `Reader`? +### When to use a `Reader` -**Use a `Reader` when you only need to inspect or extract data without creating a new manifest.** +**Use a `Reader` when the goal is only to inspect or extract data without creating a new manifest.** - Validating whether an asset has C2PA credentials - Displaying provenance information to a user @@ -495,13 +1098,13 @@ reader.get_resource(thumb_id, stream); // extract a thumbnail The `Reader` is read-only. It never modifies the source asset. -### When should I use a `Builder`? +### When to use a `Builder` -**Use a `Builder` when you are creating a manifest from scratch on an asset that has no existing C2PA data, or when you intentionally want to start with a clean slate.** +**Use a `Builder` when creating a manifest from scratch on an asset that has no existing C2PA data, or when intentionally starting with a clean slate.** - Signing a brand-new asset for the first time - Adding C2PA credentials to an unsigned asset -- Creating a manifest where you define all content yourself +- Creating a manifest with all content defined from scratch ```cpp c2pa::Builder builder(context, manifest_json); @@ -511,12 +1114,13 @@ builder.sign(source_path, output_path, signer); Every call to the `Builder` constructor or `Builder::from_archive()` creates a new `Builder`. There is no way to modify an existing signed manifest directly. -### When should I use both `Reader` and `Builder` together? +### When to use both `Reader` and `Builder` together -**Use both when you need to filter content from an existing manifest into a new one. The `Reader` extracts data, your code filters it, and a new `Builder` receives only the selected parts.** +**Use both when filtering content from an existing manifest into a new one. The `Reader` extracts data, application code filters it, and a new `Builder` receives only the selected parts.** - Filtering specific ingredients from a manifest - Dropping specific assertions while keeping others +- Filtering actions (keeping some, removing others) - Merging ingredients from multiple signed assets or archives - Extracting content from an ingredients catalog - Re-signing with different settings while keeping some original content @@ -527,7 +1131,7 @@ c2pa::Reader reader(context, "signed.jpg"); auto parsed = json::parse(reader.json()); // Filter what to keep -auto kept = filter(parsed); // your filtering logic +auto kept = filter(parsed); // application-specific filtering logic // Create a new Builder with only the filtered content c2pa::Builder builder(context, kept.dump()); @@ -539,18 +1143,18 @@ builder.sign(source, output, signer); | Approach | What it does | When to use | |----------|-------------|-------------| -| `add_ingredient(json, path)` | Reads the source asset, extracts its manifest store automatically, generates a thumbnail | Adding a signed asset as an ingredient; the library handles everything | -| Inject via `with_definition()` + `add_resource()` | When providing the ingredient JSON and all binary resources manually | Reconstructing from an archive or merging from multiple readers, where you already have the data extracted | +| `add_ingredient(json, path)` | Reads the source (a signed asset, an unsigned file, or a `.c2pa` archive), extracts its manifest store automatically, generates a thumbnail | Adding an ingredient where the library should handle extraction. Works with ingredient catalog archives too: pass the archive path and the library extracts the manifest data | +| Inject via `with_definition()` + `add_resource()` | Accepts the ingredient JSON and all binary resources provided manually | Reconstructing from a reader or merging from multiple readers, where the data has already been extracted | -### When should I use archives? +### When to use archives -There are two distinct archive concepts: +There are two distinct archive concepts (see also [Working stores](https://opensource.contentauthenticity.org/docs/rust-sdk/docs/working-stores)): -**Builder archives (Working stores archives)** (`to_archive` / `from_archive` / `with_archive`) serialize the full `Builder` state (manifest definition, resources, ingredients) so it can be resumed or signed later, possibly on a different machine or in a different process. The archive is not yet signed. Use builder archives when: +**Builder archives (Working store archives)** (`to_archive` / `from_archive` / `with_archive`) serialize the full `Builder` state (manifest definition, resources, ingredients) so it can be resumed or signed later, possibly on a different machine or in a different process. The archive is not yet signed. Use builder archives when: - Signing must happen on a different machine (e.g., an HSM server) -- You want to checkpoint work-in-progress before signing -- You need to transmit a `Builder` state across a network boundary +- Checkpointing work-in-progress before signing +- Transmitting a `Builder` state across a network boundary **Ingredient archives** contain the manifest store data (`.c2pa` binary) from ingredients that were added to a `Builder`. When a signed asset is added as an ingredient via `add_ingredient()`, the library extracts and stores its manifest store as `manifest_data` within the ingredient record. When the `Builder` is then serialized via `to_archive()`, these ingredient manifest stores are included. Use ingredient archives when: @@ -558,42 +1162,34 @@ There are two distinct archive concepts: - Preserving provenance history from source assets - Transferring ingredient data between `Reader` and `Builder` -Key consideration for builder archives: `from_archive()` creates a new `Builder` with **default** context settings. If you need specific settings (e.g., thumbnails disabled), use `with_archive()` on a `Builder` that already has the desired context: +Key consideration for builder archives: `from_archive()` creates a new `Builder` with **default** context settings. If specific settings are needed (e.g., thumbnails disabled), use `with_archive()` on a `Builder` that already has the desired context: ```cpp -// Preserves your context settings +// Preserves the caller's context settings c2pa::Builder builder(my_context); builder.with_archive(archive_stream); builder.sign(source, output, signer); ``` -### Can I modify a manifest in place? +### Can a manifest be modified in place? -**No.** C2PA manifests are cryptographically signed. Any modification invalidates the signature. The only way to "modify" a manifest is to create a new `Builder` with the changes you want and sign it. This is by design: it ensures the integrity of the provenance chain. +**No.** C2PA manifests are cryptographically signed. Any modification invalidates the signature. The only way to "modify" a manifest is to create a new `Builder` with the desired changes and sign it. This is by design: it ensures the integrity of the provenance chain. -### What happens to the provenance chain when I rebuild? - -When you create a new manifest, the chain is preserved once you add the original asset as an ingredient. The ingredient carries the original's manifest data, so validators can trace the full history: - -```mermaid -flowchart RL - C[Filtered manifest] -->|ingredient| B[Edited manifest] - B -->|ingredient| A[Original manifest] -``` +### What happens to the provenance chain when rebuilding a working store? -If you don't add the original as an ingredient, the provenance chain is broken: the new manifest has no link to the original. This might be intentional (starting fresh) or a mistake (losing provenance). +When creating a new manifest, the chain is preserved once the original asset is added as an ingredient. The ingredient carries the original's manifest data, so validators can trace the full history. If the original is not added as an ingredient, the provenance chain is broken: the new manifest has no link to the original. This might be intentional (starting fresh) or a mistake (losing provenance). ### Quick reference decision tree ```mermaid flowchart TD - Q1{Need to read an\nexisting manifest?} - Q1 -->|No| USE_B[Use Builder alone new manifest from scratch] + Q1{Need to read an existing manifest?} + Q1 -->|No| USE_B["Use Builder alone (new manifest from scratch)"] Q1 -->|Yes| Q2{Need to create a new/modified manifest?} - Q2 -->|No| USE_R[Use Reader alone inspect/extract only] + Q2 -->|No| USE_R["Use Reader alone (inspect/extract only)"] Q2 -->|Yes| USE_BR[Use both Reader + Builder] - USE_BR --> Q3{What to keep?} - Q3 -->|Everything| P1["add_ingredient() with original asset"] - Q3 -->|Some parts| P2["Read JSON, filter, create new Builder"] - Q3 -->|Nothing| P3["New Builder alone fresh manifest"] + USE_BR --> Q3{What to keep from the existing manifest?} + Q3 -->|Everything| P1["add_ingredient() with original asset or archive path"] + Q3 -->|Some parts| P2["1. Read: reader.json() + get_resource() 2. Filter: pick ingredients & actions to keep 3. Build: new Builder with filtered JSON 4. Transfer: .add_resource for kept binaries 5. Sign: builder.sign()"] + Q3 -->|Nothing| P3["New Builder alone (fresh manifest, no prior provenance)"] ``` diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index 4418cde4..f2bedb87 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -3772,3 +3772,749 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchives) { // Reset settings c2pa::load_settings(R"({"verify": {"verify_after_reading": true}})", "json"); } + +TEST_F(BuilderTest, AddMultipleActions) +{ + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto context = c2pa::Context(); + auto builder = c2pa::Builder(context, manifest); + + builder.add_action(R"({ + "action": "c2pa.color_adjustments", + "parameters": { "name": "brightnesscontrast" } + })"); + + builder.add_action(R"({ + "action": "c2pa.filtered", + "parameters": { "name": "A filter" }, + "description": "Filtering applied" + })"); + + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + auto output_path = get_temp_path("multiple_actions.jpg"); + + std::vector manifest_data; + ASSERT_NO_THROW(manifest_data = builder.sign(source_path, output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + string active = parsed["active_manifest"]; + + bool found_color = false, found_filter = false; + for (auto& assertion : parsed["manifests"][active]["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] == "c2pa.color_adjustments") found_color = true; + if (action["action"] == "c2pa.filtered") { + found_filter = true; + EXPECT_EQ(action["description"], "Filtering applied"); + } + } + } + } + EXPECT_TRUE(found_color) << "c2pa.color_adjustments not found"; + EXPECT_TRUE(found_filter) << "c2pa.filtered not found"; +} + +TEST_F(BuilderTest, LinkActionToIngredient) +{ + auto context = c2pa::Context(); + + std::string instance_id = "xmp:iid:939a4c48-0dff-44ec-8f95-61f52b11618f"; + + json manifest_json = { + {"claim_generator_info", json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions"}, + {"data", { + {"actions", json::array({ + { + {"action", "c2pa.created"}, + {"digitalSourceType", "http://c2pa.org/digitalsourcetype/compositeCapture"} + }, + { + {"action", "c2pa.placed"}, + {"description", "Placed image into composition"}, + {"parameters", { + {"ingredientIds", json::array({instance_id})} + }} + } + })} + }} + } + })} + }; + + auto builder = c2pa::Builder(context, manifest_json.dump()); + + json ingredient = { + {"title", "source_asset.jpg"}, + {"relationship", "componentOf"}, + {"instance_id", instance_id} + }; + builder.add_ingredient(ingredient.dump(), c2pa_test::get_fixture_path("A.jpg")); + + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + auto output_path = get_temp_path("link_action.jpg"); + + std::vector manifest_data; + ASSERT_NO_THROW(manifest_data = builder.sign(source_path, output_path, signer)); + + // Read back and verify the ingredientIds were resolved to JUMBF URLs + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + string active = parsed["active_manifest"]; + + bool found_linked_action = false; + for (auto& assertion : parsed["manifests"][active]["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] == "c2pa.placed" && + action.contains("parameters") && + action["parameters"].contains("ingredients")) { + auto& ings = action["parameters"]["ingredients"]; + ASSERT_GE(ings.size(), 1u); + EXPECT_TRUE(ings[0].contains("url")); + // URL should be a JUMBF path + std::string url = ings[0]["url"]; + EXPECT_TRUE(url.find("c2pa.ingredient") != std::string::npos) + << "URL should reference an ingredient assertion, got: " << url; + found_linked_action = true; + } + } + } + } + EXPECT_TRUE(found_linked_action) << "Linked c2pa.placed action not found"; +} + +TEST_F(BuilderTest, ExtractIngredientsFromArchiveAndReuse) +{ + auto context = c2pa::Context(); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // Step 1: Build a working store with ingredients and archive it + auto builder = c2pa::Builder(context, manifest); + builder.add_ingredient(R"({"title": "A.jpg", "relationship": "componentOf"})", + c2pa_test::get_fixture_path("A.jpg")); + builder.add_ingredient(R"({"title": "C.jpg", "relationship": "componentOf"})", + c2pa_test::get_fixture_path("C.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(builder.to_archive(archive_stream)); + + // Step 2: Read the archive and extract only one of the 2 ingredients + archive_stream.seekg(0); + c2pa::Reader reader(context, "application/c2pa", archive_stream); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto ingredients = parsed["manifests"][active]["ingredients"]; + + ASSERT_GE(ingredients.size(), 2u) << "Archive should have 2 ingredients"; + + // Pick only ingredient with title A.jpg (this is one way of filtering) + json selected = json::array(); + for (auto& ingredient : ingredients) { + if (ingredient["title"] == "A.jpg") { + selected.push_back(ingredient); + } + } + + // Step 3: Create a new Builder with only selected ingredients + json new_manifest = json::parse(manifest); + new_manifest["ingredients"] = selected; + auto new_builder = c2pa::Builder(context, new_manifest.dump()); + + // Transfer binary resources + for (auto& ingredient : selected) { + if (ingredient.contains("thumbnail") && ingredient["thumbnail"].contains("identifier")) { + std::string id = ingredient["thumbnail"]["identifier"]; + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, stream); + stream.seekg(0); + new_builder.add_resource(id, stream); + } + if (ingredient.contains("manifest_data") && + ingredient["manifest_data"].is_object() && + ingredient["manifest_data"].contains("identifier")) { + std::string id = ingredient["manifest_data"]["identifier"]; + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, stream); + stream.seekg(0); + new_builder.add_resource(id, stream); + } + } + + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + auto output_path = get_temp_path("extracted_ingredient.jpg"); + + std::vector manifest_data; + ASSERT_NO_THROW(manifest_data = new_builder.sign(source_path, output_path, signer)); + + // Verify the output has only 1 ingredient + auto out_reader = c2pa::Reader(context, output_path); + auto out_parsed = json::parse(out_reader.json()); + std::string out_active = out_parsed["active_manifest"]; + auto out_ingredients = out_parsed["manifests"][out_active]["ingredients"]; + EXPECT_EQ(out_ingredients.size(), 1u) << "Output should have exactly 1 ingredient"; + EXPECT_EQ(out_ingredients[0]["title"], "A.jpg"); +} + +TEST_F(BuilderTest, FilterActionsWithLinkedIngredients) +{ + // When filtering, we keep c2pa.created (required by spec) + // and c2pa.placed (wanted) + its ingredient + auto context = c2pa::Context(); + + std::string instance_id = "xmp:iid:abcd1234-ef56-7890-abcd-ef1234567890"; + + // Build a manifest with c2pa.created (spec mandatory), + // c2pa.placed (linked), + // and c2pa.filtered (to be dropped) + json manifest_json = { + {"claim_generator_info", json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions"}, + {"data", { + {"actions", json::array({ + { + {"action", "c2pa.created"}, + {"digitalSourceType", "http://c2pa.org/digitalsourcetype/compositeCapture"} + }, + { + {"action", "c2pa.placed"}, + {"description", "Placed an image"}, + {"parameters", { + {"ingredientIds", json::array({instance_id})} + }} + }, + { + {"action", "c2pa.filtered"}, + {"parameters", {{"name", "blur"}}} + } + })} + }} + } + })} + }; + + auto builder = c2pa::Builder(context, manifest_json.dump()); + + json ingredient = { + {"title", "asset.jpg"}, + {"relationship", "componentOf"}, + {"instance_id", instance_id} + }; + builder.add_ingredient(ingredient.dump(), c2pa_test::get_fixture_path("A.jpg")); + + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + auto signed_path = get_temp_path("linked_source.jpg"); + + ASSERT_NO_THROW(builder.sign(source_path, signed_path, signer)); + + // Read the signed asset and filter: + // Keep c2pa.created (required), + // keep c2pa.placed (wanted with ingredient), + // drop c2pa.filtered + auto reader = c2pa::Reader(context, signed_path); + auto parsed = json::parse(reader.json()); + string active = parsed["active_manifest"]; + auto source_manifest = parsed["manifests"][active]; + + // Filter actions and track needed ingredients + json kept_actions = json::array(); + std::set needed_labels; + + for (auto& assertion : source_manifest["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + for (auto& action : assertion["data"]["actions"]) { + std::string action_type = action["action"]; + // Always keep required actions (c2pa.created / c2pa.opened) + if (action_type == "c2pa.created" || action_type == "c2pa.opened") { + kept_actions.push_back(action); + } + // Keep c2pa.placed (wanted) and track its linked ingredients + else if (action_type == "c2pa.placed") { + kept_actions.push_back(action); + if (action.contains("parameters") && + action["parameters"].contains("ingredients")) { + for (auto& ref : action["parameters"]["ingredients"]) { + std::string url = ref["url"]; + std::string label = url.substr(url.rfind('/') + 1); + needed_labels.insert(label); + } + } + } + // Drop everything else (c2pa.filtered, etc.) + } + } + } + + ASSERT_GE(kept_actions.size(), 2u) << "Should have kept c2pa.created + c2pa.placed"; + ASSERT_FALSE(needed_labels.empty()) << "c2pa.placed should reference at least one ingredient"; + + // Keep only ingredients referenced by kept actions + json kept_ingredients = json::array(); + for (auto& ing : source_manifest["ingredients"]) { + if (ing.contains("label") && needed_labels.count(ing["label"])) { + kept_ingredients.push_back(ing); + } + } + ASSERT_FALSE(kept_ingredients.empty()) << "Should have kept at least one ingredient"; + + // Build the new manifest + json new_manifest = json::parse(R"({ + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}] + })"); + new_manifest["ingredients"] = kept_ingredients; + new_manifest["assertions"] = json::array({ + { + {"label", "c2pa.actions"}, + {"data", {{"actions", kept_actions}}} + } + }); + + auto new_builder = c2pa::Builder(context, new_manifest.dump()); + + // Transfer binary resources for kept ingredients + for (auto& ing : kept_ingredients) { + if (ing.contains("thumbnail") && ing["thumbnail"].contains("identifier")) { + std::string id = ing["thumbnail"]["identifier"]; + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, stream); + stream.seekg(0); + new_builder.add_resource(id, stream); + } + if (ing.contains("manifest_data") && + ing["manifest_data"].is_object() && + ing["manifest_data"].contains("identifier")) { + std::string id = ing["manifest_data"]["identifier"]; + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, stream); + stream.seekg(0); + new_builder.add_resource(id, stream); + } + } + + auto output_path = get_temp_path("filtered_linked.jpg"); + ASSERT_NO_THROW(new_builder.sign(source_path, output_path, signer)); + + // Verify: output has c2pa.created + c2pa.placed but NOT c2pa.filtered + auto out_reader = c2pa::Reader(context, output_path); + auto out_parsed = json::parse(out_reader.json()); + string out_active = out_parsed["active_manifest"]; + + bool found_created = false, found_placed = false; + for (auto& assertion : out_parsed["manifests"][out_active]["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] == "c2pa.created") found_created = true; + if (action["action"] == "c2pa.placed") found_placed = true; + EXPECT_NE(action["action"], "c2pa.filtered") + << "c2pa.filtered should have been filtered out"; + } + } + } + EXPECT_TRUE(found_created) << "c2pa.created must be present (required for valid v2)"; + EXPECT_TRUE(found_placed) << "c2pa.placed should be in the output"; + + // Verify the output has the linked ingredient + auto out_ingredients = out_parsed["manifests"][out_active]["ingredients"]; + EXPECT_GE(out_ingredients.size(), 1u) << "Should have at least 1 ingredient"; +} + +TEST_F(BuilderTest, WalkTree) +{ + auto context = c2pa::Context(); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + + // Layer 1: Create a new asset with c2pa.created + c2pa.color_adjustments + auto builder1 = c2pa::Builder(context, manifest); + builder1.add_action(R"({"action": "c2pa.color_adjustments", "parameters": {"name": "contrast"}})"); + auto signed1_path = get_temp_path("tree_layer1.jpg"); + ASSERT_NO_THROW(builder1.sign(source_path, signed1_path, signer)); + + // Layer 2: Open layer1 (c2pa.opened + parentOf ingredient) and add c2pa.filtered + std::string instance_id = "tree-layer1"; + json layer2_manifest = { + {"claim_generator_info", json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions"}, + {"data", { + {"actions", json::array({ + { + {"action", "c2pa.opened"}, + {"parameters", { + {"ingredientIds", json::array({instance_id})} + }} + } + })} + }} + } + })} + }; + auto builder2 = c2pa::Builder(context, layer2_manifest.dump()); + builder2.add_action(R"({"action": "c2pa.filtered", "parameters": {"name": "sharpen"}})"); + builder2.add_ingredient( + json({{"title", "layer1.jpg"}, {"relationship", "parentOf"}, {"instance_id", instance_id}}).dump(), + signed1_path); + auto signed2_path = get_temp_path("tree_layer2.jpg"); + ASSERT_NO_THROW(builder2.sign(source_path, signed2_path, signer)); + + // Read the final asset and walk the tree + auto reader = c2pa::Reader(context, signed2_path); + auto parsed = json::parse(reader.json()); + string active = parsed["active_manifest"]; + auto active_manifest = parsed["manifests"][active]; + + // The active manifest should have c2pa.opened and c2pa.filtered + bool found_opened = false, found_filtered = false; + for (auto& assertion : active_manifest["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] == "c2pa.opened") found_opened = true; + if (action["action"] == "c2pa.filtered") found_filtered = true; + } + } + } + EXPECT_TRUE(found_opened) << "Active manifest should have c2pa.opened"; + EXPECT_TRUE(found_filtered) << "Active manifest should have c2pa.filtered"; + + // Walk into the ingredient's manifest (the tree) + ASSERT_TRUE(active_manifest.contains("ingredients")); + ASSERT_FALSE(active_manifest["ingredients"].empty()); + + bool found_ingredient_with_manifest = false; + for (auto& ingredient : active_manifest["ingredients"]) { + if (ingredient.contains("active_manifest")) { + found_ingredient_with_manifest = true; + std::string ing_label = ingredient["active_manifest"]; + + // The ingredient's manifest should be in the flat manifests dictionary + ASSERT_TRUE(parsed["manifests"].contains(ing_label)) + << "Ingredient manifest should be in the manifests dictionary"; + + auto ing_manifest = parsed["manifests"][ing_label]; + + // The ingredient's manifest should have c2pa.created and c2pa.color_adjustments + bool found_created = false, found_contrast = false; + for (auto& assertion : ing_manifest["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] == "c2pa.created") found_created = true; + if (action["action"] == "c2pa.color_adjustments") { + found_contrast = true; + EXPECT_EQ(action["parameters"]["name"], "contrast"); + } + } + } + } + EXPECT_TRUE(found_created) + << "Ingredient manifest should have c2pa.created from layer 1"; + EXPECT_TRUE(found_contrast) + << "Ingredient manifest should have c2pa.color_adjustments from layer 1"; + } + } + EXPECT_TRUE(found_ingredient_with_manifest) + << "Should have at least one ingredient with its own manifest (tree structure)"; +} + +TEST_F(BuilderTest, AddIngredientsUsingLabelParentOfComponentOf) +{ + auto context = c2pa::Context(); + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + + // The labels used for linking may not be the ones that will be in the + // manifest. They are just indicators on which ingredient to link with what. + auto manifest_json = R"( + { + "claim_generator_info": [{ "name": "TestAddIngredientsUsingLabelParentOfComponentOf", "version": "0.1" }], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": { + "ingredientIds": ["c2pa.ingredient.v3_1"] + } + }, + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["c2pa.ingredient.v3_2"] + } + } + ] + } + } + ] + } + )"; + + auto builder = c2pa::Builder(context, manifest_json); + + auto ingredient_json_1 = R"( + { + "title": "C.jpg", + "format": "image/jpeg", + "relationship": "parentOf", + "label": "c2pa.ingredient.v3_1" + } + )"; + + auto ingredient_json_2 = R"( + { + "title": "A.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3_2" + } + )"; + + builder.add_ingredient(ingredient_json_1, c2pa_test::get_fixture_path("C.jpg")); + builder.add_ingredient(ingredient_json_2, c2pa_test::get_fixture_path("A.jpg")); + + auto output_path = get_temp_path("signed_labelled_ingredient_3.jpg"); + ASSERT_NO_THROW(builder.sign(source_path, output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto reader_json = reader.json(); + ASSERT_TRUE(reader_json.find("TestAddIngredientsUsingLabelParentOfComponentOf") != std::string::npos); + + // Verify both ingredients are present with correct relationships + auto parsed = json::parse(reader_json); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_GE(ingredients.size(), 2u); + + // Find each ingredient by title and check relationship + bool found_parent = false, found_component = false; + for (auto& ing : ingredients) { + if (ing["title"] == "C.jpg") { + EXPECT_EQ(ing["relationship"], "parentOf"); + found_parent = true; + } + if (ing["title"] == "A.jpg") { + EXPECT_EQ(ing["relationship"], "componentOf"); + found_component = true; + } + } + EXPECT_TRUE(found_parent) << "Should have parentOf ingredient (C.jpg)"; + EXPECT_TRUE(found_component) << "Should have componentOf ingredient (A.jpg)"; + + // Verify both actions resolved their ingredientIds + for (auto& assertion : parsed["manifests"][active]["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] == "c2pa.opened" || action["action"] == "c2pa.placed") { + ASSERT_TRUE(action.contains("parameters")); + ASSERT_TRUE(action["parameters"].contains("ingredients")) + << action["action"].get() + << " should have resolved ingredientIds to ingredients[] URLs"; + } + } + } + } +} + +TEST_F(BuilderTest, AddIngredientFromArchiveWithOverride) +{ + auto context = c2pa::Context(); + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + + // Step 1: Create a Builder with an ingredient titled "my-first-title", + // then archive it to a .c2pa file (ingredient archive) + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto builder1 = c2pa::Builder(context, manifest_str); + builder1.add_ingredient( + R"({"title": "my-first-title", "relationship": "componentOf"})", + c2pa_test::get_fixture_path("A.jpg")); + + auto archive_path = get_temp_path("example-ingredient.c2pa"); + ASSERT_NO_THROW(builder1.to_archive(archive_path)); + ASSERT_TRUE(fs::exists(archive_path)); + + // Step 2: Create a new Builder and add the ingredient from the .c2pa archive, + // but override the title to "overridden-title" + auto builder2 = c2pa::Builder(context, manifest_str); + json ingredient_override = { + {"title", "overridden-title"}, + {"relationship", "parentOf"} + }; + builder2.add_ingredient(ingredient_override.dump(), archive_path); + + auto output_path = get_temp_path("archive_override_result.jpg"); + ASSERT_NO_THROW(builder2.sign(source_path, output_path, signer)); + + // Step 3: Read back and verify which title ended up in the signed manifest + 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_GE(ingredients.size(), 1u); + auto& ing = ingredients[0]; + + // Check whether the override title or the original archive title won + std::string actual_title = ing["title"]; + std::string actual_relationship = ing["relationship"]; + + // Verify the override title is what ends up in the signed manifest + EXPECT_EQ(actual_title, "overridden-title") + << "Title from add_ingredient JSON should override the archive's original title"; + EXPECT_EQ(actual_relationship, "parentOf") + << "Relationship from add_ingredient JSON should override the archive's original relationship"; +} + +TEST_F(BuilderTest, AddIngredientFromArchiveWithCustomProperties) +{ + auto context = c2pa::Context(); + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + + // Step 1: Create an ingredient archive with a basic ingredient + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto builder1 = c2pa::Builder(context, manifest_str); + builder1.add_ingredient( + R"({"title": "original.jpg", "relationship": "componentOf"})", + c2pa_test::get_fixture_path("A.jpg")); + + auto archive_path = get_temp_path("custom-props-ingredient.c2pa"); + ASSERT_NO_THROW(builder1.to_archive(archive_path)); + + // Step 2: Add the archived ingredient with overridden + additional custom properties + std::string custom_instance = "tracking:proj-7:asset-42"; + json ingredient_with_custom_props = { + {"title", "renamed-asset.jpg"}, + {"relationship", "parentOf"}, + {"instance_id", custom_instance}, + {"description", "Overridden ingredient with extra properties"}, + {"informational_URI", "https://example.com/assets/42"} + }; + + auto builder2 = c2pa::Builder(context, manifest_str); + builder2.add_ingredient(ingredient_with_custom_props.dump(), archive_path); + + auto output_path = get_temp_path("archive_custom_props_result.jpg"); + ASSERT_NO_THROW(builder2.sign(source_path, output_path, signer)); + + // Step 3: Verify all properties survived signing + 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_GE(ingredients.size(), 1u); + auto& ing = ingredients[0]; + + EXPECT_EQ(ing["title"], "renamed-asset.jpg") + << "Title override should be preserved"; + EXPECT_EQ(ing["relationship"], "parentOf") + << "Relationship override should be preserved"; + + // Check which additional custom properties survived + if (ing.contains("instance_id")) { + EXPECT_EQ(ing["instance_id"], custom_instance); + } + if (ing.contains("description")) { + EXPECT_EQ(ing["description"], "Overridden ingredient with extra properties"); + } + if (ing.contains("informational_URI")) { + EXPECT_EQ(ing["informational_URI"], "https://example.com/assets/42"); + } +} + +TEST_F(BuilderTest, CustomParamsInActions) +{ + auto context = c2pa::Context(); + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + + std::string instance_id = "custom-param-test-1"; + + json manifest_json = { + {"claim_generator_info", json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions"}, + {"data", { + {"actions", json::array({ + { + {"action", "c2pa.created"}, + {"digitalSourceType", "http://c2pa.org/digitalsourcetype/compositeCapture"}, + {"parameters", { + {"test.example.tool", "my-tool"}, + {"test.example.session_id", "session-abc-123"} + }} + }, + { + {"action", "c2pa.placed"}, + {"description", "Placed an image"}, + {"parameters", { + {"test.example.layer_id", "layer-42"}, + {"ingredientIds", json::array({instance_id})} + }} + } + })} + }} + } + })} + }; + + auto builder = c2pa::Builder(context, manifest_json.dump()); + builder.add_ingredient( + json({{"title", "photo.jpg"}, {"relationship", "componentOf"}, {"instance_id", instance_id}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + auto output_path = get_temp_path("vendor_params.jpg"); + ASSERT_NO_THROW(builder.sign(source_path, output_path, signer)); + + // Read back and verify custom params survive signing + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + string active = parsed["active_manifest"]; + + bool found_created_params = false; + bool found_placed_params = false; + for (auto& assertion : parsed["manifests"][active]["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] == "c2pa.created" && action.contains("parameters")) { + auto& params = action["parameters"]; + if (params.contains("test.example.tool")) { + EXPECT_EQ(params["test.example.tool"], "my-tool"); + EXPECT_EQ(params["test.example.session_id"], "session-abc-123"); + found_created_params = true; + } + } + if (action["action"] == "c2pa.placed" && action.contains("parameters")) { + auto& params = action["parameters"]; + if (params.contains("test.example.layer_id")) { + EXPECT_EQ(params["test.example.layer_id"], "layer-42"); + found_placed_params = true; + } + // Also verify ingredientIds resolved to URL + EXPECT_TRUE(params.contains("ingredients")) + << "ingredientIds should be resolved to ingredients[] URLs"; + } + } + } + } + EXPECT_TRUE(found_created_params) + << "Custom params on c2pa.created should survive signing"; + EXPECT_TRUE(found_placed_params) + << "Custom params on c2pa.placed should survive signing"; +}