From b45785aead1a3da1295fa74636bc969e948acbf6 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:05:15 -0700 Subject: [PATCH 1/3] fix: WIP --- docs/selective-manifests.md | 177 ++++++++++++- docs/working-stores.md | 99 ++++++++ include/c2pa.hpp | 12 + src/c2pa_builder.cpp | 20 ++ tests/builder.test.cpp | 495 +++++++++++++++++++++++++++++++++++- 5 files changed, 800 insertions(+), 3 deletions(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index b4c301b..7877376 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -441,7 +441,18 @@ An **ingredient archive** contains the manifest store from an asset that was add The key difference: a builder archive is a work-in-progress (unsigned). An ingredient archive carries the provenance history of a source asset for reuse as an ingredient in other working stores. -### The ingredients catalog pattern +### Two ways to produce an ingredient archive + +The SDK supports two approaches for producing an ingredient archive. They share the same `.c2pa` binary format and are interchangeable from the consumer side. + +| Approach | Entry point | When to use | +| --- | --- | --- | +| JSON-override builder archive | `Builder` + `add_ingredient` + `to_archive` | Full control over the manifest definition; multi-ingredient slicing via the read-filter-rebuild pattern. | +| Dedicated single-ingredient API | `add_ingredient` then `write_ingredient_archive(id, stream)` | One ingredient per archive, simpler call site, no manual JSON manipulation. | + +The dedicated API requires the `builder.generate_c2pa_archive` setting on the producing builder. For the full contract (id resolution, error cases, examples), see [Single-ingredient archive APIs](./working-stores.md#single-ingredient-archive-apis) in the working stores guide. + +## The ingredients catalog pattern An **ingredients catalog** is a collection of archived ingredients that can be selected when constructing a final manifest. Each archive holds ingredients; at build time the caller selects only the ones needed. @@ -467,7 +478,11 @@ flowchart TD style X fill:#f99,stroke:#c00 ``` +The catalog can be implemented in two ways. Variant 1 uses one multi-ingredient builder archive and the read-filter-rebuild pattern to slice out a subset. Variant 2 uses one archive per ingredient and the dedicated single-ingredient API. + +### Variant 1: read-filter-rebuild from a multi-ingredient archive +Use this variant when the catalog already exists as a single `.c2pa` builder archive containing many ingredients, and the consumer picks a subset by reading, filtering, and rebuilding. ```cpp // Read from a catalog of archived ingredients @@ -516,6 +531,78 @@ for (auto& ingredient : selected) { builder.sign(source_path, output_path, signer); ``` +### Variant 2: one ingredient per archive via the dedicated API + +Use this variant when ingredients are produced and consumed independently. The producer registers each ingredient on a builder and writes one archive per ingredient, keyed by `instance_id`. The consumer assembles a final builder by loading only the archives it needs via `add_ingredient_from_archive`. The producing builder must have the `builder.generate_c2pa_archive` setting enabled. + +Producer side, build the catalog: + +```cpp +auto settings = c2pa::Settings(); +settings.set("builder.generate_c2pa_archive", "true"); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto catalog_builder = c2pa::Builder(context, manifest_json); +catalog_builder.add_ingredient( + R"({"title": "photo-A.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-A"})", + "photo-A.jpg"); +catalog_builder.add_ingredient( + R"({"title": "photo-B.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-B"})", + "photo-B.jpg"); + +// One archive per ingredient, keyed by the instance_id used at registration. +std::stringstream archive_a(std::ios::in | std::ios::out | std::ios::binary); +std::stringstream archive_b(std::ios::in | std::ios::out | std::ios::binary); +catalog_builder.write_ingredient_archive("catalog:ingredient-A", archive_a); +catalog_builder.write_ingredient_archive("catalog:ingredient-B", archive_b); +``` + +Consumer side, pick one archive and load it: + +```cpp +auto final_builder = c2pa::Builder(context, manifest_json); +archive_b.seekg(0); +final_builder.add_ingredient_from_archive(archive_b); + +final_builder.sign(source_path, output_path, signer); +``` + +The signed output contains exactly the picked ingredient (`photo-B.jpg` here). `archive_a` stays unused. + +A single action can link several ingredients loaded this way. With three archives (`ing-a`, `ing-b`, `ing-c`) loaded into one signing builder, a `c2pa.placed` action that lists all three ids in `ingredientIds` resolves to three distinct ingredient URLs after signing: + +```cpp +auto signing_builder = c2pa::Builder(context, R"({ + "claim_generator_info": [{"name": "an-application", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [{ + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["ing-a", "ing-b", "ing-c"] + } + }] + } + }] +})"); + +archive_a.seekg(0); +archive_b.seekg(0); +archive_c.seekg(0); +signing_builder.add_ingredient_from_archive(archive_a); +signing_builder.add_ingredient_from_archive(archive_b); +signing_builder.add_ingredient_from_archive(archive_c); + +signing_builder.sign(source_path, output_path, signer); +``` + +#### Choosing between variants + +Variant 1 fits when the catalog already exists as one multi-ingredient builder archive and the consumer wants a subset of it. Variant 2 fits when ingredients are produced and consumed independently: each archive holds one and only one ingredient, and the call sites stay short. Both produce the same signed output. + ### Identifying ingredients in archives When building an ingredient archive, you can set `instance_id` on the ingredient to give it a stable, caller-controlled identifier. This field survives archiving and signing unchanged, so it can be used to look up a specific ingredient from a catalog archive. The `description` and `informational_URI` fields also survive and can carry additional metadata about the ingredient's origin. @@ -718,6 +805,41 @@ for (auto& ingredient : selected) { new_builder.sign(source_path, output_path, signer); ``` +#### Extracting with the dedicated archive API + +The same end-to-end flow (build a working store with ingredients, archive, then reuse a subset elsewhere) is shorter when using `write_ingredient_archive` and `add_ingredient_from_archive`. The producer writes one archive per ingredient, the sink loads only the ones it needs. The `builder.generate_c2pa_archive` setting must be enabled on the producing builder. + +```cpp +auto settings = c2pa::Settings(); +settings.set("builder.generate_c2pa_archive", "true"); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +// Producer: register two ingredients keyed by instance_id, archive each separately. +auto producer = c2pa::Builder(context, manifest_json); +producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-A"})", + "A.jpg"); +producer.add_ingredient( + R"({"title": "C.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-C"})", + "C.jpg"); + +std::stringstream archive_a(std::ios::in | std::ios::out | std::ios::binary); +std::stringstream archive_c(std::ios::in | std::ios::out | std::ios::binary); +producer.write_ingredient_archive("catalog:ingredient-A", archive_a); +producer.write_ingredient_archive("catalog:ingredient-C", archive_c); + +// Sink: load only ingredient-A. +auto sink = c2pa::Builder(context, manifest_json); +archive_a.seekg(0); +sink.add_ingredient_from_archive(archive_a); + +sink.sign(source_path, output_path, signer); +``` + +The signed output contains exactly the loaded ingredient. No JSON parsing, no manual `add_resource` calls. + ### Reading ingredient details from an ingredient archive An ingredient archive is a serialized `Builder` containing exactly one and only one ingredient (see [Builder archives vs. ingredient archives](#builder-archives-vs-ingredient-archives)). Reading it with `Reader` allows the caller to inspect the ingredient before deciding whether to use it: its thumbnail, whether it carries provenance (e.g. an active manifest), validation status, relationship, etc. @@ -835,12 +957,65 @@ builder.add_ingredient( builder.sign(source_path, output_path, signer); ``` +##### Linking with the dedicated archive API + +The same linking flow works when the ingredient is loaded with `add_ingredient_from_archive`. The id used at write time on the producer (passed as the first argument to `write_ingredient_archive`) becomes the linking key on the signing builder. Reference that same id in `ingredientIds`. + +Producer side: + +```cpp +auto settings = c2pa::Settings(); +settings.set("builder.generate_c2pa_archive", "true"); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto archive_builder = c2pa::Builder(context, manifest_json); +archive_builder.add_ingredient( + R"({"title": "photo.jpg", "relationship": "parentOf", "label": "my-ingredient"})", + "photo.jpg"); + +std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); +archive_builder.write_ingredient_archive("my-ingredient", archive); +``` + +Signing side, link `my-ingredient` to `c2pa.opened`: + +```cpp +auto signing_builder = c2pa::Builder(context, R"({ + "claim_generator_info": [{"name": "an-application", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [{ + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": { "ingredientIds": ["my-ingredient"] } + }] + } + }] +})"); + +archive.seekg(0); +signing_builder.add_ingredient_from_archive(archive); + +signing_builder.sign(source_path, output_path, signer); +``` + +The same id can appear in `ingredientIds` of more than one action. A `c2pa.opened` and a `c2pa.placed` action that both list `my-ingredient` resolve to the same ingredient URL after signing. + +For `c2pa.placed`, the relationship on the producing builder is `componentOf` instead of `parentOf`. Otherwise the linking pattern is identical. + +A signing builder can mix the dedicated API with the existing `add_ingredient(json, source)` overloads in the same build. Linking by id works the same regardless of how each ingredient reached the builder. For example, an action that lists `via-add`, `via-stream`, `via-archive` in `ingredientIds` resolves to three distinct ingredient URLs when one ingredient is added by file, one by stream, and one by ingredient archive. + ### Merging multiple working stores In some cases you may need to merge ingredients from multiple working stores (builder archives) into a single `Builder`. This should be a **fallback strategy**—the recommended practice is to maintain a single active working store and add ingredients incrementally (archived ingredient catalogs help with this). Merging is available when multiple working stores must be consolidated. When merging from multiple sources, resource identifier URIs can collide. Rename identifiers with a unique suffix when needed. Use two passes: (1) collect ingredients with collision handling, build the manifest, create the builder; (2) re-read each archive and transfer resources (use original ID for `get_resource()`, renamed ID for `add_resource()` when collisions occurred). +When each source contributes one ingredient, the dedicated single-ingredient API sidesteps this resource-identifier collision case: each archive holds one and only one ingredient, and `add_ingredient_from_archive` registers it cleanly on the consuming builder. See [Single-ingredient archive APIs](./working-stores.md#single-ingredient-archive-apis) in the working stores guide. The two-pass approach below remains the right tool when sources hold multiple ingredients each and a full merge is required. + ```cpp std::set used_ids; int suffix_counter = 0; diff --git a/docs/working-stores.md b/docs/working-stores.md index d2b0847..2410800 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -609,6 +609,8 @@ const std::string ingredient_json = R"({ builder.add_ingredient(ingredient_json, "base_layer.png"); ``` +For the dedicated single-ingredient archive APIs, see [Single-ingredient archive APIs](#single-ingredient-archive-apis) below. For the multi-archive catalog use case, see [The ingredients catalog pattern](./selective-manifests.md#the-ingredients-catalog-pattern) in the selective manifests guide. + ## Working with archives An *archive* (C2PA archive) is a serialized working store (`Builder` object) saved to a file or stream. @@ -730,6 +732,103 @@ void sign_asset() { } ``` +### Single-ingredient archive APIs + +The `Builder` class exposes two dedicated APIs for moving a single ingredient between builders without manual JSON manipulation: + +- `Builder::write_ingredient_archive(id, stream)` writes one already-registered ingredient out as a single-ingredient JUMBF archive. +- `Builder::add_ingredient_from_archive(stream)` loads one such archive into a builder. + +#### How `add_ingredient` and `write_ingredient_archive` interact + +`add_ingredient(json, source)` is the registration step. It hashes the source asset, builds the ingredient assertion, and stores the ingredient in the builder under an id read from the JSON. The id is the `label` field if present, otherwise `instance_id`. + +`write_ingredient_archive(id, stream)` is a lookup step rather than a factory. It finds an ingredient that was already registered under `id` and serializes that one ingredient as a JUMBF archive (tagged `ARCHIVE_TYPE_INGREDIENT`). Calling it without a prior `add_ingredient` for that id throws `c2pa::C2paException`. + +Two more contract points to keep in mind: + +- The producing builder must have the `builder.generate_c2pa_archive` setting enabled. Otherwise `write_ingredient_archive` throws. +- The exported archive is not a lossless slice of the parent. It contains one cloned ingredient and a fresh claim instance id. Any other ingredients on the parent builder are omitted. + +`add_ingredient_from_archive(stream)` adds the ingredient back to a consuming builder, keyed by the same id the producer used. + +#### Example 1: Write a single-ingredient archive + +```cpp +auto settings = c2pa::Settings(); +settings.set("builder.generate_c2pa_archive", "true"); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto builder = c2pa::Builder(context, manifest_json); + +// Register three ingredients. The `label` becomes each ingredient's id. +builder.add_ingredient( + R"({"title": "first.jpg", "relationship": "componentOf", "label": "first"})", + "first.jpg"); +builder.add_ingredient( + R"({"title": "second.jpg", "relationship": "componentOf", "label": "second"})", + "second.jpg"); +builder.add_ingredient( + R"({"title": "third.jpg", "relationship": "componentOf", "label": "third"})", + "third.jpg"); + +// Look up "second" and write only that one to the archive stream. +std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); +builder.write_ingredient_archive("second", archive); +``` + +The archive contains exactly one ingredient. Reading it back through `c2pa::Reader` with format `application/c2pa` shows a single-ingredient manifest. + +#### Example 2: Load an ingredient archive into a fresh builder + +```cpp +auto settings = c2pa::Settings(); +settings.set("builder.generate_c2pa_archive", "true"); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto consumer = c2pa::Builder(context, manifest_json); + +// `archive` is a stream produced by write_ingredient_archive on another builder. +archive.seekg(0); +consumer.add_ingredient_from_archive(archive); + +// The ingredient is now registered on `consumer`. Sign as usual. +consumer.sign("source.jpg", "output.jpg", signer); +``` + +#### Id resolution + +The id passed to `write_ingredient_archive` matches against fields on the registered ingredient JSON in the order: + +1. `label` if it is set and non-empty. +2. `instance_id` if no `label` is set. + +When only `instance_id` is set (no `label`), the `instance_id` value is the lookup key. The same key is the one to use in `ingredientIds` when linking the loaded ingredient to an action. + +#### Errors + +`write_ingredient_archive` throws `c2pa::C2paException` when: + +- The producing `Builder` has no prior `add_ingredient` registration. The lookup table is empty, so no id can resolve. +- The id does not match any registered ingredient's `label` or `instance_id`. Registering ingredient `real-id` and then asking for `wrong-id` throws. + +```cpp +auto builder = c2pa::Builder(context, manifest_json); +builder.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf", "label": "real-id"})", + "photo.jpg"); + +std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); +// Throws c2pa::C2paException: "wrong-id" was never registered. +builder.write_ingredient_archive("wrong-id", stream); +``` + +For a multi-archive use case (one catalog, many ingredients picked at build time), see [The ingredients catalog pattern](./selective-manifests.md#the-ingredients-catalog-pattern) in the selective manifests guide. It compares the read-filter-rebuild approach with this dedicated single-ingredient API. + ## Embedded vs external manifests By default, manifest stores are **embedded** directly into the asset file. You can also use **external** or **remote** manifest stores. diff --git a/include/c2pa.hpp b/include/c2pa.hpp index 0a0d5a5..ed8abc3 100644 --- a/include/c2pa.hpp +++ b/include/c2pa.hpp @@ -1276,6 +1276,18 @@ namespace c2pa /// @note Prefer using the streaming APIs if possible. void to_archive(const std::filesystem::path &dest_path); + /// @brief Write a single-ingredient archive for the named ingredient. + /// @param ingredient_id The instance_id of the ingredient within this builder. + /// @param dest The output stream to write the ingredient archive to. + /// @note Requires the `generate_c2pa_archive` context setting to be enabled. + /// @throws C2paException for errors encountered by the C2PA library. + void write_ingredient_archive(const std::string &ingredient_id, std::ostream &dest); + + /// @brief Add an ingredient to this builder from a per-ingredient archive stream. + /// @param archive The input stream containing the archive produced by write_ingredient_archive. + /// @throws C2paException for errors encountered by the C2PA library. + void add_ingredient_from_archive(std::istream &archive); + /// @brief Create a hashed placeholder from the builder. /// @param reserved_size The size required for a signature from the intended signer (in bytes). /// @param format The mime format or extension of the asset. diff --git a/src/c2pa_builder.cpp b/src/c2pa_builder.cpp index 686dd8a..e61d0ae 100644 --- a/src/c2pa_builder.cpp +++ b/src/c2pa_builder.cpp @@ -363,6 +363,26 @@ namespace c2pa to_archive(*dest); } + void Builder::write_ingredient_archive(const std::string &ingredient_id, std::ostream &dest) + { + CppOStream c_dest(dest); + int result = c2pa_builder_write_ingredient_archive(builder, ingredient_id.c_str(), c_dest.c_stream); + if (result < 0) + { + throw C2paException(); + } + } + + void Builder::add_ingredient_from_archive(std::istream &archive) + { + CppIStream c_archive(archive); + int result = c2pa_builder_add_ingredient_from_archive(builder, c_archive.c_stream); + if (result < 0) + { + throw C2paException(); + } + } + std::vector Builder::data_hashed_placeholder(uintptr_t reserve_size, const std::string &format) { const unsigned char *c2pa_manifest_bytes = nullptr; diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index 153f6e3..41ae3c2 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -5354,7 +5354,6 @@ TEST_F(BuilderTest, MultiphaseRebuildFromArchiveWithUpdatedProperties2) // Verify everything from both phases made it through. auto signed_reader = c2pa::Reader(context, output_path); - std::cout << signed_reader.json() << std::endl; auto signed_parsed = json::parse(signed_reader.json()); std::string signed_active = signed_parsed["active_manifest"]; auto& signed_manifest = signed_parsed["manifests"][signed_active]; @@ -5991,7 +5990,6 @@ TEST_F(BuilderTest, ArchiveIngredientWithProvenanceRoundTripAndReuse) c2pa::Reader archive_reader(context, "application/c2pa", archive_in); std::string archive_json; ASSERT_NO_THROW(archive_json = archive_reader.json()); - std::cout << archive_json << std::endl; auto parsed = json::parse(archive_json); ASSERT_TRUE(parsed.contains("active_manifest")); @@ -6027,3 +6025,496 @@ TEST_F(BuilderTest, ArchiveIngredientWithProvenanceRoundTripAndReuse) EXPECT_EQ(out_ingredients[0]["title"], "C.jpg"); EXPECT_EQ(out_ingredients[0]["relationship"], "componentOf"); } + +// Extract ingredient from archive, then reuse it. +// write_ingredient_archive per-ingredient -> selective add_ingredient_from_archive. +TEST_F(BuilderTest, ExtractIngredientsFromArchiveAndReuseUsingArchiveApi) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // Build a store with two ingredients, write each to its own ingredient archive. + auto builder = c2pa::Builder(context, manifest_str); + builder.add_ingredient( + json({{"title", "A.jpg"}, {"relationship", "componentOf"}, {"instance_id", "catalog:ingredient-A"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + builder.add_ingredient( + json({{"title", "C.jpg"}, {"relationship", "componentOf"}, {"instance_id", "catalog:ingredient-C"}}).dump(), + c2pa_test::get_fixture_path("C.jpg")); + + std::stringstream streamA(std::ios::in | std::ios::out | std::ios::binary); + std::stringstream streamC(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(builder.write_ingredient_archive("catalog:ingredient-A", streamA)); + ASSERT_NO_THROW(builder.write_ingredient_archive("catalog:ingredient-C", streamC)); + + auto builder2 = c2pa::Builder(context, manifest_str); + streamA.seekg(0); + ASSERT_NO_THROW(builder2.add_ingredient_from_archive(streamA)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("extract_reuse_new.jpg"); + ASSERT_NO_THROW(builder2.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u) << "Only ingredient-A should be present"; + EXPECT_EQ(ingredients[0]["title"], "A.jpg"); +} + +// Link a parentOf ingredient archive to an opened action. +// The ingredient_id passed to write_ingredient_archive survives the round-trip via the +// archive metadata's archive:ingredient_id field, so the signing builder references the +// loaded ingredient by that producer-side id. +TEST_F(BuilderTest, LinkIngredientArchiveParentOfOpenedUsingArchiveApi) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto archive_builder = c2pa::Builder(context, manifest_str); + archive_builder.add_ingredient( + json({{"title", "photo.jpg"}, {"relationship", "parentOf"}, {"label", "my-ingredient"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(archive_builder.write_ingredient_archive("my-ingredient", stream)); + + auto manifest_json = make_manifest_with_action("c2pa.opened", "my-ingredient", + "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"); + auto signing_builder = c2pa::Builder(context, manifest_json.dump()); + stream.seekg(0); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("link_ingredient_archive_parentof_opened.jpg"); + bool linked = verify_ingredient_linked(signing_builder, output_path, signer, "c2pa.opened"); + EXPECT_TRUE(linked); +} + +// Link a componentOf ingredient archive to a placed action. +TEST_F(BuilderTest, LinkIngredientArchiveComponentOfPlacedUsingArchiveApi) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto archive_builder = c2pa::Builder(context, manifest_str); + archive_builder.add_ingredient( + json({{"title", "photo.jpg"}, {"relationship", "componentOf"}, {"label", "my-ingredient"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(archive_builder.write_ingredient_archive("my-ingredient", stream)); + + auto manifest_json = make_manifest_with_action("c2pa.placed", "my-ingredient"); + auto signing_builder = c2pa::Builder(context, manifest_json.dump()); + stream.seekg(0); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("link_ingredient_archive_componentof_placed.jpg"); + bool linked = verify_ingredient_linked(signing_builder, output_path, signer, "c2pa.placed"); + EXPECT_TRUE(linked); +} + +// Link same ingredient to 2 different actions +TEST_F(BuilderTest, LinkIngredientArchiveToBothOpenedAndPlacedUsingArchiveApi) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto archive_builder = c2pa::Builder(context, manifest_str); + archive_builder.add_ingredient( + json({{"title", "photo.jpg"}, {"relationship", "parentOf"}, {"label", "shared-ingredient"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(archive_builder.write_ingredient_archive("shared-ingredient", stream)); + + json manifest_json = { + {"claim_generator_info", json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions.v2"}, + {"data", {{"actions", json::array({ + { + {"action", "c2pa.opened"}, + {"digitalSourceType", "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}, + {"parameters", {{"ingredientIds", json::array({"shared-ingredient"})}}} + }, + { + {"action", "c2pa.placed"}, + {"parameters", {{"ingredientIds", json::array({"shared-ingredient"})}}} + } + })}}} + } + })} + }; + + auto signing_builder = c2pa::Builder(context, manifest_json.dump()); + stream.seekg(0); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(stream)); + + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + auto output_path = get_temp_path("link_ingredient_archive_both.jpg"); + ASSERT_NO_THROW(signing_builder.sign(source_path, output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& manifest = parsed["manifests"][active]; + + json opened_action, placed_action; + bool found_opened = false, found_placed = false; + for (auto& assertion : manifest["assertions"]) { + if (assertion["label"] != "c2pa.actions.v2") continue; + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] == "c2pa.opened") { opened_action = action; found_opened = true; } + if (action["action"] == "c2pa.placed") { placed_action = action; found_placed = true; } + } + } + ASSERT_TRUE(found_opened) << "c2pa.opened action not found"; + ASSERT_TRUE(found_placed) << "c2pa.placed action not found"; + + ASSERT_TRUE(opened_action.contains("parameters")); + ASSERT_TRUE(opened_action["parameters"].contains("ingredients")); + ASSERT_EQ(opened_action["parameters"]["ingredients"].size(), 1u); + + ASSERT_TRUE(placed_action.contains("parameters")); + ASSERT_TRUE(placed_action["parameters"].contains("ingredients")); + ASSERT_EQ(placed_action["parameters"]["ingredients"].size(), 1u); + + std::string opened_url = opened_action["parameters"]["ingredients"][0]["url"]; + std::string placed_url = placed_action["parameters"]["ingredients"][0]["url"]; + EXPECT_EQ(opened_url, placed_url) << "Both actions should link the same ingredient archive"; + EXPECT_EQ(opened_url, "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"); +} + +// Catalog pattern: write per-ingredient archives indexed by instance_id, +// then assemble any subset directly via add_ingredient_from_archive. +TEST_F(BuilderTest, IngredientCatalogUsingArchiveApi) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // Build catalog: two ingredient archives indexed by instance_id. + auto catalog_builder = c2pa::Builder(context, manifest_str); + catalog_builder.add_ingredient( + json({{"title", "photo-A.jpg"}, {"relationship", "componentOf"}, {"instance_id", "catalog:ingredient-A"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + catalog_builder.add_ingredient( + json({{"title", "photo-B.jpg"}, {"relationship", "componentOf"}, {"instance_id", "catalog:ingredient-B"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream streamA(std::ios::in | std::ios::out | std::ios::binary); + std::stringstream streamB(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(catalog_builder.write_ingredient_archive("catalog:ingredient-A", streamA)); + ASSERT_NO_THROW(catalog_builder.write_ingredient_archive("catalog:ingredient-B", streamB)); + + // Assemble final builder using only ingredient-B from the catalog. + auto final_builder = c2pa::Builder(context, manifest_str); + streamB.seekg(0); + ASSERT_NO_THROW(final_builder.add_ingredient_from_archive(streamB)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("catalog_new.jpg"); + ASSERT_NO_THROW(final_builder.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u) << "Only ingredient-B should be present"; + EXPECT_EQ(ingredients[0]["title"], "photo-B.jpg"); + if (ingredients[0].contains("instance_id")) { + EXPECT_EQ(ingredients[0]["instance_id"], "catalog:ingredient-B"); + } +} + +// Three ingredient archives with distinct ids loaded into one signing builder, with a +// single action linking all three. +TEST_F(BuilderTest, LinkThreeIngredientArchivesDistinctIdsUsingArchiveApi) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto build_archive = [&](const std::string& id, const std::string& title, std::stringstream& stream) { + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + json({{"title", title}, {"relationship", "componentOf"}, {"label", id}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + producer.write_ingredient_archive(id, stream); + }; + + std::stringstream archive_a(std::ios::in | std::ios::out | std::ios::binary); + std::stringstream archive_b(std::ios::in | std::ios::out | std::ios::binary); + std::stringstream archive_c(std::ios::in | std::ios::out | std::ios::binary); + build_archive("ing-a", "Ingredient A", archive_a); + build_archive("ing-b", "Ingredient B", archive_b); + build_archive("ing-c", "Ingredient C", archive_c); + + json manifest_json = { + {"claim_generator_info", json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions.v2"}, + {"data", {{"actions", json::array({ + { + {"action", "c2pa.placed"}, + {"parameters", {{"ingredientIds", json::array({"ing-a", "ing-b", "ing-c"})}}} + } + })}}} + } + })} + }; + + auto signing_builder = c2pa::Builder(context, manifest_json.dump()); + archive_a.seekg(0); + archive_b.seekg(0); + archive_c.seekg(0); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(archive_a)); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(archive_b)); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(archive_c)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("link_three_ingredient_archives.jpg"); + ASSERT_NO_THROW(signing_builder.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& manifest = parsed["manifests"][active]; + + json placed_action; + bool found_placed = false; + for (auto& assertion : manifest["assertions"]) { + if (assertion["label"] != "c2pa.actions.v2") continue; + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] == "c2pa.placed") { + placed_action = action; + found_placed = true; + } + } + } + ASSERT_TRUE(found_placed); + ASSERT_EQ(placed_action["parameters"]["ingredients"].size(), 3u); + + std::set urls; + for (auto& ing : placed_action["parameters"]["ingredients"]) { + urls.insert(ing["url"].get()); + } + EXPECT_EQ(urls.size(), 3u) << "Three distinct ingredient URLs expected"; + EXPECT_TRUE(urls.count("self#jumbf=c2pa.assertions/c2pa.ingredient.v3")); + EXPECT_TRUE(urls.count("self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1")); + EXPECT_TRUE(urls.count("self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2")); +} + +// Mix add_ingredient overloads with the dedicated ingredient archive API in the same +// builder. Action links every ingredient by its caller-supplied id regardless +// of how it was added. +TEST_F(BuilderTest, MixIngredientApisLinkByLabel) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto archive_producer = c2pa::Builder(context, manifest_str); + archive_producer.add_ingredient( + json({{"title", "via-archive.jpg"}, {"relationship", "componentOf"}, {"label", "via-archive"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + archive_producer.write_ingredient_archive("via-archive", archive_stream); + + json manifest_json = { + {"claim_generator_info", json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions.v2"}, + {"data", {{"actions", json::array({ + { + {"action", "c2pa.placed"}, + {"parameters", {{"ingredientIds", json::array({"via-add", "via-stream", "via-archive"})}}} + } + })}}} + } + })} + }; + + auto signing_builder = c2pa::Builder(context, manifest_json.dump()); + + signing_builder.add_ingredient( + json({{"title", "via-add.jpg"}, {"relationship", "componentOf"}, {"label", "via-add"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::ifstream stream_src(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(stream_src.good()); + signing_builder.add_ingredient( + json({{"title", "via-stream.jpg"}, {"relationship", "componentOf"}, {"label", "via-stream"}}).dump(), + "image/jpeg", + stream_src); + stream_src.close(); + + archive_stream.seekg(0); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("mix_old_new_apis.jpg"); + ASSERT_NO_THROW(signing_builder.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& manifest = parsed["manifests"][active]; + + json placed_action; + for (auto& assertion : manifest["assertions"]) { + if (assertion["label"] != "c2pa.actions.v2") continue; + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] == "c2pa.placed") placed_action = action; + } + } + ASSERT_FALSE(placed_action.is_null()); + ASSERT_EQ(placed_action["parameters"]["ingredients"].size(), 3u) + << "All three ingredients should resolve via their caller-supplied ids"; +} + +TEST_F(BuilderTest, IngredientArchiveFallsBackToInstanceIdWhenNoLabel) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + json({{"title", "anon.jpg"}, + {"relationship", "componentOf"}, + {"instance_id", "xmp:iid:anon-fixture"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); + producer.write_ingredient_archive("xmp:iid:anon-fixture", archive); + + json manifest_json = { + {"claim_generator_info", json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions.v2"}, + {"data", {{"actions", json::array({ + { + {"action", "c2pa.placed"}, + {"parameters", {{"ingredientIds", json::array({"xmp:iid:anon-fixture"})}}} + } + })}}} + } + })} + }; + + auto signing_builder = c2pa::Builder(context, manifest_json.dump()); + archive.seekg(0); + ASSERT_NO_THROW(signing_builder.add_ingredient_from_archive(archive)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("ingredient_archive_no_label_fallback.jpg"); + ASSERT_NO_THROW(signing_builder.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); +} + +// Empty builder: write_ingredient_archive cannot fabricate an ingredient +// from the id alone. With no prior add_ingredient, the lookup fails. +TEST_F(BuilderTest, WriteIngredientArchiveWithoutAddIngredientThrows) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(context, manifest_str); + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + EXPECT_THROW(builder.write_ingredient_archive("never-added", stream), c2pa::C2paException); +} + +// Id mismatch: the id arg must match an id previously supplied via +// add_ingredient's JSON (label or instance_id). +TEST_F(BuilderTest, WriteIngredientArchiveWithUnknownIdThrows) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(context, manifest_str); + builder.add_ingredient( + json({{"title", "photo.jpg"}, {"relationship", "componentOf"}, {"label", "real-id"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + EXPECT_THROW(builder.write_ingredient_archive("wrong-id", stream), c2pa::C2paException); +} + +// When the builder has many ingredients, write_ingredient_archive +// puts only the requested one in the archive. +TEST_F(BuilderTest, WriteIngredientArchiveContainsOnlyTargetIngredient) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(context, manifest_str); + builder.add_ingredient( + json({{"title", "first.jpg"}, {"relationship", "componentOf"}, {"label", "first"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + builder.add_ingredient( + json({{"title", "second.jpg"}, {"relationship", "componentOf"}, {"label", "second"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + builder.add_ingredient( + json({{"title", "third.jpg"}, {"relationship", "componentOf"}, {"label", "third"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(builder.write_ingredient_archive("second", archive)); + + archive.seekg(0); + c2pa::Reader reader(context, "application/c2pa", archive); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u) << "Archive should contain only the requested ingredient"; + EXPECT_EQ(ingredients[0]["title"], "second.jpg"); +} From d58b1ff670383472eda36bcabea8bab7df20d472 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Fri, 22 May 2026 21:30:13 -0700 Subject: [PATCH 2/3] fix: Huge docs update --- docs/selective-manifests.md | 250 ++++++++++++++++-------- docs/working-stores.md | 248 ++++++++++++++++++++++-- tests/builder.test.cpp | 368 ++++++++++++++++++++++++++++++++++++ 3 files changed, 774 insertions(+), 92 deletions(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 70d696e..073d231 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -445,10 +445,10 @@ The key difference: a builder archive is a work-in-progress (unsigned). An ingre The SDK supports two approaches for producing an ingredient archive. They share the same `.c2pa` binary format and are interchangeable from the consumer side. -| Approach | Entry point | When to use | +| Approach | Entry point | Status | | --- | --- | --- | -| JSON-override builder archive | `Builder` + `add_ingredient` + `to_archive` | Full control over the manifest definition; multi-ingredient slicing via the read-filter-rebuild pattern. | -| Dedicated single-ingredient API | `add_ingredient` then `write_ingredient_archive(id, stream)` | One ingredient per archive, simpler call site, no manual JSON manipulation. | +| Dedicated ingredient archive APIs | `add_ingredient` then `write_ingredient_archive(id, stream)` | **Current** | +| Read-filter-rebuild APIs | `Builder` + `add_ingredient` + `to_archive`, then `Reader` + manual JSON | **Legacy** (see [catalog migration guide](#migration-guide-catalog-pattern) and [extraction migration guide](#migration-guide-ingredient-extraction)) | The dedicated API requires the `builder.generate_c2pa_archive` setting on the producing builder. For the full contract (id resolution, error cases, examples), see [Single-ingredient archive APIs](./working-stores.md#single-ingredient-archive-apis) in the working stores guide. @@ -478,11 +478,86 @@ flowchart TD style X fill:#f99,stroke:#c00 ``` -The catalog can be implemented in two ways. Variant 1 uses one multi-ingredient builder archive and the read-filter-rebuild pattern to slice out a subset. Variant 2 uses one archive per ingredient and the dedicated single-ingredient API. +The catalog can be implemented two ways. The dedicated (ingredient) archives API uses one archive per ingredient. -### Variant 1: read-filter-rebuild from a multi-ingredient archive +A legacy approach uses one multi-ingredient builder archive and the read-filter-rebuild pattern to slice out a subset of ingredients (and resources). -Use this variant when the catalog already exists as a single `.c2pa` builder archive containing many ingredients, and the consumer picks a subset by reading, filtering, and rebuilding. +### Dedicated archives API: one ingredient per archive + +The producer registers each ingredient on a builder and writes one archive per ingredient, keyed by `instance_id`. The consumer assembles a final builder by loading only the archives it needs via `add_ingredient_from_archive`. The producing builder must have the `builder.generate_c2pa_archive` setting enabled. + +The first argument to `write_ingredient_archive` is the *archive key*: it locates the ingredient on the producer (matched against either `label` or `instance_id`) and becomes the `ingredientIds` value to use on the signing builder. See [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking) for the full rules. + +Producer side, build the catalog: + +```cpp +auto settings = c2pa::Settings(); +settings.set("builder.generate_c2pa_archive", "true"); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto catalog_builder = c2pa::Builder(context, manifest_json); +catalog_builder.add_ingredient( + R"({"title": "photo-A.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-A"})", + "photo-A.jpg"); +catalog_builder.add_ingredient( + R"({"title": "photo-B.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-B"})", + "photo-B.jpg"); + +// One archive per ingredient, keyed by the instance_id used at registration. +std::stringstream archive_a(std::ios::in | std::ios::out | std::ios::binary); +std::stringstream archive_b(std::ios::in | std::ios::out | std::ios::binary); +catalog_builder.write_ingredient_archive("catalog:ingredient-A", archive_a); +catalog_builder.write_ingredient_archive("catalog:ingredient-B", archive_b); +``` + +Consumer side, pick one archive and load it: + +```cpp +auto final_builder = c2pa::Builder(context, manifest_json); +archive_b.seekg(0); +final_builder.add_ingredient_from_archive(archive_b); + +final_builder.sign(source_path, output_path, signer); +``` + +The signed output contains exactly the picked ingredient (`photo-B.jpg` here). `archive_a` stays unused. + +A single action can link several ingredients loaded this way. With three archives (`ing-a`, `ing-b`, `ing-c`) loaded into one signing builder, a `c2pa.placed` action that lists all three ids in `ingredientIds` resolves to three distinct ingredient URLs after signing: + +```cpp +auto signing_builder = c2pa::Builder(context, R"({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [{ + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["ing-a", "ing-b", "ing-c"] + } + }] + } + }] +})"); + +archive_a.seekg(0); +archive_b.seekg(0); +archive_c.seekg(0); +signing_builder.add_ingredient_from_archive(archive_a); +signing_builder.add_ingredient_from_archive(archive_b); +signing_builder.add_ingredient_from_archive(archive_c); + +signing_builder.sign(source_path, output_path, signer); +``` + +### Legacy catalog: read-filter-rebuild APIs + +> [!NOTE] +> **Legacy approach.** This pattern requires manual JSON parsing and `add_resource` loops to transfer binary data. See [Migration guide](#migration-guide-catalog-pattern) to use use the [dedicated ingredient archive APIs](#dedicated-archives-api-one-ingredient-per-archive) instead. + +Use this approach when the catalog already exists as a single `.c2pa` builder archive containing many ingredients and you need to pick a subset by reading, filtering, and rebuilding. ```cpp // Read from a catalog of archived ingredients @@ -531,11 +606,11 @@ for (auto& ingredient : selected) { builder.sign(source_path, output_path, signer); ``` -### Variant 2: one ingredient per archive via the dedicated API +#### Migration guide: catalog pattern -Use this variant when ingredients are produced and consumed independently. The producer registers each ingredient on a builder and writes one archive per ingredient, keyed by `instance_id`. The consumer assembles a final builder by loading only the archives it needs via `add_ingredient_from_archive`. The producing builder must have the `builder.generate_c2pa_archive` setting enabled. +Switch to the dedicated ingredient archive APIs: set `instance_id` per ingredient, call `write_ingredient_archive` once per ingredient on the producer, and `add_ingredient_from_archive` on the consumer. No JSON parsing or `add_resource` loops required. The producing builder needs `builder.generate_c2pa_archive` enabled. -Producer side, build the catalog: +Producer side: ```cpp auto settings = c2pa::Settings(); @@ -552,62 +627,62 @@ catalog_builder.add_ingredient( R"({"title": "photo-B.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-B"})", "photo-B.jpg"); -// One archive per ingredient, keyed by the instance_id used at registration. std::stringstream archive_a(std::ios::in | std::ios::out | std::ios::binary); std::stringstream archive_b(std::ios::in | std::ios::out | std::ios::binary); catalog_builder.write_ingredient_archive("catalog:ingredient-A", archive_a); catalog_builder.write_ingredient_archive("catalog:ingredient-B", archive_b); ``` -Consumer side, pick one archive and load it: +Consumer side: ```cpp auto final_builder = c2pa::Builder(context, manifest_json); archive_b.seekg(0); final_builder.add_ingredient_from_archive(archive_b); - final_builder.sign(source_path, output_path, signer); ``` -The signed output contains exactly the picked ingredient (`photo-B.jpg` here). `archive_a` stays unused. +Action linking also changes between the two approaches. Legacy catalog code linked ingredients via `label` set on the signing builder's `add_ingredient` JSON; `instance_id` was not accepted. The dedicated archive API accepts the archive key passed to `write_ingredient_archive`, which can be either `label` or `instance_id`. See [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking). -A single action can link several ingredients loaded this way. With three archives (`ing-a`, `ing-b`, `ing-c`) loaded into one signing builder, a `c2pa.placed` action that lists all three ids in `ingredientIds` resolves to three distinct ingredient URLs after signing: +#### Choosing between approaches -```cpp -auto signing_builder = c2pa::Builder(context, R"({ - "claim_generator_info": [{"name": "an-application", "version": "1.0"}], - "assertions": [{ - "label": "c2pa.actions.v2", - "data": { - "actions": [{ - "action": "c2pa.placed", - "parameters": { - "ingredientIds": ["ing-a", "ing-b", "ing-c"] - } - }] - } - }] -})"); +The legacy read-filter-rebuild APIs fit when the catalog already exists as one multi-ingredient builder archive and the consumer wants a subset of it. The dedicated ingredient archive APIs fit when ingredients are produced and consumed independently: each archive holds exactly one ingredient, and the call sites stay short. Both produce the same signed output. -archive_a.seekg(0); -archive_b.seekg(0); -archive_c.seekg(0); -signing_builder.add_ingredient_from_archive(archive_a); -signing_builder.add_ingredient_from_archive(archive_b); -signing_builder.add_ingredient_from_archive(archive_c); +### Identifying ingredients in archives -signing_builder.sign(source_path, output_path, signer); -``` +Setting `instance_id` on an ingredient gives it a stable, caller-controlled identifier. This field survives archiving and signing unchanged, so it can locate a specific ingredient in a catalog. The `description` and `informational_URI` fields also survive and can carry additional metadata about the ingredient's origin. -#### Choosing between variants +For the legacy load path (`add_ingredient(json, "application/c2pa", archive)`), `instance_id` cannot be used as a linking key in `ingredientIds`; use `label` instead (see [Linking an archived ingredient to an action](#linking-an-archived-ingredient-to-an-action)). For the dedicated `write_ingredient_archive` + `add_ingredient_from_archive` ingredient archive APIs, the archive key can be either `label` or `instance_id` and becomes the `ingredientIds` value (see [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking)). -Variant 1 fits when the catalog already exists as one multi-ingredient builder archive and the consumer wants a subset of it. Variant 2 fits when ingredients are produced and consumed independently: each archive holds one and only one ingredient, and the call sites stay short. Both produce the same signed output. +With the dedicated single-ingredient API, `instance_id` also serves as the lookup key passed to `write_ingredient_archive`. Set it on `add_ingredient`, then pass the same value to write the archive: -### Identifying ingredients in archives +```cpp +// Producer: register ingredient with instance_id, write its archive. +auto settings = c2pa::Settings(); +settings.set("builder.generate_c2pa_archive", "true"); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto builder = c2pa::Builder(context, manifest_str); +builder.add_ingredient( + R"({"title": "photo-A.jpg", "relationship": "componentOf", "instance_id": "catalog:photo-A"})", + source_path); + +std::stringstream archive_a(std::ios::in | std::ios::out | std::ios::binary); +builder.write_ingredient_archive("catalog:photo-A", archive_a); + +// Consumer: load this archive directly, no Reader loop required. +auto consumer = c2pa::Builder(context, manifest_json); +archive_a.seekg(0); +consumer.add_ingredient_from_archive(archive_a); +consumer.sign(source_path, output_path, signer); +``` -When building an ingredient archive, you can set `instance_id` on the ingredient to give it a stable, caller-controlled identifier. This field survives archiving and signing unchanged, so it can be used to look up a specific ingredient from a catalog archive. The `description` and `informational_URI` fields also survive and can carry additional metadata about the ingredient's origin. +#### Legacy: `to_archive` + Reader loop -`instance_id` is only for identification and catalog lookups. It cannot be used as a linking key in `ingredientIds` when linking ingredient archives to actions — use `label` for that (see [Linking an archived ingredient to an action](#linking-an-archived-ingredient-to-an-action)). +> [!NOTE] +> **Legacy approach.** The pattern below archives a multi-ingredient builder and uses a `Reader` loop to find ingredients by `instance_id`. ```cpp // Set instance_id when adding the ingredient to the archive builder. @@ -719,7 +794,47 @@ for (auto& action : actions) { ### Extracting ingredients from a working store -An example workflow is to build up a working store with multiple ingredients, archive it, and then later extract specific ingredients from that archive to use in a new working store. +A way to extract a specific ingredient from a working store is with the dedicated ingredient archive APIs: the producer writes one archive per ingredient with `write_ingredient_archive`, and the consumer loads only what it needs with `add_ingredient_from_archive`. The read-filter-rebuild APIs are the legacy approach. + +#### Dedicated ingredient archive APIs + +The producer registers each ingredient keyed by `instance_id`, writes one archive per ingredient, and the consumer loads only the needed one. The `builder.generate_c2pa_archive` setting must be enabled on the producing builder. + +```cpp +auto settings = c2pa::Settings(); +settings.set("builder.generate_c2pa_archive", "true"); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +// Producer: register two ingredients keyed by instance_id, archive each separately. +auto producer = c2pa::Builder(context, manifest_json); +producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-A"})", + "A.jpg"); +producer.add_ingredient( + R"({"title": "C.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-C"})", + "C.jpg"); + +std::stringstream archive_a(std::ios::in | std::ios::out | std::ios::binary); +std::stringstream archive_c(std::ios::in | std::ios::out | std::ios::binary); +producer.write_ingredient_archive("catalog:ingredient-A", archive_a); +producer.write_ingredient_archive("catalog:ingredient-C", archive_c); + +// Consumer: load only ingredient-A, no JSON parsing, no add_resource loop. +auto sink = c2pa::Builder(context, manifest_json); +archive_a.seekg(0); +sink.add_ingredient_from_archive(archive_a); + +sink.sign(source_path, output_path, signer); +``` + +The signed output contains exactly the loaded ingredient. + +#### Legacy: read-filter-rebuild APIs + +> [!NOTE] +> **Legacy approach.** This pattern archives the full working store, then reads it back with `Reader`, filters ingredients in JSON, and transfers binary resources manually. ```mermaid flowchart TD @@ -738,8 +853,6 @@ flowchart TD end ``` - - **Step 1:** Build a working store and archive it: ```cpp @@ -805,40 +918,17 @@ for (auto& ingredient : selected) { new_builder.sign(source_path, output_path, signer); ``` -#### Extracting with the dedicated archive API - -The same end-to-end flow (build a working store with ingredients, archive, then reuse a subset elsewhere) is shorter when using `write_ingredient_archive` and `add_ingredient_from_archive`. The producer writes one archive per ingredient, the sink loads only the ones it needs. The `builder.generate_c2pa_archive` setting must be enabled on the producing builder. +##### Migration guide: ingredient extraction -```cpp -auto settings = c2pa::Settings(); -settings.set("builder.generate_c2pa_archive", "true"); -auto context = c2pa::Context::ContextBuilder() - .with_settings(std::move(settings)) - .create_context(); - -// Producer: register two ingredients keyed by instance_id, archive each separately. -auto producer = c2pa::Builder(context, manifest_json); -producer.add_ingredient( - R"({"title": "A.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-A"})", - "A.jpg"); -producer.add_ingredient( - R"({"title": "C.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-C"})", - "C.jpg"); - -std::stringstream archive_a(std::ios::in | std::ios::out | std::ios::binary); -std::stringstream archive_c(std::ios::in | std::ios::out | std::ios::binary); -producer.write_ingredient_archive("catalog:ingredient-A", archive_a); -producer.write_ingredient_archive("catalog:ingredient-C", archive_c); - -// Sink: load only ingredient-A. -auto sink = c2pa::Builder(context, manifest_json); -archive_a.seekg(0); -sink.add_ingredient_from_archive(archive_a); +| Step | Legacy (manual) approach | Current dedicated ingredient archive APIs approach | +| --- | --- | --- | +| Archive | `builder.to_archive(stream)` (full builder) | `builder.write_ingredient_archive(id, stream)` (one ingredient) | +| Load | `Reader` + JSON parse + filter loop + `add_resource` per resource | `builder2.add_ingredient_from_archive(stream)` | +| Setting required | None | `builder.generate_c2pa_archive = "true"` on producer | -sink.sign(source_path, output_path, signer); -``` +The dedicated ingredient archive APIs require no JSON parsing and no `add_resource` calls. Each archive holds exactly one ingredient. -The signed output contains exactly the loaded ingredient. No JSON parsing, no manual `add_resource` calls. +Action linking differs between the two paths. With the legacy approach, the signing builder must re-assert `label` on its `add_ingredient` JSON to link to an action; `instance_id` is not accepted. With the dedicated ingredient archive APIs, the archive key passed to `write_ingredient_archive` (either `label` or `instance_id` from the producer ingredient) flows through and becomes the `ingredientIds` value. See [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking). ### Reading ingredient details from an ingredient archive @@ -907,20 +997,22 @@ if (ingredient.contains("thumbnail")) { #### Ingredient vs. ingredient archive -A plain ingredient is a source asset (image, video, document) the builder reads at `add_ingredient` time, with `label` (primary) or `instance_id` (fallback) usable as linking keys. An ingredient archive is a `.c2pa` file containing one already-formed ingredient. When passed to `add_ingredient`, the builder treats its contents as opaque provenance. The only linking key the action can resolve is the `label` set on the *current* `add_ingredient` call. +A plain ingredient is a source asset (image, video, document) the builder reads at `add_ingredient` time, with `label` (primary) or `instance_id` (fallback) usable as linking keys. An ingredient archive is a `.c2pa` file containing one already-formed ingredient. When the archive is loaded via the legacy `add_ingredient(json, "application/c2pa", archive)` path, the only linking key the action can resolve is the `label` set on the *current* `add_ingredient` call. When loaded via `add_ingredient_from_archive`, the linking key is the archive key passed to `write_ingredient_archive` (either `label` or `instance_id` on the producer). For a side-by-side comparison, see [Ingredient vs. ingredient archive](working-stores.md#ingredient-vs-ingredient-archive) in the working-stores doc. #### Linking an archived ingredient to an action -Linking an **archived** ingredient to an action is **label-driven**: archived ingredients can only be linked to actions using labels. +When the archived ingredient is loaded via the legacy `add_ingredient(json, "application/c2pa", archive)` path, linking is **label-driven**: archived ingredients can only be linked to actions using labels. For the dedicated `write_ingredient_archive` + `add_ingredient_from_archive` ingredient archive APIs, the archive key (either `label` or `instance_id`) drives linking. See [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking). To do so, set a `label` on the archived ingredient's JSON passed to `add_ingredient` on the builder, and use that same string in the action's `ingredientIds`. Reading the archive first is *not* required to link it. `Reader` is only useful when the caller wants to preview the ingredient (thumbnail, provenance, validation status) before deciding whether to use it (see [Reading ingredient details from an ingredient archive](#reading-ingredient-details-from-an-ingredient-archive)). +This section covers the **legacy** load path: producer calls `to_archive`, signing builder calls `add_ingredient(json, "application/c2pa", archive)`. For the dedicated `write_ingredient_archive` + `add_ingredient_from_archive` ingredient archive APIs, the archive key (either `label` or `instance_id`) flows through and becomes the `ingredientIds` value. See [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking). + > [!WARNING] -> **`instance_id` does not work as a linking key for ingredient archives.** Use `label` instead. +> **For the legacy load path, `instance_id` does not work as a linking key for ingredient archives.** Use `label` instead. > > **Labels baked into the archive ingredient at archive-creation time do not carry through as linking keys either.** The label must be re-asserted on the signing builder's `add_ingredient` call so action and archived ingredient properly link. diff --git a/docs/working-stores.md b/docs/working-stores.md index 723c605..5f82c38 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -446,11 +446,13 @@ Ingredients represent source materials used to create an asset, preserving the p A **(plain) ingredient** is a source asset that the builder reads at `add_ingredient` time. The builder sees the asset's bytes, and stores live required ingredient data (including any caller-set `instance_id`) inside the new manifest. -An **ingredient archive** (in c2pa-archive-format) is a `.c2pa` file produced by `to_archive()` that already contains a fully-formed ingredient ("a ready to use ingredient"). When passed to `add_ingredient`, the builder treats the archive's contents as opaque provenance: the archive's internal fields are not exposed as live JSON the signing builder can introspect (or use for linking to actions). Only the JSON the caller supplies in the current `add_ingredient` call is visible to the builder in that round. +An **ingredient archive** (in c2pa-archive-format) is a `.c2pa` file that already contains a fully-formed ingredient. It can be produced with `write_ingredient_archive` (dedicated ingredient archive APIs) or with `to_archive()` on a builder holding one ingredient (legacy). When passed to `add_ingredient`, the builder treats the archive's contents as opaque provenance: the archive's internal fields are not exposed as live JSON the signing builder can introspect (or use for linking to actions). Only the JSON the caller supplies in the current `add_ingredient` call is visible to the builder in that round. -This difference governs how each can be linked to an action via `ingredientIds`: +For the dedicated ingredient archive APIs, see [Single-ingredient archive APIs](#single-ingredient-archive-apis). -| Aspect | Ingredient | Ingredient archive | +This difference governs how each can be linked to an action via `ingredientIds`. The table below describes the **legacy** load path for ingredient archives, where the archive is passed directly to `add_ingredient` with format `"application/c2pa"`: + +| Aspect | Ingredient | Ingredient archive (legacy load via `add_ingredient(json, "application/c2pa", archive)`) | | --- | --- | --- | | Source format passed to `add_ingredient` | Asset MIME type (`image/jpeg`, `video/mp4`, ...) or asset path | `application/c2pa` or path to a `.c2pa` ingredient archive file | | What it is | "Live" asset | A serialized manifest store (opaque provenance) | @@ -458,6 +460,8 @@ This difference governs how each can be linked to an action via `ingredientIds`: | Linking via `instance_id` | Alternative to using `label` | Does not link, signing-time error | | Linking via a `label` baked in at archive-creation time | N/A (not an archive) | Does not carry through, must be re-asserted on the signing builder, set on the signing builder's `add_ingredient` JSON parameter | +For the dedicated `write_ingredient_archive` + `add_ingredient_from_archive` ingredient archive APIs, linking rules differ: the archive key (either `label` or `instance_id`) flows through and becomes the `ingredientIds` value. See [Lookup keys and action linking](#lookup-keys-and-action-linking). + ### Adding ingredients to a working store When creating a manifest, add ingredients to preserve the provenance chain: @@ -485,7 +489,7 @@ ingredient_stream.close(); // have an archived ingredient (1 ingredient per archive) at hand. // The JSON parameter would then override what was in the archive and would be used for // The ingredient added to the working store. -// builder.add_ingredient(ingredient_json, "applciation/c2pa", ingredient archive); +// builder.add_ingredient(ingredient_json, "application/c2pa", ingredient archive); // Sign: ingredients become part of the manifest store builder.sign("new_asset.jpg", "signed_asset.jpg", signer); @@ -493,10 +497,12 @@ builder.sign("new_asset.jpg", "signed_asset.jpg", signer); ### Linking an ingredient archive to an action +This section covers the **legacy** load path: producer calls `to_archive`, signing builder calls `add_ingredient(json, "application/c2pa", archive)`. For the dedicated `write_ingredient_archive` + `add_ingredient_from_archive` ingredient archive APIs, see [Lookup keys and action linking](#lookup-keys-and-action-linking). + > [!IMPORTANT] -> **Linking an ingredient archive is `label`-driven only.** +> **For the legacy load path, linking an ingredient archive is `label`-driven only.** > -> - `instance_id` does not work as a linking key for ingredient archives, use `label` instead. +> - `instance_id` does not work as a linking key for ingredient archives loaded via `add_ingredient(json, "application/c2pa", archive)`. Use `label` instead. > - Labels baked into the archive at archive-creation time do not carry through. The label must be re-asserted in the signing builder's `add_ingredient` JSON. > - Both rules apply whether the archive is added by file path or by stream. > @@ -661,6 +667,9 @@ Using archives provides these advantages: The default binary format of an archive is the **C2PA JUMBF binary format** (`application/c2pa`), which is the standard way to save and restore working stores. +> [!NOTE] +> `to_archive`, `from_archive`, and `with_archive` are for saving and restoring a full working store (manifest definition + all resources). For ingredient archive workflows — producing one archive per ingredient and selectively loading ingredients — use the [single-ingredient archive APIs](#single-ingredient-archive-apis) instead. + ### Saving a working store to archive ```cpp @@ -771,6 +780,9 @@ void sign_asset() { ### Single-ingredient archive APIs +> [!NOTE] +> These are the recommended dedicated ingredient archive APIs for ingredient archive workflows. Use `write_ingredient_archive` and `add_ingredient_from_archive` in preference to the legacy `to_archive` / `from_archive` pattern for ingredient use cases. + The `Builder` class exposes two dedicated APIs for moving a single ingredient between builders without manual JSON manipulation: - `Builder::write_ingredient_archive(id, stream)` writes one already-registered ingredient out as a single-ingredient JUMBF archive. @@ -839,12 +851,7 @@ consumer.sign("source.jpg", "output.jpg", signer); #### Id resolution -The id passed to `write_ingredient_archive` matches against fields on the registered ingredient JSON in the order: - -1. `label` if it is set and non-empty. -2. `instance_id` if no `label` is set. - -When only `instance_id` is set (no `label`), the `instance_id` value is the lookup key. The same key is the one to use in `ingredientIds` when linking the loaded ingredient to an action. +The id passed to `write_ingredient_archive` is matched against each registered ingredient's `label` and its `instance_id`. The first ingredient whose `label` or `instance_id` equals the id is selected (OR-match, no precedence). If both are set on the same ingredient, pass whichever value is to be used as the linking key. See [Lookup keys and action linking](#lookup-keys-and-action-linking) for the full table of linking outcomes. #### Errors @@ -864,7 +871,222 @@ std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); builder.write_ingredient_archive("wrong-id", stream); ``` -For a multi-archive use case (one catalog, many ingredients picked at build time), see [The ingredients catalog pattern](./selective-manifests.md#the-ingredients-catalog-pattern) in the selective manifests guide. It compares the read-filter-rebuild approach with this dedicated single-ingredient API. +For a multi-archive use case (one catalog, many ingredients picked at build time), see [The ingredients catalog pattern](./selective-manifests.md#the-ingredients-catalog-pattern) in the selective manifests guide. + +#### Migration guide: from `to_archive` / `from_archive` to single-ingredient APIs + +The legacy approach wrapped one ingredient in a full builder archive, then restored it with `from_archive`: + +```cpp +// Legacy: one ingredient archived as a full builder, restored with from_archive +auto builder = c2pa::Builder(context, manifest_json); +builder.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf"})", + "photo.jpg"); +builder.to_archive("ingredient.c2pa"); + +// Consumer: +auto restored = c2pa::Builder::from_archive("ingredient.c2pa"); +restored.sign("source.jpg", "output.jpg", signer); +``` + +With the dedicated ingredient archive APIs, the producer writes a single-ingredient archive directly, and the consumer loads it with `add_ingredient_from_archive`: + +```cpp +// Current API: one archive per ingredient via write_ingredient_archive +auto settings = c2pa::Settings(); +settings.set("builder.generate_c2pa_archive", "true"); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto builder = c2pa::Builder(context, manifest_json); +builder.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf", "instance_id": "my-photo"})", + "photo.jpg"); + +std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); +builder.write_ingredient_archive("my-photo", archive); + +// Consumer: +auto consumer = c2pa::Builder(context, manifest_json); +archive.seekg(0); +consumer.add_ingredient_from_archive(archive); +consumer.sign("source.jpg", "output.jpg", signer); +``` + +Key differences: no JSON parsing, no `add_resource` loops, each archive holds exactly one ingredient, and the consumer loads selectively without deserializing anything else. + +Action linking also changes between the two APIs. The legacy load path (`add_ingredient(json, "application/c2pa", archive)`) accepts only `label` as the linking key on the signing builder's `add_ingredient` JSON. See [Linking an ingredient archive to an action](#linking-an-ingredient-archive-to-an-action). The dedicated ingredient archive APIs (`write_ingredient_archive` + `add_ingredient_from_archive`) accept the archive key, which can be either `label` or `instance_id`. See [Lookup keys and action linking](#lookup-keys-and-action-linking). When migrating code that linked by label, pass that same label as the archive key to keep `ingredientIds` unchanged. + +## How `instance_id` survives archiving and signing + +### What is an instance_id? + +`instance_id` is a string field on an ingredient. It is optional in C2PA ingredient assertion starting versions 2, which the SDK currently writes by default. Version 1 required it. + +In priority order, this value comes from: + +1. The caller: if you set `instance_id` in the JSON passed to `add_ingredient`, that value is stored as-is. No normalization or transformation is applied. +2. XMP fallback: if no `instance_id` was provided and the source asset has `xmpMM:InstanceID` in its XMP metadata, the library reads that value and sets it on the ingredient. +3. Auto-generated default: if neither caller nor XMP provided a value, the library generates `xmp.iid:` automatically (required for V1 assertion compatibility). + +### Instance_id across operations + +`instance_id` is kept through every archiving and signing operation this library performs. The table below covers the common paths: + +| Operation | `instance_id` kept? | +| --- | --- | +| `add_ingredient`, `write_ingredient_archive`, `add_ingredient_from_archive`, then sign | Yes | +| `add_ingredient`, `to_archive`, then `Reader::json()` | Yes | +| `add_ingredient`, sign, then `Reader::json()` (no archive) | Yes | +| `add_ingredient_from_archive` (loaded from prior archive), sign, then `Reader::json()` | Yes | + +### Lookup keys and action linking + +The first argument to `write_ingredient_archive`, called the _archive key_, has two roles. It locates the ingredient on the producer builder by matching against either `label` or `instance_id`. It also becomes the `ingredientIds` value on the signing builder: `add_ingredient_from_archive` stores the archive key in the archive metadata and restores it as the ingredient's linking label. + +Whatever string you pass as the archive key is the string you must use in `ingredientIds`. + +| Producer sets | Archive key to pass | `ingredientIds` value | +| --- | --- | --- | +| `label` only | `label` value | same `label` value | +| `instance_id` only | `instance_id` value | same `instance_id` value | +| both `label` and `instance_id` | either value | same string you passed | + +The linking label is a builder-only concept. It does not appear in `Reader::json()` output after signing. Only `instance_id` is observable in the signed manifest. + +If the archive key matches neither `label` nor `instance_id` of any ingredient on the producer builder, `write_ingredient_archive` throws immediately with `C2paException`. + +#### Linking with `instance_id` only + +When no `label` is set, pass the `instance_id` value to `write_ingredient_archive`. Use that same string in `ingredientIds` on the signing builder. + +Producer: + +```cpp +// Producer: archive one ingredient identified by instance_id. +auto producer = c2pa::Builder(context, manifest_str); +producer.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf", + "instance_id": "catalog:photo-A"})", + source_path); + +std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); +producer.write_ingredient_archive("catalog:photo-A", archive); +``` + +Signing builder: + +```cpp +// Signing builder: load archive, then reference the same string in ingredientIds. +auto signing_manifest = R"({ + "claim_generator_info": [{"name": "app", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["catalog:photo-A"]}}]} + }] +})"; + +auto consumer = c2pa::Builder(context, signing_manifest); +archive.seekg(0); +consumer.add_ingredient_from_archive(archive); +consumer.sign(source_path, output_path, signer); +``` + +#### Linking with `label` only + +When only `label` is set, pass the `label` value to `write_ingredient_archive`. Use that same string in `ingredientIds`. + +Producer: + +```cpp +auto producer = c2pa::Builder(context, manifest_str); +producer.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf", + "label": "my-photo"})", + source_path); + +std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); +producer.write_ingredient_archive("my-photo", archive); +``` + +Signing builder: + +```cpp +auto signing_manifest = R"({ + "claim_generator_info": [{"name": "app", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["my-photo"]}}]} + }] +})"; + +auto consumer = c2pa::Builder(context, signing_manifest); +archive.seekg(0); +consumer.add_ingredient_from_archive(archive); +consumer.sign(source_path, output_path, signer); +``` + +#### Linking when both `label` and `instance_id` are set + +If both `label` and `instance_id` are set on an ingredient, pass whichever value is to be used as the linking key to `write_ingredient_archive`. That string, and only that string, is what `ingredientIds` must reference on the signing builder. + +Producer (passing `label` as the key): + +```cpp +auto producer = c2pa::Builder(context, manifest_str); +producer.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf", + "label": "my-photo", "instance_id": "iid:abc123"})", + source_path); + +std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); +// Pass "my-photo": this becomes the ingredientIds key. +// Passing "iid:abc123" instead would also work, but then ingredientIds +// must use "iid:abc123", not "my-photo". +producer.write_ingredient_archive("my-photo", archive); +``` + +Signing builder: + +```cpp +// ingredientIds uses "my-photo": the value passed to write_ingredient_archive. +auto signing_manifest = R"({ + "claim_generator_info": [{"name": "app", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["my-photo"]}}]} + }] +})"; + +auto consumer = c2pa::Builder(context, signing_manifest); +archive.seekg(0); +consumer.add_ingredient_from_archive(archive); +consumer.sign(source_path, output_path, signer); +``` + +### Catalog lookups with the read-filter-rebuild APIs + +With the legacy `to_archive` + `Reader` pattern, `instance_id` survives into the Reader output and can be used to find a specific ingredient by scanning `Reader::json()`: + +```cpp +auto reader = c2pa::Reader(context, archive_path); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto& ingredients = parsed["manifests"][active]["ingredients"]; + +for (auto& ing : ingredients) { + if (ing.contains("instance_id") && ing["instance_id"] == "catalog:photo-A") { + // Found the ingredient + } +} +``` + +Using the dedicated archive API, this loop is unnecessary: each archive holds exactly and explicitly one ingredient, so `add_ingredient_from_archive` loads precisely what was written. ## Embedded vs external manifests diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index e40012a..2e4275d 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -6838,3 +6838,371 @@ TEST_F(BuilderTest, WriteIngredientArchiveContainsOnlyTargetIngredient) ASSERT_EQ(ingredients.size(), 1u) << "Archive should contain only the requested ingredient"; EXPECT_EQ(ingredients[0]["title"], "second.jpg"); } + +// instance_id set on add_ingredient survives write_ingredient_archive → +// add_ingredient_from_archive → signing, and is readable via Reader::json(). +TEST_F(BuilderTest, InstanceIdSurvivesWriteIngredientArchiveRoundTripAndSigning) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + + auto manifest_str = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}] + })"; + + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", "instance_id": "iid:survival-test-001"})", + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(producer.write_ingredient_archive("iid:survival-test-001", archive_stream)); + + auto consumer = c2pa::Builder(context, manifest_str); + archive_stream.seekg(0); + ASSERT_NO_THROW(consumer.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("iid_survives_ingredient_archive.jpg"); + ASSERT_NO_THROW(consumer.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + ASSERT_TRUE(ingredients[0].contains("instance_id")) + << "instance_id should survive write_ingredient_archive + add_ingredient_from_archive + sign"; + EXPECT_EQ(ingredients[0]["instance_id"], "iid:survival-test-001"); +} + +// When neither caller nor XMP provides instance_id, the library auto-generates +// an xmp.iid: value so the ingredient assertion is valid. +TEST_F(BuilderTest, InstanceIdAutoGeneratedWhenNotProvided) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + + auto manifest_str = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}] + })"; + + // Add ingredient with NO instance_id in JSON. + // A.jpg has no XMP metadata, so the library must generate one. + auto builder = c2pa::Builder(context, manifest_str); + builder.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf"})", + c2pa_test::get_fixture_path("A.jpg")); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("iid_auto_generated.jpg"); + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + + // instance_id should be present and start with "xmp.iid:" + ASSERT_TRUE(ingredients[0].contains("instance_id")) + << "Library should auto-generate instance_id when none is provided"; + std::string iid = ingredients[0]["instance_id"]; + EXPECT_EQ(iid.substr(0, 8), "xmp.iid:") + << "Auto-generated instance_id should start with xmp.iid:, got: " << iid; +} + +// instance_id set on add_ingredient survives to_archive and is +// readable via Reader::json() on the archive. +TEST_F(BuilderTest, InstanceIdSurvivesToArchiveAndReader) +{ + auto context = c2pa::Context::ContextBuilder().create_context(); + + auto manifest_str = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}] + })"; + + auto builder = c2pa::Builder(context, manifest_str); + builder.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", "instance_id": "iid:legacy-survival-001"})", + c2pa_test::get_fixture_path("A.jpg")); + + auto archive_path = get_temp_path("iid_survives_to_archive.c2pa"); + ASSERT_NO_THROW(builder.to_archive(archive_path)); + + auto reader = c2pa::Reader(context, archive_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_FALSE(ingredients.empty()); + + bool found = false; + for (auto& ing : ingredients) { + if (ing.contains("instance_id") && ing["instance_id"] == "iid:legacy-survival-001") { + found = true; + EXPECT_EQ(ing["title"], "A.jpg"); + } + } + ASSERT_TRUE(found) << "instance_id should survive to_archive and be readable via Reader"; +} + +// The ingredient_id passed to write_ingredient_archive is restored as the label +// on the loaded ingredient in the signing builder. This means ingredientIds in +// actions must use the same value that was passed to write_ingredient_archive, +// regardless of whether that value was the label or instance_id at archive-creation time. +TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredPrefersLabelInSigningBuilder) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + + auto manifest_str = R"({"claim_generator_info": [{"name": "test", "version": "1.0"}]})"; + + // Producer sets both label and instance_id on the ingredient. + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", + "label": "my-label", "instance_id": "iid:label-survival-test"})", + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + // write_ingredient_archive accepts label or instance_id as the lookup key. + // The value passed here becomes the restored label in the signing builder. + ASSERT_NO_THROW(producer.write_ingredient_archive("my-label", archive_stream)); + + // Signing builder loads the archive. + // ingredientIds must use "my-label": the value passed to write_ingredient_archive, + // because that value is restored as the ingredient's label in the signing builder. + auto signing_manifest = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["my-label"]}}]} + }] + })"; + + auto consumer = c2pa::Builder(context, signing_manifest); + archive_stream.seekg(0); + ASSERT_NO_THROW(consumer.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("archive_ingredient_id_restored_as_label.jpg"); + ASSERT_NO_THROW(consumer.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + + // instance_id survives the CBOR assertion round-trip. + ASSERT_TRUE(ingredients[0].contains("instance_id")); + EXPECT_EQ(ingredients[0]["instance_id"], "iid:label-survival-test"); +} + +// When the ingredient has only a label (no instance_id), the label is passed to +// write_ingredient_archive and is restored as the label in the signing builder. +// ingredientIds must use the label value. +TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelUsingLabelOnly) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + + auto manifest_str = R"({"claim_generator_info": [{"name": "test", "version": "1.0"}]})"; + + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", "label": "only-label"})", + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(producer.write_ingredient_archive("only-label", archive_stream)); + + auto signing_manifest = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["only-label"]}}]} + }] + })"; + + auto consumer = c2pa::Builder(context, signing_manifest); + archive_stream.seekg(0); + ASSERT_NO_THROW(consumer.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("archive_label_only_ingredient.jpg"); + ASSERT_NO_THROW(consumer.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + EXPECT_EQ(ingredients[0]["title"], "A.jpg"); +} + +// When the ingredient has only an instance_id (no label), the instance_id value is +// passed to write_ingredient_archive and is restored as the label in the signing +// builder. ingredientIds must use that instance_id value. +TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelUsingInstanceIdOnly) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + + auto manifest_str = R"({"claim_generator_info": [{"name": "test", "version": "1.0"}]})"; + + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", "instance_id": "iid:only-instance"})", + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + // No label set, so pass instance_id as the lookup key. + ASSERT_NO_THROW(producer.write_ingredient_archive("iid:only-instance", archive_stream)); + + // ingredientIds uses the instance_id value, same string passed to write_ingredient_archive. + auto signing_manifest = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["iid:only-instance"]}}]} + }] + })"; + + auto consumer = c2pa::Builder(context, signing_manifest); + archive_stream.seekg(0); + ASSERT_NO_THROW(consumer.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("archive_instance_id_only_ingredient.jpg"); + ASSERT_NO_THROW(consumer.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + + // instance_id survives in the CBOR assertion. + ASSERT_TRUE(ingredients[0].contains("instance_id")); + EXPECT_EQ(ingredients[0]["instance_id"], "iid:only-instance"); +} + +// Both label and instance_id set. write_ingredient_archive called with label. +// The label is the lookup key and becomes the restored label in signing builder. +// ingredientIds must use the label value. +TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelBothSetUseLabelForLinking) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + + auto manifest_str = R"({"claim_generator_info": [{"name": "test", "version": "1.0"}]})"; + + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", + "label": "lbl:both-set", "instance_id": "iid:both-set"})", + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(producer.write_ingredient_archive("lbl:both-set", archive_stream)); + + auto signing_manifest = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["lbl:both-set"]}}]} + }] + })"; + + auto consumer = c2pa::Builder(context, signing_manifest); + archive_stream.seekg(0); + ASSERT_NO_THROW(consumer.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("archive_both_set_pass_label.jpg"); + ASSERT_NO_THROW(consumer.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + + // instance_id from the CBOR assertion survives unchanged. + ASSERT_TRUE(ingredients[0].contains("instance_id")); + EXPECT_EQ(ingredients[0]["instance_id"], "iid:both-set"); +} + +// Both label and instance_id set. write_ingredient_archive called with instance_id. +// The instance_id string becomes the restored label in signing builder. +// ingredientIds must use the instance_id value, not the label. +TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelBothSetUseInstanceIdForLinking) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "true"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + + auto manifest_str = R"({"claim_generator_info": [{"name": "test", "version": "1.0"}]})"; + + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + R"({"title": "A.jpg", "relationship": "componentOf", + "label": "lbl:both-set2", "instance_id": "iid:both-set2"})", + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + // Pass instance_id — lookup finds it via the instance_id branch of the OR-match. + ASSERT_NO_THROW(producer.write_ingredient_archive("iid:both-set2", archive_stream)); + + // ingredientIds must use "iid:both-set2", the value passed to write_ingredient_archive. + // Using "lbl:both-set2" here would fail because the restored label is "iid:both-set2". + auto signing_manifest = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.placed", + "parameters": {"ingredientIds": ["iid:both-set2"]}}]} + }] + })"; + + auto consumer = c2pa::Builder(context, signing_manifest); + archive_stream.seekg(0); + ASSERT_NO_THROW(consumer.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("archive_both_set_pass_iid.jpg"); + ASSERT_NO_THROW(consumer.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + ASSERT_TRUE(ingredients[0].contains("instance_id")); + EXPECT_EQ(ingredients[0]["instance_id"], "iid:both-set2"); +} From 5a7c81fec3d0ac08cf8b456cae13e2f58c0b0d89 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Sat, 23 May 2026 08:33:32 -0700 Subject: [PATCH 3/3] fix: Cleanup build helper --- compile_commands.json | 1 - 1 file changed, 1 deletion(-) delete mode 120000 compile_commands.json diff --git a/compile_commands.json b/compile_commands.json deleted file mode 120000 index 7c1ac71..0000000 --- a/compile_commands.json +++ /dev/null @@ -1 +0,0 @@ -build/debug/compile_commands.json \ No newline at end of file