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 01/13] 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 b4c301b2..78773762 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 d2b08470..2410800f 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 0a0d5a52..ed8abc3c 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 686dd8a8..e61d0ae2 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 153f6e3a..41ae3c2d 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 02/13] 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 70d696ea..073d2312 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 723c605f..5f82c384 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 e40012a3..2e4275d7 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 03/13] 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 7c1ac711..00000000 --- a/compile_commands.json +++ /dev/null @@ -1 +0,0 @@ -build/debug/compile_commands.json \ No newline at end of file From 69c3fdead635084b01a3e13901e95659f8add810 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Sat, 23 May 2026 20:52:10 -0700 Subject: [PATCH 04/13] fix: Docs --- docs/selective-manifests.md | 5 +- docs/working-stores.md | 105 ++++++++++++++++++++++++++++++------ 2 files changed, 92 insertions(+), 18 deletions(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 073d2312..55d30af5 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -419,13 +419,12 @@ for (auto& assertion : manifest["assertions"]) { | --- | --- | --- | | **Who controls it** | Caller (any string) | Caller (any string, or from XMP metadata) | | **Priority for linking** | Primary: checked first | Fallback: used when label is absent/empty | -| **When to use** | JSON-defined manifests where the caller controls the ingredient definition | Programmatic workflows using `read_ingredient_file()` or XMP-based IDs | +| **When to use** | JSON-defined manifests where the caller controls the ingredient definition | Programmatic workflows where a stable identifier persisting unchanged across rebuilds is needed (`read_ingredient_file()` is deprecated) | | **Survives signing** | SDK may reassign the actual assertion label | Unchanged | | **Stable across rebuilds** | The caller controls the build-time value; the post-signing label may change | Yes, always the same set value | -**Use `label`** when defining manifests in JSON. -**Use `instance_id`** when working programmatically with ingredients whose identity comes from other sources, or when a stable identifier that persists unchanged across rebuilds is needed. +Use `label` when defining manifests in JSON. Use `instance_id` when working programmatically with ingredients whose identity comes from other sources, or when a stable identifier that persists unchanged across rebuilds is needed. ## Working with archives diff --git a/docs/working-stores.md b/docs/working-stores.md index 5f82c384..b8560635 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -450,17 +450,27 @@ An **ingredient archive** (in c2pa-archive-format) is a `.c2pa` file that alread For the dedicated ingredient archive APIs, see [Single-ingredient archive APIs](#single-ingredient-archive-apis). -This difference governs how each can be linked to an action via `ingredientIds`. The table below describes the **legacy** load path for ingredient archives, where the archive is passed directly to `add_ingredient` with format `"application/c2pa"`: +This difference governs how each can be linked to an action via `ingredientIds`. The table below covers all three cases: plain ingredients, ingredient archives loaded via the dedicated APIs (recommended), and ingredient archives loaded via the legacy `add_ingredient` path: -| Aspect | Ingredient | Ingredient archive (legacy load via `add_ingredient(json, "application/c2pa", archive)`) | +| Aspect | Ingredient | Ingredient archive (dedicated APIs: `write_ingredient_archive` + `add_ingredient_from_archive`) | Ingredient archive (legacy load via `add_ingredient(json, "application/c2pa", archive)`) | +| --- | --- | --- | --- | +| Source format passed to `add_ingredient` | Asset MIME type (`image/jpeg`, `video/mp4`, ...) or asset path | N/A — loaded via `add_ingredient_from_archive(stream)` | `application/c2pa` or path to a `.c2pa` ingredient archive file | +| What it is | "Live" asset | A serialized single-ingredient archive (opaque provenance) | A serialized manifest store (opaque provenance) | +| Linking via `label` | Primary linking key, set on the signing builder's `add_ingredient` JSON parameter | Pass `label` value as archive key to `write_ingredient_archive`; flows through as `ingredientIds` value | Only linking key that works, set on the signing builder's `add_ingredient` JSON | +| Linking via `instance_id` | Alternative to using `label` | Pass `instance_id` value as archive key to `write_ingredient_archive`; flows through as `ingredientIds` value | Does not link, signing-time error | +| Linking via a `label` baked in at archive-creation time | N/A (not an archive) | N/A — archive key is set explicitly at `write_ingredient_archive` call time | Does not carry through, must be re-asserted on the signing builder's `add_ingredient` JSON parameter | + +#### When to use `label` vs `instance_id` + +| Property | `label` | `instance_id` | | --- | --- | --- | -| Source format passed to `add_ingredient` | Asset MIME type (`image/jpeg`, `video/mp4`, ...) or asset path | `application/c2pa` or path to a `.c2pa` ingredient archive file | -| What it is | "Live" asset | A serialized manifest store (opaque provenance) | -| Linking via `label` | Primary linking key, set on the signing builder's `add_ingredient` JSON parameter | Only linking key that works, set on the signing builder's `add_ingredient` JSON | -| Linking via `instance_id` | Alternative to using `label` | Does not link, signing-time error | -| Linking via a `label` baked in at archive-creation time | N/A (not an archive) | Does not carry through, must be re-asserted on the signing builder, set on the signing builder's `add_ingredient` JSON parameter | +| Who controls it | Caller (any string) | Caller (any string, or from XMP metadata) | +| Priority for linking | Primary: checked first | Fallback: used when `label` is absent/empty | +| When to use | JSON-defined manifests where the caller controls the ingredient definition | Programmatic workflows where a stable identifier persisting unchanged across rebuilds is needed | +| Survives signing | SDK may reassign the actual assertion label in the signed manifest | Unchanged | +| Stable across rebuilds | The caller controls the build-time value; the post-signing label may change | Yes, always the same set value | -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). +Use `label` when defining manifests in JSON. Use `instance_id` when a stable identifier that persists unchanged across rebuilds is needed. The `label` used at build time may be reassigned by the SDK during signing and will not appear unchanged in `Reader::json()` output. ### Adding ingredients to a working store @@ -497,7 +507,60 @@ 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). +Two paths exist for loading an ingredient archive and linking it to an action. The dedicated ingredient archive APIs (`write_ingredient_archive` + `add_ingredient_from_archive`) are recommended. The legacy path (`add_ingredient(json, "application/c2pa", archive)`) is deprecated. + +#### Using the dedicated ingredient archive APIs + +The archive key passed to `write_ingredient_archive` (either a `label` or `instance_id` value) flows through automatically and becomes the `ingredientIds` value on the signing builder. No re-assertion is needed. + +Producer — register the ingredient and write the archive, keyed by `instance_id`: + +```cpp +auto settings = c2pa::Settings(); +settings.set("builder.generate_c2pa_archive", "true"); +auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + +auto producer = c2pa::Builder(context, manifest_str); +producer.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf", + "instance_id": "my-ingredient"})", + "photo.jpg"); + +std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); +producer.write_ingredient_archive("my-ingredient", archive); +``` + +Signing builder — use the same archive key string in `ingredientIds`, then load the archive: + +```cpp +auto signing_manifest = R"({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [{ + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["my-ingredient"] + } + }] + } + }] +})"; + +auto consumer = c2pa::Builder(context, signing_manifest); +archive.seekg(0); +consumer.add_ingredient_from_archive(archive); +consumer.sign("source.jpg", "signed.jpg", signer); +``` + +For the full table of linking outcomes when `label`, `instance_id`, or both are set, see [Lookup keys and action linking](#lookup-keys-and-action-linking). + +When linking multiple ingredient archives, write one archive per ingredient with a distinct key and load each with `add_ingredient_from_archive`. List all keys in the appropriate `ingredientIds` arrays. + +#### Using the legacy load path (deprecated) > [!IMPORTANT] > **For the legacy load path, linking an ingredient archive is `label`-driven only.** @@ -508,13 +571,12 @@ This section covers the **legacy** load path: producer calls `to_archive`, signi > > Attempting to link via `instance_id`, or relying on a baked-in label alone, produces a sign-time error: `Action ingredientId not found: `. See [Troubleshooting linking errors](#troubleshooting-linking-errors). -To link an ingredient archive to an action via `ingredientIds`, set a `label` on the JSON passed to `add_ingredient` on the signing builder, and use the same string in the action's `ingredientIds` array. A label, as linking key, links ingredients and actions using it together: the label identifies the link. Labels are build-time linking keys only. The SDK may reassign the actual label in the signed manifest during signing. - +To link an ingredient archive to an action via `ingredientIds`, set a `label` on the JSON passed to `add_ingredient` on the signing builder, and use the same string in the action's `ingredientIds` array. Labels are build-time linking keys only. The SDK may reassign the actual label in the signed manifest during signing. ```cpp c2pa::Context context; -// Step 1: Create the ingredient archive. +// Step 1: Create the ingredient archive (legacy). auto manifest_str = read_file("training.json"); auto archive_builder = c2pa::Builder(context, manifest_str); archive_builder.add_ingredient( @@ -562,7 +624,7 @@ archive_stream.close(); builder.sign("source.jpg", "signed.jpg", signer); ``` -When linking multiple ingredient archives, give each a distinct label and reference it in the appropriate action's `ingredientIds` array. +When linking multiple ingredient archives with the legacy path, give each a distinct label and reference it in the appropriate action's `ingredientIds` array. If each ingredient has its own action (e.g., one `c2pa.opened` for the parent and one `c2pa.placed` for a composited element), set up two actions with separate `ingredientIds`: @@ -667,8 +729,21 @@ 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. +### Which archive API to use + +Two archive API families share the same binary format but serve different purposes: + +| | Full-builder APIs | Single-ingredient APIs | +| --- | --- | --- | +| APIs | `to_archive`, `from_archive`, `with_archive` | `write_ingredient_archive`, `add_ingredient_from_archive` | +| What is archived | Entire builder: manifest definition + all ingredients + all resources | One ingredient only (other builder state is omitted) | +| Typical use | Checkpoint or transfer a manifest-in-progress between sessions or machines | Ingredient catalog; selectively load individual ingredients at sign time | +| Requires setting | None | `builder.generate_c2pa_archive = true` on the producing builder | +| Linking key | N/A (full builder is restored as-is) | Archive key (`label` or `instance_id`) flows through automatically as `ingredientIds` value | + +Use `to_archive` / `from_archive` to pause and resume a signing workflow, or to hand off a complete manifest-in-progress to another process or machine. Use `write_ingredient_archive` / `add_ingredient_from_archive` to distribute or cache individual ingredients independently, or to assemble a manifest from a catalog of pre-archived ingredients at sign time. + +The subsections below cover the full-builder APIs. For single-ingredient archive workflows, see [Single-ingredient archive APIs](#single-ingredient-archive-apis). ### Saving a working store to archive From 162f98a6e0169780d89c5a70375b9e240b11ccad Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 27 May 2026 10:59:05 -0700 Subject: [PATCH 05/13] chore: Bump project version to 0.23.11 and C2PA version to 0.85.0 --- CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 246282ed..aaa362e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,10 +14,10 @@ cmake_minimum_required(VERSION 3.27) # This is the current version of this C++ project -project(c2pa-c VERSION 0.23.10) +project(c2pa-c VERSION 0.23.11) # Set the version of the c2pa_rs library used -set(C2PA_VERSION "0.84.1") +set(C2PA_VERSION "0.85.0") set(CMAKE_POLICY_DEFAULT_CMP0135 NEW) set(CMAKE_C_STANDARD 17) From c13a83ed957e97b81494d1dfde7b026df6615fc8 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 28 May 2026 14:55:48 -0700 Subject: [PATCH 06/13] fix: All the docs uopdates --- CMakeLists.txt | 7 ++++ Makefile | 23 +++++++++- docs/selective-manifests.md | 42 +++++++++++-------- docs/working-stores.md | 15 +++---- tests/builder.test.cpp | 83 +++++++++++++++++++++++++++++-------- 5 files changed, 127 insertions(+), 43 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index aaa362e3..6ad933b2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,6 +65,13 @@ if(ENABLE_SANITIZERS) # Note: LeakSanitizer is not supported on macOS (both x86_64 and ARM64) # On Linux, LeakSanitizer is integrated with AddressSanitizer if(APPLE) + # Apple clang (Xcode 16) ships a compiler-rt whose AddressSanitizer + # runtime may abort at startup on macOS 26+ Use a newer toolchain + # (e.g. Homebrew LLVM) for sanitizer builds; see `make test-san`. + if(CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang") + message(WARNING + "Sanitizers requested with AppleClang. Its ASan runtime may aborts at startup on macOS 26+.") + endif() add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer -g) add_link_options(-fsanitize=address,undefined) message(STATUS "Sanitizers enabled on macOS: ASAN and UBSAN (LSan not supported on macOS)") diff --git a/Makefile b/Makefile index 038d0ef4..e68e466a 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,21 @@ ifdef C2PA_RS_PATH CMAKE_OPTS += -DC2PA_RS_PATH="$(C2PA_RS_PATH)" endif +# Sanitizer builds need a compiler-rt that matches the running OS. On macOS 26+ +# the Apple clang (Xcode 16) AddressSanitizer runtime aborts at process startup +# with "sanitizer_malloc_mac.inc:189 CHECK failed: ((!asan_init_is_running))" +# before main() runs, so the sanitizer build must use a newer toolchain. +# Default to a Homebrew LLVM clang on macOS; override with SAN_CC / SAN_CXX. +SAN_CMAKE_OPTS := +ifeq ($(OS),Darwin) +LLVM_PREFIX := $(shell brew --prefix llvm 2>/dev/null) +SAN_CC ?= $(if $(LLVM_PREFIX),$(LLVM_PREFIX)/bin/clang,) +SAN_CXX ?= $(if $(LLVM_PREFIX),$(LLVM_PREFIX)/bin/clang++,) +ifneq ($(SAN_CC),) +SAN_CMAKE_OPTS += -DCMAKE_C_COMPILER=$(SAN_CC) -DCMAKE_CXX_COMPILER=$(SAN_CXX) +endif +endif + # Default target all: clean test examples @@ -43,7 +58,13 @@ test-release: clean release # Test with sanitizers (ASAN + UBSAN) test-san: clean - cmake -S . -B $(DEBUG_BUILD_DIR) -G "Ninja" -DCMAKE_BUILD_TYPE=Debug -DENABLE_SANITIZERS=ON $(CMAKE_OPTS) + @if [ "$(OS)" = "Darwin" ] && [ -z "$(SAN_CC)" ]; then \ + echo "ERROR: no Homebrew LLVM found. Apple clang's ASan runtime aborts at startup on macOS 26+."; \ + echo " Install one with 'brew install llvm', or set SAN_CC / SAN_CXX to a clang whose"; \ + echo " compiler-rt supports this OS."; \ + exit 1; \ + fi + cmake -S . -B $(DEBUG_BUILD_DIR) -G "Ninja" -DCMAKE_BUILD_TYPE=Debug -DENABLE_SANITIZERS=ON $(CMAKE_OPTS) $(SAN_CMAKE_OPTS) cmake --build $(DEBUG_BUILD_DIR) cd $(DEBUG_BUILD_DIR) && ctest --output-on-failure diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 073d2312..14554ce1 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -431,11 +431,11 @@ for (auto& assertion : manifest["assertions"]) { A `Builder` represents a **working store**: a manifest that is being assembled but has not yet been signed. Archives serialize this working store (definition + resources) to a `.c2pa` binary format, allowing to save, transfer, or resume the work later. For more background on working stores and archives, see [Working stores](https://opensource.contentauthenticity.org/docs/rust-sdk/docs/working-stores). -There are two distinct types of archives, sharing the same binary format but being conceptually different: builder archives (working store archives) and ingredient archives. +There are two distinct types of archives, sharing the same binary format but being conceptually different: builder archives and ingredient archives. ### Builder archives vs. ingredient archives -A **builder archive** (also called a working store archive) is a serialized snapshot of a `Builder`. It contains the manifest definition, all resources, and any ingredients that were added. It is created by `builder.to_archive()` and restored with `Builder::from_archive()` or `builder.with_archive()`. +A **builder archive** is a serialized snapshot of a `Builder` (i.e. of a working store). The term *working store* refers to the unsigned `Builder` itself; the *builder archive* is its serialized `.c2pa` form. The archive contains the manifest definition, all resources, and any ingredients that were added. It is created by `builder.to_archive()` and restored with `Builder::from_archive()` or `builder.with_archive()`. An **ingredient archive** contains the manifest store from an asset that was added as an ingredient. @@ -447,10 +447,10 @@ The SDK supports two approaches for producing an ingredient archive. They share | Approach | Entry point | Status | | --- | --- | --- | -| 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)) | +| Dedicated ingredient archive APIs | `add_ingredient` then `write_ingredient_archive(id, stream)` | **Recommended** | +| Read-filter-rebuild pattern | `Builder` + `add_ingredient` + `to_archive`, then `Reader` + manual JSON | **Older pattern** (see [catalog migration guide](#migration-guide-catalog-pattern) and [extraction migration guide](#migration-guide-ingredient-extraction)) | -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. +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 @@ -480,19 +480,21 @@ flowchart TD The catalog can be implemented two ways. The dedicated (ingredient) archives API uses one archive per ingredient. -A legacy approach uses one multi-ingredient builder archive and the read-filter-rebuild pattern to slice out a subset of ingredients (and resources). +Alternatively, a single builder archive can hold many ingredients (a multi-ingredient builder archive is still just a builder archive, not a deprecated format), and the read-filter-rebuild *pattern* slices out a subset of ingredients (and resources) from it. The pattern is the older approach; the archive itself is not legacy. ### Dedicated archives API: one ingredient per archive -The producer registers each ingredient on a builder and writes one archive per ingredient, keyed by `instance_id`. The consumer assembles a final builder by loading only the archives it needs via `add_ingredient_from_archive`. The producing builder must have the `builder.generate_c2pa_archive` setting enabled. +The producer registers each ingredient on a builder and writes one archive per ingredient, keyed by `instance_id`. The consumer assembles a final builder by loading only the archives it needs via `add_ingredient_from_archive`. The first argument to `write_ingredient_archive` is the *archive key*: it locates the ingredient on the producer (matched against either `label` or `instance_id`) and becomes the `ingredientIds` value to use on the signing builder. See [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking) for the full rules. +> [!NOTE] +> `"relationship": "componentOf"` is shown explicitly below, but `componentOf` is the default the SDK applies when `relationship` is omitted. + Producer side, build the catalog: ```cpp auto settings = c2pa::Settings(); -settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -504,12 +506,17 @@ catalog_builder.add_ingredient( catalog_builder.add_ingredient( R"({"title": "photo-B.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-B"})", "photo-B.jpg"); +catalog_builder.add_ingredient( + R"({"title": "photo-C.jpg", "relationship": "componentOf", "instance_id": "catalog:ingredient-C"})", + "photo-C.jpg"); // One archive per ingredient, keyed by the instance_id used at registration. std::stringstream archive_a(std::ios::in | std::ios::out | std::ios::binary); std::stringstream archive_b(std::ios::in | std::ios::out | std::ios::binary); +std::stringstream archive_c(std::ios::in | std::ios::out | std::ios::binary); catalog_builder.write_ingredient_archive("catalog:ingredient-A", archive_a); catalog_builder.write_ingredient_archive("catalog:ingredient-B", archive_b); +catalog_builder.write_ingredient_archive("catalog:ingredient-C", archive_c); ``` Consumer side, pick one archive and load it: @@ -524,7 +531,7 @@ 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: +A single action can link several ingredients loaded this way. With the three archives from the producer above (`catalog:ingredient-A`, `catalog:ingredient-B`, `catalog:ingredient-C`) loaded into one signing builder, a `c2pa.placed` action that lists all three ids in `ingredientIds` resolves to three distinct ingredient URLs after signing: ```cpp auto signing_builder = c2pa::Builder(context, R"({ @@ -535,7 +542,7 @@ auto signing_builder = c2pa::Builder(context, R"({ "actions": [{ "action": "c2pa.placed", "parameters": { - "ingredientIds": ["ing-a", "ing-b", "ing-c"] + "ingredientIds": ["catalog:ingredient-A", "catalog:ingredient-B", "catalog:ingredient-C"] } }] } @@ -608,13 +615,12 @@ builder.sign(source_path, output_path, signer); #### Migration guide: catalog pattern -Switch to the dedicated ingredient archive APIs: set `instance_id` per ingredient, call `write_ingredient_archive` once per ingredient on the producer, and `add_ingredient_from_archive` on the consumer. No JSON parsing or `add_resource` loops required. The producing builder needs `builder.generate_c2pa_archive` 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. 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(); @@ -652,14 +658,18 @@ The legacy read-filter-rebuild APIs fit when the catalog already exists as one m 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. +For the dedicated archive methods, `instance_id` is the preferred key: it is the only one of these identifiers observable in the signed manifest. A `label` is a builder-only linking key and does not appear in the signed output (see [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking)). + For the legacy load path (`add_ingredient(json, "application/c2pa", archive)`), `instance_id` cannot be used as a linking key in `ingredientIds`; use `label` instead (see [Linking an archived ingredient to an action](#linking-an-archived-ingredient-to-an-action)). For the dedicated `write_ingredient_archive` + `add_ingredient_from_archive` ingredient archive APIs, the archive key can be either `label` or `instance_id` and becomes the `ingredientIds` value (see [Lookup keys and action linking](working-stores.md#lookup-keys-and-action-linking)). With the dedicated single-ingredient API, `instance_id` also serves as the lookup key passed to `write_ingredient_archive`. Set it on `add_ingredient`, then pass the same value to write the archive: +> [!NOTE] +> A caller-set `instance_id` replaces the ingredient asset's own XMP `instance_id`. Use a value you control (as in `catalog:photo-A` below) when the archive key matters more than preserving the asset's original XMP id. + ```cpp // Producer: register ingredient with instance_id, write its archive. auto settings = c2pa::Settings(); -settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -798,11 +808,10 @@ A way to extract a specific ingredient from a working store is with the dedicate #### 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. +The producer registers each ingredient keyed by `instance_id`, writes one archive per ingredient, and the consumer loads only the needed one. ```cpp auto settings = c2pa::Settings(); -settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -924,7 +933,7 @@ new_builder.sign(source_path, output_path, signer); | --- | --- | --- | | 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 | +| Setting required | None | `builder.generate_c2pa_archive = "true"` on producer (current default) | The dedicated ingredient archive APIs require no JSON parsing and no `add_resource` calls. Each archive holds exactly one ingredient. @@ -1114,7 +1123,6 @@ 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(); diff --git a/docs/working-stores.md b/docs/working-stores.md index 5f82c384..6a60e26d 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -448,6 +448,11 @@ A **(plain) ingredient** is a source asset that the builder reads at `add_ingred 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. +Once an ingredient is archived, the original ingredient asset is no longer needed: the `.c2pa` ingredient archive stands in for it and carries the ingredient's provenance. + +> [!NOTE] +> The relationship is one-directional. For legacy support you can _read_ an ingredient out of a builder archive, but you should not try to restore a `Builder` from an ingredient archive — consume it as an ingredient with `add_ingredient_from_archive` (or the legacy `add_ingredient(json, "application/c2pa", archive)` path) instead. + For the dedicated ingredient archive APIs, see [Single-ingredient archive APIs](#single-ingredient-archive-apis). This difference governs how each can be linked to an action via `ingredientIds`. The table below describes the **legacy** load path for ingredient archives, where the archive is passed directly to `add_ingredient` with format `"application/c2pa"`: @@ -794,10 +799,7 @@ The `Builder` class exposes two dedicated APIs for moving a single ingredient be `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. +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. @@ -805,7 +807,6 @@ Two more contract points to keep in mind: ```cpp auto settings = c2pa::Settings(); -settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -834,7 +835,6 @@ The archive contains exactly one ingredient. Reading it back through `c2pa::Read ```cpp auto settings = c2pa::Settings(); -settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -895,7 +895,6 @@ With the dedicated ingredient archive APIs, the producer writes a single-ingredi ```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(); @@ -999,6 +998,8 @@ consumer.sign(source_path, output_path, signer); When only `label` is set, pass the `label` value to `write_ingredient_archive`. Use that same string in `ingredientIds`. +This works even though `label` is not preserved as an ingredient field. The label string is not written into `instance_id`, and it does not appear in the signed manifest. `add_ingredient_from_archive` carries the archive key in the archive's metadata and restores it as a builder-only linking key, so the action resolves to the ingredient at signing time. See [Lookup keys and action linking](#lookup-keys-and-action-linking) for the full mechanism. + Producer: ```cpp diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index 2e4275d7..745b5615 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -6351,7 +6351,8 @@ TEST_F(BuilderTest, ArchiveIngredientWithProvenanceRoundTripAndReuse) TEST_F(BuilderTest, ExtractIngredientsFromArchiveAndReuseUsingArchiveApi) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -6394,7 +6395,8 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchiveAndReuseUsingArchiveApi) TEST_F(BuilderTest, LinkIngredientArchiveParentOfOpenedUsingArchiveApi) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -6424,7 +6426,8 @@ TEST_F(BuilderTest, LinkIngredientArchiveParentOfOpenedUsingArchiveApi) TEST_F(BuilderTest, LinkIngredientArchiveComponentOfPlacedUsingArchiveApi) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -6453,7 +6456,8 @@ TEST_F(BuilderTest, LinkIngredientArchiveComponentOfPlacedUsingArchiveApi) TEST_F(BuilderTest, LinkIngredientArchiveToBothOpenedAndPlacedUsingArchiveApi) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -6532,7 +6536,8 @@ TEST_F(BuilderTest, LinkIngredientArchiveToBothOpenedAndPlacedUsingArchiveApi) TEST_F(BuilderTest, IngredientCatalogUsingArchiveApi) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -6577,7 +6582,8 @@ TEST_F(BuilderTest, IngredientCatalogUsingArchiveApi) TEST_F(BuilderTest, LinkThreeIngredientArchivesDistinctIdsUsingArchiveApi) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -6660,7 +6666,8 @@ TEST_F(BuilderTest, LinkThreeIngredientArchivesDistinctIdsUsingArchiveApi) TEST_F(BuilderTest, MixIngredientApisLinkByLabel) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -6729,7 +6736,8 @@ TEST_F(BuilderTest, MixIngredientApisLinkByLabel) TEST_F(BuilderTest, IngredientArchiveFallsBackToInstanceIdWhenNoLabel) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -6774,7 +6782,8 @@ TEST_F(BuilderTest, IngredientArchiveFallsBackToInstanceIdWhenNoLabel) TEST_F(BuilderTest, WriteIngredientArchiveWithoutAddIngredientThrows) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -6790,7 +6799,8 @@ TEST_F(BuilderTest, WriteIngredientArchiveWithoutAddIngredientThrows) TEST_F(BuilderTest, WriteIngredientArchiveWithUnknownIdThrows) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -6810,7 +6820,8 @@ TEST_F(BuilderTest, WriteIngredientArchiveWithUnknownIdThrows) TEST_F(BuilderTest, WriteIngredientArchiveContainsOnlyTargetIngredient) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -6844,7 +6855,8 @@ TEST_F(BuilderTest, WriteIngredientArchiveContainsOnlyTargetIngredient) TEST_F(BuilderTest, InstanceIdSurvivesWriteIngredientArchiveRoundTripAndSigning) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -6884,7 +6896,8 @@ TEST_F(BuilderTest, InstanceIdSurvivesWriteIngredientArchiveRoundTripAndSigning) TEST_F(BuilderTest, InstanceIdAutoGeneratedWhenNotProvided) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -6918,6 +6931,35 @@ TEST_F(BuilderTest, InstanceIdAutoGeneratedWhenNotProvided) << "Auto-generated instance_id should start with xmp.iid:, got: " << iid; } +// When add_ingredient JSON omits "relationship", the SDK applies "componentOf" as the default. +TEST_F(BuilderTest, RelationshipDefaultsToComponentOf) +{ + auto context = c2pa::Context::ContextBuilder().create_context(); + + auto manifest_str = R"({ + "claim_generator_info": [{"name": "test", "version": "1.0"}] + })"; + + // Add ingredient with NO relationship in JSON. + auto builder = c2pa::Builder(context, manifest_str); + builder.add_ingredient( + R"({"title": "A.jpg"})", + c2pa_test::get_fixture_path("A.jpg")); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("relationship_defaults_component_of.jpg"); + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + ASSERT_TRUE(ingredients[0].contains("relationship")); + EXPECT_EQ(ingredients[0]["relationship"], "componentOf") + << "Omitting relationship should default to componentOf"; +} + // instance_id set on add_ingredient survives to_archive and is // readable via Reader::json() on the archive. TEST_F(BuilderTest, InstanceIdSurvivesToArchiveAndReader) @@ -6959,7 +7001,8 @@ TEST_F(BuilderTest, InstanceIdSurvivesToArchiveAndReader) TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredPrefersLabelInSigningBuilder) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -7015,7 +7058,8 @@ TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredPrefersLabelInSignin TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelUsingLabelOnly) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -7061,7 +7105,8 @@ TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelUsingLabelOnl TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelUsingInstanceIdOnly) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -7112,7 +7157,8 @@ TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelUsingInstance TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelBothSetUseLabelForLinking) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -7162,7 +7208,8 @@ TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelBothSetUseLab TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelBothSetUseInstanceIdForLinking) { auto settings = c2pa::Settings(); - settings.set("builder.generate_c2pa_archive", "true"); + // builder.generate_c2pa_archive has become default +// settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); From e19bd214174db8a743907ec4cb3a0465c1d1157d Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 28 May 2026 14:57:23 -0700 Subject: [PATCH 07/13] fix: All the docs uopdates --- CMakeLists.txt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6ad933b2..aaa362e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,13 +65,6 @@ if(ENABLE_SANITIZERS) # Note: LeakSanitizer is not supported on macOS (both x86_64 and ARM64) # On Linux, LeakSanitizer is integrated with AddressSanitizer if(APPLE) - # Apple clang (Xcode 16) ships a compiler-rt whose AddressSanitizer - # runtime may abort at startup on macOS 26+ Use a newer toolchain - # (e.g. Homebrew LLVM) for sanitizer builds; see `make test-san`. - if(CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang") - message(WARNING - "Sanitizers requested with AppleClang. Its ASan runtime may aborts at startup on macOS 26+.") - endif() add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer -g) add_link_options(-fsanitize=address,undefined) message(STATUS "Sanitizers enabled on macOS: ASAN and UBSAN (LSan not supported on macOS)") From ab10cd60ef76d876fd4e12bd401d20c1b1147fca Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 28 May 2026 14:59:34 -0700 Subject: [PATCH 08/13] fix: All the docs uopdates --- Makefile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index e68e466a..b06bbd46 100644 --- a/Makefile +++ b/Makefile @@ -15,10 +15,11 @@ ifdef C2PA_RS_PATH endif # Sanitizer builds need a compiler-rt that matches the running OS. On macOS 26+ -# the Apple clang (Xcode 16) AddressSanitizer runtime aborts at process startup -# with "sanitizer_malloc_mac.inc:189 CHECK failed: ((!asan_init_is_running))" +# the Apple clang (Xcode 16) AddressSanitizer runtime may abort at process startup +# with "sanitizer_malloc_mac.inc CHECK failed: ((!asan_init_is_running))" # before main() runs, so the sanitizer build must use a newer toolchain. -# Default to a Homebrew LLVM clang on macOS; override with SAN_CC / SAN_CXX. +# Default to a Homebrew LLVM clang on macOS, as it may be new enough +# (consider updating if still failing). Override with SAN_CC / SAN_CXX. SAN_CMAKE_OPTS := ifeq ($(OS),Darwin) LLVM_PREFIX := $(shell brew --prefix llvm 2>/dev/null) @@ -59,9 +60,8 @@ test-release: clean release # Test with sanitizers (ASAN + UBSAN) test-san: clean @if [ "$(OS)" = "Darwin" ] && [ -z "$(SAN_CC)" ]; then \ - echo "ERROR: no Homebrew LLVM found. Apple clang's ASan runtime aborts at startup on macOS 26+."; \ - echo " Install one with 'brew install llvm', or set SAN_CC / SAN_CXX to a clang whose"; \ - echo " compiler-rt supports this OS."; \ + echo "ERROR: no Homebrew LLVM found. Apple clang's ASan runtime may abort at startup on macOS 26+."; \ + echo " Install a recent one with 'brew install llvm', or set SAN_CC/SAN_CXX to a clang whose compiler-rt supports this OS."; \ exit 1; \ fi cmake -S . -B $(DEBUG_BUILD_DIR) -G "Ninja" -DCMAKE_BUILD_TYPE=Debug -DENABLE_SANITIZERS=ON $(CMAKE_OPTS) $(SAN_CMAKE_OPTS) From 810c09e6659be64e007da538978cdc5fa30a1cf6 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 28 May 2026 15:01:41 -0700 Subject: [PATCH 09/13] fix: Build --- Makefile | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index b06bbd46..cf1f4fd3 100644 --- a/Makefile +++ b/Makefile @@ -15,16 +15,17 @@ ifdef C2PA_RS_PATH endif # Sanitizer builds need a compiler-rt that matches the running OS. On macOS 26+ -# the Apple clang (Xcode 16) AddressSanitizer runtime may abort at process startup +# the Apple clang (Xcode 16) AddressSanitizer runtime can abort at process startup # with "sanitizer_malloc_mac.inc CHECK failed: ((!asan_init_is_running))" -# before main() runs, so the sanitizer build must use a newer toolchain. -# Default to a Homebrew LLVM clang on macOS, as it may be new enough -# (consider updating if still failing). Override with SAN_CC / SAN_CXX. +# before main() runs. If a Homebrew LLVM clang is actually installed, use it for +# sanitizer builds; otherwise fall back to the default toolchain (fine on older +# macOS and on Linux). Override with SAN_CC / SAN_CXX. +# Note: `brew --prefix llvm` prints a path even when llvm is NOT installed, so the +# binary's existence must be verified (-x) before using it. SAN_CMAKE_OPTS := ifeq ($(OS),Darwin) -LLVM_PREFIX := $(shell brew --prefix llvm 2>/dev/null) -SAN_CC ?= $(if $(LLVM_PREFIX),$(LLVM_PREFIX)/bin/clang,) -SAN_CXX ?= $(if $(LLVM_PREFIX),$(LLVM_PREFIX)/bin/clang++,) +SAN_CC ?= $(shell c="$$(brew --prefix llvm 2>/dev/null)/bin/clang"; [ -x "$$c" ] && echo "$$c") +SAN_CXX ?= $(shell c="$$(brew --prefix llvm 2>/dev/null)/bin/clang++"; [ -x "$$c" ] && echo "$$c") ifneq ($(SAN_CC),) SAN_CMAKE_OPTS += -DCMAKE_C_COMPILER=$(SAN_CC) -DCMAKE_CXX_COMPILER=$(SAN_CXX) endif @@ -59,10 +60,12 @@ test-release: clean release # Test with sanitizers (ASAN + UBSAN) test-san: clean - @if [ "$(OS)" = "Darwin" ] && [ -z "$(SAN_CC)" ]; then \ - echo "ERROR: no Homebrew LLVM found. Apple clang's ASan runtime may abort at startup on macOS 26+."; \ - echo " Install a recent one with 'brew install llvm', or set SAN_CC/SAN_CXX to a clang whose compiler-rt supports this OS."; \ - exit 1; \ + @if [ -n "$(SAN_CC)" ]; then \ + echo "Sanitizer build using $(SAN_CC)"; \ + elif [ "$(OS)" = "Darwin" ]; then \ + echo "NOTE: using the default toolchain for sanitizers. If tests abort at startup with"; \ + echo " 'asan_init_is_running' (Apple clang on macOS 26+), run 'brew install llvm'"; \ + echo " or set SAN_CC/SAN_CXX to a clang whose compiler-rt supports this OS."; \ fi cmake -S . -B $(DEBUG_BUILD_DIR) -G "Ninja" -DCMAKE_BUILD_TYPE=Debug -DENABLE_SANITIZERS=ON $(CMAKE_OPTS) $(SAN_CMAKE_OPTS) cmake --build $(DEBUG_BUILD_DIR) From ae324bbb9a2d1f1f2b87cd8bfab667c5545011a9 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:12:16 -0700 Subject: [PATCH 10/13] fix: Update tests --- compile_commands.json | 1 + docs/selective-manifests.md | 2 +- docs/working-stores.md | 3 +- include/c2pa.hpp | 6 +- tests/builder.test.cpp | 141 +++++++++++++++++++++++++----------- 5 files changed, 104 insertions(+), 49 deletions(-) create mode 120000 compile_commands.json diff --git a/compile_commands.json b/compile_commands.json new file mode 120000 index 00000000..7c1ac711 --- /dev/null +++ b/compile_commands.json @@ -0,0 +1 @@ +build/debug/compile_commands.json \ No newline at end of file diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 33badf63..5ece2767 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -561,7 +561,7 @@ 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. +> **Legacy approach.** This pattern requires manual JSON parsing and `add_resource` loops to transfer binary data. See [Migration guide](#migration-guide-catalog-pattern) to use the [dedicated ingredient archive APIs](#dedicated-archives-api-one-ingredient-per-archive) instead. Use this approach when the catalog already exists as a single `.c2pa` builder archive containing many ingredients and you need to pick a subset by reading, filtering, and rebuilding. diff --git a/docs/working-stores.md b/docs/working-stores.md index d23c2f9e..c8446e5e 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -522,7 +522,6 @@ Producer — register the ingredient and write the archive, keyed by `instance_i ```cpp auto settings = c2pa::Settings(); -settings.set("builder.generate_c2pa_archive", "true"); auto context = c2pa::Context::ContextBuilder() .with_settings(std::move(settings)) .create_context(); @@ -743,7 +742,7 @@ Two archive API families share the same binary format but serve different purpos | APIs | `to_archive`, `from_archive`, `with_archive` | `write_ingredient_archive`, `add_ingredient_from_archive` | | What is archived | Entire builder: manifest definition + all ingredients + all resources | One ingredient only (other builder state is omitted) | | Typical use | Checkpoint or transfer a manifest-in-progress between sessions or machines | Ingredient catalog; selectively load individual ingredients at sign time | -| Requires setting | None | `builder.generate_c2pa_archive = true` on the producing builder | +| Requires setting | None | `builder.generate_c2pa_archive = true` on the producing builder (this is the current default) | | Linking key | N/A (full builder is restored as-is) | Archive key (`label` or `instance_id`) flows through automatically as `ingredientIds` value | Use `to_archive` / `from_archive` to pause and resume a signing workflow, or to hand off a complete manifest-in-progress to another process or machine. Use `write_ingredient_archive` / `add_ingredient_from_archive` to distribute or cache individual ingredients independently, or to assemble a manifest from a catalog of pre-archived ingredients at sign time. diff --git a/include/c2pa.hpp b/include/c2pa.hpp index 68a31350..0e2c88fe 100644 --- a/include/c2pa.hpp +++ b/include/c2pa.hpp @@ -1289,9 +1289,11 @@ namespace c2pa 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 ingredient_id The ingredient's `label` if set, otherwise its `instance_id`, + /// as supplied to add_ingredient. /// @param dest The output stream to write the ingredient archive to. - /// @note Requires the `generate_c2pa_archive` context setting to be enabled. + /// @note Requires the `generate_c2pa_archive` context setting to be enabled + /// (enabled by default). /// @throws C2paException for errors encountered by the C2PA library. void write_ingredient_archive(const std::string &ingredient_id, std::ostream &dest); diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index 745b5615..2a9da21c 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -3390,41 +3390,39 @@ TEST_F(BuilderTest, ArchiveToFilePath) { TEST_F(BuilderTest, ExtractIngredientsFromArchive) { auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - // Archive each ingredient individually using the new archive API. - // Each single-ingredient builder is archived to a stream, then added to the new builder. + // Archive each ingredient individually using the dedicated ingredient archive API. + // Each ingredient is registered under a label, then written to its own archive stream. auto archive_ingredient = [&](const std::string& ingredient_json, + const std::string& ingredient_id, const fs::path& asset_path) -> std::stringstream { auto b = c2pa::Builder(manifest); b.add_ingredient(ingredient_json, asset_path); std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); - b.to_archive(ss); + b.write_ingredient_archive(ingredient_id, ss); return ss; }; auto archive1 = archive_ingredient( - R"({"title": "A.jpg", "relationship": "parentOf"})", + R"({"title": "A.jpg", "relationship": "parentOf", "label": "ing-a"})", + "ing-a", c2pa_test::get_fixture_path("A.jpg")); auto archive2 = archive_ingredient( - R"({"title": "C.jpg", "relationship": "componentOf"})", + R"({"title": "C.jpg", "relationship": "componentOf", "label": "ing-c"})", + "ing-c", c2pa_test::get_fixture_path("C.jpg")); auto archive3 = archive_ingredient( - R"({"title": "sample.gif", "relationship": "componentOf"})", + R"({"title": "sample.gif", "relationship": "componentOf", "label": "ing-gif"})", + "ing-gif", c2pa_test::get_fixture_path("sample1.gif")); // Add each archived ingredient to the new builder using the archive API. auto merged_builder = c2pa::Builder(manifest); archive1.seekg(0); - EXPECT_NO_THROW(merged_builder.add_ingredient( - R"({"title": "A.jpg", "relationship": "parentOf"})", - "application/c2pa", archive1)); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive1)); archive2.seekg(0); - EXPECT_NO_THROW(merged_builder.add_ingredient( - R"({"title": "C.jpg", "relationship": "componentOf"})", - "application/c2pa", archive2)); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive2)); archive3.seekg(0); - EXPECT_NO_THROW(merged_builder.add_ingredient( - R"({"title": "sample.gif", "relationship": "componentOf"})", - "application/c2pa", archive3)); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive3)); // Sign and verify auto signer = c2pa_test::create_test_signer(); @@ -3449,47 +3447,46 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchive) { TEST_F(BuilderTest, ExtractIngredientsFromArchiveToBuilder) { auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - // Helper: archive a single ingredient and return the stream. + // Helper: register a single ingredient under a label and write its dedicated + // ingredient archive to a stream. auto make_ingredient_archive = [&](const std::string& ingredient_json, + const std::string& ingredient_id, const fs::path& asset_path) -> std::stringstream { auto b = c2pa::Builder(manifest); b.add_ingredient(ingredient_json, asset_path); std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); - b.to_archive(ss); + b.write_ingredient_archive(ingredient_id, ss); return ss; }; // Archive 1 contains A.jpg and C.jpg (as separate per-ingredient archives) auto archive_a = make_ingredient_archive( - R"({"title": "A.jpg", "relationship": "parentOf"})", + R"({"title": "A.jpg", "relationship": "parentOf", "label": "ing-a"})", + "ing-a", c2pa_test::get_fixture_path("A.jpg")); auto archive_c = make_ingredient_archive( - R"({"title": "C.jpg", "relationship": "componentOf"})", + R"({"title": "C.jpg", "relationship": "componentOf", "label": "ing-c"})", + "ing-c", c2pa_test::get_fixture_path("C.jpg")); // Archive 2 contains sample.gif auto archive_gif = make_ingredient_archive( - R"({"title": "sample.gif", "relationship": "componentOf"})", + R"({"title": "sample.gif", "relationship": "componentOf", "label": "ing-gif"})", + "ing-gif", c2pa_test::get_fixture_path("sample1.gif")); - // Build merged builder by adding each archived ingredient via the archive API. - // Call add_ingredient twice for "archive 1" group, once for "archive 2" group. + // Build merged builder by loading each archived ingredient via the archive API. + // Load twice for "archive 1" group, once for "archive 2" group. auto merged_builder = c2pa::Builder(manifest); archive_a.seekg(0); - EXPECT_NO_THROW(merged_builder.add_ingredient( - R"({"title": "A.jpg", "relationship": "parentOf"})", - "application/c2pa", archive_a)); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive_a)); archive_c.seekg(0); - EXPECT_NO_THROW(merged_builder.add_ingredient( - R"({"title": "C.jpg", "relationship": "componentOf"})", - "application/c2pa", archive_c)); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive_c)); archive_gif.seekg(0); - EXPECT_NO_THROW(merged_builder.add_ingredient( - R"({"title": "sample.gif", "relationship": "componentOf"})", - "application/c2pa", archive_gif)); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive_gif)); // Sign and verify auto signer = c2pa_test::create_test_signer(); @@ -3512,43 +3509,45 @@ TEST_F(BuilderTest, ExtractIngredientsFromArchiveToBuilder) { TEST_F(BuilderTest, ExtractIngredientsFromArchives) { auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - // Helper: wrap a single asset in a per-ingredient archive using the new API. + // Helper: register a single asset under a label and write its dedicated + // per-ingredient archive. auto make_ingredient_archive = [&](const std::string& ingredient_json, + const std::string& ingredient_id, const fs::path& asset_path) -> std::stringstream { auto b = c2pa::Builder(manifest); b.add_ingredient(ingredient_json, asset_path); std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); - b.to_archive(ss); + b.write_ingredient_archive(ingredient_id, ss); return ss; }; // "Archive group 1": A.jpg and C.jpg — each gets its own per-ingredient archive auto archive_a = make_ingredient_archive( - R"({"title": "A.jpg", "relationship": "parentOf"})", + R"({"title": "A.jpg", "relationship": "parentOf", "label": "ing-a"})", + "ing-a", c2pa_test::get_fixture_path("A.jpg")); auto archive_c = make_ingredient_archive( - R"({"title": "C.jpg", "relationship": "componentOf"})", + R"({"title": "C.jpg", "relationship": "componentOf", "label": "ing-c"})", + "ing-c", c2pa_test::get_fixture_path("C.jpg")); // "Archive group 2": sample1.gif — per-ingredient archive auto archive_gif = make_ingredient_archive( - R"({"title": "sample.gif", "relationship": "componentOf"})", + R"({"title": "sample.gif", "relationship": "componentOf", "label": "ing-gif"})", + "ing-gif", c2pa_test::get_fixture_path("sample1.gif")); - // Merge all three ingredients into one builder via the new archive API + // Merge all three ingredients into one builder via the dedicated archive API auto merged_builder = c2pa::Builder(manifest); archive_a.seekg(0); - EXPECT_NO_THROW(merged_builder.add_ingredient( - R"({"title": "A.jpg", "relationship": "parentOf"})", "application/c2pa", archive_a)); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive_a)); archive_c.seekg(0); - EXPECT_NO_THROW(merged_builder.add_ingredient( - R"({"title": "C.jpg", "relationship": "componentOf"})", "application/c2pa", archive_c)); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive_c)); archive_gif.seekg(0); - EXPECT_NO_THROW(merged_builder.add_ingredient( - R"({"title": "sample.gif", "relationship": "componentOf"})", "application/c2pa", archive_gif)); + EXPECT_NO_THROW(merged_builder.add_ingredient_from_archive(archive_gif)); // Sign the merged builder auto signer = c2pa_test::create_test_signer(); @@ -6774,7 +6773,8 @@ TEST_F(BuilderTest, IngredientArchiveFallsBackToInstanceIdWhenNoLabel) 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)); + EXPECT_TRUE(verify_ingredient_linked(signing_builder, output_path, signer, "c2pa.placed")) + << "instance_id passed as archive key should link the loaded ingredient to the action"; } // Empty builder: write_ingredient_archive cannot fabricate an ingredient @@ -7253,3 +7253,56 @@ TEST_F(BuilderTest, IngredientIdPassedToWriteArchiveRestoredAsLabelBothSetUseIns ASSERT_TRUE(ingredients[0].contains("instance_id")); EXPECT_EQ(ingredients[0]["instance_id"], "iid:both-set2"); } + +// write_ingredient_archive requires the c2pa (JUMBF) working-store format. +TEST_F(BuilderTest, WriteIngredientArchiveThrowsWhenSettingDisabled) +{ + auto settings = c2pa::Settings(); + settings.set("builder.generate_c2pa_archive", "false"); + auto context = c2pa::Context::ContextBuilder() + .with_settings(std::move(settings)) + .create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(context, manifest_str); + builder.add_ingredient( + json({{"title", "photo.jpg"}, {"relationship", "componentOf"}, {"label", "my-ingredient"}}).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + EXPECT_THROW(builder.write_ingredient_archive("my-ingredient", stream), c2pa::C2paException); +} + +TEST_F(BuilderTest, ProvenanceSurvivesIngredientArchiveRoundTrip) +{ + auto context = c2pa::Context::ContextBuilder().create_context(); + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // C.jpg is C2PA-signed, so the ingredient records an active manifest. + auto producer = c2pa::Builder(context, manifest_str); + producer.add_ingredient( + json({{"title", "C.jpg"}, {"relationship", "componentOf"}, {"instance_id", "iid:provenance-roundtrip"}}).dump(), + c2pa_test::get_fixture_path("C.jpg")); + + std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(producer.write_ingredient_archive("iid:provenance-roundtrip", archive_stream)); + + auto consumer = c2pa::Builder(context, manifest_str); + archive_stream.seekg(0); + ASSERT_NO_THROW(consumer.add_ingredient_from_archive(archive_stream)); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("provenance_survives_ingredient_archive.jpg"); + ASSERT_NO_THROW(consumer.sign(c2pa_test::get_fixture_path("A.jpg"), output_path, signer)); + + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + EXPECT_EQ(ingredients[0]["title"], "C.jpg"); + ASSERT_TRUE(ingredients[0].contains("instance_id")); + EXPECT_EQ(ingredients[0]["instance_id"], "iid:provenance-roundtrip"); + EXPECT_TRUE(ingredients[0].contains("active_manifest")) + << "Ingredient provenance (active_manifest) should survive the archive round-trip"; +} From bd90db1925871c2ef541cf2fc6056bf229e55375 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:12:32 -0700 Subject: [PATCH 11/13] fix: Update tests --- 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 7c1ac711..00000000 --- a/compile_commands.json +++ /dev/null @@ -1 +0,0 @@ -build/debug/compile_commands.json \ No newline at end of file From cbeb01d7163ea95b9385af445483420339ebd83e Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 15 Jun 2026 08:40:52 -0700 Subject: [PATCH 12/13] fix: Merge syntax error --- tests/builder.test.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index 14f93808..0544d5dd 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -7133,6 +7133,8 @@ TEST_F(BuilderTest, ProvenanceSurvivesIngredientArchiveRoundTrip) EXPECT_EQ(ingredients[0]["instance_id"], "iid:provenance-roundtrip"); EXPECT_TRUE(ingredients[0].contains("active_manifest")) << "Ingredient provenance (active_manifest) should survive the archive round-trip"; +} + TEST(SignerTest, InvalidCredentialsThrowFromConstructor) { EXPECT_THROW( c2pa::Signer("Es256", "not a certificate", "not a private key"), From 72fd07404595e28583bc5fc4ce6b1e02bfbedf50 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:14:07 -0700 Subject: [PATCH 13/13] Bump project version to 0.24.1 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 68a03ce0..8ffa3cef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,7 +14,7 @@ cmake_minimum_required(VERSION 3.27) # This is the current version of this C++ project -project(c2pa-c VERSION 0.24.0) +project(c2pa-c VERSION 0.24.1) # Set the version of the c2pa_rs library used set(C2PA_VERSION "0.88.0")