From 2a747aa19789322a75702a990eaa512fe3ddbc52 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 17 Mar 2026 18:25:52 -0700 Subject: [PATCH 1/5] fix: Docs initial commit --- docs/selective-manifests.md | 55 +--------- docs/working-stores.md | 54 ++++++++++ tests/builder.test.cpp | 206 ++++++++++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 53 deletions(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 30e6561..d7637a7 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -748,11 +748,9 @@ if (ingredient.contains("thumbnail")) { #### Linking an archived ingredient to an action -After reading the ingredient details from an ingredient archive, the ingredient can be added to a new `Builder` and linked to an action. The preferred approach is to assign a `label` in the `add_ingredient` call and use that label as the linking key in `ingredientIds`. If the archived ingredient carries an `instance_id`, you can use that instead. +After reading the ingredient details from an ingredient archive, the ingredient can be added to a new `Builder` and linked to an action. You must assign a `label` in the `add_ingredient` call on the signing builder and use that label as the linking key in `ingredientIds`. Labels baked into the archive ingredient are not carried through, and `instance_id` does not work as a linking key for ingredient archives. -Note that labels are only used as build-time linking keys. The SDK may reassign the actual label in the signed manifest. An `instance_id`, on the other hand, is preserved as-is through signing and can be read back unchanged from the final manifest. - -##### Using a label +Note that labels are only used as build-time linking keys. The SDK may reassign the actual label in the signed manifest. Assign a `label` in the `add_ingredient` call and reference that same label in `ingredientIds`. This works whether or not the ingredient has an `instance_id`. @@ -802,55 +800,6 @@ builder.add_ingredient( builder.sign(source_path, output_path, signer); ``` -##### Using an `instance_id` - -If the ingredient archive carries an `instance_id` and you need a stable identifier that persists unchanged in the signed manifest, you can use the `instance_id` as the linking key in `ingredientIds` instead of a label. - -```cpp -c2pa::Context context; - -// Read the ingredient archive and extract the instance_id -std::ifstream archive_file("ingredient_archive.c2pa", std::ios::binary); -c2pa::Reader reader(context, "application/c2pa", archive_file); -auto parsed = json::parse(reader.json()); -std::string active = parsed["active_manifest"]; -auto& ingredient = parsed["manifests"][active]["ingredients"][0]; -std::string instance_id = ingredient["instance_id"]; - -json manifest_json = { - {"claim_generator_info", json::array({{{"name", "an-application"}, {"version", "1.0"}}})}, - {"assertions", json::array({ - { - {"label", "c2pa.actions.v2"}, - {"data", { - {"actions", json::array({ - { - {"action", "c2pa.placed"}, - {"parameters", { - {"ingredientIds", json::array({instance_id})} - }} - } - })} - }} - } - })} -}; - -c2pa::Builder builder(context, manifest_json.dump()); - -archive_file.seekg(0); -builder.add_ingredient( - json({ - {"title", ingredient["title"]}, - {"relationship", "componentOf"}, - {"instance_id", instance_id} - }).dump(), - "application/c2pa", - archive_file); - -builder.sign(source_path, output_path, signer); -``` - ### Merging multiple working stores In some cases you may need to merge ingredients from multiple working stores (builder archives) into a single `Builder`. This should be a **fallback strategy**—the recommended practice is to maintain a single active working store and add ingredients incrementally (archived ingredient catalogs help with this). Merging is available when multiple working stores must be consolidated. diff --git a/docs/working-stores.md b/docs/working-stores.md index efdfe09..adc151c 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -477,6 +477,60 @@ ingredient_stream.close(); builder.sign("new_asset.jpg", "signed_asset.jpg", signer); ``` +### Linking an ingredient archive to an action + +To link an ingredient archive to an action via `ingredientIds`, you must use a `label` set in the `add_ingredient` call on the signing builder. Labels baked into the archive ingredient are not carried through, and `instance_id` does not work as a linking key for ingredient archives regardless of where it is set. + +```cpp +c2pa::Context context; + +// Step 1: Create the ingredient archive +auto manifest_str = read_file("training.json"); +auto archive_builder = c2pa::Builder(context, manifest_str); +archive_builder.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf"})", + "photo.jpg"); +archive_builder.to_archive("ingredient.c2pa"); + +// Step 2: Build a manifest with an action that references the ingredient +auto manifest_json = R"({ + "claim_generator_info": [{"name": "my-app", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [{ + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["my-ingredient"] + } + }] + } + }] +})"; + +auto builder = c2pa::Builder(context, manifest_json); + +// Step 3: Add the ingredient archive with a label matching the ingredientIds value. +// The label MUST be set here, on the signing builder's add_ingredient call. +builder.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf", "label": "my-ingredient"})", + "ingredient.c2pa"); + +builder.sign("source.jpg", "signed.jpg", signer); +``` + +When linking multiple ingredient archives, give each a distinct label and reference them separately in `ingredientIds`: + +```cpp +// Two actions, each linked to a different ingredient archive +builder.add_ingredient( + R"({"title": "base.jpg", "relationship": "componentOf", "label": "base-layer"})", + "base_ingredient.c2pa"); +builder.add_ingredient( + R"({"title": "overlay.jpg", "relationship": "componentOf", "label": "overlay-layer"})", + "overlay_ingredient.c2pa"); +``` + ### Ingredient relationships Specify the relationship between the ingredient and the current asset: diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index c778daf..0709efd 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -80,6 +80,76 @@ class BuilderTest : public ::testing::Test { temp_files.clear(); temp_dirs.clear(); } + + // Helper: Creates an ingredient archive (.c2pa) from a single ingredient. + void create_ingredient_archive( + const fs::path& archive_path, + const std::string& archive_ingredient_json) + { + auto context = c2pa::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(archive_ingredient_json, c2pa_test::get_fixture_path("A.jpg")); + builder.to_archive(archive_path); + } + + // Helper: Builds a manifest JSON with a single action that references an + // ingredientId linking key. + json make_manifest_with_action(const std::string& action_name, + const std::string& linking_key, + const std::string& digital_source_type = "") + { + json action_obj = {{"action", action_name}}; + if (!digital_source_type.empty()) { + action_obj["digitalSourceType"] = digital_source_type; + } + action_obj["parameters"] = {{"ingredientIds", json::array({linking_key})}}; + + return { + {"claim_generator_info", json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions.v2"}, + {"data", {{"actions", json::array({action_obj})}}} + } + })} + }; + } + + // Helper: Signs with builder, reads back, and checks whether the given + // action has a resolved ingredients array with the expected JUMBF URL. + bool verify_ingredient_linked( + c2pa::Builder& builder, + const fs::path& output_path, + c2pa::Signer& signer, + const std::string& expected_action, + const std::string& expected_ingredient_label = "c2pa.ingredient.v3") + { + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + builder.sign(source_path, output_path, signer); + + auto context = c2pa::Context(); + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& manifest = parsed["manifests"][active]; + + for (auto& assertion : manifest["assertions"]) { + if (assertion["label"] != "c2pa.actions.v2") continue; + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] != expected_action) continue; + if (!action.contains("parameters")) return false; + auto& params = action["parameters"]; + if (!params.contains("ingredients")) return false; + auto& ingredients = params["ingredients"]; + if (!ingredients.is_array() || ingredients.empty()) return false; + std::string url = ingredients[0]["url"]; + std::string expected_url = "self#jumbf=c2pa.assertions/" + expected_ingredient_label; + return url == expected_url; + } + } + return false; + } }; TEST_F(BuilderTest, supported_mime_types_returns_types) { @@ -4437,6 +4507,142 @@ TEST_F(BuilderTest, AddIngredientFromArchiveWithCustomProperties) } } + +TEST_F(BuilderTest, LinkArchive_Label_OnSigningBuilder_Placed) +{ + // No label on the archive ingredient + auto archive_path = get_temp_path("label_on_signing_placed.c2pa"); + create_ingredient_archive(archive_path, + R"({"title": "photo.jpg", "relationship": "componentOf"})"); + + auto manifest_json = make_manifest_with_action("c2pa.placed", "my-ingredient"); + auto context = c2pa::Context(); + auto builder = c2pa::Builder(context, manifest_json.dump()); + + // Label set here, on the signing builder's add_ingredient call + builder.add_ingredient( + R"({"title": "photo.jpg", "relationship": "componentOf", "label": "my-ingredient"})", + archive_path); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("link_label_on_signing_placed.jpg"); + + bool linked = verify_ingredient_linked(builder, output_path, signer, "c2pa.placed"); + std::cout << "[LinkArchive_Label_OnSigningBuilder_Placed] linked = " << linked << std::endl; + EXPECT_TRUE(linked) + << "Expected label set on the signing builder's add_ingredient to link with ingredientIds"; +} + +TEST_F(BuilderTest, LinkArchive_Label_OnSigningBuilder_Opened) +{ + auto archive_path = get_temp_path("label_on_signing_opened.c2pa"); + create_ingredient_archive(archive_path, + R"({"title": "photo.jpg", "relationship": "parentOf"})"); + + auto manifest_json = make_manifest_with_action("c2pa.opened", "my-ingredient", + "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"); + auto context = c2pa::Context(); + auto builder = c2pa::Builder(context, manifest_json.dump()); + + builder.add_ingredient( + R"({"title": "photo.jpg", "relationship": "parentOf", "label": "my-ingredient"})", + archive_path); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("link_label_on_signing_opened.jpg"); + + bool linked = verify_ingredient_linked(builder, output_path, signer, "c2pa.opened"); + std::cout << "[LinkArchive_Label_OnSigningBuilder_Opened] linked = " << linked << std::endl; + EXPECT_TRUE(linked) + << "Expected label set on the signing builder's add_ingredient to link with ingredientIds"; +} + +TEST_F(BuilderTest, LinkArchive_TwoIngredients_Labels) +{ + // Create two separate ingredient archives + auto archive1 = get_temp_path("two_labels_archive1.c2pa"); + create_ingredient_archive(archive1, + R"({"title": "photo-placed.jpg", "relationship": "componentOf"})"); + auto archive2 = get_temp_path("two_labels_archive2.c2pa"); + create_ingredient_archive(archive2, + R"({"title": "photo-opened.jpg", "relationship": "parentOf"})"); + + // Manifest with two actions, each referencing a different ingredient label + 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({"ingredient-for-placed"})}}} + }, + { + {"action", "c2pa.opened"}, + {"digitalSourceType", "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}, + {"parameters", {{"ingredientIds", json::array({"ingredient-for-opened"})}}} + } + })}}} + } + })} + }; + + auto context = c2pa::Context(); + auto builder = c2pa::Builder(context, manifest_json.dump()); + + // Add both archives with distinct labels on the signing builder + builder.add_ingredient( + R"({"title": "photo-placed.jpg", "relationship": "componentOf", "label": "ingredient-for-placed"})", + archive1); + builder.add_ingredient( + R"({"title": "photo-opened.jpg", "relationship": "parentOf", "label": "ingredient-for-opened"})", + archive2); + + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + auto output_path = get_temp_path("two_ingredients_labels.jpg"); + ASSERT_NO_THROW(builder.sign(source_path, output_path, signer)); + + // Read back and verify each action links to the correct ingredient + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& manifest = parsed["manifests"][active]; + + // Find both actions + json placed_action, opened_action; + bool found_placed = false, found_opened = 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; } + if (action["action"] == "c2pa.opened") { opened_action = action; found_opened = true; } + } + } + ASSERT_TRUE(found_placed) << "c2pa.placed action not found"; + ASSERT_TRUE(found_opened) << "c2pa.opened action not found"; + + // Verify placed action links to one ingredient + ASSERT_TRUE(placed_action.contains("parameters")); + ASSERT_TRUE(placed_action["parameters"].contains("ingredients")); + ASSERT_EQ(placed_action["parameters"]["ingredients"].size(), 1u); + std::string placed_url = placed_action["parameters"]["ingredients"][0]["url"]; + + // Verify opened action links to a different ingredient + ASSERT_TRUE(opened_action.contains("parameters")); + ASSERT_TRUE(opened_action["parameters"].contains("ingredients")); + ASSERT_EQ(opened_action["parameters"]["ingredients"].size(), 1u); + std::string opened_url = opened_action["parameters"]["ingredients"][0]["url"]; + + // The two URLs should be different (no cross-linking) + EXPECT_NE(placed_url, opened_url) + << "Each action should link to a different ingredient, but both resolved to: " << placed_url; + + std::cout << "[LinkArchive_TwoIngredients_Labels] placed_url = " << placed_url + << ", opened_url = " << opened_url << std::endl; +} + TEST_F(BuilderTest, CustomParamsInActions) { auto context = c2pa::Context(); From ef83d3dda617d327329a2ca74834d004736e39b8 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 17 Mar 2026 19:10:50 -0700 Subject: [PATCH 2/5] fix: Docs initial commit --- PLAN.md | 63 ++++++++++++++++++++++++ docs/working-stores.md | 61 ++++++++++++++++++++++- tests/builder.test.cpp | 108 +++++++++++++++++++++++++++++++---------- 3 files changed, 204 insertions(+), 28 deletions(-) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..6c738ad --- /dev/null +++ b/PLAN.md @@ -0,0 +1,63 @@ +# Plan: Add "Identifying ingredients" section with vendor parameter tests + +## Context + +The ingredients catalog pattern in `selective-manifests.md` shows how to pick ingredients from archives, but lacks guidance on how to tag ingredients with unique, application-specific identifiers that survive archiving and signing. The C2PA spec allows vendor-namespaced parameters (e.g., `com.mycompany.asset_id`) in action `parameters`. The existing `CustomParamsInActions` test proves these survive signing, but there's no test covering the archive round-trip (to_archive → from_archive → sign → read back). + +The new "Identifying ingredients" section should be added after the ingredients catalog pattern, showing how to use vendor parameters on actions to tag ingredients with unique IDs. + +## Step 1: Write tests + +Add to `BuilderTest` in [tests/builder.test.cpp](tests/builder.test.cpp), after the existing `CustomParamsInActions` test (~line 4719). + +### Test 1: `VendorParamsSurviveArchiveRoundTrip` + +Verifies vendor parameters on actions survive: build → archive → restore → sign → read back. + +1. Create a manifest with `c2pa.placed` action containing vendor params like `com.example.asset_id: "asset-42"` and `ingredientIds` +2. Add an ingredient with a matching label +3. Archive with `to_archive()` +4. Restore with `Builder::from_archive()` +5. Sign +6. Read back, verify: + - `com.example.asset_id` is `"asset-42"` in the placed action's parameters + - `ingredients` array is resolved (ingredientIds linked) + +### Test 2: `VendorParamsSurviveArchiveWithArchiveRoundTrip` + +Same but uses `with_archive()` instead of `from_archive()` to verify Context preservation path. + +### Test 3: `VendorParamsIdentifyIngredientsInCatalog` + +End-to-end catalog workflow: create an archive with two ingredients, each tagged with a different `com.example.asset_id` via their linked actions. Archive, restore, sign, read back, verify each action still has its unique vendor ID. + +## Step 2: Run tests + +```bash +cd build/debug && cmake --build . --target c2pa_c_tests && ./tests/c2pa_c_tests --gtest_filter="BuilderTest.VendorParams*" +``` + +## Step 3: Add documentation section + +In [docs/selective-manifests.md](docs/selective-manifests.md), add `### Identifying ingredients` after the ingredients catalog pattern section (after line 517, before `### Overriding ingredient properties`). + +Content: + +- Explain that vendor-namespaced parameters on actions can tag ingredients with unique, application-specific IDs +- These IDs survive archiving and signing +- Useful for tracking which external asset an ingredient came from, or for filtering ingredients after signing +- Code example showing the pattern: set `com.mycompany.asset_id` in the action's parameters alongside `ingredientIds`, then read it back after signing +- Note about naming convention (reverse domain notation) + +## Files to modify + +| File | Change | +| --- | --- | +| [tests/builder.test.cpp](tests/builder.test.cpp) | Add 3 tests after `CustomParamsInActions` | +| [docs/selective-manifests.md](docs/selective-manifests.md) | Add "Identifying ingredients" subsection after ingredients catalog pattern | + +## Verification + +1. All 3 new tests pass +2. Existing tests still pass +3. Documentation is consistent with test results diff --git a/docs/working-stores.md b/docs/working-stores.md index adc151c..7233a24 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -519,18 +519,75 @@ builder.add_ingredient( builder.sign("source.jpg", "signed.jpg", signer); ``` -When linking multiple ingredient archives, give each a distinct label and reference them separately in `ingredientIds`: +When linking multiple ingredient archives, 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`: + +```cpp +auto manifest_json = R"({ + "claim_generator_info": [{"name": "my-app", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": { "ingredientIds": ["parent-photo"] } + }, + { + "action": "c2pa.placed", + "parameters": { "ingredientIds": ["overlay-graphic"] } + } + ] + } + }] +})"; + +auto builder = c2pa::Builder(context, manifest_json); + +builder.add_ingredient( + R"({"title": "photo.jpg", "relationship": "parentOf", "label": "parent-photo"})", + "photo_archive.c2pa"); +builder.add_ingredient( + R"({"title": "overlay.png", "relationship": "componentOf", "label": "overlay-graphic"})", + "overlay_archive.c2pa"); + +builder.sign("source.jpg", "signed.jpg", signer); +``` + +A single `c2pa.placed` action can also reference several `componentOf` ingredients composited together. List all labels in the `ingredientIds` array: ```cpp -// Two actions, each linked to a different ingredient archive +auto manifest_json = R"({ + "claim_generator_info": [{"name": "my-app", "version": "1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [{ + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["base-layer", "overlay-layer"] + } + }] + } + }] +})"; + +auto builder = c2pa::Builder(context, manifest_json); + builder.add_ingredient( R"({"title": "base.jpg", "relationship": "componentOf", "label": "base-layer"})", "base_ingredient.c2pa"); builder.add_ingredient( R"({"title": "overlay.jpg", "relationship": "componentOf", "label": "overlay-layer"})", "overlay_ingredient.c2pa"); + +builder.sign("source.jpg", "signed.jpg", signer); ``` +After signing, the action's `parameters.ingredients` array contains one resolved URL per ingredient. + ### Ingredient relationships Specify the relationship between the ingredient and the current asset: diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index 0709efd..7a28849 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -93,8 +93,7 @@ class BuilderTest : public ::testing::Test { builder.to_archive(archive_path); } - // Helper: Builds a manifest JSON with a single action that references an - // ingredientId linking key. + // Helper: Builds a manifest JSON with one action that references one ingredient for linking. json make_manifest_with_action(const std::string& action_name, const std::string& linking_key, const std::string& digital_source_type = "") @@ -116,8 +115,7 @@ class BuilderTest : public ::testing::Test { }; } - // Helper: Signs with builder, reads back, and checks whether the given - // action has a resolved ingredients array with the expected JUMBF URL. + // Helper: Verifies an ingredient linked to an action. bool verify_ingredient_linked( c2pa::Builder& builder, const fs::path& output_path, @@ -4508,7 +4506,7 @@ TEST_F(BuilderTest, AddIngredientFromArchiveWithCustomProperties) } -TEST_F(BuilderTest, LinkArchive_Label_OnSigningBuilder_Placed) +TEST_F(BuilderTest, LinkArchiveLabelOnSigningBuilderPlaced) { // No label on the archive ingredient auto archive_path = get_temp_path("label_on_signing_placed.c2pa"); @@ -4519,7 +4517,7 @@ TEST_F(BuilderTest, LinkArchive_Label_OnSigningBuilder_Placed) auto context = c2pa::Context(); auto builder = c2pa::Builder(context, manifest_json.dump()); - // Label set here, on the signing builder's add_ingredient call + // Label set on the signing builder's add_ingredient call builder.add_ingredient( R"({"title": "photo.jpg", "relationship": "componentOf", "label": "my-ingredient"})", archive_path); @@ -4528,12 +4526,10 @@ TEST_F(BuilderTest, LinkArchive_Label_OnSigningBuilder_Placed) auto output_path = get_temp_path("link_label_on_signing_placed.jpg"); bool linked = verify_ingredient_linked(builder, output_path, signer, "c2pa.placed"); - std::cout << "[LinkArchive_Label_OnSigningBuilder_Placed] linked = " << linked << std::endl; - EXPECT_TRUE(linked) - << "Expected label set on the signing builder's add_ingredient to link with ingredientIds"; + EXPECT_TRUE(linked); } -TEST_F(BuilderTest, LinkArchive_Label_OnSigningBuilder_Opened) +TEST_F(BuilderTest, LinkArchiveLabelOnSigningBuilderOpened) { auto archive_path = get_temp_path("label_on_signing_opened.c2pa"); create_ingredient_archive(archive_path, @@ -4552,14 +4548,11 @@ TEST_F(BuilderTest, LinkArchive_Label_OnSigningBuilder_Opened) auto output_path = get_temp_path("link_label_on_signing_opened.jpg"); bool linked = verify_ingredient_linked(builder, output_path, signer, "c2pa.opened"); - std::cout << "[LinkArchive_Label_OnSigningBuilder_Opened] linked = " << linked << std::endl; - EXPECT_TRUE(linked) - << "Expected label set on the signing builder's add_ingredient to link with ingredientIds"; + EXPECT_TRUE(linked); } -TEST_F(BuilderTest, LinkArchive_TwoIngredients_Labels) +TEST_F(BuilderTest, LinkArchiveTwoIngredientsUsingLabels) { - // Create two separate ingredient archives auto archive1 = get_temp_path("two_labels_archive1.c2pa"); create_ingredient_archive(archive1, R"({"title": "photo-placed.jpg", "relationship": "componentOf"})"); @@ -4567,7 +4560,6 @@ TEST_F(BuilderTest, LinkArchive_TwoIngredients_Labels) create_ingredient_archive(archive2, R"({"title": "photo-opened.jpg", "relationship": "parentOf"})"); - // Manifest with two actions, each referencing a different ingredient label json manifest_json = { {"claim_generator_info", json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, {"assertions", json::array({ @@ -4591,7 +4583,6 @@ TEST_F(BuilderTest, LinkArchive_TwoIngredients_Labels) auto context = c2pa::Context(); auto builder = c2pa::Builder(context, manifest_json.dump()); - // Add both archives with distinct labels on the signing builder builder.add_ingredient( R"({"title": "photo-placed.jpg", "relationship": "componentOf", "label": "ingredient-for-placed"})", archive1); @@ -4604,13 +4595,11 @@ TEST_F(BuilderTest, LinkArchive_TwoIngredients_Labels) auto output_path = get_temp_path("two_ingredients_labels.jpg"); ASSERT_NO_THROW(builder.sign(source_path, output_path, signer)); - // Read back and verify each action links to the correct ingredient auto reader = c2pa::Reader(context, output_path); auto parsed = json::parse(reader.json()); std::string active = parsed["active_manifest"]; auto& manifest = parsed["manifests"][active]; - // Find both actions json placed_action, opened_action; bool found_placed = false, found_opened = false; for (auto& assertion : manifest["assertions"]) { @@ -4623,24 +4612,91 @@ TEST_F(BuilderTest, LinkArchive_TwoIngredients_Labels) ASSERT_TRUE(found_placed) << "c2pa.placed action not found"; ASSERT_TRUE(found_opened) << "c2pa.opened action not found"; - // Verify placed action links to one ingredient ASSERT_TRUE(placed_action.contains("parameters")); ASSERT_TRUE(placed_action["parameters"].contains("ingredients")); ASSERT_EQ(placed_action["parameters"]["ingredients"].size(), 1u); std::string placed_url = placed_action["parameters"]["ingredients"][0]["url"]; - // Verify opened action links to a different ingredient ASSERT_TRUE(opened_action.contains("parameters")); ASSERT_TRUE(opened_action["parameters"].contains("ingredients")); ASSERT_EQ(opened_action["parameters"]["ingredients"].size(), 1u); std::string opened_url = opened_action["parameters"]["ingredients"][0]["url"]; - // The two URLs should be different (no cross-linking) - EXPECT_NE(placed_url, opened_url) - << "Each action should link to a different ingredient, but both resolved to: " << placed_url; + EXPECT_NE(placed_url, opened_url); +} + +TEST_F(BuilderTest, LinkArchiveMultipleIngredientsInOnePlacedAction) +{ + // c2pa.placed supports multiple ingredientIds — all composited at once. + auto archive1 = get_temp_path("multi_in_one_archive1.c2pa"); + create_ingredient_archive(archive1, + R"({"title": "base-layer.jpg", "relationship": "componentOf"})"); + auto archive2 = get_temp_path("multi_in_one_archive2.c2pa"); + create_ingredient_archive(archive2, + R"({"title": "overlay-layer.jpg", "relationship": "componentOf"})"); + + // One c2pa.placed action referencing two ingredients + 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({"base-layer", "overlay-layer"})}}} + } + })}}} + } + })} + }; + + auto context = c2pa::Context(); + auto builder = c2pa::Builder(context, manifest_json.dump()); - std::cout << "[LinkArchive_TwoIngredients_Labels] placed_url = " << placed_url - << ", opened_url = " << opened_url << std::endl; + builder.add_ingredient( + R"({"title": "base-layer.jpg", "relationship": "componentOf", "label": "base-layer"})", + archive1); + builder.add_ingredient( + R"({"title": "overlay-layer.jpg", "relationship": "componentOf", "label": "overlay-layer"})", + archive2); + + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + auto output_path = get_temp_path("multi_in_one_placed.jpg"); + ASSERT_NO_THROW(builder.sign(source_path, output_path, signer)); + + // Read back and verify the single placed action has two resolved ingredients + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + + json placed_action; + bool found = false; + for (auto& assertion : parsed["manifests"][active]["assertions"]) { + if (assertion["label"] != "c2pa.actions.v2") continue; + for (auto& action : assertion["data"]["actions"]) { + if (action["action"] == "c2pa.placed") { + placed_action = action; + found = true; + } + } + } + ASSERT_TRUE(found) << "c2pa.placed action not found"; + ASSERT_TRUE(placed_action.contains("parameters")); + ASSERT_TRUE(placed_action["parameters"].contains("ingredients")); + auto& ingredients = placed_action["parameters"]["ingredients"]; + ASSERT_EQ(ingredients.size(), 2u) + << "c2pa.placed should reference both ingredients"; + + // The two URLs should be different + std::string url0 = ingredients[0]["url"]; + std::string url1 = ingredients[1]["url"]; + EXPECT_NE(url0, url1) + << "Each ingredient should have a distinct URL"; + + std::cout << "[LinkArchiveMultipleIngredientsInOnePlacedAction] url0 = " << url0 + << ", url1 = " << url1 << std::endl; } TEST_F(BuilderTest, CustomParamsInActions) From 1cd8780bd09f59d7030503572bfd1ec27c4ebc6d Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:15:19 -0700 Subject: [PATCH 3/5] fix: Clean up --- PLAN.md | 63 --------------------------------------------------------- 1 file changed, 63 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 6c738ad..0000000 --- a/PLAN.md +++ /dev/null @@ -1,63 +0,0 @@ -# Plan: Add "Identifying ingredients" section with vendor parameter tests - -## Context - -The ingredients catalog pattern in `selective-manifests.md` shows how to pick ingredients from archives, but lacks guidance on how to tag ingredients with unique, application-specific identifiers that survive archiving and signing. The C2PA spec allows vendor-namespaced parameters (e.g., `com.mycompany.asset_id`) in action `parameters`. The existing `CustomParamsInActions` test proves these survive signing, but there's no test covering the archive round-trip (to_archive → from_archive → sign → read back). - -The new "Identifying ingredients" section should be added after the ingredients catalog pattern, showing how to use vendor parameters on actions to tag ingredients with unique IDs. - -## Step 1: Write tests - -Add to `BuilderTest` in [tests/builder.test.cpp](tests/builder.test.cpp), after the existing `CustomParamsInActions` test (~line 4719). - -### Test 1: `VendorParamsSurviveArchiveRoundTrip` - -Verifies vendor parameters on actions survive: build → archive → restore → sign → read back. - -1. Create a manifest with `c2pa.placed` action containing vendor params like `com.example.asset_id: "asset-42"` and `ingredientIds` -2. Add an ingredient with a matching label -3. Archive with `to_archive()` -4. Restore with `Builder::from_archive()` -5. Sign -6. Read back, verify: - - `com.example.asset_id` is `"asset-42"` in the placed action's parameters - - `ingredients` array is resolved (ingredientIds linked) - -### Test 2: `VendorParamsSurviveArchiveWithArchiveRoundTrip` - -Same but uses `with_archive()` instead of `from_archive()` to verify Context preservation path. - -### Test 3: `VendorParamsIdentifyIngredientsInCatalog` - -End-to-end catalog workflow: create an archive with two ingredients, each tagged with a different `com.example.asset_id` via their linked actions. Archive, restore, sign, read back, verify each action still has its unique vendor ID. - -## Step 2: Run tests - -```bash -cd build/debug && cmake --build . --target c2pa_c_tests && ./tests/c2pa_c_tests --gtest_filter="BuilderTest.VendorParams*" -``` - -## Step 3: Add documentation section - -In [docs/selective-manifests.md](docs/selective-manifests.md), add `### Identifying ingredients` after the ingredients catalog pattern section (after line 517, before `### Overriding ingredient properties`). - -Content: - -- Explain that vendor-namespaced parameters on actions can tag ingredients with unique, application-specific IDs -- These IDs survive archiving and signing -- Useful for tracking which external asset an ingredient came from, or for filtering ingredients after signing -- Code example showing the pattern: set `com.mycompany.asset_id` in the action's parameters alongside `ingredientIds`, then read it back after signing -- Note about naming convention (reverse domain notation) - -## Files to modify - -| File | Change | -| --- | --- | -| [tests/builder.test.cpp](tests/builder.test.cpp) | Add 3 tests after `CustomParamsInActions` | -| [docs/selective-manifests.md](docs/selective-manifests.md) | Add "Identifying ingredients" subsection after ingredients catalog pattern | - -## Verification - -1. All 3 new tests pass -2. Existing tests still pass -3. Documentation is consistent with test results From a63a187fa057bee7d2233e6e5a9b2adde0e67fc4 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 17 Mar 2026 19:29:37 -0700 Subject: [PATCH 4/5] fix: Docs --- docs/selective-manifests.md | 35 +++++++- tests/builder.test.cpp | 154 ++++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 1 deletion(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index d7637a7..96f6338 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -516,7 +516,40 @@ for (auto& ingredient : selected) { builder.sign(source_path, output_path, signer); ``` -### Overriding ingredient properties +### 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. The `description` and `informational_URI` fields also survive and can carry additional metadata about the ingredient's origin. + +```cpp +// Set instance_id when adding the ingredient to the archive builder +auto builder = c2pa::Builder(context, manifest_str); +builder.add_ingredient( + R"({ + "title": "photo-A.jpg", + "relationship": "componentOf", + "instance_id": "catalog:photo-A" + })", + source_path); + +builder.to_archive("catalog.c2pa"); +``` + +Later, when reading the archive, select ingredients by their `instance_id`: + +```cpp +auto reader = c2pa::Reader(context, "catalog.c2pa"); +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 target ingredient + } +} +``` + +### Overriding ingredient properties When adding an ingredient from an archive or from a file, the JSON passed to `add_ingredient()` can override properties like `title` and `relationship`. This is useful when reusing archived ingredients in a different context: diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index 7a28849..aa534c5 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -4505,6 +4505,160 @@ TEST_F(BuilderTest, AddIngredientFromArchiveWithCustomProperties) } } +TEST_F(BuilderTest, IngredientFieldsSurviveArchive) +{ + auto context = c2pa::Context(); + + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto builder = c2pa::Builder(context, manifest_str); + json ingredient_json = { + {"title", "tracked-asset.jpg"}, + {"relationship", "componentOf"}, + {"instance_id", "tracking:project-7:asset-42"}, + {"description", "A tracked ingredient"}, + {"informational_URI", "https://example.com/assets/42"} + }; + builder.add_ingredient(ingredient_json.dump(), c2pa_test::get_fixture_path("A.jpg")); + + auto archive_path = get_temp_path("fields_survive_archive.c2pa"); + ASSERT_NO_THROW(builder.to_archive(archive_path)); + + // Read the archive back and check which fields survived + 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_GE(ingredients.size(), 1u); + auto& ing = ingredients[0]; + + EXPECT_EQ(ing["title"], "tracked-asset.jpg"); + EXPECT_EQ(ing["relationship"], "componentOf"); + + // Log which optional fields survived archiving + bool instance_id_survived = ing.contains("instance_id") + && ing["instance_id"] == "tracking:project-7:asset-42"; + bool description_survived = ing.contains("description") + && ing["description"] == "A tracked ingredient"; + bool informational_uri_survived = ing.contains("informational_URI") + && ing["informational_URI"] == "https://example.com/assets/42"; + + std::cout << "[IngredientFieldsSurviveArchive]" + << " instance_id=" << instance_id_survived + << " description=" << description_survived + << " informational_URI=" << informational_uri_survived << std::endl; + + // At least instance_id should survive — it is the candidate identifier + EXPECT_TRUE(instance_id_survived) + << "instance_id set on the archive ingredient should survive archiving"; +} + +TEST_F(BuilderTest, IngredientFieldsSurviveArchiveThenSign) +{ + auto context = c2pa::Context(); + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + + // Create an archive with an ingredient carrying identifying fields + auto manifest_str = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto builder1 = c2pa::Builder(context, manifest_str); + json ingredient_json = { + {"title", "tracked-asset.jpg"}, + {"relationship", "componentOf"}, + {"instance_id", "tracking:project-7:asset-42"}, + {"description", "A tracked ingredient"}, + {"informational_URI", "https://example.com/assets/42"} + }; + builder1.add_ingredient(ingredient_json.dump(), c2pa_test::get_fixture_path("A.jpg")); + + auto archive_path = get_temp_path("fields_survive_sign.c2pa"); + ASSERT_NO_THROW(builder1.to_archive(archive_path)); + + // Add the archive as ingredient to a second builder, no overrides + auto builder2 = c2pa::Builder(context, manifest_str); + builder2.add_ingredient( + R"({"title": "tracked-asset.jpg", "relationship": "componentOf"})", + archive_path); + + auto output_path = get_temp_path("fields_survive_sign_result.jpg"); + ASSERT_NO_THROW(builder2.sign(source_path, output_path, signer)); + + // Read signed asset and check ingredient fields + auto reader = c2pa::Reader(context, output_path); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto& ingredients = parsed["manifests"][active]["ingredients"]; + + ASSERT_GE(ingredients.size(), 1u); + auto& ing = ingredients[0]; + + bool instance_id_survived = ing.contains("instance_id") + && ing["instance_id"] == "tracking:project-7:asset-42"; + bool description_survived = ing.contains("description") + && ing["description"] == "A tracked ingredient"; + bool informational_uri_survived = ing.contains("informational_URI") + && ing["informational_URI"] == "https://example.com/assets/42"; + + std::cout << "[IngredientFieldsSurviveArchiveThenSign]" + << " instance_id=" << instance_id_survived + << " description=" << description_survived + << " informational_URI=" << informational_uri_survived << std::endl; + + EXPECT_TRUE(instance_id_survived) + << "instance_id should survive archive-then-sign round-trip"; +} + +TEST_F(BuilderTest, InstanceIdAsIngredientIdentifierInCatalog) +{ + auto context = c2pa::Context(); + + // Create an archive with two ingredients, each with a different instance_id + 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-A.jpg"}, + {"relationship", "componentOf"}, + {"instance_id", "catalog:photo-A"} + }).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + builder.add_ingredient( + json({ + {"title", "photo-B.jpg"}, + {"relationship", "componentOf"}, + {"instance_id", "catalog:photo-B"} + }).dump(), + c2pa_test::get_fixture_path("A.jpg")); + + auto archive_path = get_temp_path("catalog_instance_id.c2pa"); + ASSERT_NO_THROW(builder.to_archive(archive_path)); + + // Read the archive and pick an ingredient by instance_id + 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_EQ(ingredients.size(), 2u); + + // Find the ingredient with instance_id "catalog:photo-B" + json* found = nullptr; + for (auto& ing : ingredients) { + if (ing.contains("instance_id") && ing["instance_id"] == "catalog:photo-B") { + found = &ing; + break; + } + } + + ASSERT_NE(found, nullptr) + << "Should find ingredient by instance_id 'catalog:photo-B' in archive"; + EXPECT_EQ((*found)["title"], "photo-B.jpg"); + + std::cout << "[InstanceIdAsIngredientIdentifierInCatalog] found ingredient: " + << (*found)["title"] << std::endl; +} TEST_F(BuilderTest, LinkArchiveLabelOnSigningBuilderPlaced) { From be6a7544b009d267ad2545442114fe125d1aa505 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 17 Mar 2026 19:42:54 -0700 Subject: [PATCH 5/5] fix: Docs --- docs/selective-manifests.md | 6 ++-- tests/builder.test.cpp | 59 +++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 96f6338..3c169d6 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -518,7 +518,9 @@ builder.sign(source_path, output_path, signer); ### 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. The `description` and `informational_URI` fields also survive and can carry additional metadata about the ingredient's origin. +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. + +`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)). ```cpp // Set instance_id when adding the ingredient to the archive builder @@ -544,7 +546,7 @@ auto& ingredients = parsed["manifests"][active]["ingredients"]; for (auto& ing : ingredients) { if (ing.contains("instance_id") && ing["instance_id"] == "catalog:photo-A") { - // Found the target ingredient + // Do something with the found ingredient... } } ``` diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index aa534c5..3ce3d2f 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -4505,6 +4505,65 @@ TEST_F(BuilderTest, AddIngredientFromArchiveWithCustomProperties) } } +TEST_F(BuilderTest, LinkArchiveInstanceIdFromArchiveOnSigningBuilder) +{ + // Verify that instance_id does NOT work as a linking key for ingredient + // archives, even when the instance_id is read from the archive and set + // on the signing builder's add_ingredient call. + auto context = c2pa::Context(); + auto signer = c2pa_test::create_test_signer(); + auto source_path = c2pa_test::get_fixture_path("A.jpg"); + + // Create archive with ingredient carrying instance_id + auto archive_path = get_temp_path("iid_from_archive_linking.c2pa"); + create_ingredient_archive(archive_path, + R"({"title": "photo.jpg", "relationship": "componentOf", "instance_id": "xmp:iid:test-archive-link"})"); + + // Read archive, extract instance_id + auto reader = c2pa::Reader(context, archive_path); + auto archive_parsed = json::parse(reader.json()); + std::string active = archive_parsed["active_manifest"]; + auto& archive_ingredient = archive_parsed["manifests"][active]["ingredients"][0]; + ASSERT_TRUE(archive_ingredient.contains("instance_id")); + std::string instance_id = archive_ingredient["instance_id"]; + + // Build manifest with ingredientIds referencing that instance_id + 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({instance_id})}}} + } + })}}} + } + })} + }; + + auto builder = c2pa::Builder(context, manifest_json.dump()); + + // Set instance_id on the signing builder's add_ingredient + std::ifstream archive_stream(archive_path, std::ios::binary); + builder.add_ingredient( + json({ + {"title", archive_ingredient["title"]}, + {"relationship", "componentOf"}, + {"instance_id", instance_id} + }).dump(), + "application/c2pa", + archive_stream); + archive_stream.close(); + + auto output_path = get_temp_path("iid_from_archive_linking_result.jpg"); + + // Signing throws because instance_id cannot be used as a linking key + // for ingredient archives. Use label instead. + EXPECT_THROW(builder.sign(source_path, output_path, signer), c2pa::C2paException); +} + TEST_F(BuilderTest, IngredientFieldsSurviveArchive) { auto context = c2pa::Context();