diff --git a/docs/faqs.md b/docs/faqs.md new file mode 100644 index 00000000..51315a2e --- /dev/null +++ b/docs/faqs.md @@ -0,0 +1,121 @@ +# FAQs + +## When do I use `Reader` vs. `Builder` + +## Quick reference decision tree + +```mermaid +flowchart TD + 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 -->|Yes| USE_BR[Use both Reader + Builder] + 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)"] +``` + +### When to use `Reader` + +**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 +- Extracting thumbnails for display +- Checking trust status and validation results +- Inspecting ingredient chains + +```cpp +c2pa::Reader reader(context, "image.jpg"); +auto json = reader.json(); // inspect the manifest +reader.get_resource(thumb_id, stream); // extract a thumbnail +``` + +The `Reader` is read-only. It never modifies the source asset. + +### When to use a `Builder` + +**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 with all content defined from scratch + +```cpp +c2pa::Builder builder(context, manifest_json); +builder.add_ingredient(ingredient_json, source_path); // add source material +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 to use both `Reader` and `Builder` together + +**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 + +```cpp +// Read existing (does not modify the asset) +c2pa::Reader reader(context, "signed.jpg"); +auto parsed = json::parse(reader.json()); + +// Filter what to keep +auto kept = filter(parsed); // application-specific filtering logic + +// Create a new Builder with only the filtered content +c2pa::Builder builder(context, kept.dump()); +// ... transfer resources ... +builder.sign(source, output, signer); +``` + +## How should I add ingredients? + +There are two ways: using `add_ingredient()` and injecting ingredient JSON via `with_definition()`. The table below summarizes these options. + +| Approach | What it does | When to use | +| --- | --- | --- | +| `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 to use archives + +There are two distinct archive concepts: + +- **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) + - 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: + + - Building an ingredients catalog for pick-and-choose workflows + - Preserving provenance history from source assets + - Transferring ingredient data between `Reader` and `Builder` + +See also [Working stores](https://opensource.contentauthenticity.org/docs/rust-sdk/docs/working-stores). + +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 the caller's context settings +c2pa::Builder builder(my_context); +builder.with_archive(archive_stream); +builder.sign(source, output, signer); +``` + +## 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 desired changes and sign it. This is by design: it ensures the integrity of the provenance chain. + +## What happens to the provenance chain when rebuilding a working store? + +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). \ No newline at end of file diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md new file mode 100644 index 00000000..b22f4d0a --- /dev/null +++ b/docs/selective-manifests.md @@ -0,0 +1,1098 @@ +# Selective manifest construction + +You can use `Builder` and `Reader` together to selectively construct manifests—keeping only the parts you need and omitting the rest. This is useful when you don't want to include all ingredients in a working store (for example, when some ingredient assets are not visible). + +This process is best described as *filtering* or *rebuilding* a working store: + +1. Read an existing manifest. +2. Choose which elements to retain. +3. Build a new manifest containing only those elements. + +A manifest is a signed data structure attached to an asset that records provenance and which source assets (ingredients) contributed to it. It contains assertions (statements about the asset), ingredients (references to other assets), and references to binary resources (such as thumbnails). + +Since both `Reader` and `Builder` are **read-only** by design (neither has a `remove()` method), to exclude content you must **read what exists, filter to keep what you need, and create a new** `Builder` **with only that information**. This produces a new `Builder` instance—a "rebuild." + +> [!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 concepts + +```mermaid +flowchart LR + A[Signed Asset] -->|Reader| B[JSON + Resources] + B -->|Filter| C[Filtered Data] + C -->|new Builder| D[New Builder] + D -->|sign| E[New Asset] +``` + + + +The fundamental workflow is: + +1. **Read** the existing manifest with `Reader` to get JSON and binary resources +2. **Identify and filter** the parts to keep (parse the JSON, select and gather elements) +3. **Create a new `Builder`** with only the selected parts based on the applied filtering rules +4. **Sign** the new `Builder` into the output asset + +## Reading an existing manifest + +Use `Reader` to extract the manifest store JSON and any binary resources (thumbnails, manifest data). The source asset is never modified. + +```cpp +c2pa::Context context; +c2pa::Reader reader(context, "image/jpeg", source_stream); + +// Get the full manifest store as JSON +std::string store_json = reader.json(); +auto parsed = json::parse(store_json); + +// Identify the active manifest, which is the current/latest manifest +std::string active = parsed["active_manifest"]; +auto manifest = parsed["manifests"][active]; + +// Access specific parts +auto ingredients = manifest["ingredients"]; +auto assertions = manifest["assertions"]; +auto thumbnail_id = manifest["thumbnail"]["identifier"]; +``` + +### Extracting binary resources + +The JSON returned by `reader.json()` only contains string identifiers (JUMBF URIs) for binary data like thumbnails and ingredient manifest stores. Extract the actual binary content by using `get_resource()`: + +```cpp +// Extract a thumbnail to a stream +std::stringstream thumb_stream(std::ios::in | std::ios::out | std::ios::binary); +reader.get_resource(thumbnail_id, thumb_stream); + +// Or extract to a file +reader.get_resource(thumbnail_id, fs::path("thumbnail.jpg")); +``` + +## Filtering into a new Builder + +Each example below creates a **new `Builder`** from filtered data. The original asset and its manifest store are never modified. + +When transferring ingredients from a `Reader` to a new `Builder`, you must transfer both the JSON metadata and the associated binary resources (thumbnails, manifest data). The JSON contains identifiers that reference those resources; the same identifiers must be used when calling `builder.add_resource()`. + +> **Transferring binary resources:** For each kept ingredient, call `reader.get_resource(id, stream)` for any `thumbnail` or `manifest_data` it contains, then `builder.add_resource(id, stream)` with the same identifier. + +### Keep only specific 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 ingredients = parsed["manifests"][active]["ingredients"]; + +// Filter: keep only ingredients with a specific relationship +json kept_ingredients = json::array(); +for (auto& ingredient : ingredients) { + if (ingredient["relationship"] == "parentOf") { + kept_ingredients.push_back(ingredient); + } +} + +// Create a new Builder with only the kept ingredients +json new_manifest = json::parse(base_manifest_json); +new_manifest["ingredients"] = kept_ingredients; + +c2pa::Builder builder(context, new_manifest.dump()); + +// Transfer binary resources for kept ingredients (see note above) +for (auto& ingredient : kept_ingredients) { + if (ingredient.contains("thumbnail")) { + std::string id = ingredient["thumbnail"]["identifier"]; + std::stringstream s(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, s); + s.seekg(0); + builder.add_resource(id, s); + } + if (ingredient.contains("manifest_data")) { + std::string id = ingredient["manifest_data"]["identifier"]; + std::stringstream s(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, s); + s.seekg(0); + builder.add_resource(id, s); + } +} + +// Sign the new Builder into an output asset +builder.sign(source_path, output_path, signer); +``` + +### Keep only specific assertions + +```cpp +auto assertions = parsed["manifests"][active]["assertions"]; + +json kept_assertions = json::array(); +for (auto& assertion : assertions) { + // Keep training-mining assertions, filter out everything else + if (assertion["label"] == "c2pa.training-mining") { + kept_assertions.push_back(assertion); + } +} + +json new_manifest = json::parse(R"({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}] +})"); +new_manifest["assertions"] = kept_assertions; + +// Create a new Builder with only the filtered assertions +c2pa::Builder builder(context, new_manifest.dump()); +builder.sign(source_path, output_path, signer); +``` + +### Start fresh and preserve provenance + +Sometimes all existing assertions and ingredients may need to be discarded but the provenance chain should be maintained nevertheless. This is done by creating a new `Builder` with a new manifest definition and adding the original signed asset as an ingredient using `add_ingredient()`. + +The function `add_ingredient()` does not copy the original's assertions into the new manifest. Instead, it stores the original's entire manifest store as opaque binary data inside the ingredient record. This means: + +- The new manifest has its own, independent set of assertions +- The original's full manifest is preserved inside the ingredient, so validators can inspect the full provenance history +- The provenance chain is unbroken: anyone reading the new asset can follow the ingredient link back to the original + +```mermaid +flowchart TD + subgraph Original["Original Signed Asset"] + OA["Assertions: A, B, C"] + OI["Ingredients: X, Y"] + end + subgraph NewBuilder["New Builder"] + NA["Assertions: (empty or new)"] + NI["Ingredient: original.jpg (contains full original manifest as binary data)"] + end + Original -->|"add_ingredient()"| NI + NI -.->|"validators can trace back"| Original + + style NA fill:#efe,stroke:#090 + style NI fill:#efe,stroke:#090 +``` + + + +```cpp +// Create a new Builder with a new definition +c2pa::Builder builder(context); +builder.with_definition(R"({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [] +})"); + +// Add the original as an ingredient to preserve provenance chain. +// add_ingredient() stores the original's manifest as binary data inside the ingredient, +// but does NOT copy the original's assertions into this new manifest. +builder.add_ingredient(R"({"title": "original.jpg", "relationship": "parentOf"})", + original_signed_path); +builder.sign(source_path, output_path, signer); +``` + +## Adding actions to a working store + +Actions record what was done to an asset (e.g., color adjustments, cropping, placing content). Use `builder.add_action()` to add them to a working store. + +```cpp +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" +})"); +``` + +### 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 is linked to the action using `ingredientIds` (in the action's `parameters`), referencing a matching key in 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); +``` + +##### Linking multiple ingredients + +When linking multiple ingredients, each ingredient needs a unique label. + +> [!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. + +```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); +``` + +#### Linking with `instance_id` + +When no `label` is set on an ingredient, the SDK matches `ingredientIds` against `instance_id`. + +```cpp +c2pa::Context context; + +// instance_id is used as the linking identifier and must 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. + +#### Reading linked ingredients + +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 + } + } + } + } +} +``` + +#### When to use `label` vs `instance_id` + +| 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 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 + +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. + +```mermaid +flowchart TD + subgraph Catalog["Ingredients Catalog (archived)"] + A1["Archive: photos.c2pa (ingredients from photo shoot)"] + A2["Archive: graphics.c2pa (ingredients from design assets)"] + A3["Archive: audio.c2pa (ingredients from audio tracks)"] + end + subgraph Build["Final Builder"] + direction TB + SEL["Pick and choose ingredients from any archive in the catalog"] + FB["New Builder with selected ingredients only"] + end + A1 -->|"select photo_1, photo_3"| SEL + A2 -->|"select logo"| SEL + A3 -. "skip (not needed)" .-> X((not used)) + SEL --> FB + FB -->|sign| OUT[Signed Output Asset] + + style A3 fill:#eee,stroke:#999 + style X fill:#f99,stroke:#c00 +``` + + + +```cpp +// Read from a catalog of archived ingredients +c2pa::Context archive_ctx; // Add settings if needed, e.g. verify options + +// Open one archive from the catalog +archive_stream.seekg(0); +c2pa::Reader reader(archive_ctx, "application/c2pa", archive_stream); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto available_ingredients = parsed["manifests"][active]["ingredients"]; + +// Pick only the needed ingredients +json selected = json::array(); +for (auto& ingredient : available_ingredients) { + if (ingredient["title"] == "photo_1.jpg" || ingredient["title"] == "logo.png") { + selected.push_back(ingredient); + } +} + +// Create a new Builder with selected ingredients +json manifest = json::parse(R"({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}] +})"); +manifest["ingredients"] = selected; +c2pa::Builder builder(context, manifest.dump()); + +// Transfer binary resources for 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); + } + 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); +``` + +### Overriding ingredient properties + +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 the rest (thumbnail, manifest_data, format) from the source. This works with signed assets, `.c2pa` archives, or unsigned files. + +### Using custom vendor parameters in actions + +The C2PA specification allows **vendor-namespaced parameters** on actions using reverse domain notation. These parameters survive signing and can be read back, useful for tagging actions with IDs that support 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 (e.g., `com.mycompany.tool`, `net.example.session_id`). Some namespaces (e.g., `c2pa` or `cawg`) may be reserved. + +### 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. + +```mermaid +flowchart TD + 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 (does not modify it) +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"]; +``` + +**Step 3:** Create a new Builder with the extracted ingredients: + +```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); + 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); + new_builder.add_resource(id, stream); + } +} + +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) into a single `Builder`. This should be a **fallback strategy**—the recommended practice is to maintain a single active working store and add ingredients incrementally (archived ingredient catalogs help with this). Merging is available when multiple working stores must be consolidated. + +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). + +```cpp +std::set used_ids; +int suffix_counter = 0; +json all_ingredients = json::array(); +std::vector> archive_info; // (stream, ingredient count) + +// Pass 1: Collect ingredients, renaming IDs on collision +for (auto& archive_stream : archives) { + archive_stream.seekg(0); + c2pa::Reader reader(archive_ctx, "application/c2pa", archive_stream); + auto parsed = json::parse(reader.json()); + auto ingredients = parsed["manifests"][parsed["active_manifest"]]["ingredients"]; + + for (auto& ingredient : ingredients) { + for (const char* key : {"thumbnail", "manifest_data"}) { + if (!ingredient.contains(key)) continue; + std::string id = ingredient[key]["identifier"]; + if (used_ids.count(id)) { + ingredient[key]["identifier"] = id + "__" + std::to_string(++suffix_counter); + } + used_ids.insert(ingredient[key]["identifier"].get()); + } + all_ingredients.push_back(ingredient); + } + archive_info.emplace_back(&archive_stream, ingredients.size()); +} + +json manifest = json::parse(R"({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}] +})"); +manifest["ingredients"] = all_ingredients; +c2pa::Builder builder(context, manifest.dump()); + +// Pass 2: Transfer resources (match by ingredient index) +size_t idx = 0; +for (auto& [stream, count] : archive_info) { + stream->seekg(0); + c2pa::Reader reader(archive_ctx, "application/c2pa", *stream); + auto parsed = json::parse(reader.json()); + auto orig = parsed["manifests"][parsed["active_manifest"]]["ingredients"]; + + for (size_t i = 0; i < count; ++i) { + auto& o = orig[i]; + auto& m = all_ingredients[idx++]; + for (const char* key : {"thumbnail", "manifest_data"}) { + if (!o.contains(key)) continue; + std::stringstream s(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(o[key]["identifier"].get(), s); + s.seekg(0); + builder.add_resource(m[key]["identifier"].get(), s); + } + } +} + +builder.sign(source_path, output_path, signer); +``` + +## Retrieving actions from a working store + +Actions are stored in the `c2pa.actions.v2` assertion. Use `Reader` to extract them from a signed asset or an archived Builder. + +### 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 + +Use the same approach with format `"application/c2pa"` and an archive stream: + +```cpp +std::ifstream archive_file("builder_archive.c2pa", std::ios::binary); +c2pa::Reader reader(context, "application/c2pa", archive_file); +// Then parse and iterate assertions as in the example above +``` + +### Understanding the manifest tree + +The `Reader` returns a manifest store—a dictionary of manifests keyed by label (a URN like `contentauth:urn:uuid:...`). Conceptually it forms a tree: each manifest has assertions and ingredients; ingredients with `manifest_data` carry their own manifest store, which can have its own ingredients and assertions recursively. The `active_manifest` key indicates the root. + +```mermaid +flowchart TD + subgraph Store["Manifest Store"] + M1["Active Manifest\n- assertions (including c2pa.actions.v2)\n- ingredients"] + M2["Ingredient A's manifest\n- its own c2pa.actions.v2\n- its own ingredients"] + M3["Ingredient B's manifest\n- its own c2pa.actions.v2"] + end + M1 -->|"ingredient A has manifest_data"| M2 + M1 -->|"ingredient B has manifest_data"| M3 + M1 -.-|"ingredient C has no manifest_data"| M5["Ingredient C\n(unsigned asset, no provenance)"] + M2 -->|"may have its own ingredients..."| M4["...deeper in the tree"] + + style M5 fill:#eee,stroke:#999,stroke-dasharray: 5 5 +``` + + + +Not every ingredient has provenance. An unsigned asset added as an ingredient has `title`, `format`, and `relationship`, but no `manifest_data` and no entry in the `"manifests"` dictionary. Walking the tree reveals the full provenance chain: what each actor did at each step, including actions performed and ingredients 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 + +To remove actions, use the same read–filter–rebuild pattern: **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). + +#### `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 + +#### `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) + +#### Example + +The code below provides an example of 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. + +### Remove the manifest from the asset entirely + +Use `set_no_embed()` so the signed asset contains no embedded manifest. The manifest bytes are returned from `sign()` and can be stored separately (as a sidecar file, on a server, etc.): + +```mermaid +flowchart LR + subgraph Default["Default (embedded)"] + A1[Output Asset] --- A2[Image data + C2PA manifest] + end + + subgraph NoEmbed["With set_no_embed()"] + B1[Output Asset] ~~~ B2[Manifest bytes with store as sidecar or uploaded to server] + end +``` + + + +```cpp +c2pa::Builder builder(context, manifest_json); +builder.set_no_embed(); +builder.set_remote_url("<>"); + +auto manifest_bytes = builder.sign("image/jpeg", source, dest, signer); +// manifest_bytes contains the full manifest store +// Upload manifest_bytes to the remote URL +// The output asset has no embedded manifest +``` + +Reading back: + +```cpp +c2pa::Reader reader(context, "image/jpeg", dest); +reader.is_embedded(); // false +reader.remote_url(); // "<>" +``` + +## Complete workflow diagram + +```mermaid +flowchart TD + subgraph Step1["Step 1: READ"] + SA[Signed Asset] -->|Reader| RD["reader.json() -- full manifest JSON\nreader.get_resource(id, stream) -- binary"] + end + + subgraph Step2["Step 2: FILTER"] + RD --> FI[Parse JSON] + FI --> F1[Pick ingredients to keep] + 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 (optional)"] + AI --> AA[".add_action to record new actions (optional)"] + end + + subgraph Step4["Step 4: SIGN"] + AA --> SIGN["builder.sign(source, output, signer)"] + SIGN --> OUT[Output asset with new manifest containing only filtered content] + end +``` + diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index a2fd14e2..9ac8fbbd 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -3773,6 +3773,752 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchives) { 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"; +} + TEST_F(BuilderTest, NonAsciiSourcePathForSign) { // Copy A.jpg to a temp path with a non-ASCII name rather than relying on