diff --git a/docs/faqs.md b/docs/faqs.md index 0863130d..b1023d18 100644 --- a/docs/faqs.md +++ b/docs/faqs.md @@ -121,5 +121,5 @@ These file-based functions not attached to an API-exposed object, `read_file`, ` | Removed function | Equivalent | | --- | --- | | `read_file(path)` | `Reader::from_asset(ctx, format, stream)` then `Reader::json()`. Pull binary resources with `Reader::get_resource(uri, dest)`. | -| `read_ingredient_file(path, data_dir)` | `Builder::add_ingredient(ingredient_json, source_path)`, which adds ingredients to the active Builder. To recover the formed ingredient JSON (if needed) and its resources, archive the working store and read it back, or move the ingredient with the dedicated `write_ingredient_archive` / `add_ingredient_from_archive` APIs. See [Extracting ingredients from a working store](selective-manifests.md#extracting-ingredients-from-a-working-store). | +| `read_ingredient_file(path, data_dir)` | `Builder::add_ingredient(ingredient_json, source_path)`, which adds ingredients to the active Builder. To recover the formed ingredient JSON (if needed) and its resources, archive the working store and read it back, or move the ingredient with the dedicated `write_ingredient_archive` / `add_ingredient_from_archive` APIs. There is no single-call replacement; but the full behavior (extract the ingredient JSON, thumbnail, and manifest_data to a directory, then reuse that directory to sign an asset using a Builder) can be reimplemented as shown in examples from [Migrating from `read_ingredient_file`](selective-manifests.md#migrating-from-read_ingredient_file). See also [Extracting ingredients from a working store](selective-manifests.md#extracting-ingredients-from-a-working-store). | | `sign_file(src, dst, manifest, SignerInfo*, data_dir)` | `Builder::sign(source_path, dest_path, signer)` with a `Signer`. | diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 5ece2767..ef527ef8 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -428,7 +428,7 @@ Use `label` when defining manifests in JSON. Use `instance_id` when working prog ## 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). +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, so the work can be saved, transferred, or resumed 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 and ingredient archives. @@ -1024,7 +1024,7 @@ This section covers the **legacy** load path: producer calls `to_archive`, signi > > **Labels baked into the archive ingredient at archive-creation time do not carry through as linking keys either.** The label must be re-asserted on the signing builder's `add_ingredient` call so action and archived ingredient properly link. -Labels are build-time linking keys only. A label, as linking key, links ingredients and actions using it together: the label identifies the link. The SDK may reassign the actual label in the signed manifest. +Labels are build-time linking keys only. An action and an ingredient that share the same label string are linked (the label acts as a linking key: the label identifies the link, and is consumed at signing time). The SDK may reassign the actual label in the signed manifest. ##### Minimal archive to action linking example @@ -1063,7 +1063,7 @@ builder.add_ingredient( })", archive_path); -// Note that a signing, the SDK may reassign the labels +// Note that at signing, the SDK may reassign the labels builder.sign(source_path, output_path, signer); ``` @@ -1249,6 +1249,405 @@ for (auto& [stream, count] : archive_info) { builder.sign(source_path, output_path, signer); ``` +### Migrations + +#### Using `add_resource` instead of `set_base_path` + +`set_base_path` carries a `@deprecated` note in the C++ header. `add_resource` is its replacement, registering each resource directly on a `Builder` instance. + +An ingredient's JSON references its (binary) resources by an `identifier`. `set_base_path(dir)` resolved every identifier implicitly, by matching each name against one directory on disk. `add_resource(identifier, path)` does the same job explicitly: one call per identifier the ingredient JSON declares, naming the file to load for it. + +Here is a `set_base_path` sign step: + +```cpp +c2pa::Builder sign_builder(context, manifest.dump()); +sign_builder.set_base_path(dir.string()); // resolves every identifier by name +sign_builder.sign(carrier_path, output_path, signer); +``` + +And the equivalent with `add_resource`, registering each referenced resource for every ingredient: + +```cpp +c2pa::Builder sign_builder(context, manifest.dump()); +for (const auto& ingredient : ingredients) { + if (ingredient.contains("thumbnail")) { + std::string thumb_id = ingredient["thumbnail"]["identifier"]; + sign_builder.add_resource(thumb_id, dir / thumb_id); + } + if (ingredient.contains("manifest_data")) { + std::string md_id = ingredient["manifest_data"]["identifier"]; + sign_builder.add_resource(md_id, dir / md_id); + } +} +sign_builder.sign(carrier_path, output_path, signer); +``` + +Register a resource for every `identifier` the ingredient JSON references, or the sign call will be missing resources (and fail signing). `set_base_path` did this implicitly by resolving names against the directory set as base path. + +`add_resource` also has a stream overload, `add_resource(identifier, stream)`, when the resource is already in memory rather than on disk (see [Dedicated ingredient archive APIs](#dedicated-ingredient-archive-apis)). + +#### Working with ingredient directories + +A legacy ingredient could live on disk as an ingredient directory containing an `ingredient.json` file, an optional `manifest_data.c2pa` manifest store, and an optional thumbnail file. You may have generated such a directory previously (see [Migrating from `read_ingredient_file`](#migrating-from-read_ingredient_file) below) or receive one from another source. To use it on a `Builder`, add the ingredient and resolve its resources: each `ResourceRef` `identifier` in the JSON is a path relative to the directory, resolved through `set_base_path` or registered explicitly with `add_resource` (see [Using `add_resource` instead of `set_base_path`](#using-add_resource-instead-of-set_base_path)). + +```text +my_ingredient/ + ingredient.json # ingredient metadata + resource references + manifest_data.c2pa # optional: the ingredient's C2PA manifest store (a JUMBF blob) + .jpg # optional: a thumbnail referenced by ingredient.json +``` + +The asset itself is not in the directory: the directory holds metadata and a manifest store, not the image or video the ingredient describes. You supply the asset stream separately when adding the ingredient. `ingredient.json` holds only the ingredient fields (title, format, relationship), plus `ResourceRef` entries whose `identifier` is relative to the directory: + +```json +{ + "title": "C.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "thumbnail": { + "format": "image/jpeg", + "identifier": "self#jumbf=/c2pa/contentauth:urn:uuid:.../c2pa.thumbnail.claim.jpeg" + }, + "manifest_data": { + "format": "application/c2pa", + "identifier": "manifest_data.c2pa" + } +} +``` + +Two things decide how to re-add the ingredient: whether its asset is still available, and what the directory contains. The asset is checked first — `add_ingredient` needs an asset stream, so without one the ingredient is injected straight into the definition. + +```mermaid +flowchart TD + Start([ingredient directory]) --> Asset{still have
the asset?} + + Asset -->|"yes"| Has{"directory holds what?"} + Asset -->|"no"| NA["inject ingredient into manifest[ingredients]
fill validation_results from the store if missing
add_resource(manifest_data.c2pa)
(needs manifest_data.c2pa)"] + + Has -->|"manifest_data.c2pa"| A["set_base_path(directory)
add_ingredient(json, image/jpeg, asset)
manifest_data resolved eagerly at add time"] + Has -->|"thumbnail only"| B["set_base_path(directory)
add_ingredient(json, image/jpeg, asset)
thumbnail resolved lazily at sign time"] + Has -->|"ingredient.json only"| J["add_ingredient(json, image/jpeg, asset)
no set_base_path: nothing on disk to resolve"] + + A --> Sign + B --> Sign + J --> Sign + NA --> Sign + Sign["builder.sign(source, output, signer)"] +``` + +`manifest_data` and the thumbnail resolve at different times, and that timing decides when the ingredient directory can be deleted: + +```mermaid +flowchart LR + subgraph AddTime["add_ingredient (add time)"] + MD["manifest_data resolved eagerly:
store read into the Builder now"] + end + subgraph SignTime["sign (sign time)"] + TH["thumbnail resolved lazily:
file read from disk now"] + end + MD -->|"directory can be deleted
after add_ingredient"| SafeMD([safe to delete early]) + TH -->|"directory must survive
until sign"| SafeTH([keep until sign]) +``` + +Resources resolve through either a directory-wide `set_base_path` or a per-resource `add_resource`: + +```mermaid +flowchart TD + Q{relative identifiers
collide across directories?} + Q -->|"no"| BP["set_base_path(directory):
one directory resolves all identifiers"] + Q -->|"yes, or you want no base_path"| AR["add_resource(unique id, bytes):
register each resource explicitly"] + BP --> Note["set_base_path is @deprecated;
add_resource is the forward-looking default"] + AR --> Note +``` + +##### Ingredient directory with a manifest store + +When `ingredient.json` declares a `manifest_data` reference to a `manifest_data.c2pa` file, that file is the ingredient's own signed C2PA manifest store, so the ingredient carries full provenance (its history, validation, and nested ingredients) into your new manifest. The `manifest_data` reference is resolved eagerly, when you call `add_ingredient`, so the directory can be deleted right after the add. + +Two things must work together for the SDK to reconstitute the ingredient: + +1. `ingredient.json` declares a `manifest_data` reference. +2. `builder.set_base_path(directory)` is set so that reference resolves from disk. + +Add the ingredient through the asset branch with the ingredient's asset format (for example `"image/jpeg"`) and the asset stream the manifest binds to. Do not pass `"application/c2pa"` here: that branch expects a dedicated ingredient archive, and a bare `manifest_data.c2pa` store is rejected with `"expected an ingredient archive"`. Load the store through the base_path route instead. + +```cpp +// Inputs: dir (ingredient.json + manifest_data.c2pa + the signed asset), +// context, manifest_json, source_asset, output_path, signer. +std::string ing_json = read_text_file(dir / "ingredient.json"); +c2pa::Builder builder(context, manifest_json); + +// Point base_path at the directory so "manifest_data.c2pa" resolves. +builder.set_base_path(dir.string()); + +// Asset branch: real asset format + the asset stream (not "application/c2pa"). +std::ifstream asset_stream(dir / "asset.jpg", std::ios::binary); +builder.add_ingredient(ing_json, "image/jpeg", asset_stream); + +builder.sign(source_asset, output_path, signer); +``` + +Reading the signed output back, the ingredient carries an `active_manifest`, not just metadata. Without `set_base_path`, the declared `manifest_data` reference cannot be resolved and `sign` throws `ResourceNotFound`. + +For many manifest-store directories, set `base_path` per directory right before adding each one. The reference resolves at add time, so the sequential override is correct even though `set_base_path` is global and last-write-wins. + +```cpp +c2pa::Builder builder(context, manifest_json); +for (const fs::path &dir : dirs) { + std::string ing_json = read_text_file(dir / "ingredient.json"); + builder.set_base_path(dir.string()); // current directory + std::ifstream asset(dir / "asset.jpg", std::ios::binary); + builder.add_ingredient(ing_json, "image/jpeg", asset); +} +builder.sign(source_asset, output_path, signer); +``` + +##### Ingredient directory with a relative thumbnail only + +A directory with no `manifest_data.c2pa`, just `ingredient.json` and a thumbnail file, carries only metadata plus a thumbnail; the signed output has no `active_manifest`. The thumbnail is resolved lazily, at sign time, so the file must still exist on disk when you call `sign` (or you must inline it with `add_resource`). + +```cpp +std::string ing_json = read_text_file(dir / "ingredient.json"); +c2pa::Builder builder(context, manifest_json); +builder.set_base_path(dir.string()); // resolves the relative "thumb.jpg" + +std::ifstream asset(source_asset, std::ios::binary); +builder.add_ingredient(ing_json, "image/jpeg", asset); + +builder.sign(source_asset, output_path, signer); +``` + +Without `set_base_path`, the relative thumbnail cannot be found and signing throws. Because a thumbnail is resolved at sign time rather than at add time, that failure surfaces at `sign`, not at `add_ingredient` (see the resolution-timing diagram in [Working with ingredient directories](#working-with-ingredient-directories)). + +If two thumbnail-only directories each carry a different thumbnail under the same relative name (`thumb.jpg`), one global `base_path` cannot serve both. Give each thumbnail a unique identifier and inline its bytes with `add_resource`, so neither ingredient depends on `base_path` (see [Using `add_resource` instead of `set_base_path`](#using-add_resource-instead-of-set_base_path)). + +```cpp +c2pa::Builder builder(context, manifest_json); + +json ing_a = json::parse(read_text_file(dir_a / "ingredient.json")); +json ing_c = json::parse(read_text_file(dir_c / "ingredient.json")); +ing_a["thumbnail"]["identifier"] = "thumb_a.jpg"; +ing_c["thumbnail"]["identifier"] = "thumb_c.jpg"; + +std::ifstream ta(dir_a / "thumb.jpg", std::ios::binary); +std::ifstream tc(dir_c / "thumb.jpg", std::ios::binary); +builder.add_resource("thumb_a.jpg", ta); +builder.add_resource("thumb_c.jpg", tc); + +std::ifstream s1(source_asset, std::ios::binary); +std::ifstream s2(source_asset, std::ios::binary); +builder.add_ingredient(ing_a.dump(), "image/jpeg", s1); +builder.add_ingredient(ing_c.dump(), "image/jpeg", s2); + +builder.sign(source_asset, output_path, signer); +``` + +##### Ingredient directory with ingredient.json only + +If a directory has neither a `manifest_data.c2pa` nor a thumbnail, just `ingredient.json`, there is nothing on disk to resolve, so `set_base_path` is not needed. Add the ingredient from your asset stream as plain metadata. The signed ingredient has its `title` and `relationship` but no `active_manifest`, because there was no prior manifest to carry. + +```cpp +std::string ing_json = read_text_file(dir / "ingredient.json"); +c2pa::Builder builder(context, manifest_json); +// No set_base_path: the JSON references no files. +std::ifstream asset(source_asset, std::ios::binary); +builder.add_ingredient(ing_json, "image/jpeg", asset); +builder.sign(source_asset, output_path, signer); +``` + +##### Loading an ingredient directory without using an asset stream + +When the original ingredient asset is gone, skip `add_ingredient`, place the ingredient in the manifest definition, and supply its manifest store with `add_resource`. This is the read-filter-rebuild pattern, and it works from the directory's two files alone: `ingredient.json` and `manifest_data.c2pa`. + +The one field the definition-injection route depends on is `validation_results`. Some `ingredient.json` files already carry it and some do not. When it is absent, signing is rejected with `"Encoding: unable to encode assertion data"`, because the `add_ingredient` route derives the field from the loaded store plus the asset stream while the definition-injection route has no such step. Check whether the parsed JSON already has `validation_results`: if it does, inject it as-is; if it does not, read the store once with a `Reader` and copy its `validation_results` into the ingredient. Either way, transfer the same store bytes with `add_resource`. + +```cpp +namespace fs = std::filesystem; +fs::path dir = /* the ingredient directory */; + +// 1. Ingredient fields from the directory's ingredient.json. +json ingredient = json::parse(read_text_file(dir / "ingredient.json")); + +// 2. Fill validation_results only when the ingredient.json lacks it. Some +// directories already carry the field; read it from the store otherwise. +if (!ingredient.contains("validation_results")) { + std::ifstream store_in(dir / "manifest_data.c2pa", std::ios::binary); + c2pa::Reader store_reader(context, "application/c2pa", store_in); + json store_parsed = json::parse(store_reader.json()); + ingredient["validation_results"] = store_parsed["validation_results"]; +} + +// 3. Assemble the injectable ingredient JSON. +ingredient["manifest_data"] = { + {"format", "application/c2pa"}, + {"identifier", "manifest_data.c2pa"}, +}; + +// 4. Inject into the definition. +json manifest = json::parse(manifest_json); +manifest["ingredients"] = json::array({ingredient}); +c2pa::Builder builder(context, manifest.dump()); + +// 5. Carry the store bytes under the matching identifier. +std::ifstream store_res(dir / "manifest_data.c2pa", std::ios::binary); +builder.add_resource("manifest_data.c2pa", store_res); + +// 6. Sign. +builder.sign(source_asset, output_path, signer); +``` + +The signed ingredient carries an `active_manifest`. Because the store was validated without its asset, its `validation_results` reports an `assertion.dataHash.mismatch`, the state to carry forward (since the original asset is gone). For several no-asset directories, give each directory's `manifest_data` a distinct identifier (`add_resource` identifiers are global to the Builder), and keep each stream alive until `sign` returns. + +##### Mixing an ingredient directory with a modern archive + +Directory ingredients and modern dedicated ingredient archives can be added to the same Builder in any order. Every `add_ingredient*` method appends to the same internal ingredient list; there is no version gate. + +```cpp +c2pa::Builder builder(context, manifest_json); + +// Directory ingredient (manifest store on disk). +std::string dir_json = read_text_file(dir / "ingredient.json"); +builder.set_base_path(dir.string()); +std::ifstream asset_stream(dir / "asset.jpg", std::ios::binary); +builder.add_ingredient(dir_json, "image/jpeg", asset_stream); + +// Modern dedicated ingredient archive. +archive.seekg(0); +builder.add_ingredient_from_archive(archive); + +builder.sign(source_asset, output_path, signer); +``` + +When the asset is gone, swap the `add_ingredient` + `set_base_path` step for the [no-asset route](#loading-an-ingredient-directory-without-using-an-asset-stream) above and still add the modern archive with `add_ingredient_from_archive`. + +##### Re-archiving an ingredient directory into the modern `.c2pa` format + +The on-disk directory format is awkward to carry around: multiple files, on-disk relative references, a Builder-global `base_path`. If you still have the ingredients, convert each one to a modern dedicated ingredient archive once, then use only the archives. Loading a directory and calling `write_ingredient_archive` produces that archive, which bundles the manifest store and no longer depends on `base_path` or on the asset file. + +```cpp +void migrate_directory_to_archive(const c2pa::Context &context, + const fs::path &dir, + const fs::path &archive_out, + const std::string &manifest_json) { + c2pa::Builder b(context, manifest_json); + std::string ing_json = read_text_file(dir / "ingredient.json"); + b.set_base_path(dir.string()); + std::ifstream asset(dir / "asset.jpg", std::ios::binary); + b.add_ingredient(ing_json, "image/jpeg", asset); + + std::ofstream out(archive_out, std::ios::binary); + b.write_ingredient_archive("ing-mig", out); // label from ingredient.json + // The directory is now redundant; it can be deleted. +} + +// Later, on any builder, with the directory long gone: +void use_migrated_archive(c2pa::Builder &builder, const fs::path &archive_in) { + std::ifstream in(archive_in, std::ios::binary); + builder.add_ingredient_from_archive(in); +} +``` + +Give each ingredient a `"label"` in its JSON so you can name it in `write_ingredient_archive`. When the original asset is gone, migrate through the [no-asset route](#loading-an-ingredient-directory-without-using-an-asset-stream) first, then call `write_ingredient_archive` on that ingredient; the migrated `validation_results` records the same hard-binding mismatch that comes from validating a store without its asset. + +##### Pitfalls + +The Builder does not de-duplicate ingredients. This applies to every `add_ingredient*` route, not just directories: adding the same ingredient N times, whether by asset path, asset stream, directory, or archive, produces N ingredients in the signed manifest. The Builder never merges or skips a repeat, so guard against double-adds in your own code. + +A `manifest_data` reference that resolves but points at corrupt bytes fails at `sign`, not at `add_ingredient`, and with a different message than a missing reference. A missing or unresolved reference throws `ResourceNotFound` (for example when `set_base_path` is not set); a resolvable but corrupt `manifest_data.c2pa` throws `"Verify: invalid JUMBF header"`. Both surface at `sign` rather than at `add_ingredient` time, so validate a store before relying on it. + +##### Linking a directory ingredient to an action + +A directory ingredient links to an action the same way any other ingredient does: through `parameters.ingredientIds` on the action, matched against the ingredient's `label` first and its `instance_id` as a fallback (see [Linking actions to ingredients](#linking-actions-to-ingredients) for the full resolution rules). + +Give the `ingredient.json` a `label`, reference that same string in the action's `ingredientIds`, and after signing the action's resolved `ingredients[].url` points at the ingredient assertion (`self#jumbf=.../c2pa.ingredient.*`). + +Note that some `ingredient.json` files may already carry an `instance_id` (read from the asset's XMP, or written during an earlier extraction), so an ingredient can arrive with an `instance_id` even before you add a `label`. + +When an ingredient carries both a `label` and an `instance_id`, the `label` takes precedence. The SDK checks each `ingredientIds` value against labels before instance_ids, so a value that matches a `label` resolves to that ingredient even if some other ingredient has a matching `instance_id`. The `instance_id` is consulted only for an id that matches no label. + +The `label` used for linking is not preserved verbatim on the ingredient after signing. The SDK consumes it as the linking (identifying) key and rewrites it to the ingredient assertion's own label: reading the signed output back, the ingredient's `label` is an SDK-assigned label, not the string that was assigned in the `label` value. An explicit `instance_id`, by contrast, stays in the ingredient data unchanged. So if you need a stable, caller-controlled identifier that survives signing and round-trips, use `instance_id`. Use `label` only to link an ingredient to an action before signing; it is not a durable identifier for the ingredient itself. + +```cpp +// ingredient.json declares the label the action will reference: +// { "title": "...", "relationship": "parentOf", "label": "dir-parent", +// "manifest_data": { "format": "application/c2pa", "identifier": "manifest_data.c2pa" } } + +// The signing manifest's c2pa.actions assertion references that label: +// { "action": "c2pa.opened", "parameters": { "ingredientIds": ["dir-parent"] } } + +builder.set_base_path(dir.string()); +std::ifstream asset(dir / "asset.jpg", std::ios::binary); +builder.add_ingredient(dir_json, "image/jpeg", asset); +builder.sign(source_asset, output_path, signer); +``` + +An unknown id in `ingredientIds` is rejected at sign with `"Action ingredientId not found"`. + +#### Migrating from `read_ingredient_file` + +The deprecated and removed function `read_ingredient_file(source_path, data_dir)` read an asset, returned a fully formed ingredient JSON, and wrote the ingredient's binary resources (thumbnail and manifest data) to `data_dir`, so a later signing step could load that directory and embed the ingredient. The behavior can be reimplemented with `Builder` and `Reader` objects: form the ingredient, archive it, read it back, write the resources to disk under stable non-colliding names, then reuse the directory on a Builder to sign. + +The legacy API derived each file name from the ingredient's `instance_id` rather than a fixed name (to avoid collisions). That is what lets several ingredients share one output directory without their thumbnail or `manifest_data` files overwriting each other. + +```cpp +namespace fs = std::filesystem; + +// Map a mime format ("image/jpeg") to a file extension, +// and turn an instance_id into a collision-resistant filename stem. +auto ext_for_format = [](const std::string& mime) { + std::string ext = mime.substr(mime.find('/') + 1); + return ext == "jpeg" ? std::string("jpg") : ext; +}; +auto uuid_stem = [](const std::string& instance_id) { + auto colon = instance_id.rfind(':'); + std::string stem = (colon != std::string::npos) ? instance_id.substr(colon + 1) + : instance_id; + return stem.empty() ? std::string("ingredient") : stem; +}; + +// Extract: form the ingredient, archive just it, and write its JSON, +// thumbnail and nested manifest_data (if any) into output_dir. +fs::create_directories(output_dir); +c2pa::Builder builder(context, "{}"); +builder.add_ingredient( + R"({"label": "my-ingredient", "title": "source.jpg", "relationship": "componentOf"})", + source_path); + +std::stringstream archive_buf(std::ios::in | std::ios::out | std::ios::binary); +builder.write_ingredient_archive("my-ingredient", archive_buf); +archive_buf.seekg(0); + +c2pa::Reader reader(context, "application/c2pa", archive_buf); +auto store = json::parse(reader.json()); +std::string active = store["active_manifest"]; +json ingredient = store["manifests"][active]["ingredients"][0]; + +std::string stem = uuid_stem(ingredient.value("instance_id", std::string())); +if (ingredient.contains("thumbnail")) { + std::string name = stem + "." + ext_for_format(ingredient["thumbnail"]["format"]); + reader.get_resource(ingredient["thumbnail"]["identifier"], output_dir / name); + ingredient["thumbnail"]["identifier"] = name; // rewrite to the on-disk name +} +if (ingredient.contains("manifest_data")) { + std::string name = stem + ".c2pa"; + reader.get_resource(ingredient["manifest_data"]["identifier"], output_dir / name); + ingredient["manifest_data"]["identifier"] = name; +} +ingredient.erase("label"); // drop the archive-only assertion label before reuse +std::ofstream(output_dir / (stem + ".json")) << ingredient.dump(2); + +// Reuse: load the extracted ingredient (repeat the block above per ingredient to +// collect several) and sign a carrier, resolving resources from output_dir. +json manifest = {{"ingredients", json::array({ingredient})}}; +c2pa::Builder sign_builder(context, manifest.dump()); +sign_builder.set_base_path(output_dir.string()); // resolves resources +sign_builder.sign(carrier_path, output_path, signer); +``` + +To reproduce the full `read_ingredient_file` directory behavior for multiple ingredients, run the extract block once per source and append each resulting `ingredient` object to the `ingredients` array before signing. Deriving the file names from each ingredient's `instance_id` keeps them unique, so the extracted resources for every ingredient coexist in one directory without overwriting each other. To sign without `set_base_path` (deprecated), register each resource with `add_resource` instead (see [Using `add_resource` instead of `set_base_path`](#using-add_resource-instead-of-set_base_path)); to load a directory you received rather than generated, see [Working with ingredient directories](#working-with-ingredient-directories). + +An ingredient formed through `add_ingredient` from an asset stream or path always usually has an `instance_id`: the SDK takes the asset's XMP `instanceID` when present, otherwise it synthesizes one of the form `xmp:iid:`. The stem helper splits on the last colon, so both the XMP form (`xmp.iid:`) and the synthesized form yield the UUID. Two edge cases justify the `"ingredient"` fallback in `uuid_stem`: a synthesized `instance_id` is random, so it is unique within a run but not stable across runs; and an ingredient built directly with the v2 constructor, without passing an asset stream, has no `instance_id` at all and omits the field. Set an explicit `instance_id` (or a `label`) when you need a stable, caller-controlled name. + ## 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. @@ -1305,8 +1704,6 @@ flowchart TD 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:** @@ -1358,7 +1755,7 @@ for (auto& ingredient : active_manifest["ingredients"]) { } ``` -## Filtering actions +## Filtering actions To remove actions, use the same read–filter–rebuild pattern: **read, pick the ones to keep, create a new Builder**. diff --git a/tests/fixtures/ingredient-legacy-folder-migration/contentauth_urn_uuid_b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.thumbnail.claim.jpeg b/tests/fixtures/ingredient-legacy-folder-migration/contentauth_urn_uuid_b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.thumbnail.claim.jpeg new file mode 100644 index 00000000..1ef62384 Binary files /dev/null and b/tests/fixtures/ingredient-legacy-folder-migration/contentauth_urn_uuid_b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.thumbnail.claim.jpeg differ diff --git a/tests/fixtures/ingredient-legacy-folder-migration/ingredient.json b/tests/fixtures/ingredient-legacy-folder-migration/ingredient.json new file mode 100644 index 00000000..09e41cbe --- /dev/null +++ b/tests/fixtures/ingredient-legacy-folder-migration/ingredient.json @@ -0,0 +1,59 @@ +{ + "title": "C.jpg", + "format": "image/jpeg", + "instance_id": "xmp:iid:547f138e-d98d-45b5-a13c-8fd092b4ebc6", + "thumbnail": { + "format": "image/jpeg", + "identifier": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.thumbnail.claim.jpeg", + "hash": "Nrhmnt6qXlHbrpaDmEMb98m3TyN3PJwniriapw7QM4k=" + }, + "relationship": "componentOf", + "active_manifest": "contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3", + "validation_results": { + "activeManifest": { + "success": [ + { + "code": "claimSignature.insideValidity", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.signature", + "explanation": "claim signature valid" + }, + { + "code": "claimSignature.validated", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.signature", + "explanation": "claim signature valid" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.thumbnail.claim.jpeg", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/stds.schema-org.CreativeWork", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/stds.schema-org.CreativeWork" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.actions", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.hash.data", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data" + }, + { + "code": "assertion.dataHash.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.hash.data", + "explanation": "data hash valid" + } + ], + "informational": [], + "failure": [] + } + }, + "manifest_data": { + "format": "application/c2pa", + "identifier": "manifest_data.c2pa" + } +} \ No newline at end of file diff --git a/tests/fixtures/ingredient-legacy-folder-migration/manifest_data.c2pa b/tests/fixtures/ingredient-legacy-folder-migration/manifest_data.c2pa new file mode 100644 index 00000000..0ded51e2 Binary files /dev/null and b/tests/fixtures/ingredient-legacy-folder-migration/manifest_data.c2pa differ diff --git a/tests/migrations.test.cpp b/tests/migrations.test.cpp index c8ef4728..bb84e6ea 100644 --- a/tests/migrations.test.cpp +++ b/tests/migrations.test.cpp @@ -13,9 +13,12 @@ #include #include #include +#include #include #include #include +#include +#include #include "include/test_utils.hpp" @@ -143,104 +146,393 @@ TEST_F(LegacyApiMigrationTest, ReadFile_WithDataDir_ExtractResources) { // What it did: read an asset, returned the formed ingredient JSON string, and // wrote the ingredient's binary resources (thumbnail, and any manifest data) to // data_dir. -// Current API: Builder::add_ingredient(ingredient_json, source_path). The Builder -// reads the source, forms the ingredient, and embeds its resources in the working -// store; the call returns void. Forming an ingredient and recovering its -// JSON + resources are both available through the working store. add_ingredient -// forms it; archiving the working store and reading it back yields the same -// ingredient JSON, and Reader::get_resource pulls the resources. A dedicated -// "file -> ingredient JSON + thumbnail on disk" wrapper is redundant. -// Notes: This example reconstructs the old return value and side effect -// as closely as possible using the read-filter-rebuild pattern: add the -// ingredient, archive the working store, read the archive back, and pull the -// ingredient JSON + thumbnail. Two differences worth noting: -// - read_ingredient_file derived "title" from the file name (it read a path -// and ran extension_to_mime/title logic). add_ingredient only sets "title" -// if the caller supplies it in the ingredient JSON, so we pass it -// explicitly to reproduce the old output. -// - The old top-level "format" was the MIME type "image/jpeg" (read_ingredient_file -// went through extension_to_mime). The current working-store ingredient stores -// the short extension "jpg" at top level, but the MIME type is still present on -// the thumbnail ("image/jpeg"). The old test only did a substring search for -// "image/jpeg" over the whole ingredient JSON, which still matches today via -// the thumbnail. We assert both the new top-level "jpg" and the thumbnail MIME -// so the difference is explicit rather than hidden behind a substring match. -// Generated identifiers (instance_id, the thumbnail JUMBF URI) are not pinned. -TEST_F(LegacyApiMigrationTest, ReadIngredientFile_ReconstructIngredientJsonAndResources) { +// Current API: there is no single-call replacement, but the behavior can be +// reimplemented on top of Builder/Reader: +// 1. add_ingredient(json, source_path) forms the ingredient in a working store, +// 2. write_ingredient_archive(id, buf) archives just that ingredient, +// 3. Reader(ctx, "application/c2pa", buf) reads it back, and +// Reader::get_resource(uri, path) writes the thumbnail / manifest_data to disk, +// 4. Builder(ctx, {"ingredients": [...]}) + set_base_path(dir) + sign() reuses the +// extracted directory to sign an asset. +// This is the flow the ingredient-from-file example demonstrates, and the two tests +// below exercise it end to end. +// Notes: The identifier fields in the recovered ingredient JSON point at internal JUMBF +// URIs. To make the directory self-contained (and loadable via set_base_path) we +// rewrite each identifier to the bare file name it was written under. File names are +// derived from the ingredient's instance_id so multiple ingredients can share one +// output directory without their thumbnail / manifest_data files colliding, which is +// what the second (adversarial) test verifies. set_base_path is slated for a future +// deprecation (see add_resource); the example uses it because it is the terser path. +TEST_F(LegacyApiMigrationTest, ReadIngredientFile_ExtractToDirThenSignCarrier) { auto context = std::make_shared(); - auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - // Case 1: A.jpg, a plain ingredient with no embedded manifest store. + // Collision-resistant filename stem from an instance_id (the trailing UUID), + // and a mime -> extension mapping, inline as in the example. + auto uuid_stem = [](const std::string& instance_id) { + auto colon = instance_id.rfind(':'); + std::string stem = (colon != std::string::npos) ? instance_id.substr(colon + 1) + : instance_id; + return stem.empty() ? std::string("ingredient") : stem; + }; + auto ext_for_format = [](const std::string& mime) { + std::string ext = mime.substr(mime.find('/') + 1); + return ext == "jpeg" ? std::string("jpg") : ext; + }; + + auto output_dir = get_temp_path("read_ingredient_extract_dir"); + fs::create_directories(output_dir); + + // --- Extract phase: form C.jpg as an ingredient, archive it, read it back, and + // write its JSON + thumbnail + nested manifest_data to output_dir. C.jpg carries + // its own manifest store, so it exercises the manifest_data path too. + json ingredient; { - auto builder = c2pa::Builder(context, manifest); - builder.add_ingredient(R"({"title": "A.jpg", "relationship": "componentOf"})", - c2pa_test::get_fixture_path("A.jpg")); + const std::string ingredient_id = "my-ingredient"; + auto builder = c2pa::Builder(context, "{}"); + builder.add_ingredient( + R"({"label": ")" + ingredient_id + + R"(", "title": "C.jpg", "relationship": "componentOf"})", + c2pa_test::get_fixture_path("C.jpg")); - auto archive_path = get_temp_path("ingredient_a.c2pa"); - ASSERT_NO_THROW(builder.to_archive(archive_path)); + std::stringstream archive_buf(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(builder.write_ingredient_archive(ingredient_id, archive_buf)); + archive_buf.seekg(0); - std::ifstream archive_in(archive_path, std::ios::binary); - ASSERT_TRUE(archive_in.is_open()); - c2pa::Reader archive_reader(context, "application/c2pa", archive_in); - auto parsed = json::parse(archive_reader.json()); + c2pa::Reader reader(context, "application/c2pa", archive_buf); + auto manifest_store = json::parse(reader.json()); + std::string active = manifest_store["active_manifest"]; + auto& ingredients = manifest_store["manifests"][active]["ingredients"]; + ASSERT_FALSE(ingredients.empty()); + ingredient = ingredients[0]; + + std::string stem = uuid_stem(ingredient.value("instance_id", std::string())); - ASSERT_TRUE(parsed.contains("active_manifest")); - std::string active = parsed["active_manifest"]; - auto ingredients = parsed["manifests"][active]["ingredients"]; - ASSERT_EQ(ingredients.size(), 1u); - - // The reconstructed ingredient carries the same fields the old - // read_ingredient_file return value did (title, format, thumbnail, - // relationship). - auto ingredient = ingredients[0]; - EXPECT_EQ(ingredient["title"], "A.jpg"); - EXPECT_EQ(ingredient["relationship"], "componentOf"); - // Top-level format is the short extension now, the old MIME "image/jpeg" - // still appears on the thumbnail. - EXPECT_EQ(ingredient["format"], "jpg"); ASSERT_TRUE(ingredient.contains("thumbnail")); - EXPECT_EQ(ingredient["thumbnail"]["format"], "image/jpeg"); - ASSERT_TRUE(ingredient["thumbnail"].contains("identifier")); + std::string thumb_uri = ingredient["thumbnail"]["identifier"]; + std::string thumb_name = stem + "." + + ext_for_format(ingredient["thumbnail"]["format"]); + ASSERT_GT(reader.get_resource(thumb_uri, output_dir / thumb_name), 0); + ingredient["thumbnail"]["identifier"] = thumb_name; + + ASSERT_TRUE(ingredient.contains("manifest_data")); + std::string md_uri = ingredient["manifest_data"]["identifier"]; + std::string md_name = stem + ".c2pa"; + ASSERT_GT(reader.get_resource(md_uri, output_dir / md_name), 0); + ingredient["manifest_data"]["identifier"] = md_name; + + // Drop the archive-only assertion label so the ingredient keys cleanly on reuse. + ingredient.erase("label"); + std::ofstream(output_dir / (stem + ".json")) << ingredient.dump(2); - // The data_dir side effect, now caller-driven: pull the thumbnail resource. - std::string thumbnail_id = ingredient["thumbnail"]["identifier"]; - auto thumb_path = get_temp_path("ingredient_a_thumb.jpg"); - auto byte_count = archive_reader.get_resource(thumbnail_id, thumb_path); - EXPECT_GT(byte_count, 0); - EXPECT_TRUE(fs::exists(thumb_path)); - EXPECT_GT(fs::file_size(thumb_path), 0u); + // The data_dir side effect: JSON + thumbnail + manifest_data all on disk. + EXPECT_TRUE(fs::exists(output_dir / (stem + ".json"))); + EXPECT_GT(fs::file_size(output_dir / thumb_name), 0u); + EXPECT_GT(fs::file_size(output_dir / md_name), 0u); } - // Case 2: C.jpg, which carries a manifest store. The old - // ReadIngredientFileWhoHasAManifestStore checked that the ingredient additionally - // exposed provenance. The reconstructed ingredient does the same. - { - auto builder = c2pa::Builder(context, manifest); - builder.add_ingredient(R"({"title": "C.jpg", "relationship": "componentOf"})", - c2pa_test::get_fixture_path("C.jpg")); + // --- Sign phase: load the extracted ingredient from output_dir and embed it into a + // (carrier) asset, resolving the thumbnail / manifest_data files via set_base_path. + json sign_manifest = {{"ingredients", json::array({ingredient})}}; + auto builder = c2pa::Builder(context, sign_manifest.dump()); + builder.set_base_path(output_dir.string()); - auto archive_path = get_temp_path("ingredient_c.c2pa"); - ASSERT_NO_THROW(builder.to_archive(archive_path)); + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("read_ingredient_signed.jpg"); + std::vector manifest_data; + ASSERT_NO_THROW(manifest_data = builder.sign(c2pa_test::get_fixture_path("A.jpg"), + output_path, signer)); + EXPECT_FALSE(manifest_data.empty()); - std::ifstream archive_in(archive_path, std::ios::binary); - ASSERT_TRUE(archive_in.is_open()); - c2pa::Reader archive_reader(context, "application/c2pa", archive_in); - auto parsed = json::parse(archive_reader.json()); + auto reader = c2pa::Reader::from_asset(context, output_path); + ASSERT_TRUE(reader.has_value()); + auto parsed = json::parse(reader->json()); + std::string active = parsed["active_manifest"]; + auto& signed_ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(signed_ingredients.size(), 1u); + EXPECT_EQ(signed_ingredients[0]["title"], "C.jpg"); - std::string active = parsed["active_manifest"]; - auto ingredients = parsed["manifests"][active]["ingredients"]; - ASSERT_EQ(ingredients.size(), 1u); - auto ingredient = ingredients[0]; - - EXPECT_EQ(ingredient["title"], "C.jpg"); - EXPECT_EQ(ingredient["relationship"], "componentOf"); - // Provenance markers present because C.jpg has a manifest store. These are - // the extra fields the old ReadIngredientFileWhoHasAManifestStore checked. - EXPECT_TRUE(ingredient.contains("active_manifest")); - ASSERT_TRUE(ingredient.contains("manifest_data")); - EXPECT_EQ(ingredient["manifest_data"]["format"], "application/c2pa"); - EXPECT_TRUE(ingredient.contains("validation_results")); + fs::remove_all(output_dir); +} + +// Extract two ingredients from the same working store into the SAME directory +// and confirm their thumbnail and manifest_data files do not overwrite each other, then sign +// an asset with both and confirm both survive the round-trip. Fixed names like +// "thumbnail.jpg" and "manifest_data.c2pa" would fail this test; instance_id-derived +// names, which are unique per ingredient, pass it. +TEST_F(LegacyApiMigrationTest, ReadIngredientFile_MultipleIngredientsSameStoreNoCollision) { + auto context = std::make_shared(); + + auto uuid_stem = [](const std::string& instance_id) { + auto colon = instance_id.rfind(':'); + std::string stem = (colon != std::string::npos) ? instance_id.substr(colon + 1) + : instance_id; + return stem.empty() ? std::string("ingredient") : stem; + }; + auto ext_for_format = [](const std::string& mime) { + std::string ext = mime.substr(mime.find('/') + 1); + return ext == "jpeg" ? std::string("jpg") : ext; + }; + + auto output_dir = get_temp_path("read_ingredient_multi_dir"); + fs::create_directories(output_dir); + + // Two ingredients in one working store, each keyed by a distinct instance_id. + // A.jpg has no nested manifest; C.jpg carries one, so only C.jpg writes a .c2pa file. + struct Spec { std::string id; std::string fixture; std::string title; }; + const std::vector specs = { + {"mig:ingredient-A", "A.jpg", "A.jpg"}, + {"mig:ingredient-C", "C.jpg", "C.jpg"}, + }; + + auto builder = c2pa::Builder(context, "{}"); + for (const auto& s : specs) { + builder.add_ingredient( + R"({"instance_id": ")" + s.id + R"(", "title": ")" + s.title + + R"(", "relationship": "componentOf"})", + c2pa_test::get_fixture_path(s.fixture)); + } + + std::vector extracted; + std::vector thumb_names; + for (const auto& s : specs) { + std::stringstream buf(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(builder.write_ingredient_archive(s.id, buf)); + buf.seekg(0); + + c2pa::Reader reader(context, "application/c2pa", buf); + auto store = json::parse(reader.json()); + std::string active = store["active_manifest"]; + auto& ings = store["manifests"][active]["ingredients"]; + ASSERT_FALSE(ings.empty()); + json ingredient = ings[0]; + + std::string stem = uuid_stem(s.id); + ASSERT_TRUE(ingredient.contains("thumbnail")); + std::string thumb_name = stem + "." + + ext_for_format(ingredient["thumbnail"]["format"]); + ASSERT_GT(reader.get_resource(ingredient["thumbnail"]["identifier"], + output_dir / thumb_name), 0); + ingredient["thumbnail"]["identifier"] = thumb_name; + thumb_names.push_back(thumb_name); + + if (ingredient.contains("manifest_data")) { + std::string md_name = stem + ".c2pa"; + ASSERT_GT(reader.get_resource(ingredient["manifest_data"]["identifier"], + output_dir / md_name), 0); + ingredient["manifest_data"]["identifier"] = md_name; + } + + ingredient.erase("label"); + std::ofstream(output_dir / (stem + ".json")) << ingredient.dump(2); + extracted.push_back(ingredient); + } + + // Collision guard: the two ingredients wrote distinct, non-empty thumbnail files. + ASSERT_EQ(thumb_names.size(), 2u); + EXPECT_NE(thumb_names[0], thumb_names[1]); + EXPECT_GT(fs::file_size(output_dir / thumb_names[0]), 0u); + EXPECT_GT(fs::file_size(output_dir / thumb_names[1]), 0u); + + size_t json_count = 0, c2pa_count = 0; + for (const auto& entry : fs::directory_iterator(output_dir)) { + if (entry.path().extension() == ".json") ++json_count; + if (entry.path().extension() == ".c2pa") ++c2pa_count; + } + EXPECT_EQ(json_count, 2u); + EXPECT_EQ(c2pa_count, 1u); + + // Sign an asset with both extracted ingredients, resolving resources from the dir. + json sign_manifest = {{"ingredients", extracted}}; + auto sign_builder = c2pa::Builder(context, sign_manifest.dump()); + sign_builder.set_base_path(output_dir.string()); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("read_ingredient_multi_signed.jpg"); + std::vector manifest_data; + ASSERT_NO_THROW(manifest_data = sign_builder.sign(c2pa_test::get_fixture_path("A.jpg"), + output_path, signer)); + EXPECT_FALSE(manifest_data.empty()); + + // Both ingredients survived the round-trip into the signed asset. + auto reader = c2pa::Reader::from_asset(context, output_path); + ASSERT_TRUE(reader.has_value()); + auto parsed = json::parse(reader->json()); + std::string active = parsed["active_manifest"]; + auto& signed_ings = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(signed_ings.size(), 2u); + std::vector titles; + for (const auto& ing : signed_ings) titles.push_back(ing.value("title", std::string())); + EXPECT_NE(std::find(titles.begin(), titles.end(), "A.jpg"), titles.end()); + EXPECT_NE(std::find(titles.begin(), titles.end(), "C.jpg"), titles.end()); + + fs::remove_all(output_dir); +} + +// The extract-and-reuse flow above signs with set_base_path, which is deprecated. +// add_resource(identifier, path) is the replacement: register each referenced resource +// on the builder instead of pointing it at a directory. This test signs the same +// extracted ingredients both ways and checks that the two signed manifests carry the same +// ingredients with the same resources attached. The check ignores identifiers: two +// independent signs generate different signatures and fresh JUMBF identifier URIs, so it +// compares structure (title, relationship, format, and resource presence) rather than raw +// JSON. +TEST_F(LegacyApiMigrationTest, ReadIngredientFile_AddResourceMatchesSetBasePath) { + auto context = std::make_shared(); + + auto uuid_stem = [](const std::string& instance_id) { + auto colon = instance_id.rfind(':'); + std::string stem = (colon != std::string::npos) ? instance_id.substr(colon + 1) + : instance_id; + return stem.empty() ? std::string("ingredient") : stem; + }; + auto ext_for_format = [](const std::string& mime) { + std::string ext = mime.substr(mime.find('/') + 1); + return ext == "jpeg" ? std::string("jpg") : ext; + }; + + auto output_dir = get_temp_path("read_ingredient_addresource_dir"); + fs::create_directories(output_dir); + + // --- Extract once: two ingredients into one directory (shared input for both signs). + struct Spec { std::string id; std::string fixture; std::string title; }; + const std::vector specs = { + {"mig:ingredient-A", "A.jpg", "A.jpg"}, + {"mig:ingredient-C", "C.jpg", "C.jpg"}, + }; + + auto builder = c2pa::Builder(context, "{}"); + for (const auto& s : specs) { + builder.add_ingredient( + R"({"instance_id": ")" + s.id + R"(", "title": ")" + s.title + + R"(", "relationship": "componentOf"})", + c2pa_test::get_fixture_path(s.fixture)); + } + + std::vector extracted; + std::vector thumb_names; + for (const auto& s : specs) { + std::stringstream buf(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(builder.write_ingredient_archive(s.id, buf)); + buf.seekg(0); + + c2pa::Reader reader(context, "application/c2pa", buf); + auto store = json::parse(reader.json()); + std::string active = store["active_manifest"]; + auto& ings = store["manifests"][active]["ingredients"]; + ASSERT_FALSE(ings.empty()); + json ingredient = ings[0]; + + std::string stem = uuid_stem(s.id); + ASSERT_TRUE(ingredient.contains("thumbnail")); + std::string thumb_name = stem + "." + + ext_for_format(ingredient["thumbnail"]["format"]); + ASSERT_GT(reader.get_resource(ingredient["thumbnail"]["identifier"], + output_dir / thumb_name), 0); + ingredient["thumbnail"]["identifier"] = thumb_name; + thumb_names.push_back(thumb_name); + + if (ingredient.contains("manifest_data")) { + std::string md_name = stem + ".c2pa"; + ASSERT_GT(reader.get_resource(ingredient["manifest_data"]["identifier"], + output_dir / md_name), 0); + ingredient["manifest_data"]["identifier"] = md_name; + } + + ingredient.erase("label"); + std::ofstream(output_dir / (stem + ".json")) << ingredient.dump(2); + extracted.push_back(ingredient); + } + + // Shared extract stays covered: distinct thumbnails, 2 JSON, 1 manifest_data. + ASSERT_EQ(thumb_names.size(), 2u); + EXPECT_NE(thumb_names[0], thumb_names[1]); + size_t json_count = 0, c2pa_count = 0; + for (const auto& entry : fs::directory_iterator(output_dir)) { + if (entry.path().extension() == ".json") ++json_count; + if (entry.path().extension() == ".c2pa") ++c2pa_count; + } + EXPECT_EQ(json_count, 2u); + EXPECT_EQ(c2pa_count, 1u); + + auto signer = c2pa_test::create_test_signer(); + + // --- Sign path A: set_base_path resolves resources against the directory. + auto builder_a = c2pa::Builder(context, json{{"ingredients", extracted}}.dump()); + builder_a.set_base_path(output_dir.string()); + auto out_a = get_temp_path("read_ingredient_addresource_a.jpg"); + std::vector md_a; + ASSERT_NO_THROW(md_a = builder_a.sign(c2pa_test::get_fixture_path("A.jpg"), out_a, signer)); + EXPECT_FALSE(md_a.empty()); + + // --- Sign path B: no set_base_path; register every referenced resource explicitly. + auto builder_b = c2pa::Builder(context, json{{"ingredients", extracted}}.dump()); + for (const auto& ing : extracted) { + std::string thumb_id = ing["thumbnail"]["identifier"]; + builder_b.add_resource(thumb_id, output_dir / thumb_id); + if (ing.contains("manifest_data")) { + std::string md_id = ing["manifest_data"]["identifier"]; + builder_b.add_resource(md_id, output_dir / md_id); + } + } + auto out_b = get_temp_path("read_ingredient_addresource_b.jpg"); + std::vector md_b; + ASSERT_NO_THROW(md_b = builder_b.sign(c2pa_test::get_fixture_path("A.jpg"), out_b, signer)); + EXPECT_FALSE(md_b.empty()); + + // --- Cross-check: both signed manifests carry the same ingredients + resources. + // Reduce each ingredient to identifier-safe structure: stable fields plus, for each + // resource, only its presence and format (drop the generated identifier / hashes). + auto normalize = [](const json& ingredients) { + json out = json::array(); + for (const auto& ing : ingredients) { + json n; + n["title"] = ing.value("title", std::string()); + n["relationship"] = ing.value("relationship", std::string()); + n["format"] = ing.value("format", std::string()); + for (const char* key : {"thumbnail", "manifest_data"}) { + if (ing.contains(key)) + n[key] = {{"present", true}, + {"format", ing[key].value("format", std::string())}}; + } + out.push_back(n); + } + // Order-independent: the two builders may list ingredients in either order. + std::sort(out.begin(), out.end(), [](const json& a, const json& b) { + return a["title"].get() < b["title"].get(); + }); + return out; + }; + + auto reader_a = c2pa::Reader::from_asset(context, out_a); + auto reader_b = c2pa::Reader::from_asset(context, out_b); + ASSERT_TRUE(reader_a.has_value()); + ASSERT_TRUE(reader_b.has_value()); + auto parsed_a = json::parse(reader_a->json()); + auto parsed_b = json::parse(reader_b->json()); + auto& ings_a = parsed_a["manifests"][parsed_a["active_manifest"].get()]["ingredients"]; + auto& ings_b = parsed_b["manifests"][parsed_b["active_manifest"].get()]["ingredients"]; + + ASSERT_EQ(ings_a.size(), 2u); + ASSERT_EQ(ings_b.size(), ings_a.size()); + // The two sign paths are equivalent: same ingredients, same resources attached. + EXPECT_EQ(normalize(ings_a), normalize(ings_b)); + + // Also check each manifest directly: both carry A.jpg and C.jpg, every ingredient has + // a thumbnail, and exactly one ingredient per manifest has manifest_data. + for (auto* ings : {&ings_a, &ings_b}) { + std::vector titles; + size_t md_count = 0; + for (const auto& ing : *ings) { + titles.push_back(ing.value("title", std::string())); + EXPECT_TRUE(ing.contains("thumbnail")); + if (ing.contains("manifest_data")) ++md_count; + } + EXPECT_NE(std::find(titles.begin(), titles.end(), "A.jpg"), titles.end()); + EXPECT_NE(std::find(titles.begin(), titles.end(), "C.jpg"), titles.end()); + EXPECT_EQ(md_count, 1u); } + + fs::remove_all(output_dir); } // Legacy API: std::string c2pa::read_ingredient_file(const fs::path& source_path, @@ -321,3 +613,986 @@ TEST_F(LegacyApiMigrationTest, SignFile_SignsAssetToOutput) { auto parsed = json::parse(reader->json()); EXPECT_TRUE(parsed.contains("active_manifest")); } + +// Test fixture: per-test temp dirs under tests/build, cleaned up in TearDown. +class LegacyFolderIngredient : public ::testing::Test { +protected: + std::vector temp_dirs; + + fs::path make_temp_dir(const std::string &name) { + fs::path build_dir = fs::path(__FILE__).parent_path() / "build"; + fs::create_directories(build_dir); + fs::path dir = build_dir / ("legacy-ingredient-" + name); + if (fs::exists(dir)) { + fs::remove_all(dir); + } + fs::create_directories(dir); + temp_dirs.push_back(dir); + return dir; + } + + void TearDown() override { + for (const auto &dir : temp_dirs) { + if (fs::exists(dir)) { + fs::remove_all(dir); + } + } + temp_dirs.clear(); + } + + // A legacy folder ingredient is reconstituted from two things: the manifest + // store bytes (manifest_data.c2pa) AND the signed asset they are hash-bound + // to. Sign a fixture asset, return the manifest-store bytes plus the path to + // the signed asset that carries them. + struct SignedSeed { + std::vector manifest_bytes; // contents of manifest_data.c2pa + fs::path signed_asset; // asset the manifest binds to + }; + SignedSeed sign_seed(const fs::path &asset, const std::string &name) { + auto manifest = + c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto signer = c2pa_test::create_test_signer(); + auto builder = c2pa::Builder(manifest); + fs::path out = make_temp_dir("seed-" + name) / "signed.jpg"; + auto bytes = builder.sign(asset, out, signer); + return {std::move(bytes), out}; + } + + // Write a legacy folder: ingredient.json (+ optional manifest_data.c2pa, + // + optional thumbnail copied into the folder). Returns the folder path. + fs::path write_legacy_folder(const std::string &name, + const json &ingredient_json, + const std::vector *manifest_data, + const fs::path *thumbnail_src, + const std::string &thumbnail_name) { + fs::path dir = make_temp_dir(name); + if (manifest_data != nullptr) { + std::ofstream m(dir / "manifest_data.c2pa", std::ios::binary); + m.write(reinterpret_cast(manifest_data->data()), + static_cast(manifest_data->size())); + } + if (thumbnail_src != nullptr) { + fs::copy_file(*thumbnail_src, dir / thumbnail_name, + fs::copy_options::overwrite_existing); + } + std::ofstream j(dir / "ingredient.json"); + j << ingredient_json.dump(2); + return dir; + } + + // A signed manifest validates when its validation_state is Valid or Trusted + // (the es256 test cert is not trusted, so the good state here is "Valid"). + static bool is_valid_state(const json &parsed) { + if (!parsed.contains("validation_state")) return false; + const auto &s = parsed["validation_state"]; + return s == "Valid" || s == "Trusted"; + } + + // Sign `source` with `builder`, assert the active manifest validates, and + // return the active manifest's ingredients array. + json sign_and_read_ingredients(c2pa::Builder &builder, + const fs::path &source, + const std::string &out_name) { + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir(out_name) / "out.jpg"; + builder.sign(source, out, signer); + auto reader = c2pa::Reader(out); + auto parsed = json::parse(reader.json()); + EXPECT_TRUE(is_valid_state(parsed)) + << out_name << " validation_state=" + << (parsed.contains("validation_state") + ? parsed["validation_state"].dump() : ""); + std::string active = parsed["active_manifest"]; + return parsed["manifests"][active]["ingredients"]; + } + + // Build a self-contained legacy folder (ingredient.json declaring a + // manifest_data ref + manifest_data.c2pa + the signed asset it binds to) on + // a throwaway builder, archive it, and merge it into `builder` via + // add_ingredient_from_archive. Keeping set_base_path scoped to the + // throwaway builder (rather than calling it on the shared `builder`) + // matters: base_path is retained as builder-level state, and merging an + // archive into a builder that has it set routes the archive's resources + // through a disk-write path keyed on their raw (colon-bearing) JUMBF URIs, + // which fails on Windows. See docs/faqs or the migration plan for detail. + void add_legacy_folder_ingredient(c2pa::Builder &builder, const std::string &name, + const std::string &title, const std::string &label) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), name); + json ing = { + {"title", title}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"label", label}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path folder = write_legacy_folder(name, ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, folder / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); + { + auto scoped = c2pa::Builder(manifest); + scoped.set_base_path(folder.string()); + std::ifstream asset_stream(folder / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(scoped.add_ingredient(ing_json, "image/jpeg", asset_stream)); + scoped.write_ingredient_archive(label, archive); + } + archive.seekg(0); + ASSERT_NO_THROW(builder.add_ingredient_from_archive(archive)); + } + + // Build a modern dedicated ingredient archive from a fixture asset and add it + // to `builder` via add_ingredient_from_archive. The archive stream must + // outlive the call, so it is owned by the caller. + std::shared_ptr add_modern_archive( + c2pa::Builder &builder, const std::string &manifest, + const std::string &label, const std::string &title, + const fs::path &asset) { + auto archive = std::make_shared( + std::ios::in | std::ios::out | std::ios::binary); + { + auto ab = c2pa::Builder(manifest); + json ing = {{"title", title}, {"relationship", "componentOf"}, {"label", label}}; + ab.add_ingredient(ing.dump(), asset); + ab.write_ingredient_archive(label, *archive); + } + archive->seekg(0); + // EXPECT (not ASSERT): ASSERT_* expands to `return;`, which is illegal in + // a non-void helper. + EXPECT_NO_THROW(builder.add_ingredient_from_archive(*archive)); + return archive; + } + + // Assert every expected title is present in the ingredients array. + void expect_titles(const json &ingredients, + const std::vector &expected) { + std::vector titles; + for (const auto &i : ingredients) titles.push_back(i["title"]); + for (const auto &t : expected) { + EXPECT_NE(std::find(titles.begin(), titles.end(), t), titles.end()) + << "missing ingredient titled " << t; + } + } + +}; + +// A folder declaring manifest_data.c2pa loads its provenance when set_base_path points at the folder, and fails at sign with no base_path. +TEST_F(LegacyFolderIngredient, ManifestDataResolvedViaBasePath) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "caseA"); + + json ing = { + {"title", "legacy parent"}, + {"format", "image/jpeg"}, + {"relationship", "parentOf"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path folder = + write_legacy_folder("caseA", ing, &seed.manifest_bytes, nullptr, ""); + // The signed asset the manifest binds to lives alongside in the folder. + fs::copy_file(seed.signed_asset, folder / "asset.jpg", + fs::copy_options::overwrite_existing); + + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // POSITIVE: base_path set; manifest_data reference resolves from the folder. + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + std::ifstream asset_stream(folder / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + + auto src = c2pa_test::get_fixture_path("A.jpg"); + auto ingredients = sign_and_read_ingredients(builder, src, "caseA-out"); + ASSERT_EQ(ingredients.size(), 1u); + EXPECT_EQ(ingredients[0]["title"], "legacy parent"); + EXPECT_EQ(ingredients[0]["relationship"], "parentOf"); + // Provenance carried from the legacy manifest_data, not just bare metadata. + EXPECT_TRUE(ingredients[0].contains("active_manifest")) + << "manifest_data.c2pa should be resolved and carried via base_path"; + + // COUNTER: no base_path. The ingredient.json declares a manifest_data + // reference ("manifest_data.c2pa") that can only be resolved from disk via + // base_path. Without it, resolution fails hard (ResourceNotFound) at sign + // time, proving base_path is what makes the legacy manifest_data load. + auto builder2 = c2pa::Builder(manifest); + std::ifstream plain(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_NO_THROW(builder2.add_ingredient(ing_json, "image/jpeg", plain)); + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("caseA-nobasepath") / "out.jpg"; + EXPECT_ANY_THROW(builder2.sign(src, out, signer)) + << "without base_path the declared manifest_data reference is unresolved"; +} + +// A folder with a relative thumbnail resolves it at sign time via set_base_path, and fails at sign without base_path. +TEST_F(LegacyFolderIngredient, BasePathResolvesRelativeThumbnail) { + auto asset = c2pa_test::get_fixture_path("A.jpg"); + auto thumb_src = c2pa_test::get_fixture_path("A.jpg"); + + json ing = { + {"title", "legacy with thumb"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"thumbnail", {{"format", "image/jpeg"}, {"identifier", "thumb.jpg"}}}, + }; + fs::path folder = + write_legacy_folder("caseB", ing, nullptr, &thumb_src, "thumb.jpg"); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // POSITIVE: base_path set to the folder; the relative "thumb.jpg" resolves. + { + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + std::ifstream src(asset, std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", src)); + EXPECT_NO_THROW(sign_and_read_ingredients(builder, asset, "caseB-ok")); + } + + // COUNTER: no base_path. The relative thumbnail cannot be found, so signing + // (which serializes resources) must fail. + { + auto builder = c2pa::Builder(manifest); + std::ifstream src(asset, std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", src)); + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("caseB-fail") / "out.jpg"; + EXPECT_ANY_THROW(builder.sign(asset, out, signer)) + << "without set_base_path the relative thumbnail must be unresolved"; + } +} + +// Two self-contained folders loop with per-folder base_path and produce two ingredients. +TEST_F(LegacyFolderIngredient, MultipleCaseA_Loop) { + auto asset = c2pa_test::get_fixture_path("A.jpg"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + struct Spec { std::string name; std::string title; std::string rel; }; + std::vector specs = { + {"m-parent", "first legacy", "parentOf"}, + {"m-comp", "second legacy", "componentOf"}, + }; + + auto builder = c2pa::Builder(manifest); + for (const auto &s : specs) { + auto seed = sign_seed(asset, s.name); + json ing = { + {"title", s.title}, + {"format", "image/jpeg"}, + {"relationship", s.rel}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path folder = write_legacy_folder(s.name, ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, folder / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + // Each folder is self-contained: set base_path per folder right before + // adding, so its manifest_data.c2pa resolves. (base_path is global and + // last-wins, but here we resolve eagerly at add time, one folder at a + // time, so the sequential override is correct.) + builder.set_base_path(folder.string()); + std::ifstream asset_stream(folder / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + } + + auto ingredients = sign_and_read_ingredients(builder, asset, "m-out"); + ASSERT_EQ(ingredients.size(), 2u); + std::vector titles = {ingredients[0]["title"], ingredients[1]["title"]}; + EXPECT_NE(std::find(titles.begin(), titles.end(), "first legacy"), titles.end()); + EXPECT_NE(std::find(titles.begin(), titles.end(), "second legacy"), titles.end()); +} + +// Two folders sharing a thumbnail name collide under one global base_path, so add_resource with unique identifiers is the fix. +TEST_F(LegacyFolderIngredient, MultipleBasePathCollisionFixedByAddResource) { + auto asset = c2pa_test::get_fixture_path("A.jpg"); + auto thumb_a = c2pa_test::get_fixture_path("A.jpg"); + auto thumb_c = c2pa_test::get_fixture_path("C.jpg"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // Two folders, each with its own thumbnail referenced by the SAME relative + // name but living in different folders. + auto make_ing = [](const std::string &title) { + return json{ + {"title", title}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"thumbnail", {{"format", "image/jpeg"}, {"identifier", "thumb.jpg"}}}, + }; + }; + fs::path folder_a = + write_legacy_folder("collide-a", make_ing("ing A"), nullptr, &thumb_a, "thumb.jpg"); + fs::path folder_c = + write_legacy_folder("collide-c", make_ing("ing C"), nullptr, &thumb_c, "thumb.jpg"); + std::string ing_a = c2pa_test::read_text_file(folder_a / "ingredient.json"); + std::string ing_c = c2pa_test::read_text_file(folder_c / "ingredient.json"); + + // GOTCHA: one global base_path. Whichever folder it points at resolves; + // the other ingredient's identically-named thumbnail resolves to the WRONG + // bytes or fails. base_path is global, so this cannot serve both folders. + { + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder_a.string()); // only folder_a + std::ifstream s1(asset, std::ios::binary); + std::ifstream s2(asset, std::ios::binary); + builder.add_ingredient(ing_a, "image/jpeg", s1); + builder.add_ingredient(ing_c, "image/jpeg", s2); + // Both ingredients now reference "thumb.jpg" resolved from folder_a, so + // folder_c's distinct thumbnail is lost. We assert the collision is + // observable: signing succeeds but both thumbnails came from folder_a, + // OR (depending on internal dedup) is simply not what the user intended. + // The point of the test is the FIX below, so we only require that the + // naive approach cannot distinguish the two sources. + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("collide-naive") / "out.jpg"; + // Not asserting throw/no-throw here: the defect is silent (wrong bytes), + // which is exactly why the add_resource fix is recommended. + EXPECT_NO_THROW(builder.sign(asset, out, signer)); + } + + // FIX: give each thumbnail a UNIQUE identifier and inline its bytes with + // add_resource, so neither ingredient depends on a global base_path. + { + auto builder = c2pa::Builder(manifest); + + json ia = make_ing("ing A"); + ia["thumbnail"]["identifier"] = "thumb_a.jpg"; + json ic = make_ing("ing C"); + ic["thumbnail"]["identifier"] = "thumb_c.jpg"; + + std::ifstream ta(folder_a / "thumb.jpg", std::ios::binary); + std::ifstream tc(folder_c / "thumb.jpg", std::ios::binary); + builder.add_resource("thumb_a.jpg", ta); + builder.add_resource("thumb_c.jpg", tc); + + std::ifstream s1(asset, std::ios::binary); + std::ifstream s2(asset, std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ia.dump(), "image/jpeg", s1)); + ASSERT_NO_THROW(builder.add_ingredient(ic.dump(), "image/jpeg", s2)); + + auto ingredients = sign_and_read_ingredients(builder, asset, "collide-fixed"); + EXPECT_EQ(ingredients.size(), 2u) + << "inlining resources lets both distinct-thumbnail ingredients resolve"; + } +} + +// A legacy folder ingredient and a modern archive ingredient both go on one Builder. +TEST_F(LegacyFolderIngredient, MixLegacyAndModernArchiveOnOneBuilder) { + auto src = c2pa_test::get_fixture_path("A.jpg"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // One builder, both routes: neither route calls set_base_path on `builder` + // itself, since add_ingredient_from_archive merges resources into whatever + // base_path state the receiving builder already carries. + auto builder = c2pa::Builder(manifest); + add_legacy_folder_ingredient(builder, "mix-legacy", "legacy ingredient", "legacy-mix"); + + // Modern dedicated ingredient archive built from C.jpg. + std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); + { + auto ab = c2pa::Builder(manifest); + ab.add_ingredient( + R"({"title": "modern ingredient", "relationship": "componentOf", "label": "ing-modern"})", + c2pa_test::get_fixture_path("C.jpg")); + ab.write_ingredient_archive("ing-modern", archive); + } + archive.seekg(0); + ASSERT_NO_THROW(builder.add_ingredient_from_archive(archive)); + + auto ingredients = sign_and_read_ingredients(builder, src, "mix-out"); + ASSERT_EQ(ingredients.size(), 2u); + std::vector titles = {ingredients[0]["title"], ingredients[1]["title"]}; + EXPECT_NE(std::find(titles.begin(), titles.end(), "legacy ingredient"), titles.end()); + EXPECT_NE(std::find(titles.begin(), titles.end(), "modern ingredient"), titles.end()); +} + +// Three legacy folder ingredients plus one modern archive go on one Builder. +TEST_F(LegacyFolderIngredient, MixMultipleLegacyAndOneModern) { + auto src = c2pa_test::get_fixture_path("A.jpg"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(manifest); + add_legacy_folder_ingredient(builder, "m1-legacy-a", "legacy A", "legacy-m1-a"); + add_legacy_folder_ingredient(builder, "m1-legacy-b", "legacy B", "legacy-m1-b"); + add_legacy_folder_ingredient(builder, "m1-legacy-c", "legacy C", "legacy-m1-c"); + auto a1 = add_modern_archive(builder, manifest, "ing-modern", "modern X", + c2pa_test::get_fixture_path("C.jpg")); + + auto ingredients = sign_and_read_ingredients(builder, src, "m1-out"); + ASSERT_EQ(ingredients.size(), 4u); + expect_titles(ingredients, {"legacy A", "legacy B", "legacy C", "modern X"}); +} + +// Two legacy folder ingredients plus two modern archives go on one Builder. +TEST_F(LegacyFolderIngredient, MixMultipleLegacyAndMultipleModern) { + auto src = c2pa_test::get_fixture_path("A.jpg"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(manifest); + add_legacy_folder_ingredient(builder, "m2-legacy-a", "legacy A", "legacy-m2-a"); + add_legacy_folder_ingredient(builder, "m2-legacy-b", "legacy B", "legacy-m2-b"); + auto a1 = add_modern_archive(builder, manifest, "ing-modern-1", "modern X", + c2pa_test::get_fixture_path("C.jpg")); + auto a2 = add_modern_archive(builder, manifest, "ing-modern-2", "modern Y", + c2pa_test::get_fixture_path("sample1.gif")); + + auto ingredients = sign_and_read_ingredients(builder, src, "m2-out"); + ASSERT_EQ(ingredients.size(), 4u); + expect_titles(ingredients, {"legacy A", "legacy B", "modern X", "modern Y"}); +} + +// Loads a legacy folder fixture (ingredient.json + manifest_data.c2pa + self#jumbf thumbnail). +TEST_F(LegacyFolderIngredient, LegacyIngredientFolderLoading) { + fs::path folder = + c2pa_test::get_fixture_path("ingredient-legacy-folder-migration"); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + + std::ifstream asset_stream(c2pa_test::get_fixture_path("C.jpg"), std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + + auto ingredients = + sign_and_read_ingredients(builder, c2pa_test::get_fixture_path("A.jpg"), + "real-legacy-out"); + ASSERT_EQ(ingredients.size(), 1u); + EXPECT_EQ(ingredients[0]["title"], "C.jpg"); + EXPECT_EQ(ingredients[0]["relationship"], "componentOf"); + // The legacy manifest_data is carried: the ingredient references an active + // manifest reconstituted from manifest_data.c2pa. + EXPECT_TRUE(ingredients[0].contains("active_manifest")) + << "legacy manifest_data.c2pa should be resolved via base_path"; +} + +// Loads the legacy folder fixture with no asset stream by injecting the ingredient into the definition and carrying its store with add_resource. +TEST_F(LegacyFolderIngredient, LegacyIngredientFolderLoadingNoAssetStream) { + fs::path folder = + c2pa_test::get_fixture_path("ingredient-legacy-folder-migration"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // The ingredient fields, including a manifest_data ref, come from the folder's + // ingredient.json. + json ingredient = json::parse(c2pa_test::read_text_file(folder / "ingredient.json")); + ASSERT_TRUE(ingredient.contains("manifest_data")); + + // Fill validation_results only when the ingredient.json lacks it. Older folders + // may already carry the field (this fixture does); read it from the store + // otherwise, since the definition-injection route cannot derive it. + if (!ingredient.contains("validation_results")) { + std::ifstream store_in(folder / "manifest_data.c2pa", std::ios::binary); + c2pa::Reader store_reader("application/c2pa", store_in); + ingredient["validation_results"] = + json::parse(store_reader.json())["validation_results"]; + } + + // Inject the ingredient into the definition, then carry the store bytes under + // the matching identifier. No asset stream is added. + json def = json::parse(manifest); + def["ingredients"] = json::array({ingredient}); + auto builder = c2pa::Builder(def.dump()); + + std::ifstream store_res(folder / "manifest_data.c2pa", std::ios::binary); + ASSERT_NO_THROW(builder.add_resource("manifest_data.c2pa", store_res)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("real-legacy-noasset-out") / "out.jpg"; + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), out, signer)); + + auto reader = c2pa::Reader(out); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + EXPECT_EQ(ingredients[0]["title"], "C.jpg"); + // Provenance is carried even with no asset stream. + EXPECT_TRUE(ingredients[0].contains("active_manifest")) + << "injected manifest_data.c2pa should be carried via add_resource"; +} + +// Re-archives the legacy folder fixture into an ingredient archive, then adds that archive to a fresh Builder. +TEST_F(LegacyFolderIngredient, LegacyIngredientFolderLoadingReArchiveThenAdd) { + fs::path folder = + c2pa_test::get_fixture_path("ingredient-legacy-folder-migration"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // Give the ingredient a label so write_ingredient_archive can name it. + json ingredient = json::parse(c2pa_test::read_text_file(folder / "ingredient.json")); + ingredient["label"] = "ing-mig"; + + // Load the legacy folder via base_path with a (carrier) asset, + // then emit it in the modern archive format. + std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); + { + auto b = c2pa::Builder(manifest); + b.set_base_path(folder.string()); + std::ifstream asset(c2pa_test::get_fixture_path("C.jpg"), std::ios::binary); + ASSERT_NO_THROW(b.add_ingredient(ingredient.dump(), "image/jpeg", asset)); + ASSERT_NO_THROW(b.write_ingredient_archive("ing-mig", archive)); + } + + // The archive bundles the manifest store, so a fresh Builder loads it with no + // base_path and keeps the provenance. + archive.seekg(0); + auto builder = c2pa::Builder(manifest); + ASSERT_NO_THROW(builder.add_ingredient_from_archive(archive)); + + auto ingredients = + sign_and_read_ingredients(builder, c2pa_test::get_fixture_path("A.jpg"), + "real-legacy-rearchive-out"); + ASSERT_EQ(ingredients.size(), 1u); + EXPECT_EQ(ingredients[0]["title"], "C.jpg"); + EXPECT_TRUE(ingredients[0].contains("active_manifest")) + << "re-archived legacy ingredient should keep its provenance"; +} + +// manifest_data resolves eagerly at add_ingredient, so deleting the directory +// before sign still signs and carries provenance. +TEST_F(LegacyFolderIngredient, ManifestDataDirectoryDeletableAfterAdd) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "eager-del"); + json ing = { + {"title", "eager legacy"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path folder = write_legacy_folder("eager-del", ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, folder / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + std::ifstream asset_stream(folder / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + asset_stream.close(); + + // Delete the whole directory AFTER add_ingredient, BEFORE sign. + fs::remove_all(folder); + ASSERT_FALSE(fs::exists(folder)); + + // Eager resolution: manifest_data is already in the Builder, so sign succeeds + // and the ingredient keeps its provenance despite the directory being gone. + auto ingredients = sign_and_read_ingredients( + builder, c2pa_test::get_fixture_path("A.jpg"), "eager-del-out"); + ASSERT_EQ(ingredients.size(), 1u); + EXPECT_TRUE(ingredients[0].contains("active_manifest")) + << "manifest_data resolved eagerly; directory deletable before sign"; +} + +// A thumbnail resolves lazily at sign, so deleting the directory before sign +// makes sign fail; deleting after sign is fine. +TEST_F(LegacyFolderIngredient, ThumbnailDirectoryMustSurviveUntilSign) { + auto asset = c2pa_test::get_fixture_path("A.jpg"); + auto thumb_src = c2pa_test::get_fixture_path("A.jpg"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto make_ing = []() { + return json{ + {"title", "lazy thumb"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"thumbnail", {{"format", "image/jpeg"}, {"identifier", "thumb.jpg"}}}, + }; + }; + + // Negative test: delete the directory before sign. The lazy thumbnail cannot be + // read at sign time, so sign must throw. + { + fs::path folder = + write_legacy_folder("lazy-del", make_ing(), nullptr, &thumb_src, "thumb.jpg"); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + std::ifstream src(asset, std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", src)); + src.close(); + + fs::remove_all(folder); + ASSERT_FALSE(fs::exists(folder)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("lazy-del-fail") / "out.jpg"; + EXPECT_ANY_THROW(builder.sign(asset, out, signer)) + << "thumbnail resolved lazily; deleting the directory before sign must fail"; + } + + // Postive test: keep the directory until sign, then it is safe to delete. + { + fs::path folder = + write_legacy_folder("lazy-keep", make_ing(), nullptr, &thumb_src, "thumb.jpg"); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + std::ifstream src(asset, std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", src)); + + EXPECT_NO_THROW(sign_and_read_ingredients(builder, asset, "lazy-keep-ok")); + // Directory survived until sign; deleting now is fine. + fs::remove_all(folder); + } +} + +// The Builder does not de-duplicate. Adding the same directory ingredient +// twice yields two ingredients in the signed manifest. +TEST_F(LegacyFolderIngredient, NoDeduplicationSameDirectoryAddedTwice) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "dedup"); + json ing = { + {"title", "dup legacy"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path folder = write_legacy_folder("dedup", ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, folder / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + // Add the identical ingredient twice. + std::ifstream a1(folder / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", a1)); + std::ifstream a2(folder / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", a2)); + + auto ingredients = sign_and_read_ingredients( + builder, c2pa_test::get_fixture_path("A.jpg"), "dedup-out"); + EXPECT_EQ(ingredients.size(), 2u) + << "Builder does not de-duplicate; two adds produce two ingredients"; +} + +// A corrupt manifest_data.c2pa fails at sign, not at add_ingredient, and +// with a different message than the missing/unresolved case (ResourceNotFound). +TEST_F(LegacyFolderIngredient, CorruptManifestDataFailsAtSign) { + json ing = { + {"title", "corrupt legacy"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + // Garbage bytes standing in for a real manifest store. + std::vector garbage(512, 0xAB); + fs::path folder = write_legacy_folder("corrupt", ing, &garbage, nullptr, ""); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + std::ifstream asset(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + // The corruption is not detected at add time. + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("corrupt-out") / "out.jpg"; + std::string msg; + try { + builder.sign(c2pa_test::get_fixture_path("A.jpg"), out, signer); + FAIL() << "sign should throw on a corrupt manifest_data.c2pa"; + } catch (const std::exception &e) { + msg = e.what(); + } + // Capture the actual message for the docs; corruption should not read as a + // plain "not found". + std::cerr << "[corrupt manifest_data sign error] " << msg << std::endl; + EXPECT_FALSE(msg.empty()); + EXPECT_EQ(msg.find("ResourceNotFound"), std::string::npos) + << "corrupt store should fail with a verify/JUMBF error, not ResourceNotFound; got: " + << msg; +} + +// An unrelated asset stream can mask an unresolved manifest_data reference. +// ingredient.json declares manifest_data but base_path is NOT set, so the ref cannot +// resolve from disk. Observe whether an unrelated asset stream lets sign succeed +// (masking) or still throws. +TEST_F(LegacyFolderIngredient, UnrelatedAssetStreamMasksUnresolvedRef) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "mask"); + json ing = { + {"title", "masking legacy"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path folder = write_legacy_folder("mask", ing, &seed.manifest_bytes, nullptr, ""); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(manifest); + // NO set_base_path: the declared manifest_data cannot resolve from disk. + // Pass an unrelated asset stream (C.jpg, not the store's bound asset). + std::ifstream unrelated(c2pa_test::get_fixture_path("C.jpg"), std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", unrelated)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("mask-out") / "out.jpg"; + bool threw = false; + std::string msg; + try { + builder.sign(c2pa_test::get_fixture_path("A.jpg"), out, signer); + } catch (const std::exception &e) { + threw = true; + msg = e.what(); + } + // Document the real behavior. If sign succeeded, the unrelated stream masked the + // unresolved manifest_data reference (the pitfall); if it threw, record why. + std::cerr << "[unrelated-asset masking] threw=" << (threw ? "yes" : "no") + << " msg=" << msg << std::endl; + if (!threw) { + auto reader = c2pa::Reader(out); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto ingredients = parsed["manifests"][active]["ingredients"]; + EXPECT_EQ(ingredients.size(), 1u) + << "unrelated asset stream masked the unresolved manifest_data and signed"; + } + SUCCEED(); +} + +TEST_F(LegacyFolderIngredient, LinkParentOfToOpened) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "link-parent"); + json ing = { + {"title", "link-parent.jpg"}, + {"format", "image/jpeg"}, + {"relationship", "parentOf"}, + {"label", "dir-parent"}, // primary ingredientIds lookup key + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path dir = write_legacy_folder("link-parent", ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, dir / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(dir / "ingredient.json"); + + json 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({"dir-parent"})}}}}, + })}}}}, + })}, + }; + + auto builder = c2pa::Builder(manifest.dump()); + builder.set_base_path(dir.string()); + std::ifstream asset_stream(dir / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("link-parent-out") / "out.jpg"; + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), out, signer)); + + auto reader = c2pa::Reader(out); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + bool found = false; + for (auto &assertion : parsed["manifests"][active]["assertions"]) { + if (assertion["label"] != "c2pa.actions.v2" && + assertion["label"] != "c2pa.actions") continue; + for (auto &a : assertion["data"]["actions"]) { + if (a["action"] != "c2pa.opened") continue; + if (a.contains("parameters") && a["parameters"].contains("ingredients")) { + auto &ings = a["parameters"]["ingredients"]; + ASSERT_GE(ings.size(), 1u); + ASSERT_TRUE(ings[0].contains("url")); + std::string url = ings[0]["url"]; + EXPECT_NE(url.find("c2pa.ingredient"), std::string::npos) + << "c2pa.opened should resolve to an ingredient assertion, got " << url; + found = true; + } + } + } + EXPECT_TRUE(found) << "linked c2pa.opened action not found in signed output"; +} + +TEST_F(LegacyFolderIngredient, LinkComponentOfToPlaced) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "link-comp"); + json ing = { + {"title", "link-comp.jpg"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"label", "dir-comp"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path dir = write_legacy_folder("link-comp", ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, dir / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(dir / "ingredient.json"); + + json manifest = { + {"claim_generator_info", + json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + {{"label", "c2pa.actions"}, + {"data", {{"actions", json::array({ + {{"action", "c2pa.placed"}, + {"parameters", {{"ingredientIds", json::array({"dir-comp"})}}}}, + })}}}}, + })}, + }; + + auto builder = c2pa::Builder(manifest.dump()); + builder.set_base_path(dir.string()); + std::ifstream asset_stream(dir / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("link-comp-out") / "out.jpg"; + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), out, signer)); + + auto reader = c2pa::Reader(out); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + bool found = false; + for (auto &assertion : parsed["manifests"][active]["assertions"]) { + if (assertion["label"] != "c2pa.actions.v2" && + assertion["label"] != "c2pa.actions") continue; + for (auto &a : assertion["data"]["actions"]) { + if (a["action"] != "c2pa.placed") continue; + if (a.contains("parameters") && a["parameters"].contains("ingredients")) { + auto &ings = a["parameters"]["ingredients"]; + ASSERT_GE(ings.size(), 1u); + ASSERT_TRUE(ings[0].contains("url")); + std::string url = ings[0]["url"]; + EXPECT_NE(url.find("c2pa.ingredient"), std::string::npos) + << "c2pa.placed should resolve to an ingredient assertion, got " << url; + found = true; + } + } + } + EXPECT_TRUE(found) << "linked c2pa.placed action not found in signed output"; +} + +TEST_F(LegacyFolderIngredient, LinkInputToToEdited) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "link-input"); + json ing = { + {"title", "link-input.jpg"}, + {"format", "image/jpeg"}, + {"relationship", "inputTo"}, + {"label", "dir-input"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path dir = write_legacy_folder("link-input", ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, dir / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(dir / "ingredient.json"); + + json manifest = { + {"claim_generator_info", + json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + {{"label", "c2pa.actions"}, + {"data", {{"actions", json::array({ + {{"action", "c2pa.edited"}, + {"parameters", {{"ingredientIds", json::array({"dir-input"})}}}}, + })}}}}, + })}, + }; + + auto builder = c2pa::Builder(manifest.dump()); + builder.set_base_path(dir.string()); + std::ifstream asset_stream(dir / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("link-input-out") / "out.jpg"; + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), out, signer)); + + auto reader = c2pa::Reader(out); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + bool found = false; + for (auto &assertion : parsed["manifests"][active]["assertions"]) { + if (assertion["label"] != "c2pa.actions.v2" && + assertion["label"] != "c2pa.actions") continue; + for (auto &a : assertion["data"]["actions"]) { + if (a["action"] != "c2pa.edited") continue; + if (a.contains("parameters") && a["parameters"].contains("ingredients")) { + auto &ings = a["parameters"]["ingredients"]; + ASSERT_GE(ings.size(), 1u); + ASSERT_TRUE(ings[0].contains("url")); + std::string url = ings[0]["url"]; + EXPECT_NE(url.find("c2pa.ingredient"), std::string::npos) + << "c2pa.edited should resolve to an ingredient assertion, got " << url; + found = true; + } + } + } + EXPECT_TRUE(found) << "linked c2pa.edited action not found in signed output"; +} + +// After signing, the `label` used for action linking is not preserved on the +// read-back ingredient: the SDK consumes it as the linking key and rewrites it. +// An explicit `instance_id`, by contrast, stays in the ingredient data. +TEST_F(LegacyFolderIngredient, LabelNotPreservedButInstanceIdIs) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "label-persist"); + const std::string my_label = "dir-link-label"; + const std::string my_iid = "xmp:iid:11111111-2222-3333-4444-555555555555"; + json ing = { + {"title", "label-persist.jpg"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"label", my_label}, + {"instance_id", my_iid}, // explicit, caller-controlled + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path dir = write_legacy_folder("label-persist", ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, dir / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(dir / "ingredient.json"); + + json manifest = { + {"claim_generator_info", + json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + {{"label", "c2pa.actions"}, + {"data", {{"actions", json::array({ + {{"action", "c2pa.placed"}, + {"parameters", {{"ingredientIds", json::array({my_label})}}}}, + })}}}}, + })}, + }; + + auto builder = c2pa::Builder(manifest.dump()); + builder.set_base_path(dir.string()); + std::ifstream asset_stream(dir / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("label-persist-out") / "out.jpg"; + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), out, signer)); + + auto reader = c2pa::Reader(out); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto &ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + const auto &out_ing = ingredients[0]; + + std::string read_label = out_ing.value("label", std::string("")); + std::string read_iid = out_ing.value("instance_id", std::string("")); + std::cerr << "[label persistence] label=" << read_label + << " instance_id=" << read_iid << std::endl; + + // The linking label is not carried through verbatim onto the read-back ingredient. + EXPECT_NE(read_label, my_label) + << "the linking label should not survive verbatim on the signed ingredient"; + // The explicit instance_id stays in the ingredient data. + EXPECT_EQ(read_iid, my_iid) + << "an explicit instance_id should be preserved on the signed ingredient"; +}