From ffbc73924495032c76ad064d1daf016dce2c080d Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:17:15 -0700 Subject: [PATCH 01/11] fix: Initial commit --- docs/faqs.md | 2 +- docs/selective-manifests.md | 65 ++++++++ tests/migrations.test.cpp | 306 +++++++++++++++++++++++++----------- 3 files changed, 281 insertions(+), 92 deletions(-) diff --git a/docs/faqs.md b/docs/faqs.md index 0863130d..b891da94 100644 --- a/docs/faqs.md +++ b/docs/faqs.md @@ -121,5 +121,5 @@ These file-based functions not attached to an API-exposed object, `read_file`, ` | Removed function | Equivalent | | --- | --- | | `read_file(path)` | `Reader::from_asset(ctx, format, stream)` then `Reader::json()`. Pull binary resources with `Reader::get_resource(uri, dest)`. | -| `read_ingredient_file(path, data_dir)` | `Builder::add_ingredient(ingredient_json, source_path)`, which adds ingredients to the active Builder. To recover the formed ingredient JSON (if needed) and its resources, archive the working store and read it back, or move the ingredient with the dedicated `write_ingredient_archive` / `add_ingredient_from_archive` APIs. See [Extracting ingredients from a working store](selective-manifests.md#extracting-ingredients-from-a-working-store). | +| `read_ingredient_file(path, data_dir)` | `Builder::add_ingredient(ingredient_json, source_path)`, which adds ingredients to the active Builder. To recover the formed ingredient JSON (if needed) and its resources, archive the working store and read it back, or move the ingredient with the dedicated `write_ingredient_archive` / `add_ingredient_from_archive` APIs. There is no single-call drop-in, but the full behavior (extract the ingredient JSON + thumbnail + manifest_data to a directory, then reuse that directory to sign a carrier, including several ingredients at once) is a short reimplementation shown with a C++ example in [Migrating from read_ingredient_file](selective-manifests.md#migrating-from-read_ingredient_file-extract-to-a-directory--reuse). See also [Extracting ingredients from a working store](selective-manifests.md#extracting-ingredients-from-a-working-store). | | `sign_file(src, dst, manifest, SignerInfo*, data_dir)` | `Builder::sign(source_path, dest_path, signer)` with a `Signer`. | diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 5ece2767..97024b50 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -839,6 +839,71 @@ sink.sign(source_path, output_path, signer); The signed output contains exactly the loaded ingredient. +#### Migrating from the removed read_ingredient_file + +The removed function `read_ingredient_file(source_path, data_dir)` read an asset, returned a formed ingredient JSON, and wrote the ingredient's binary resources (thumbnail and manifest data) to `data_dir`, so a later signing step could load that directory and embed the ingredient into a carrier. The behavior can be reimplementated with `Builder` and `Reader` objects: form the ingredient, archive it, read it back, write the resources to disk under stable non-colliding names, then reuse the directory to sign. + +The legacy API derived each file name from the ingredient's `instance_id` rather than a fixed name (to avoid collisions). That is what lets several ingredients share one output directory without their thumbnail or `manifest_data` files overwriting each other. + +```cpp +namespace fs = std::filesystem; + +// Map a mime format ("image/jpeg") to a file extension, +// and turn an instance_id into a collision-resistant filename stem. +auto ext_for_format = [](const std::string& mime) { + std::string ext = mime.substr(mime.find('/') + 1); + return ext == "jpeg" ? std::string("jpg") : ext; +}; +auto uuid_stem = [](const std::string& instance_id) { + auto colon = instance_id.rfind(':'); + std::string stem = (colon != std::string::npos) ? instance_id.substr(colon + 1) + : instance_id; + return stem.empty() ? std::string("ingredient") : stem; +}; + +// Extract: form the ingredient, archive just it, and write its JSON, +// thumbnail and nested manifest_data (if any) into output_dir. +fs::create_directories(output_dir); +c2pa::Builder builder(context, "{}"); +builder.add_ingredient( + R"({"label": "my-ingredient", "title": "source.jpg", "relationship": "componentOf"})", + source_path); + +std::stringstream archive_buf(std::ios::in | std::ios::out | std::ios::binary); +builder.write_ingredient_archive("my-ingredient", archive_buf); +archive_buf.seekg(0); + +c2pa::Reader reader(context, "application/c2pa", archive_buf); +auto store = json::parse(reader.json()); +std::string active = store["active_manifest"]; +json ingredient = store["manifests"][active]["ingredients"][0]; + +std::string stem = uuid_stem(ingredient.value("instance_id", std::string())); +if (ingredient.contains("thumbnail")) { + std::string name = stem + "." + ext_for_format(ingredient["thumbnail"]["format"]); + reader.get_resource(ingredient["thumbnail"]["identifier"], output_dir / name); + ingredient["thumbnail"]["identifier"] = name; // rewrite to the on-disk name +} +if (ingredient.contains("manifest_data")) { + std::string name = stem + ".c2pa"; + reader.get_resource(ingredient["manifest_data"]["identifier"], output_dir / name); + ingredient["manifest_data"]["identifier"] = name; +} +ingredient.erase("label"); // drop the archive-only assertion label before reuse +std::ofstream(output_dir / (stem + ".json")) << ingredient.dump(2); + +// Reuse: load the extracted ingredient (repeat the block above per ingredient to +// collect several ingredients) and sign a, resolving resources from output_dir. +json manifest = {{"ingredients", json::array({ingredient})}}; +c2pa::Builder sign_builder(context, manifest.dump()); +sign_builder.set_base_path(output_dir.string()); // resolves resources +sign_builder.sign(carrier_path, output_path, signer); +``` + +To reproduce the full `read_ingredient_file` directory behavior for multiple ingredients, run the extract block once per source and append each resulting `ingredient` object to the `ingredients` array before signing. Because file names came from `instance_id` with the legacy API, the extracted resources for every ingredient coexist in one directory once names are ensured to be unique. + +> **Note:** `set_base_path` is marked deprecated. To avoid it, register each extracted resource explicitly with `add_resource(identifier, stream)` on the signing builder instead (see [Dedicated ingredient archive APIs](#dedicated-ingredient-archive-apis) and the `add_resource` loop in the legacy pattern below). + #### Legacy: read-filter-rebuild APIs > [!NOTE] diff --git a/tests/migrations.test.cpp b/tests/migrations.test.cpp index c8ef4728..041723d6 100644 --- a/tests/migrations.test.cpp +++ b/tests/migrations.test.cpp @@ -13,9 +13,11 @@ #include #include #include +#include #include #include #include +#include #include "include/test_utils.hpp" @@ -143,104 +145,226 @@ TEST_F(LegacyApiMigrationTest, ReadFile_WithDataDir_ExtractResources) { // What it did: read an asset, returned the formed ingredient JSON string, and // wrote the ingredient's binary resources (thumbnail, and any manifest data) to // data_dir. -// Current API: Builder::add_ingredient(ingredient_json, source_path). The Builder -// reads the source, forms the ingredient, and embeds its resources in the working -// store; the call returns void. Forming an ingredient and recovering its -// JSON + resources are both available through the working store. add_ingredient -// forms it; archiving the working store and reading it back yields the same -// ingredient JSON, and Reader::get_resource pulls the resources. A dedicated -// "file -> ingredient JSON + thumbnail on disk" wrapper is redundant. -// Notes: This example reconstructs the old return value and side effect -// as closely as possible using the read-filter-rebuild pattern: add the -// ingredient, archive the working store, read the archive back, and pull the -// ingredient JSON + thumbnail. Two differences worth noting: -// - read_ingredient_file derived "title" from the file name (it read a path -// and ran extension_to_mime/title logic). add_ingredient only sets "title" -// if the caller supplies it in the ingredient JSON, so we pass it -// explicitly to reproduce the old output. -// - The old top-level "format" was the MIME type "image/jpeg" (read_ingredient_file -// went through extension_to_mime). The current working-store ingredient stores -// the short extension "jpg" at top level, but the MIME type is still present on -// the thumbnail ("image/jpeg"). The old test only did a substring search for -// "image/jpeg" over the whole ingredient JSON, which still matches today via -// the thumbnail. We assert both the new top-level "jpg" and the thumbnail MIME -// so the difference is explicit rather than hidden behind a substring match. -// Generated identifiers (instance_id, the thumbnail JUMBF URI) are not pinned. -TEST_F(LegacyApiMigrationTest, ReadIngredientFile_ReconstructIngredientJsonAndResources) { +// Current API: there is no single-call drop-in, but the whole behavior is a short +// reimplementation on top of Builder/Reader: +// 1. add_ingredient(json, source_path) forms the ingredient in a working store, +// 2. write_ingredient_archive(id, buf) archives just that ingredient, +// 3. Reader(ctx, "application/c2pa", buf) reads it back, and +// Reader::get_resource(uri, path) writes the thumbnail / manifest_data to disk, +// 4. Builder(ctx, {"ingredients": [...]}) + set_base_path(dir) + sign() reuses the +// extracted directory to sign a carrier. +// This is the flow the ingredient-from-file example demonstrates, and the two tests +// below exercise it end to end. +// Notes: The identifier fields in the recovered ingredient JSON point at internal JUMBF +// URIs. To make the directory self-contained (and loadable via set_base_path) we +// rewrite each identifier to the bare file name it was written under. File names are +// derived from the ingredient's instance_id so multiple ingredients can share one +// output directory without their thumbnail / manifest_data files colliding, which is +// what the second (adversarial) test verifies. set_base_path is slated for a future +// deprecation (see add_resource); the example uses it because it is the terser path. +TEST_F(LegacyApiMigrationTest, ReadIngredientFile_ExtractToDirThenSignCarrier) { auto context = std::make_shared(); - auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - // Case 1: A.jpg, a plain ingredient with no embedded manifest store. + // Collision-resistant filename stem from an instance_id (the trailing UUID), + // and a mime -> extension mapping, inline as in the example. + auto uuid_stem = [](const std::string& instance_id) { + auto colon = instance_id.rfind(':'); + std::string stem = (colon != std::string::npos) ? instance_id.substr(colon + 1) + : instance_id; + return stem.empty() ? std::string("ingredient") : stem; + }; + auto ext_for_format = [](const std::string& mime) { + std::string ext = mime.substr(mime.find('/') + 1); + return ext == "jpeg" ? std::string("jpg") : ext; + }; + + auto output_dir = get_temp_path("read_ingredient_extract_dir"); + fs::create_directories(output_dir); + + // --- Extract phase: form C.jpg as an ingredient, archive it, read it back, and + // write its JSON + thumbnail + nested manifest_data to output_dir. C.jpg carries + // its own manifest store, so it exercises the manifest_data path too. + json ingredient; { - auto builder = c2pa::Builder(context, manifest); - builder.add_ingredient(R"({"title": "A.jpg", "relationship": "componentOf"})", - c2pa_test::get_fixture_path("A.jpg")); - - auto archive_path = get_temp_path("ingredient_a.c2pa"); - ASSERT_NO_THROW(builder.to_archive(archive_path)); - - std::ifstream archive_in(archive_path, std::ios::binary); - ASSERT_TRUE(archive_in.is_open()); - c2pa::Reader archive_reader(context, "application/c2pa", archive_in); - auto parsed = json::parse(archive_reader.json()); - - ASSERT_TRUE(parsed.contains("active_manifest")); - std::string active = parsed["active_manifest"]; - auto ingredients = parsed["manifests"][active]["ingredients"]; - ASSERT_EQ(ingredients.size(), 1u); - - // The reconstructed ingredient carries the same fields the old - // read_ingredient_file return value did (title, format, thumbnail, - // relationship). - auto ingredient = ingredients[0]; - EXPECT_EQ(ingredient["title"], "A.jpg"); - EXPECT_EQ(ingredient["relationship"], "componentOf"); - // Top-level format is the short extension now, the old MIME "image/jpeg" - // still appears on the thumbnail. - EXPECT_EQ(ingredient["format"], "jpg"); + const std::string ingredient_id = "my-ingredient"; + auto builder = c2pa::Builder(context, "{}"); + builder.add_ingredient( + R"({"label": ")" + ingredient_id + + R"(", "title": "C.jpg", "relationship": "componentOf"})", + c2pa_test::get_fixture_path("C.jpg")); + + std::stringstream archive_buf(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(builder.write_ingredient_archive(ingredient_id, archive_buf)); + archive_buf.seekg(0); + + c2pa::Reader reader(context, "application/c2pa", archive_buf); + auto manifest_store = json::parse(reader.json()); + std::string active = manifest_store["active_manifest"]; + auto& ingredients = manifest_store["manifests"][active]["ingredients"]; + ASSERT_FALSE(ingredients.empty()); + ingredient = ingredients[0]; + + std::string stem = uuid_stem(ingredient.value("instance_id", std::string())); + ASSERT_TRUE(ingredient.contains("thumbnail")); - EXPECT_EQ(ingredient["thumbnail"]["format"], "image/jpeg"); - ASSERT_TRUE(ingredient["thumbnail"].contains("identifier")); - - // The data_dir side effect, now caller-driven: pull the thumbnail resource. - std::string thumbnail_id = ingredient["thumbnail"]["identifier"]; - auto thumb_path = get_temp_path("ingredient_a_thumb.jpg"); - auto byte_count = archive_reader.get_resource(thumbnail_id, thumb_path); - EXPECT_GT(byte_count, 0); - EXPECT_TRUE(fs::exists(thumb_path)); - EXPECT_GT(fs::file_size(thumb_path), 0u); - } + std::string thumb_uri = ingredient["thumbnail"]["identifier"]; + std::string thumb_name = stem + "." + + ext_for_format(ingredient["thumbnail"]["format"]); + ASSERT_GT(reader.get_resource(thumb_uri, output_dir / thumb_name), 0); + ingredient["thumbnail"]["identifier"] = thumb_name; - // Case 2: C.jpg, which carries a manifest store. The old - // ReadIngredientFileWhoHasAManifestStore checked that the ingredient additionally - // exposed provenance. The reconstructed ingredient does the same. - { - auto builder = c2pa::Builder(context, manifest); - builder.add_ingredient(R"({"title": "C.jpg", "relationship": "componentOf"})", - c2pa_test::get_fixture_path("C.jpg")); - - auto archive_path = get_temp_path("ingredient_c.c2pa"); - ASSERT_NO_THROW(builder.to_archive(archive_path)); - - std::ifstream archive_in(archive_path, std::ios::binary); - ASSERT_TRUE(archive_in.is_open()); - c2pa::Reader archive_reader(context, "application/c2pa", archive_in); - auto parsed = json::parse(archive_reader.json()); - - std::string active = parsed["active_manifest"]; - auto ingredients = parsed["manifests"][active]["ingredients"]; - ASSERT_EQ(ingredients.size(), 1u); - auto ingredient = ingredients[0]; - - EXPECT_EQ(ingredient["title"], "C.jpg"); - EXPECT_EQ(ingredient["relationship"], "componentOf"); - // Provenance markers present because C.jpg has a manifest store. These are - // the extra fields the old ReadIngredientFileWhoHasAManifestStore checked. - EXPECT_TRUE(ingredient.contains("active_manifest")); ASSERT_TRUE(ingredient.contains("manifest_data")); - EXPECT_EQ(ingredient["manifest_data"]["format"], "application/c2pa"); - EXPECT_TRUE(ingredient.contains("validation_results")); + std::string md_uri = ingredient["manifest_data"]["identifier"]; + std::string md_name = stem + ".c2pa"; + ASSERT_GT(reader.get_resource(md_uri, output_dir / md_name), 0); + ingredient["manifest_data"]["identifier"] = md_name; + + // Drop the archive-only assertion label so the ingredient keys cleanly on reuse. + ingredient.erase("label"); + std::ofstream(output_dir / (stem + ".json")) << ingredient.dump(2); + + // The data_dir side effect: JSON + thumbnail + manifest_data all on disk. + EXPECT_TRUE(fs::exists(output_dir / (stem + ".json"))); + EXPECT_GT(fs::file_size(output_dir / thumb_name), 0u); + EXPECT_GT(fs::file_size(output_dir / md_name), 0u); + } + + // --- Sign phase: load the extracted ingredient from output_dir and embed it into a + // carrier asset, resolving the thumbnail / manifest_data files via set_base_path. + json sign_manifest = {{"ingredients", json::array({ingredient})}}; + auto builder = c2pa::Builder(context, sign_manifest.dump()); + builder.set_base_path(output_dir.string()); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("read_ingredient_signed.jpg"); + std::vector manifest_data; + ASSERT_NO_THROW(manifest_data = builder.sign(c2pa_test::get_fixture_path("A.jpg"), + output_path, signer)); + EXPECT_FALSE(manifest_data.empty()); + + auto reader = c2pa::Reader::from_asset(context, output_path); + ASSERT_TRUE(reader.has_value()); + auto parsed = json::parse(reader->json()); + std::string active = parsed["active_manifest"]; + auto& signed_ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(signed_ingredients.size(), 1u); + EXPECT_EQ(signed_ingredients[0]["title"], "C.jpg"); + + fs::remove_all(output_dir); +} + +// Extract two ingredients from the same working store into the SAME directory +// and confirm their thumbnail and manifest_data files do not overwrite each other, then sign +// a carrier with both and confirm both survive the round-trip. Fixed names like +// "thumbnail.jpg" and "manifest_data.c2pa" would fail this test, +// instance_id-derived names to ensure name unicity pass it. +TEST_F(LegacyApiMigrationTest, ReadIngredientFile_MultipleIngredientsSameStoreNoCollision) { + auto context = std::make_shared(); + + auto uuid_stem = [](const std::string& instance_id) { + auto colon = instance_id.rfind(':'); + std::string stem = (colon != std::string::npos) ? instance_id.substr(colon + 1) + : instance_id; + return stem.empty() ? std::string("ingredient") : stem; + }; + auto ext_for_format = [](const std::string& mime) { + std::string ext = mime.substr(mime.find('/') + 1); + return ext == "jpeg" ? std::string("jpg") : ext; + }; + + auto output_dir = get_temp_path("read_ingredient_multi_dir"); + fs::create_directories(output_dir); + + // Two ingredients in one working store, each keyed by a distinct instance_id. + // A.jpg has no nested manifest; C.jpg carries one, so only C.jpg writes a .c2pa file. + struct Spec { std::string id; std::string fixture; std::string title; }; + const std::vector specs = { + {"mig:ingredient-A", "A.jpg", "A.jpg"}, + {"mig:ingredient-C", "C.jpg", "C.jpg"}, + }; + + auto builder = c2pa::Builder(context, "{}"); + for (const auto& s : specs) { + builder.add_ingredient( + R"({"instance_id": ")" + s.id + R"(", "title": ")" + s.title + + R"(", "relationship": "componentOf"})", + c2pa_test::get_fixture_path(s.fixture)); + } + + std::vector extracted; + std::vector thumb_names; + for (const auto& s : specs) { + std::stringstream buf(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(builder.write_ingredient_archive(s.id, buf)); + buf.seekg(0); + + c2pa::Reader reader(context, "application/c2pa", buf); + auto store = json::parse(reader.json()); + std::string active = store["active_manifest"]; + auto& ings = store["manifests"][active]["ingredients"]; + ASSERT_FALSE(ings.empty()); + json ingredient = ings[0]; + + std::string stem = uuid_stem(s.id); + ASSERT_TRUE(ingredient.contains("thumbnail")); + std::string thumb_name = stem + "." + + ext_for_format(ingredient["thumbnail"]["format"]); + ASSERT_GT(reader.get_resource(ingredient["thumbnail"]["identifier"], + output_dir / thumb_name), 0); + ingredient["thumbnail"]["identifier"] = thumb_name; + thumb_names.push_back(thumb_name); + + if (ingredient.contains("manifest_data")) { + std::string md_name = stem + ".c2pa"; + ASSERT_GT(reader.get_resource(ingredient["manifest_data"]["identifier"], + output_dir / md_name), 0); + ingredient["manifest_data"]["identifier"] = md_name; + } + + ingredient.erase("label"); + std::ofstream(output_dir / (stem + ".json")) << ingredient.dump(2); + extracted.push_back(ingredient); } + + // Collision guard: the two ingredients wrote distinct, non-empty thumbnail files. + ASSERT_EQ(thumb_names.size(), 2u); + EXPECT_NE(thumb_names[0], thumb_names[1]); + EXPECT_GT(fs::file_size(output_dir / thumb_names[0]), 0u); + EXPECT_GT(fs::file_size(output_dir / thumb_names[1]), 0u); + + size_t json_count = 0, c2pa_count = 0; + for (const auto& entry : fs::directory_iterator(output_dir)) { + if (entry.path().extension() == ".json") ++json_count; + if (entry.path().extension() == ".c2pa") ++c2pa_count; + } + EXPECT_EQ(json_count, 2u); + EXPECT_EQ(c2pa_count, 1u); + + // Sign an asset with both extracted ingredients, resolving resources from the dir. + json sign_manifest = {{"ingredients", extracted}}; + auto sign_builder = c2pa::Builder(context, sign_manifest.dump()); + sign_builder.set_base_path(output_dir.string()); + + auto signer = c2pa_test::create_test_signer(); + auto output_path = get_temp_path("read_ingredient_multi_signed.jpg"); + std::vector manifest_data; + ASSERT_NO_THROW(manifest_data = sign_builder.sign(c2pa_test::get_fixture_path("A.jpg"), + output_path, signer)); + EXPECT_FALSE(manifest_data.empty()); + + // Both ingredients survived the round-trip into the signed asset. + auto reader = c2pa::Reader::from_asset(context, output_path); + ASSERT_TRUE(reader.has_value()); + auto parsed = json::parse(reader->json()); + std::string active = parsed["active_manifest"]; + auto& signed_ings = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(signed_ings.size(), 2u); + std::vector titles; + for (const auto& ing : signed_ings) titles.push_back(ing.value("title", std::string())); + EXPECT_NE(std::find(titles.begin(), titles.end(), "A.jpg"), titles.end()); + EXPECT_NE(std::find(titles.begin(), titles.end(), "C.jpg"), titles.end()); + + fs::remove_all(output_dir); } // Legacy API: std::string c2pa::read_ingredient_file(const fs::path& source_path, From 94229e31851ea1a60b3534fdab7fd10b6245ae09 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:48:21 -0700 Subject: [PATCH 02/11] fix: Refactor and rewrite --- docs/faqs.md | 2 +- docs/selective-manifests.md | 27 +++++- tests/migrations.test.cpp | 173 +++++++++++++++++++++++++++++++++++- 3 files changed, 194 insertions(+), 8 deletions(-) diff --git a/docs/faqs.md b/docs/faqs.md index b891da94..623e303c 100644 --- a/docs/faqs.md +++ b/docs/faqs.md @@ -121,5 +121,5 @@ These file-based functions not attached to an API-exposed object, `read_file`, ` | Removed function | Equivalent | | --- | --- | | `read_file(path)` | `Reader::from_asset(ctx, format, stream)` then `Reader::json()`. Pull binary resources with `Reader::get_resource(uri, dest)`. | -| `read_ingredient_file(path, data_dir)` | `Builder::add_ingredient(ingredient_json, source_path)`, which adds ingredients to the active Builder. To recover the formed ingredient JSON (if needed) and its resources, archive the working store and read it back, or move the ingredient with the dedicated `write_ingredient_archive` / `add_ingredient_from_archive` APIs. There is no single-call drop-in, but the full behavior (extract the ingredient JSON + thumbnail + manifest_data to a directory, then reuse that directory to sign a carrier, including several ingredients at once) is a short reimplementation shown with a C++ example in [Migrating from read_ingredient_file](selective-manifests.md#migrating-from-read_ingredient_file-extract-to-a-directory--reuse). See also [Extracting ingredients from a working store](selective-manifests.md#extracting-ingredients-from-a-working-store). | +| `read_ingredient_file(path, data_dir)` | `Builder::add_ingredient(ingredient_json, source_path)`, which adds ingredients to the active Builder. To recover the formed ingredient JSON (if needed) and its resources, archive the working store and read it back, or move the ingredient with the dedicated `write_ingredient_archive` / `add_ingredient_from_archive` APIs. There is no single-call replacement, but the full behavior (extract the ingredient JSON, thumbnail, and manifest_data to a directory, then reuse that directory to sign a carrier, including several ingredients at once) is a short reimplementation shown with a C++ example in [Migrating from the removed read_ingredient_file](selective-manifests.md#migrating-from-the-removed-read_ingredient_file). See also [Extracting ingredients from a working store](selective-manifests.md#extracting-ingredients-from-a-working-store). | | `sign_file(src, dst, manifest, SignerInfo*, data_dir)` | `Builder::sign(source_path, dest_path, signer)` with a `Signer`. | diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 97024b50..1f42298f 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -841,7 +841,7 @@ The signed output contains exactly the loaded ingredient. #### Migrating from the removed read_ingredient_file -The removed function `read_ingredient_file(source_path, data_dir)` read an asset, returned a formed ingredient JSON, and wrote the ingredient's binary resources (thumbnail and manifest data) to `data_dir`, so a later signing step could load that directory and embed the ingredient into a carrier. The behavior can be reimplementated with `Builder` and `Reader` objects: form the ingredient, archive it, read it back, write the resources to disk under stable non-colliding names, then reuse the directory to sign. +The removed function `read_ingredient_file(source_path, data_dir)` read an asset, returned a formed ingredient JSON, and wrote the ingredient's binary resources (thumbnail and manifest data) to `data_dir`, so a later signing step could load that directory and embed the ingredient into a carrier. The behavior can be reimplemented with `Builder` and `Reader` objects: form the ingredient, archive it, read it back, write the resources to disk under stable non-colliding names, then reuse the directory to sign. The legacy API derived each file name from the ingredient's `instance_id` rather than a fixed name (to avoid collisions). That is what lets several ingredients share one output directory without their thumbnail or `manifest_data` files overwriting each other. @@ -893,16 +893,35 @@ ingredient.erase("label"); // drop the archive-only assertion label before reus std::ofstream(output_dir / (stem + ".json")) << ingredient.dump(2); // Reuse: load the extracted ingredient (repeat the block above per ingredient to -// collect several ingredients) and sign a, resolving resources from output_dir. +// collect several) and sign a carrier, resolving resources from output_dir. json manifest = {{"ingredients", json::array({ingredient})}}; c2pa::Builder sign_builder(context, manifest.dump()); sign_builder.set_base_path(output_dir.string()); // resolves resources sign_builder.sign(carrier_path, output_path, signer); ``` -To reproduce the full `read_ingredient_file` directory behavior for multiple ingredients, run the extract block once per source and append each resulting `ingredient` object to the `ingredients` array before signing. Because file names came from `instance_id` with the legacy API, the extracted resources for every ingredient coexist in one directory once names are ensured to be unique. +To reproduce the full `read_ingredient_file` directory behavior for multiple ingredients, run the extract block once per source and append each resulting `ingredient` object to the `ingredients` array before signing. Deriving the file names from each ingredient's `instance_id` keeps them unique, so the extracted resources for every ingredient coexist in one directory without overwriting each other. -> **Note:** `set_base_path` is marked deprecated. To avoid it, register each extracted resource explicitly with `add_resource(identifier, stream)` on the signing builder instead (see [Dedicated ingredient archive APIs](#dedicated-ingredient-archive-apis) and the `add_resource` loop in the legacy pattern below). +##### Without `set_base_path` + +`set_base_path` is deprecated. The equivalent is to register each resource the ingredient JSON references directly on the signing builder with `add_resource`, instead of pointing the builder at a directory. Swap the sign block above for this: + +```cpp +// Same extracted `ingredient` objects as above. Register every referenced resource +// (thumbnail, and manifest_data when present) explicitly, then sign. +c2pa::Builder sign_builder(context, manifest.dump()); +for (const auto& ingredient : ingredients) { + std::string thumb_id = ingredient["thumbnail"]["identifier"]; + sign_builder.add_resource(thumb_id, output_dir / thumb_id); + if (ingredient.contains("manifest_data")) { + std::string md_id = ingredient["manifest_data"]["identifier"]; + sign_builder.add_resource(md_id, output_dir / md_id); + } +} +sign_builder.sign(carrier_path, output_path, signer); +``` + +Both paths produce the same signed result: the same ingredients, with the same resources attached. Register a resource for every `identifier` the ingredient JSON references, or the sign call is missing one; `set_base_path` did this implicitly by resolving names against the directory. `add_resource` also has a stream overload, `add_resource(identifier, stream)`, if the resource is already in memory rather than on disk (see [Dedicated ingredient archive APIs](#dedicated-ingredient-archive-apis) and the `add_resource` loop in the legacy pattern below). #### Legacy: read-filter-rebuild APIs diff --git a/tests/migrations.test.cpp b/tests/migrations.test.cpp index 041723d6..45a8dbe7 100644 --- a/tests/migrations.test.cpp +++ b/tests/migrations.test.cpp @@ -145,7 +145,7 @@ TEST_F(LegacyApiMigrationTest, ReadFile_WithDataDir_ExtractResources) { // What it did: read an asset, returned the formed ingredient JSON string, and // wrote the ingredient's binary resources (thumbnail, and any manifest data) to // data_dir. -// Current API: there is no single-call drop-in, but the whole behavior is a short +// Current API: there is no single-call replacement, but the behavior is a short // reimplementation on top of Builder/Reader: // 1. add_ingredient(json, source_path) forms the ingredient in a working store, // 2. write_ingredient_archive(id, buf) archives just that ingredient, @@ -256,8 +256,8 @@ TEST_F(LegacyApiMigrationTest, ReadIngredientFile_ExtractToDirThenSignCarrier) { // Extract two ingredients from the same working store into the SAME directory // and confirm their thumbnail and manifest_data files do not overwrite each other, then sign // a carrier with both and confirm both survive the round-trip. Fixed names like -// "thumbnail.jpg" and "manifest_data.c2pa" would fail this test, -// instance_id-derived names to ensure name unicity pass it. +// "thumbnail.jpg" and "manifest_data.c2pa" would fail this test; instance_id-derived +// names, which are unique per ingredient, pass it. TEST_F(LegacyApiMigrationTest, ReadIngredientFile_MultipleIngredientsSameStoreNoCollision) { auto context = std::make_shared(); @@ -367,6 +367,173 @@ TEST_F(LegacyApiMigrationTest, ReadIngredientFile_MultipleIngredientsSameStoreNo fs::remove_all(output_dir); } +// The extract-and-reuse flow above signs with set_base_path, which is deprecated. +// add_resource(identifier, path) is the replacement: register each referenced resource +// on the builder instead of pointing it at a directory. This test signs the same +// extracted ingredients both ways and checks that the two signed manifests carry the same +// ingredients with the same resources attached. The check ignores identifiers: two +// independent signs generate different signatures and fresh JUMBF identifier URIs, so it +// compares structure (title, relationship, format, and resource presence) rather than raw +// JSON. +TEST_F(LegacyApiMigrationTest, ReadIngredientFile_AddResourceMatchesSetBasePath) { + auto context = std::make_shared(); + + auto uuid_stem = [](const std::string& instance_id) { + auto colon = instance_id.rfind(':'); + std::string stem = (colon != std::string::npos) ? instance_id.substr(colon + 1) + : instance_id; + return stem.empty() ? std::string("ingredient") : stem; + }; + auto ext_for_format = [](const std::string& mime) { + std::string ext = mime.substr(mime.find('/') + 1); + return ext == "jpeg" ? std::string("jpg") : ext; + }; + + auto output_dir = get_temp_path("read_ingredient_addresource_dir"); + fs::create_directories(output_dir); + + // --- Extract once: two ingredients into one directory (shared input for both signs). + struct Spec { std::string id; std::string fixture; std::string title; }; + const std::vector specs = { + {"mig:ingredient-A", "A.jpg", "A.jpg"}, + {"mig:ingredient-C", "C.jpg", "C.jpg"}, + }; + + auto builder = c2pa::Builder(context, "{}"); + for (const auto& s : specs) { + builder.add_ingredient( + R"({"instance_id": ")" + s.id + R"(", "title": ")" + s.title + + R"(", "relationship": "componentOf"})", + c2pa_test::get_fixture_path(s.fixture)); + } + + std::vector extracted; + std::vector thumb_names; + for (const auto& s : specs) { + std::stringstream buf(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW(builder.write_ingredient_archive(s.id, buf)); + buf.seekg(0); + + c2pa::Reader reader(context, "application/c2pa", buf); + auto store = json::parse(reader.json()); + std::string active = store["active_manifest"]; + auto& ings = store["manifests"][active]["ingredients"]; + ASSERT_FALSE(ings.empty()); + json ingredient = ings[0]; + + std::string stem = uuid_stem(s.id); + ASSERT_TRUE(ingredient.contains("thumbnail")); + std::string thumb_name = stem + "." + + ext_for_format(ingredient["thumbnail"]["format"]); + ASSERT_GT(reader.get_resource(ingredient["thumbnail"]["identifier"], + output_dir / thumb_name), 0); + ingredient["thumbnail"]["identifier"] = thumb_name; + thumb_names.push_back(thumb_name); + + if (ingredient.contains("manifest_data")) { + std::string md_name = stem + ".c2pa"; + ASSERT_GT(reader.get_resource(ingredient["manifest_data"]["identifier"], + output_dir / md_name), 0); + ingredient["manifest_data"]["identifier"] = md_name; + } + + ingredient.erase("label"); + std::ofstream(output_dir / (stem + ".json")) << ingredient.dump(2); + extracted.push_back(ingredient); + } + + // Shared extract stays covered: distinct thumbnails, 2 JSON, 1 manifest_data. + ASSERT_EQ(thumb_names.size(), 2u); + EXPECT_NE(thumb_names[0], thumb_names[1]); + size_t json_count = 0, c2pa_count = 0; + for (const auto& entry : fs::directory_iterator(output_dir)) { + if (entry.path().extension() == ".json") ++json_count; + if (entry.path().extension() == ".c2pa") ++c2pa_count; + } + EXPECT_EQ(json_count, 2u); + EXPECT_EQ(c2pa_count, 1u); + + auto signer = c2pa_test::create_test_signer(); + + // --- Sign path A: set_base_path resolves resources against the directory. + auto builder_a = c2pa::Builder(context, json{{"ingredients", extracted}}.dump()); + builder_a.set_base_path(output_dir.string()); + auto out_a = get_temp_path("read_ingredient_addresource_a.jpg"); + std::vector md_a; + ASSERT_NO_THROW(md_a = builder_a.sign(c2pa_test::get_fixture_path("A.jpg"), out_a, signer)); + EXPECT_FALSE(md_a.empty()); + + // --- Sign path B: no set_base_path; register every referenced resource explicitly. + auto builder_b = c2pa::Builder(context, json{{"ingredients", extracted}}.dump()); + for (const auto& ing : extracted) { + std::string thumb_id = ing["thumbnail"]["identifier"]; + builder_b.add_resource(thumb_id, output_dir / thumb_id); + if (ing.contains("manifest_data")) { + std::string md_id = ing["manifest_data"]["identifier"]; + builder_b.add_resource(md_id, output_dir / md_id); + } + } + auto out_b = get_temp_path("read_ingredient_addresource_b.jpg"); + std::vector md_b; + ASSERT_NO_THROW(md_b = builder_b.sign(c2pa_test::get_fixture_path("A.jpg"), out_b, signer)); + EXPECT_FALSE(md_b.empty()); + + // --- Cross-check: both signed manifests carry the same ingredients + resources. + // Reduce each ingredient to identifier-safe structure: stable fields plus, for each + // resource, only its presence and format (drop the generated identifier / hashes). + auto normalize = [](const json& ingredients) { + json out = json::array(); + for (const auto& ing : ingredients) { + json n; + n["title"] = ing.value("title", std::string()); + n["relationship"] = ing.value("relationship", std::string()); + n["format"] = ing.value("format", std::string()); + for (const char* key : {"thumbnail", "manifest_data"}) { + if (ing.contains(key)) + n[key] = {{"present", true}, + {"format", ing[key].value("format", std::string())}}; + } + out.push_back(n); + } + // Order-independent: the two builders may list ingredients in either order. + std::sort(out.begin(), out.end(), [](const json& a, const json& b) { + return a["title"].get() < b["title"].get(); + }); + return out; + }; + + auto reader_a = c2pa::Reader::from_asset(context, out_a); + auto reader_b = c2pa::Reader::from_asset(context, out_b); + ASSERT_TRUE(reader_a.has_value()); + ASSERT_TRUE(reader_b.has_value()); + auto parsed_a = json::parse(reader_a->json()); + auto parsed_b = json::parse(reader_b->json()); + auto& ings_a = parsed_a["manifests"][parsed_a["active_manifest"].get()]["ingredients"]; + auto& ings_b = parsed_b["manifests"][parsed_b["active_manifest"].get()]["ingredients"]; + + ASSERT_EQ(ings_a.size(), 2u); + ASSERT_EQ(ings_b.size(), ings_a.size()); + // The two sign paths are equivalent: same ingredients, same resources attached. + EXPECT_EQ(normalize(ings_a), normalize(ings_b)); + + // Also check each manifest directly: both carry A.jpg and C.jpg, every ingredient has + // a thumbnail, and exactly one ingredient per manifest has manifest_data. + for (auto* ings : {&ings_a, &ings_b}) { + std::vector titles; + size_t md_count = 0; + for (const auto& ing : *ings) { + titles.push_back(ing.value("title", std::string())); + EXPECT_TRUE(ing.contains("thumbnail")); + if (ing.contains("manifest_data")) ++md_count; + } + EXPECT_NE(std::find(titles.begin(), titles.end(), "A.jpg"), titles.end()); + EXPECT_NE(std::find(titles.begin(), titles.end(), "C.jpg"), titles.end()); + EXPECT_EQ(md_count, 1u); + } + + fs::remove_all(output_dir); +} + // Legacy API: std::string c2pa::read_ingredient_file(const fs::path& source_path, // const fs::path& data_dir) // What it did: read an asset, returned the formed ingredient JSON string, and From 75f74b2643a11f6d454cfb507dc18923ec2dbe6b Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 13 Jul 2026 08:05:33 -0700 Subject: [PATCH 03/11] fix: Update the docs --- docs/selective-manifests.md | 486 +++++++-- .../c2pa.assertions/c2pa.thumbnail.claim.jpeg | Bin 0 -> 28486 bytes .../ingredient.json | 59 ++ .../manifest_data.c2pa | Bin 0 -> 45872 bytes tests/migrations.test.cpp | 984 ++++++++++++++++++ 5 files changed, 1442 insertions(+), 87 deletions(-) create mode 100644 tests/fixtures/ingredient-legacy-folder-migration/contentauth_urn_uuid_b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.thumbnail.claim.jpeg create mode 100644 tests/fixtures/ingredient-legacy-folder-migration/ingredient.json create mode 100644 tests/fixtures/ingredient-legacy-folder-migration/manifest_data.c2pa diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 1f42298f..b7cd2ae4 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -839,90 +839,6 @@ sink.sign(source_path, output_path, signer); The signed output contains exactly the loaded ingredient. -#### Migrating from the removed read_ingredient_file - -The removed function `read_ingredient_file(source_path, data_dir)` read an asset, returned a formed ingredient JSON, and wrote the ingredient's binary resources (thumbnail and manifest data) to `data_dir`, so a later signing step could load that directory and embed the ingredient into a carrier. The behavior can be reimplemented with `Builder` and `Reader` objects: form the ingredient, archive it, read it back, write the resources to disk under stable non-colliding names, then reuse the directory to sign. - -The legacy API derived each file name from the ingredient's `instance_id` rather than a fixed name (to avoid collisions). That is what lets several ingredients share one output directory without their thumbnail or `manifest_data` files overwriting each other. - -```cpp -namespace fs = std::filesystem; - -// Map a mime format ("image/jpeg") to a file extension, -// and turn an instance_id into a collision-resistant filename stem. -auto ext_for_format = [](const std::string& mime) { - std::string ext = mime.substr(mime.find('/') + 1); - return ext == "jpeg" ? std::string("jpg") : ext; -}; -auto uuid_stem = [](const std::string& instance_id) { - auto colon = instance_id.rfind(':'); - std::string stem = (colon != std::string::npos) ? instance_id.substr(colon + 1) - : instance_id; - return stem.empty() ? std::string("ingredient") : stem; -}; - -// Extract: form the ingredient, archive just it, and write its JSON, -// thumbnail and nested manifest_data (if any) into output_dir. -fs::create_directories(output_dir); -c2pa::Builder builder(context, "{}"); -builder.add_ingredient( - R"({"label": "my-ingredient", "title": "source.jpg", "relationship": "componentOf"})", - source_path); - -std::stringstream archive_buf(std::ios::in | std::ios::out | std::ios::binary); -builder.write_ingredient_archive("my-ingredient", archive_buf); -archive_buf.seekg(0); - -c2pa::Reader reader(context, "application/c2pa", archive_buf); -auto store = json::parse(reader.json()); -std::string active = store["active_manifest"]; -json ingredient = store["manifests"][active]["ingredients"][0]; - -std::string stem = uuid_stem(ingredient.value("instance_id", std::string())); -if (ingredient.contains("thumbnail")) { - std::string name = stem + "." + ext_for_format(ingredient["thumbnail"]["format"]); - reader.get_resource(ingredient["thumbnail"]["identifier"], output_dir / name); - ingredient["thumbnail"]["identifier"] = name; // rewrite to the on-disk name -} -if (ingredient.contains("manifest_data")) { - std::string name = stem + ".c2pa"; - reader.get_resource(ingredient["manifest_data"]["identifier"], output_dir / name); - ingredient["manifest_data"]["identifier"] = name; -} -ingredient.erase("label"); // drop the archive-only assertion label before reuse -std::ofstream(output_dir / (stem + ".json")) << ingredient.dump(2); - -// Reuse: load the extracted ingredient (repeat the block above per ingredient to -// collect several) and sign a carrier, resolving resources from output_dir. -json manifest = {{"ingredients", json::array({ingredient})}}; -c2pa::Builder sign_builder(context, manifest.dump()); -sign_builder.set_base_path(output_dir.string()); // resolves resources -sign_builder.sign(carrier_path, output_path, signer); -``` - -To reproduce the full `read_ingredient_file` directory behavior for multiple ingredients, run the extract block once per source and append each resulting `ingredient` object to the `ingredients` array before signing. Deriving the file names from each ingredient's `instance_id` keeps them unique, so the extracted resources for every ingredient coexist in one directory without overwriting each other. - -##### Without `set_base_path` - -`set_base_path` is deprecated. The equivalent is to register each resource the ingredient JSON references directly on the signing builder with `add_resource`, instead of pointing the builder at a directory. Swap the sign block above for this: - -```cpp -// Same extracted `ingredient` objects as above. Register every referenced resource -// (thumbnail, and manifest_data when present) explicitly, then sign. -c2pa::Builder sign_builder(context, manifest.dump()); -for (const auto& ingredient : ingredients) { - std::string thumb_id = ingredient["thumbnail"]["identifier"]; - sign_builder.add_resource(thumb_id, output_dir / thumb_id); - if (ingredient.contains("manifest_data")) { - std::string md_id = ingredient["manifest_data"]["identifier"]; - sign_builder.add_resource(md_id, output_dir / md_id); - } -} -sign_builder.sign(carrier_path, output_path, signer); -``` - -Both paths produce the same signed result: the same ingredients, with the same resources attached. Register a resource for every `identifier` the ingredient JSON references, or the sign call is missing one; `set_base_path` did this implicitly by resolving names against the directory. `add_resource` also has a stream overload, `add_resource(identifier, stream)`, if the resource is already in memory rather than on disk (see [Dedicated ingredient archive APIs](#dedicated-ingredient-archive-apis) and the `add_resource` loop in the legacy pattern below). - #### Legacy: read-filter-rebuild APIs > [!NOTE] @@ -1333,6 +1249,404 @@ for (auto& [stream, count] : archive_info) { builder.sign(source_path, output_path, signer); ``` +### Migrations + +#### Using `add_resource` instead of `set_base_path` + +`set_base_path` is deprecated: it carries a `@deprecated` note in the C++ header. `add_resource` is its replacement, and the two sections below use it throughout. This section explains the swap once so the recipes can refer back to it. + +An ingredient's JSON references its (binary) resources by `identifier` (a thumbnail, and a `manifest_data` store when present). `set_base_path(dir)` resolved every identifier implicitly, by matching each name against one directory on disk. `add_resource(identifier, path)` does the same job explicitly: one call per identifier the ingredient JSON declares, naming the file to load for it. + +The two produce the same signed result. Here is a `set_base_path` sign step: + +```cpp +c2pa::Builder sign_builder(context, manifest.dump()); +sign_builder.set_base_path(dir.string()); // resolves every identifier by name +sign_builder.sign(carrier_path, output_path, signer); +``` + +and the equivalent with `add_resource`, registering each referenced resource for every ingredient: + +```cpp +c2pa::Builder sign_builder(context, manifest.dump()); +for (const auto& ingredient : ingredients) { + if (ingredient.contains("thumbnail")) { + std::string thumb_id = ingredient["thumbnail"]["identifier"]; + sign_builder.add_resource(thumb_id, dir / thumb_id); + } + if (ingredient.contains("manifest_data")) { + std::string md_id = ingredient["manifest_data"]["identifier"]; + sign_builder.add_resource(md_id, dir / md_id); + } +} +sign_builder.sign(carrier_path, output_path, signer); +``` + +Register a resource for every `identifier` the ingredient JSON references, or the sign call is missing one; `set_base_path` did this implicitly by resolving names against the directory. `add_resource` also has a stream overload, `add_resource(identifier, stream)`, when the resource is already in memory rather than on disk (see [Dedicated ingredient archive APIs](#dedicated-ingredient-archive-apis)). + + +#### Working with ingredient directories + +An ingredient can live on disk as a directory: an `ingredient.json` file, an optional `manifest_data.c2pa` manifest store, and an optional thumbnail file. You may generate such a directory (see [Migrating from `read_ingredient_file`](#migrating-from-read_ingredient_file) below) or receive one from another source. Loading it onto a `Builder` instance is the same in both cases: each `ResourceRef` `identifier` in the JSON is a path relative to the directory, resolved through `set_base_path` or registered explicitly with `add_resource` (see [Using `add_resource` instead of `set_base_path`](#using-add_resource-instead-of-set_base_path)). + +```text +my_ingredient/ + ingredient.json # ingredient metadata + resource references + manifest_data.c2pa # optional: the ingredient's C2PA manifest store (a JUMBF blob) + .jpg # optional: a thumbnail referenced by ingredient.json +``` + +The asset itself is not in the directory: the directory holds metadata and a manifest store, not the image or video the ingredient describes. You supply the asset stream separately when adding the ingredient. `ingredient.json` holds only the ingredient fields (title, format, relationship) plus `ResourceRef` entries whose `identifier` is relative to the directory: + +```json +{ + "title": "C.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "thumbnail": { + "format": "image/jpeg", + "identifier": "self#jumbf=/c2pa/contentauth:urn:uuid:.../c2pa.thumbnail.claim.jpeg" + }, + "manifest_data": { + "format": "application/c2pa", + "identifier": "manifest_data.c2pa" + } +} +``` + +Two questions pick the route: do you still have the ingredient's asset, and what does the directory hold. The asset gates the choice first, because `add_ingredient` needs an asset stream; without it you inject the ingredient into the definition instead. + +```mermaid +flowchart TD + Start([ingredient directory]) --> Asset{still have
the asset?} + + Asset -->|"yes"| Has{"directory holds what?"} + Asset -->|"no"| NA["inject ingredient into manifest[ingredients]
fill validation_results from the store if missing
add_resource(manifest_data.c2pa)
(needs manifest_data.c2pa)"] + + Has -->|"manifest_data.c2pa"| A["set_base_path(directory)
add_ingredient(json, image/jpeg, asset)
manifest_data resolved eagerly at add time"] + Has -->|"thumbnail only"| B["set_base_path(directory)
add_ingredient(json, image/jpeg, asset)
thumbnail resolved lazily at sign time"] + Has -->|"ingredient.json only"| J["add_ingredient(json, image/jpeg, asset)
no set_base_path: nothing on disk to resolve"] + + A --> Sign + B --> Sign + J --> Sign + NA --> Sign + Sign["builder.sign(source, output, signer)"] +``` + +Two details cut across every route below. The `manifest_data` and a thumbnail resolve at different moments, which determine when you can delete the directory: + +```mermaid +flowchart LR + subgraph AddTime["add_ingredient (add time)"] + MD["manifest_data resolved eagerly:
store read into the Builder now"] + end + subgraph SignTime["sign (sign time)"] + TH["thumbnail resolved lazily:
file read from disk now"] + end + MD -->|"directory can be deleted
after add_ingredient"| SafeMD([safe to delete early]) + TH -->|"directory must survive
until sign"| SafeTH([keep until sign]) +``` + +Resources resolve through either a directory-wide `set_base_path` or a per-resource `add_resource`: + +```mermaid +flowchart TD + Q{relative identifiers
collide across directories?} + Q -->|"no"| BP["set_base_path(directory):
one directory resolves all identifiers"] + Q -->|"yes, or you want no base_path"| AR["add_resource(unique id, bytes):
register each resource explicitly"] + BP --> Note["set_base_path is @deprecated;
add_resource is the forward-looking default"] + AR --> Note +``` + +##### Ingredient directory with a manifest store + +When `ingredient.json` declares a `manifest_data` reference to a `manifest_data.c2pa` file, that file is the ingredient's own signed C2PA manifest store, so the ingredient carries full provenance (its history, validation, and nested ingredients) into your new manifest. The `manifest_data` reference is resolved eagerly, when you call `add_ingredient`, so the directory can be deleted right after the add. + +Two things must work together for the SDK to reconstitute the ingredient: + +1. `ingredient.json` declares a `manifest_data` reference. +2. `builder.set_base_path(directory)` is set so that reference resolves from disk. + +Add the ingredient through the asset branch with the ingredient's asset format (for example `"image/jpeg"`) and the asset stream the manifest binds to. Do not pass `"application/c2pa"` here: that branch expects a dedicated ingredient archive, and a bare `manifest_data.c2pa` store is rejected with `"expected an ingredient archive"`. Load the store through the base_path route instead. + +```cpp +// Inputs: dir (ingredient.json + manifest_data.c2pa + the signed asset), +// context, manifest_json, source_asset, output_path, signer. +std::string ing_json = read_text_file(dir / "ingredient.json"); +c2pa::Builder builder(context, manifest_json); + +// Point base_path at the directory so "manifest_data.c2pa" resolves. +builder.set_base_path(dir.string()); + +// Asset branch: real asset format + the asset stream (not "application/c2pa"). +std::ifstream asset_stream(dir / "asset.jpg", std::ios::binary); +builder.add_ingredient(ing_json, "image/jpeg", asset_stream); + +builder.sign(source_asset, output_path, signer); +``` + +Reading the signed output back, the ingredient carries an `active_manifest`, not just metadata. Without `set_base_path`, the declared `manifest_data` reference cannot be resolved and `sign` throws `ResourceNotFound`. + +For many manifest-store directories, set `base_path` per directory right before adding each one. The reference resolves at add time, so the sequential override is correct even though `set_base_path` is global and last-write-wins. + +```cpp +c2pa::Builder builder(context, manifest_json); +for (const fs::path &dir : dirs) { + std::string ing_json = read_text_file(dir / "ingredient.json"); + builder.set_base_path(dir.string()); // current directory + std::ifstream asset(dir / "asset.jpg", std::ios::binary); + builder.add_ingredient(ing_json, "image/jpeg", asset); +} +builder.sign(source_asset, output_path, signer); +``` + +##### Ingredient directory with a relative thumbnail only + +A directory with no `manifest_data.c2pa`, just `ingredient.json` and a thumbnail file, carries only metadata plus a thumbnail; the signed output has no `active_manifest`. The thumbnail is resolved lazily, at sign time, so the file must still exist on disk when you call `sign` (or you must inline it with `add_resource`). + +```cpp +std::string ing_json = read_text_file(dir / "ingredient.json"); +c2pa::Builder builder(context, manifest_json); +builder.set_base_path(dir.string()); // resolves the relative "thumb.jpg" + +std::ifstream asset(source_asset, std::ios::binary); +builder.add_ingredient(ing_json, "image/jpeg", asset); + +builder.sign(source_asset, output_path, signer); +``` + +Without `set_base_path`, the relative thumbnail cannot be found and signing throws. Because a thumbnail is resolved at sign time rather than at add time, that failure surfaces at `sign`, not at `add_ingredient` (see the resolution-timing diagram in [Working with ingredient directories](#working-with-ingredient-directories)). + +If two thumbnail-only directories each carry a different thumbnail under the same relative name (`thumb.jpg`), one global `base_path` cannot serve both. Give each thumbnail a unique identifier and inline its bytes with `add_resource`, so neither ingredient depends on `base_path` (see [Using `add_resource` instead of `set_base_path`](#using-add_resource-instead-of-set_base_path)). + +```cpp +c2pa::Builder builder(context, manifest_json); + +json ing_a = json::parse(read_text_file(dir_a / "ingredient.json")); +json ing_c = json::parse(read_text_file(dir_c / "ingredient.json")); +ing_a["thumbnail"]["identifier"] = "thumb_a.jpg"; +ing_c["thumbnail"]["identifier"] = "thumb_c.jpg"; + +std::ifstream ta(dir_a / "thumb.jpg", std::ios::binary); +std::ifstream tc(dir_c / "thumb.jpg", std::ios::binary); +builder.add_resource("thumb_a.jpg", ta); +builder.add_resource("thumb_c.jpg", tc); + +std::ifstream s1(source_asset, std::ios::binary); +std::ifstream s2(source_asset, std::ios::binary); +builder.add_ingredient(ing_a.dump(), "image/jpeg", s1); +builder.add_ingredient(ing_c.dump(), "image/jpeg", s2); + +builder.sign(source_asset, output_path, signer); +``` + +##### Ingredient directory with ingredient.json only + +If a directory has neither a `manifest_data.c2pa` nor a thumbnail, just `ingredient.json`, there is nothing on disk to resolve, so `set_base_path` is not needed. Add the ingredient from your asset stream as plain metadata. The signed ingredient has its `title` and `relationship` but no `active_manifest`, because there was no prior manifest to carry. + +```cpp +std::string ing_json = read_text_file(dir / "ingredient.json"); +c2pa::Builder builder(context, manifest_json); +// No set_base_path: the JSON references no files. +std::ifstream asset(source_asset, std::ios::binary); +builder.add_ingredient(ing_json, "image/jpeg", asset); +builder.sign(source_asset, output_path, signer); +``` + +##### Loading an ingredient directory without using an asset stream + +When the original ingredient asset is gone, skip `add_ingredient`, place the ingredient in the manifest definition, and supply its manifest store with `add_resource`. This is the read-filter-rebuild pattern, and it works from the directory's two files alone: `ingredient.json` and `manifest_data.c2pa`. + +The one field the definition-injection route depends on is `validation_results`. Some `ingredient.json` files already carry it and some do not. When it is absent, signing is rejected with `"Encoding: unable to encode assertion data"`, because the `add_ingredient` route derives the field from the loaded store plus the asset stream while the definition-injection route has no such step. Check whether the parsed JSON already has `validation_results`: if it does, inject it as-is; if it does not, read the store once with a `Reader` and copy its `validation_results` into the ingredient. Either way, transfer the same store bytes with `add_resource`. + +```cpp +namespace fs = std::filesystem; +fs::path dir = /* the ingredient directory */; + +// 1. Ingredient fields from the directory's ingredient.json. +json ingredient = json::parse(read_text_file(dir / "ingredient.json")); + +// 2. Fill validation_results only when the ingredient.json lacks it. Some +// directories already carry the field; read it from the store otherwise. +if (!ingredient.contains("validation_results")) { + std::ifstream store_in(dir / "manifest_data.c2pa", std::ios::binary); + c2pa::Reader store_reader(context, "application/c2pa", store_in); + json store_parsed = json::parse(store_reader.json()); + ingredient["validation_results"] = store_parsed["validation_results"]; +} + +// 3. Assemble the injectable ingredient JSON. +ingredient["manifest_data"] = { + {"format", "application/c2pa"}, + {"identifier", "manifest_data.c2pa"}, +}; + +// 4. Inject into the definition. +json manifest = json::parse(manifest_json); +manifest["ingredients"] = json::array({ingredient}); +c2pa::Builder builder(context, manifest.dump()); + +// 5. Carry the store bytes under the matching identifier. +std::ifstream store_res(dir / "manifest_data.c2pa", std::ios::binary); +builder.add_resource("manifest_data.c2pa", store_res); + +// 6. Sign. +builder.sign(source_asset, output_path, signer); +``` + +The signed ingredient carries an `active_manifest`. Because the store was validated without its asset, its `validation_results` reports an `assertion.dataHash.mismatch`, the state to carry forward (since the original asset is gone). For several no-asset directories, give each directory's `manifest_data` a distinct identifier (`add_resource` identifiers are global to the Builder), and keep each stream alive until `sign` returns. + +##### Mixing an ingredient directory with a modern archive + +Directory ingredients and modern dedicated ingredient archives can be added to the same Builder in any order. Every `add_ingredient*` method appends to the same internal ingredient list; there is no version gate. + +```cpp +c2pa::Builder builder(context, manifest_json); + +// Directory ingredient (manifest store on disk). +std::string dir_json = read_text_file(dir / "ingredient.json"); +builder.set_base_path(dir.string()); +std::ifstream asset_stream(dir / "asset.jpg", std::ios::binary); +builder.add_ingredient(dir_json, "image/jpeg", asset_stream); + +// Modern dedicated ingredient archive. +archive.seekg(0); +builder.add_ingredient_from_archive(archive); + +builder.sign(source_asset, output_path, signer); +``` + +When the asset is gone, swap the `add_ingredient` + `set_base_path` step for the [no-asset route](#loading-an-ingredient-directory-without-using-an-asset-stream) above and still add the modern archive with `add_ingredient_from_archive`. + +##### Re-archiving an ingredient directory into the modern `.c2pa` format + +The on-disk directory format is awkward to carry around: multiple files, on-disk relative references, a Builder-global `base_path`. If you still have the ingredients, convert each one to a modern dedicated ingredient archive once, then use only the archives. Loading a directory and calling `write_ingredient_archive` produces that archive, which bundles the manifest store and no longer depends on `base_path` or on the asset file. + +```cpp +void migrate_directory_to_archive(const c2pa::Context &context, + const fs::path &dir, + const fs::path &archive_out, + const std::string &manifest_json) { + c2pa::Builder b(context, manifest_json); + std::string ing_json = read_text_file(dir / "ingredient.json"); + b.set_base_path(dir.string()); + std::ifstream asset(dir / "asset.jpg", std::ios::binary); + b.add_ingredient(ing_json, "image/jpeg", asset); + + std::ofstream out(archive_out, std::ios::binary); + b.write_ingredient_archive("ing-mig", out); // label from ingredient.json + // The directory is now redundant; it can be deleted. +} + +// Later, on any builder, with the directory long gone: +void use_migrated_archive(c2pa::Builder &builder, const fs::path &archive_in) { + std::ifstream in(archive_in, std::ios::binary); + builder.add_ingredient_from_archive(in); +} +``` + +Give each ingredient a `"label"` in its JSON so you can name it in `write_ingredient_archive`. When the original asset is gone, migrate through the [no-asset route](#loading-an-ingredient-directory-without-using-an-asset-stream) first, then call `write_ingredient_archive` on that ingredient; the migrated `validation_results` records the same hard-binding mismatch that comes from validating a store without its asset. + +##### Pitfalls + +The Builder does not de-duplicate ingredients. This applies to every `add_ingredient*` route, not just directories: adding the same ingredient N times, whether by asset path, asset stream, directory, or archive, produces N ingredients in the signed manifest. The Builder never merges or skips a repeat, so guard against double-adds in your own code. + +A `manifest_data` reference that resolves but points at corrupt bytes fails at `sign`, not at `add_ingredient`, and with a different message than a missing reference. A missing or unresolved reference throws `ResourceNotFound` (for example when `set_base_path` is not set); a resolvable but corrupt `manifest_data.c2pa` throws `"Verify: invalid JUMBF header"`. Both surface at `sign` rather than at `add_ingredient` time, so validate a store before relying on it. + +##### Linking a directory ingredient to an action + +A directory ingredient links to an action the same way any other ingredient does: through `parameters.ingredientIds` on the action, matched against the ingredient's `label` first and its `instance_id` as a fallback (see [Linking actions to ingredients](#linking-actions-to-ingredients) for the full resolution rules). + +Give the `ingredient.json` a `label`, reference that same string in the action's `ingredientIds`, and after signing the action's resolved `ingredients[].url` points at the ingredient assertion (`self#jumbf=.../c2pa.ingredient.*`). + +Note that some `ingredient.json` files may already carry an `instance_id` (read from the asset's XMP, or written during an earlier extraction), so an ingredient can arrive with an `instance_id` even before you add a `label`. + +When an ingredient carries both a `label` and an `instance_id`, the `label` takes precedence. The SDK checks each `ingredientIds` value against labels before instance_ids, so a value that matches a `label` resolves to that ingredient even if some other ingredient has a matching `instance_id`. The `instance_id` is consulted only for an id that matches no label. + +The `label` used for linking is not preserved verbatim on the ingredient after signing. The SDK consumes it as the linking (identifying) key and rewrites it to the ingredient assertion's own label: reading the signed output back, the ingredient's `label` is an SDK-assigned label, not the string that was assigned in the `label` value. An explicit `instance_id`, by contrast, stays in the ingredient data unchanged. So if you need a stable, caller-controlled identifier that survives signing and round-trips, use `instance_id`. Use `label` to identify the link itself before signing: a label identifies a link of an ingredient to an action, but is not a stable ingredient-only identifier. + +```cpp +// ingredient.json declares the label the action will reference: +// { "title": "...", "relationship": "parentOf", "label": "dir-parent", +// "manifest_data": { "format": "application/c2pa", "identifier": "manifest_data.c2pa" } } + +// The signing manifest's c2pa.actions assertion references that label: +// { "action": "c2pa.opened", "parameters": { "ingredientIds": ["dir-parent"] } } + +builder.set_base_path(dir.string()); +std::ifstream asset(dir / "asset.jpg", std::ios::binary); +builder.add_ingredient(dir_json, "image/jpeg", asset); +builder.sign(source_asset, output_path, signer); +``` + +An unknown id in `ingredientIds` is rejected at sign with `"Action ingredientId not found"`. + +#### Migrating from `read_ingredient_file` + +The deprecated and removed function `read_ingredient_file(source_path, data_dir)` read an asset, returned a fully formed ingredient JSON, and wrote the ingredient's binary resources (thumbnail and manifest data) to `data_dir`, so a later signing step could load that directory and embed the ingredient. The behavior can be reimplemented with `Builder` and `Reader` objects: form the ingredient, archive it, read it back, write the resources to disk under stable non-colliding names, then reuse the directory on a Builder to sign. + +The legacy API derived each file name from the ingredient's `instance_id` rather than a fixed name (to avoid collisions). That is what lets several ingredients share one output directory without their thumbnail or `manifest_data` files overwriting each other. + +```cpp +namespace fs = std::filesystem; + +// Map a mime format ("image/jpeg") to a file extension, +// and turn an instance_id into a collision-resistant filename stem. +auto ext_for_format = [](const std::string& mime) { + std::string ext = mime.substr(mime.find('/') + 1); + return ext == "jpeg" ? std::string("jpg") : ext; +}; +auto uuid_stem = [](const std::string& instance_id) { + auto colon = instance_id.rfind(':'); + std::string stem = (colon != std::string::npos) ? instance_id.substr(colon + 1) + : instance_id; + return stem.empty() ? std::string("ingredient") : stem; +}; + +// Extract: form the ingredient, archive just it, and write its JSON, +// thumbnail and nested manifest_data (if any) into output_dir. +fs::create_directories(output_dir); +c2pa::Builder builder(context, "{}"); +builder.add_ingredient( + R"({"label": "my-ingredient", "title": "source.jpg", "relationship": "componentOf"})", + source_path); + +std::stringstream archive_buf(std::ios::in | std::ios::out | std::ios::binary); +builder.write_ingredient_archive("my-ingredient", archive_buf); +archive_buf.seekg(0); + +c2pa::Reader reader(context, "application/c2pa", archive_buf); +auto store = json::parse(reader.json()); +std::string active = store["active_manifest"]; +json ingredient = store["manifests"][active]["ingredients"][0]; + +std::string stem = uuid_stem(ingredient.value("instance_id", std::string())); +if (ingredient.contains("thumbnail")) { + std::string name = stem + "." + ext_for_format(ingredient["thumbnail"]["format"]); + reader.get_resource(ingredient["thumbnail"]["identifier"], output_dir / name); + ingredient["thumbnail"]["identifier"] = name; // rewrite to the on-disk name +} +if (ingredient.contains("manifest_data")) { + std::string name = stem + ".c2pa"; + reader.get_resource(ingredient["manifest_data"]["identifier"], output_dir / name); + ingredient["manifest_data"]["identifier"] = name; +} +ingredient.erase("label"); // drop the archive-only assertion label before reuse +std::ofstream(output_dir / (stem + ".json")) << ingredient.dump(2); + +// Reuse: load the extracted ingredient (repeat the block above per ingredient to +// collect several) and sign a carrier, resolving resources from output_dir. +json manifest = {{"ingredients", json::array({ingredient})}}; +c2pa::Builder sign_builder(context, manifest.dump()); +sign_builder.set_base_path(output_dir.string()); // resolves resources +sign_builder.sign(carrier_path, output_path, signer); +``` + +To reproduce the full `read_ingredient_file` directory behavior for multiple ingredients, run the extract block once per source and append each resulting `ingredient` object to the `ingredients` array before signing. Deriving the file names from each ingredient's `instance_id` keeps them unique, so the extracted resources for every ingredient coexist in one directory without overwriting each other. To sign without `set_base_path` (deprecated), register each resource with `add_resource` instead (see [Using `add_resource` instead of `set_base_path`](#using-add_resource-instead-of-set_base_path)); to load a directory you received rather than generated, see [Working with ingredient directories](#working-with-ingredient-directories). + +An ingredient formed through `add_ingredient` from an asset stream or path always has an `instance_id`: the SDK takes the asset's XMP `instanceID` when present, otherwise it synthesizes one of the form `xmp:iid:`. The stem helper splits on the last colon, so both the XMP form (`xmp.iid:`) and the synthesized form yield the UUID. Two edge cases justify the `"ingredient"` fallback in `uuid_stem`: a synthesized `instance_id` is random, so it is unique within a run but not stable across runs; and an ingredient built directly with the v2 constructor, without passing an asset stream, has no `instance_id` at all and omits the field. Set an explicit `instance_id` (or a `label`) when you need a stable, caller-controlled name. + ## Retrieving actions from a working store Actions are stored in the `c2pa.actions.v2` assertion. Use `Reader` to extract them from a signed asset or an archived Builder. @@ -1389,8 +1703,6 @@ flowchart TD style M5 fill:#eee,stroke:#999,stroke-dasharray: 5 5 ``` - - Not every ingredient has provenance. An unsigned asset added as an ingredient has `title`, `format`, and `relationship`, but no `manifest_data` and no entry in the `"manifests"` dictionary. Walking the tree reveals the full provenance chain: what each actor did at each step, including actions performed and ingredients used. **To walk the tree and find actions at each level:** @@ -1442,7 +1754,7 @@ for (auto& ingredient : active_manifest["ingredients"]) { } ``` -## Filtering actions +## Filtering actions To remove actions, use the same read–filter–rebuild pattern: **read, pick the ones to keep, create a new Builder**. diff --git a/tests/fixtures/ingredient-legacy-folder-migration/contentauth_urn_uuid_b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.thumbnail.claim.jpeg b/tests/fixtures/ingredient-legacy-folder-migration/contentauth_urn_uuid_b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.thumbnail.claim.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..1ef62384c1cbfad351133b340dab5daa0282c23e GIT binary patch literal 28486 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 KvO3@Y`~yY>tp- zz4LAL)K4J@l;s5n9KDZrdLWRQh{kW013%Z@30p53Xg+B{h$q>l1QWVGPTMpZ=Ce3Q z&DF>eL6hKv%V<|3$FttPK+1euJ|oI zj9At^V4RqQ8^kmZ{Zl5T94N4n?Wi$R5Hcjj#Hzc0peRT-2{Yn=u}xx-6aiUYg5TL<$IL@&l& z!R&YC?P{`*cZqILyFV#b^yHw_g2L29YhoAgm|@ONoop>l{-$s*G1M|;R(hM)U;Dwr zLS18ESCj))yJnx4C2FP@leSFy@$}#2d+i^1N1onR!93oh14%7gn{fY4oc~>9sEsi62g8@qyhDL>)@nn zmhaiueUSHj7czAzUbYal;tTktXmy-P;giy|vbnF`*bpETk-Taj=LPJ9dCH#e<=BLi zPIn_1N1lA)*!?y~d!_N{gxS*0J2j9Tp-06!!cccXZWeg?T-{z7D(`(9pwpY|`jO|m z6?VJ0r6Nku9G?Za$N>}dj=y`;mtjIRdirjY@x2oHL=>%QFGs344#j9iJ9}J6{pq_! zquB3z<7A6O6g}D1&7kiENyqZsrLpMjvl7rSGW(~;jh1YDTsLkz^geH^L#L@2f93mNcGjH zMJKlBv$GtgjZwqyrOHY6>g^%*_udRf%Mo_Z6&XX(1WMWC=a=~Hhb*S|e3h3Df85s3 ziyI|}-ek|ojqL{osiB<<6yyOcO32&%i{Vr-`>8kTlItn2!Snc39I7M&bA zdXhaSH`a=p%J#xjrTj<-D(~G_OZ9#V?bP1(YO(!q8B#4nszrCT#g3_$A=P5vcy1X|Ejsv?RjS3I c^s0_!NVN>97TeX9A=SV5|DDpxPk#RY0BEoz2mk;8 literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingredient-legacy-folder-migration/ingredient.json b/tests/fixtures/ingredient-legacy-folder-migration/ingredient.json new file mode 100644 index 00000000..09e41cbe --- /dev/null +++ b/tests/fixtures/ingredient-legacy-folder-migration/ingredient.json @@ -0,0 +1,59 @@ +{ + "title": "C.jpg", + "format": "image/jpeg", + "instance_id": "xmp:iid:547f138e-d98d-45b5-a13c-8fd092b4ebc6", + "thumbnail": { + "format": "image/jpeg", + "identifier": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.thumbnail.claim.jpeg", + "hash": "Nrhmnt6qXlHbrpaDmEMb98m3TyN3PJwniriapw7QM4k=" + }, + "relationship": "componentOf", + "active_manifest": "contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3", + "validation_results": { + "activeManifest": { + "success": [ + { + "code": "claimSignature.insideValidity", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.signature", + "explanation": "claim signature valid" + }, + { + "code": "claimSignature.validated", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.signature", + "explanation": "claim signature valid" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.thumbnail.claim.jpeg", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/stds.schema-org.CreativeWork", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/stds.schema-org.CreativeWork" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.actions", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.hash.data", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data" + }, + { + "code": "assertion.dataHash.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:b2b1f7fa-b119-4de1-9c0d-c97fbea3f2c3/c2pa.assertions/c2pa.hash.data", + "explanation": "data hash valid" + } + ], + "informational": [], + "failure": [] + } + }, + "manifest_data": { + "format": "application/c2pa", + "identifier": "manifest_data.c2pa" + } +} \ No newline at end of file diff --git a/tests/fixtures/ingredient-legacy-folder-migration/manifest_data.c2pa b/tests/fixtures/ingredient-legacy-folder-migration/manifest_data.c2pa new file mode 100644 index 0000000000000000000000000000000000000000..0ded51e27948aee68ff281a07564139bb2492411 GIT binary patch literal 45872 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 h{^1wq8U5#v`W01Ox5 #include #include +#include #include #include "include/test_utils.hpp" @@ -612,3 +613,986 @@ TEST_F(LegacyApiMigrationTest, SignFile_SignsAssetToOutput) { auto parsed = json::parse(reader->json()); EXPECT_TRUE(parsed.contains("active_manifest")); } + +// Test fixture: per-test temp dirs under tests/build, cleaned up in TearDown. +class LegacyFolderIngredient : public ::testing::Test { +protected: + std::vector temp_dirs; + + fs::path make_temp_dir(const std::string &name) { + fs::path build_dir = fs::path(__FILE__).parent_path() / "build"; + fs::create_directories(build_dir); + fs::path dir = build_dir / ("legacy-ingredient-" + name); + if (fs::exists(dir)) { + fs::remove_all(dir); + } + fs::create_directories(dir); + temp_dirs.push_back(dir); + return dir; + } + + void TearDown() override { + for (const auto &dir : temp_dirs) { + if (fs::exists(dir)) { + fs::remove_all(dir); + } + } + temp_dirs.clear(); + } + + // A legacy folder ingredient is reconstituted from two things: the manifest + // store bytes (manifest_data.c2pa) AND the signed asset they are hash-bound + // to. Sign a fixture asset, return the manifest-store bytes plus the path to + // the signed asset that carries them. + struct SignedSeed { + std::vector manifest_bytes; // contents of manifest_data.c2pa + fs::path signed_asset; // asset the manifest binds to + }; + SignedSeed sign_seed(const fs::path &asset, const std::string &name) { + auto manifest = + c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto signer = c2pa_test::create_test_signer(); + auto builder = c2pa::Builder(manifest); + fs::path out = make_temp_dir("seed-" + name) / "signed.jpg"; + auto bytes = builder.sign(asset, out, signer); + return {std::move(bytes), out}; + } + + // Write a legacy folder: ingredient.json (+ optional manifest_data.c2pa, + // + optional thumbnail copied into the folder). Returns the folder path. + fs::path write_legacy_folder(const std::string &name, + const json &ingredient_json, + const std::vector *manifest_data, + const fs::path *thumbnail_src, + const std::string &thumbnail_name) { + fs::path dir = make_temp_dir(name); + if (manifest_data != nullptr) { + std::ofstream m(dir / "manifest_data.c2pa", std::ios::binary); + m.write(reinterpret_cast(manifest_data->data()), + static_cast(manifest_data->size())); + } + if (thumbnail_src != nullptr) { + fs::copy_file(*thumbnail_src, dir / thumbnail_name, + fs::copy_options::overwrite_existing); + } + std::ofstream j(dir / "ingredient.json"); + j << ingredient_json.dump(2); + return dir; + } + + // A signed manifest validates when its validation_state is Valid or Trusted + // (the es256 test cert is not trusted, so the good state here is "Valid"). + static bool is_valid_state(const json &parsed) { + if (!parsed.contains("validation_state")) return false; + const auto &s = parsed["validation_state"]; + return s == "Valid" || s == "Trusted"; + } + + // Sign `source` with `builder`, assert the active manifest validates, and + // return the active manifest's ingredients array. + json sign_and_read_ingredients(c2pa::Builder &builder, + const fs::path &source, + const std::string &out_name) { + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir(out_name) / "out.jpg"; + builder.sign(source, out, signer); + auto reader = c2pa::Reader(out); + auto parsed = json::parse(reader.json()); + EXPECT_TRUE(is_valid_state(parsed)) + << out_name << " validation_state=" + << (parsed.contains("validation_state") + ? parsed["validation_state"].dump() : ""); + std::string active = parsed["active_manifest"]; + return parsed["manifests"][active]["ingredients"]; + } + + // Build a self-contained legacy Case-A folder (ingredient.json declaring a + // manifest_data ref + manifest_data.c2pa + the signed asset it binds to) and + // add it to `builder` via the documented Case-A route. Returns nothing; the + // ingredient is appended to the builder. + void add_legacy_caseA(c2pa::Builder &builder, const std::string &name, + const std::string &title) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), name); + json ing = { + {"title", title}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path folder = write_legacy_folder(name, ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, folder / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + builder.set_base_path(folder.string()); + std::ifstream asset_stream(folder / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + } + + // Build a modern dedicated ingredient archive from a fixture asset and add it + // to `builder` via add_ingredient_from_archive. The archive stream must + // outlive the call, so it is owned by the caller. + std::shared_ptr add_modern_archive( + c2pa::Builder &builder, const std::string &manifest, + const std::string &label, const std::string &title, + const fs::path &asset) { + auto archive = std::make_shared( + std::ios::in | std::ios::out | std::ios::binary); + { + auto ab = c2pa::Builder(manifest); + json ing = {{"title", title}, {"relationship", "componentOf"}, {"label", label}}; + ab.add_ingredient(ing.dump(), asset); + ab.write_ingredient_archive(label, *archive); + } + archive->seekg(0); + // EXPECT (not ASSERT): ASSERT_* expands to `return;`, which is illegal in + // a non-void helper. + EXPECT_NO_THROW(builder.add_ingredient_from_archive(*archive)); + return archive; + } + + // Assert every expected title is present in the ingredients array. + void expect_titles(const json &ingredients, + const std::vector &expected) { + std::vector titles; + for (const auto &i : ingredients) titles.push_back(i["title"]); + for (const auto &t : expected) { + EXPECT_NE(std::find(titles.begin(), titles.end(), t), titles.end()) + << "missing ingredient titled " << t; + } + } + +}; + +// A folder declaring manifest_data.c2pa loads its provenance when set_base_path points at the folder, and fails at sign with no base_path. +TEST_F(LegacyFolderIngredient, ManifestDataResolvedViaBasePath) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "caseA"); + + json ing = { + {"title", "legacy parent"}, + {"format", "image/jpeg"}, + {"relationship", "parentOf"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path folder = + write_legacy_folder("caseA", ing, &seed.manifest_bytes, nullptr, ""); + // The signed asset the manifest binds to lives alongside in the folder. + fs::copy_file(seed.signed_asset, folder / "asset.jpg", + fs::copy_options::overwrite_existing); + + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // POSITIVE: base_path set; manifest_data reference resolves from the folder. + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + std::ifstream asset_stream(folder / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + + auto src = c2pa_test::get_fixture_path("A.jpg"); + auto ingredients = sign_and_read_ingredients(builder, src, "caseA-out"); + ASSERT_EQ(ingredients.size(), 1u); + EXPECT_EQ(ingredients[0]["title"], "legacy parent"); + EXPECT_EQ(ingredients[0]["relationship"], "parentOf"); + // Provenance carried from the legacy manifest_data, not just bare metadata. + EXPECT_TRUE(ingredients[0].contains("active_manifest")) + << "manifest_data.c2pa should be resolved and carried via base_path"; + + // COUNTER: no base_path. The ingredient.json declares a manifest_data + // reference ("manifest_data.c2pa") that can only be resolved from disk via + // base_path. Without it, resolution fails hard (ResourceNotFound) at sign + // time, proving base_path is what makes the legacy manifest_data load. + auto builder2 = c2pa::Builder(manifest); + std::ifstream plain(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_NO_THROW(builder2.add_ingredient(ing_json, "image/jpeg", plain)); + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("caseA-nobasepath") / "out.jpg"; + EXPECT_ANY_THROW(builder2.sign(src, out, signer)) + << "without base_path the declared manifest_data reference is unresolved"; +} + +// A folder with a relative thumbnail resolves it at sign time via set_base_path, and fails at sign without base_path. +TEST_F(LegacyFolderIngredient, BasePathResolvesRelativeThumbnail) { + auto asset = c2pa_test::get_fixture_path("A.jpg"); + auto thumb_src = c2pa_test::get_fixture_path("A.jpg"); + + json ing = { + {"title", "legacy with thumb"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"thumbnail", {{"format", "image/jpeg"}, {"identifier", "thumb.jpg"}}}, + }; + fs::path folder = + write_legacy_folder("caseB", ing, nullptr, &thumb_src, "thumb.jpg"); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // POSITIVE: base_path set to the folder; the relative "thumb.jpg" resolves. + { + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + std::ifstream src(asset, std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", src)); + EXPECT_NO_THROW(sign_and_read_ingredients(builder, asset, "caseB-ok")); + } + + // COUNTER: no base_path. The relative thumbnail cannot be found, so signing + // (which serializes resources) must fail. + { + auto builder = c2pa::Builder(manifest); + std::ifstream src(asset, std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", src)); + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("caseB-fail") / "out.jpg"; + EXPECT_ANY_THROW(builder.sign(asset, out, signer)) + << "without set_base_path the relative thumbnail must be unresolved"; + } +} + +// Two self-contained folders loop with per-folder base_path and produce two ingredients. +TEST_F(LegacyFolderIngredient, MultipleCaseA_Loop) { + auto asset = c2pa_test::get_fixture_path("A.jpg"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + struct Spec { std::string name; std::string title; std::string rel; }; + std::vector specs = { + {"m-parent", "first legacy", "parentOf"}, + {"m-comp", "second legacy", "componentOf"}, + }; + + auto builder = c2pa::Builder(manifest); + for (const auto &s : specs) { + auto seed = sign_seed(asset, s.name); + json ing = { + {"title", s.title}, + {"format", "image/jpeg"}, + {"relationship", s.rel}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path folder = write_legacy_folder(s.name, ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, folder / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + // Each folder is self-contained: set base_path per folder right before + // adding, so its manifest_data.c2pa resolves. (base_path is global and + // last-wins, but here we resolve eagerly at add time, one folder at a + // time, so the sequential override is correct.) + builder.set_base_path(folder.string()); + std::ifstream asset_stream(folder / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + } + + auto ingredients = sign_and_read_ingredients(builder, asset, "m-out"); + ASSERT_EQ(ingredients.size(), 2u); + std::vector titles = {ingredients[0]["title"], ingredients[1]["title"]}; + EXPECT_NE(std::find(titles.begin(), titles.end(), "first legacy"), titles.end()); + EXPECT_NE(std::find(titles.begin(), titles.end(), "second legacy"), titles.end()); +} + +// Two folders sharing a thumbnail name collide under one global base_path, so add_resource with unique identifiers is the fix. +TEST_F(LegacyFolderIngredient, MultipleBasePathCollisionFixedByAddResource) { + auto asset = c2pa_test::get_fixture_path("A.jpg"); + auto thumb_a = c2pa_test::get_fixture_path("A.jpg"); + auto thumb_c = c2pa_test::get_fixture_path("C.jpg"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // Two folders, each with its own thumbnail referenced by the SAME relative + // name but living in different folders. + auto make_ing = [](const std::string &title) { + return json{ + {"title", title}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"thumbnail", {{"format", "image/jpeg"}, {"identifier", "thumb.jpg"}}}, + }; + }; + fs::path folder_a = + write_legacy_folder("collide-a", make_ing("ing A"), nullptr, &thumb_a, "thumb.jpg"); + fs::path folder_c = + write_legacy_folder("collide-c", make_ing("ing C"), nullptr, &thumb_c, "thumb.jpg"); + std::string ing_a = c2pa_test::read_text_file(folder_a / "ingredient.json"); + std::string ing_c = c2pa_test::read_text_file(folder_c / "ingredient.json"); + + // GOTCHA: one global base_path. Whichever folder it points at resolves; + // the other ingredient's identically-named thumbnail resolves to the WRONG + // bytes or fails. base_path is global, so this cannot serve both folders. + { + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder_a.string()); // only folder_a + std::ifstream s1(asset, std::ios::binary); + std::ifstream s2(asset, std::ios::binary); + builder.add_ingredient(ing_a, "image/jpeg", s1); + builder.add_ingredient(ing_c, "image/jpeg", s2); + // Both ingredients now reference "thumb.jpg" resolved from folder_a, so + // folder_c's distinct thumbnail is lost. We assert the collision is + // observable: signing succeeds but both thumbnails came from folder_a, + // OR (depending on internal dedup) is simply not what the user intended. + // The point of the test is the FIX below, so we only require that the + // naive approach cannot distinguish the two sources. + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("collide-naive") / "out.jpg"; + // Not asserting throw/no-throw here: the defect is silent (wrong bytes), + // which is exactly why the add_resource fix is recommended. + EXPECT_NO_THROW(builder.sign(asset, out, signer)); + } + + // FIX: give each thumbnail a UNIQUE identifier and inline its bytes with + // add_resource, so neither ingredient depends on a global base_path. + { + auto builder = c2pa::Builder(manifest); + + json ia = make_ing("ing A"); + ia["thumbnail"]["identifier"] = "thumb_a.jpg"; + json ic = make_ing("ing C"); + ic["thumbnail"]["identifier"] = "thumb_c.jpg"; + + std::ifstream ta(folder_a / "thumb.jpg", std::ios::binary); + std::ifstream tc(folder_c / "thumb.jpg", std::ios::binary); + builder.add_resource("thumb_a.jpg", ta); + builder.add_resource("thumb_c.jpg", tc); + + std::ifstream s1(asset, std::ios::binary); + std::ifstream s2(asset, std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ia.dump(), "image/jpeg", s1)); + ASSERT_NO_THROW(builder.add_ingredient(ic.dump(), "image/jpeg", s2)); + + auto ingredients = sign_and_read_ingredients(builder, asset, "collide-fixed"); + EXPECT_EQ(ingredients.size(), 2u) + << "inlining resources lets both distinct-thumbnail ingredients resolve"; + } +} + +// A legacy folder ingredient and a modern archive ingredient both go on one Builder. +TEST_F(LegacyFolderIngredient, MixLegacyAndModernArchiveOnOneBuilder) { + auto src = c2pa_test::get_fixture_path("A.jpg"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // Legacy folder ingredient (Case A): manifest_data.c2pa + signed asset. + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "mix-legacy"); + json legacy_ing = { + {"title", "legacy ingredient"}, + {"format", "image/jpeg"}, + {"relationship", "parentOf"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path folder = + write_legacy_folder("mix-legacy", legacy_ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, folder / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string legacy_json = c2pa_test::read_text_file(folder / "ingredient.json"); + + // Modern dedicated ingredient archive built from C.jpg. + std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); + { + auto ab = c2pa::Builder(manifest); + ab.add_ingredient( + R"({"title": "modern ingredient", "relationship": "componentOf", "label": "ing-modern"})", + c2pa_test::get_fixture_path("C.jpg")); + ab.write_ingredient_archive("ing-modern", archive); + } + + // One builder, both routes. + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + std::ifstream asset_stream(folder / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(legacy_json, "image/jpeg", asset_stream)); + archive.seekg(0); + ASSERT_NO_THROW(builder.add_ingredient_from_archive(archive)); + + auto ingredients = sign_and_read_ingredients(builder, src, "mix-out"); + ASSERT_EQ(ingredients.size(), 2u); + std::vector titles = {ingredients[0]["title"], ingredients[1]["title"]}; + EXPECT_NE(std::find(titles.begin(), titles.end(), "legacy ingredient"), titles.end()); + EXPECT_NE(std::find(titles.begin(), titles.end(), "modern ingredient"), titles.end()); +} + +// Three legacy folder ingredients plus one modern archive go on one Builder. +TEST_F(LegacyFolderIngredient, MixMultipleLegacyAndOneModern) { + auto src = c2pa_test::get_fixture_path("A.jpg"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(manifest); + add_legacy_caseA(builder, "m1-legacy-a", "legacy A"); + add_legacy_caseA(builder, "m1-legacy-b", "legacy B"); + add_legacy_caseA(builder, "m1-legacy-c", "legacy C"); + auto a1 = add_modern_archive(builder, manifest, "ing-modern", "modern X", + c2pa_test::get_fixture_path("C.jpg")); + + auto ingredients = sign_and_read_ingredients(builder, src, "m1-out"); + ASSERT_EQ(ingredients.size(), 4u); + expect_titles(ingredients, {"legacy A", "legacy B", "legacy C", "modern X"}); +} + +// Two legacy folder ingredients plus two modern archives go on one Builder. +TEST_F(LegacyFolderIngredient, MixMultipleLegacyAndMultipleModern) { + auto src = c2pa_test::get_fixture_path("A.jpg"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(manifest); + add_legacy_caseA(builder, "m2-legacy-a", "legacy A"); + add_legacy_caseA(builder, "m2-legacy-b", "legacy B"); + auto a1 = add_modern_archive(builder, manifest, "ing-modern-1", "modern X", + c2pa_test::get_fixture_path("C.jpg")); + auto a2 = add_modern_archive(builder, manifest, "ing-modern-2", "modern Y", + c2pa_test::get_fixture_path("sample1.gif")); + + auto ingredients = sign_and_read_ingredients(builder, src, "m2-out"); + ASSERT_EQ(ingredients.size(), 4u); + expect_titles(ingredients, {"legacy A", "legacy B", "modern X", "modern Y"}); +} + +// Loads a legacy folder fixture (ingredient.json + manifest_data.c2pa + self#jumbf thumbnail). +TEST_F(LegacyFolderIngredient, LegacyIngredientFolderLoading) { + fs::path folder = + c2pa_test::get_fixture_path("ingredient-legacy-folder-migration"); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + + std::ifstream asset_stream(c2pa_test::get_fixture_path("C.jpg"), std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + + auto ingredients = + sign_and_read_ingredients(builder, c2pa_test::get_fixture_path("A.jpg"), + "real-legacy-out"); + ASSERT_EQ(ingredients.size(), 1u); + EXPECT_EQ(ingredients[0]["title"], "C.jpg"); + EXPECT_EQ(ingredients[0]["relationship"], "componentOf"); + // The legacy manifest_data is carried: the ingredient references an active + // manifest reconstituted from manifest_data.c2pa. + EXPECT_TRUE(ingredients[0].contains("active_manifest")) + << "legacy manifest_data.c2pa should be resolved via base_path"; +} + +// Loads the legacy folder fixture with no asset stream by injecting the ingredient into the definition and carrying its store with add_resource. +TEST_F(LegacyFolderIngredient, LegacyIngredientFolderLoadingNoAssetStream) { + fs::path folder = + c2pa_test::get_fixture_path("ingredient-legacy-folder-migration"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // The ingredient fields, including a manifest_data ref, come from the folder's + // ingredient.json. + json ingredient = json::parse(c2pa_test::read_text_file(folder / "ingredient.json")); + ASSERT_TRUE(ingredient.contains("manifest_data")); + + // Fill validation_results only when the ingredient.json lacks it. Older folders + // may already carry the field (this fixture does); read it from the store + // otherwise, since the definition-injection route cannot derive it. + if (!ingredient.contains("validation_results")) { + std::ifstream store_in(folder / "manifest_data.c2pa", std::ios::binary); + c2pa::Reader store_reader("application/c2pa", store_in); + ingredient["validation_results"] = + json::parse(store_reader.json())["validation_results"]; + } + + // Inject the ingredient into the definition, then carry the store bytes under + // the matching identifier. No asset stream is added. + json def = json::parse(manifest); + def["ingredients"] = json::array({ingredient}); + auto builder = c2pa::Builder(def.dump()); + + std::ifstream store_res(folder / "manifest_data.c2pa", std::ios::binary); + ASSERT_NO_THROW(builder.add_resource("manifest_data.c2pa", store_res)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("real-legacy-noasset-out") / "out.jpg"; + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), out, signer)); + + auto reader = c2pa::Reader(out); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + EXPECT_EQ(ingredients[0]["title"], "C.jpg"); + // Provenance is carried even with no asset stream. + EXPECT_TRUE(ingredients[0].contains("active_manifest")) + << "injected manifest_data.c2pa should be carried via add_resource"; +} + +// Re-archives the legacy folder fixture into an ingredient archive, then adds that archive to a fresh Builder. +TEST_F(LegacyFolderIngredient, LegacyIngredientFolderLoadingReArchiveThenAdd) { + fs::path folder = + c2pa_test::get_fixture_path("ingredient-legacy-folder-migration"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + // Give the ingredient a label so write_ingredient_archive can name it. + json ingredient = json::parse(c2pa_test::read_text_file(folder / "ingredient.json")); + ingredient["label"] = "ing-mig"; + + // Load the legacy folder via base_path with a carrier asset, then emit it in + // the modern archive format. + std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); + { + auto b = c2pa::Builder(manifest); + b.set_base_path(folder.string()); + std::ifstream asset(c2pa_test::get_fixture_path("C.jpg"), std::ios::binary); + ASSERT_NO_THROW(b.add_ingredient(ingredient.dump(), "image/jpeg", asset)); + ASSERT_NO_THROW(b.write_ingredient_archive("ing-mig", archive)); + } + + // The archive bundles the manifest store, so a fresh Builder loads it with no + // base_path and keeps the provenance. + archive.seekg(0); + auto builder = c2pa::Builder(manifest); + ASSERT_NO_THROW(builder.add_ingredient_from_archive(archive)); + + auto ingredients = + sign_and_read_ingredients(builder, c2pa_test::get_fixture_path("A.jpg"), + "real-legacy-rearchive-out"); + ASSERT_EQ(ingredients.size(), 1u); + EXPECT_EQ(ingredients[0]["title"], "C.jpg"); + EXPECT_TRUE(ingredients[0].contains("active_manifest")) + << "re-archived legacy ingredient should keep its provenance"; +} + +// manifest_data resolves eagerly at add_ingredient, so deleting the directory +// before sign still signs and carries provenance. +TEST_F(LegacyFolderIngredient, ManifestDataDirectoryDeletableAfterAdd) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "eager-del"); + json ing = { + {"title", "eager legacy"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path folder = write_legacy_folder("eager-del", ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, folder / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + std::ifstream asset_stream(folder / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + asset_stream.close(); + + // Delete the whole directory AFTER add_ingredient, BEFORE sign. + fs::remove_all(folder); + ASSERT_FALSE(fs::exists(folder)); + + // Eager resolution: manifest_data is already in the Builder, so sign succeeds + // and the ingredient keeps its provenance despite the directory being gone. + auto ingredients = sign_and_read_ingredients( + builder, c2pa_test::get_fixture_path("A.jpg"), "eager-del-out"); + ASSERT_EQ(ingredients.size(), 1u); + EXPECT_TRUE(ingredients[0].contains("active_manifest")) + << "manifest_data resolved eagerly; directory deletable before sign"; +} + +// A thumbnail resolves lazily at sign, so deleting the directory before sign +// makes sign fail; deleting after sign is fine. +TEST_F(LegacyFolderIngredient, ThumbnailDirectoryMustSurviveUntilSign) { + auto asset = c2pa_test::get_fixture_path("A.jpg"); + auto thumb_src = c2pa_test::get_fixture_path("A.jpg"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto make_ing = []() { + return json{ + {"title", "lazy thumb"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"thumbnail", {{"format", "image/jpeg"}, {"identifier", "thumb.jpg"}}}, + }; + }; + + // Negative test: delete the directory before sign. The lazy thumbnail cannot be + // read at sign time, so sign must throw. + { + fs::path folder = + write_legacy_folder("lazy-del", make_ing(), nullptr, &thumb_src, "thumb.jpg"); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + std::ifstream src(asset, std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", src)); + src.close(); + + fs::remove_all(folder); + ASSERT_FALSE(fs::exists(folder)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("lazy-del-fail") / "out.jpg"; + EXPECT_ANY_THROW(builder.sign(asset, out, signer)) + << "thumbnail resolved lazily; deleting the directory before sign must fail"; + } + + // Postive test: keep the directory until sign, then it is safe to delete. + { + fs::path folder = + write_legacy_folder("lazy-keep", make_ing(), nullptr, &thumb_src, "thumb.jpg"); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + std::ifstream src(asset, std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", src)); + + EXPECT_NO_THROW(sign_and_read_ingredients(builder, asset, "lazy-keep-ok")); + // Directory survived until sign; deleting now is fine. + fs::remove_all(folder); + } +} + +// The Builder does not de-duplicate. Adding the same directory ingredient +// twice yields two ingredients in the signed manifest. +TEST_F(LegacyFolderIngredient, NoDeduplicationSameDirectoryAddedTwice) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "dedup"); + json ing = { + {"title", "dup legacy"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path folder = write_legacy_folder("dedup", ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, folder / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + // Add the identical ingredient twice. + std::ifstream a1(folder / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", a1)); + std::ifstream a2(folder / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", a2)); + + auto ingredients = sign_and_read_ingredients( + builder, c2pa_test::get_fixture_path("A.jpg"), "dedup-out"); + EXPECT_EQ(ingredients.size(), 2u) + << "Builder does not de-duplicate; two adds produce two ingredients"; +} + +// A corrupt manifest_data.c2pa fails at sign, not at add_ingredient, and +// with a different message than the missing/unresolved case (ResourceNotFound). +TEST_F(LegacyFolderIngredient, CorruptManifestDataFailsAtSign) { + json ing = { + {"title", "corrupt legacy"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + // Garbage bytes standing in for a real manifest store. + std::vector garbage(512, 0xAB); + fs::path folder = write_legacy_folder("corrupt", ing, &garbage, nullptr, ""); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(manifest); + builder.set_base_path(folder.string()); + std::ifstream asset(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + // The corruption is not detected at add time. + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("corrupt-out") / "out.jpg"; + std::string msg; + try { + builder.sign(c2pa_test::get_fixture_path("A.jpg"), out, signer); + FAIL() << "sign should throw on a corrupt manifest_data.c2pa"; + } catch (const std::exception &e) { + msg = e.what(); + } + // Capture the actual message for the docs; corruption should not read as a + // plain "not found". + std::cerr << "[corrupt manifest_data sign error] " << msg << std::endl; + EXPECT_FALSE(msg.empty()); + EXPECT_EQ(msg.find("ResourceNotFound"), std::string::npos) + << "corrupt store should fail with a verify/JUMBF error, not ResourceNotFound; got: " + << msg; +} + +// An unrelated asset stream can mask an unresolved manifest_data reference. +// ingredient.json declares manifest_data but base_path is NOT set, so the ref cannot +// resolve from disk. Observe whether an unrelated asset stream lets sign succeed +// (masking) or still throws. +TEST_F(LegacyFolderIngredient, UnrelatedAssetStreamMasksUnresolvedRef) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "mask"); + json ing = { + {"title", "masking legacy"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path folder = write_legacy_folder("mask", ing, &seed.manifest_bytes, nullptr, ""); + std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + + auto builder = c2pa::Builder(manifest); + // NO set_base_path: the declared manifest_data cannot resolve from disk. + // Pass an unrelated asset stream (C.jpg, not the store's bound asset). + std::ifstream unrelated(c2pa_test::get_fixture_path("C.jpg"), std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", unrelated)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("mask-out") / "out.jpg"; + bool threw = false; + std::string msg; + try { + builder.sign(c2pa_test::get_fixture_path("A.jpg"), out, signer); + } catch (const std::exception &e) { + threw = true; + msg = e.what(); + } + // Document the real behavior. If sign succeeded, the unrelated stream masked the + // unresolved manifest_data reference (the pitfall); if it threw, record why. + std::cerr << "[unrelated-asset masking] threw=" << (threw ? "yes" : "no") + << " msg=" << msg << std::endl; + if (!threw) { + auto reader = c2pa::Reader(out); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto ingredients = parsed["manifests"][active]["ingredients"]; + EXPECT_EQ(ingredients.size(), 1u) + << "unrelated asset stream masked the unresolved manifest_data and signed"; + } + SUCCEED(); +} + +TEST_F(LegacyFolderIngredient, LinkParentOfToOpened) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "link-parent"); + json ing = { + {"title", "link-parent.jpg"}, + {"format", "image/jpeg"}, + {"relationship", "parentOf"}, + {"label", "dir-parent"}, // primary ingredientIds lookup key + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path dir = write_legacy_folder("link-parent", ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, dir / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(dir / "ingredient.json"); + + json manifest = { + {"claim_generator_info", + json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + {{"label", "c2pa.actions"}, + {"data", {{"actions", json::array({ + {{"action", "c2pa.opened"}, + {"parameters", {{"ingredientIds", json::array({"dir-parent"})}}}}, + })}}}}, + })}, + }; + + auto builder = c2pa::Builder(manifest.dump()); + builder.set_base_path(dir.string()); + std::ifstream asset_stream(dir / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("link-parent-out") / "out.jpg"; + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), out, signer)); + + auto reader = c2pa::Reader(out); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + bool found = false; + for (auto &assertion : parsed["manifests"][active]["assertions"]) { + if (assertion["label"] != "c2pa.actions.v2" && + assertion["label"] != "c2pa.actions") continue; + for (auto &a : assertion["data"]["actions"]) { + if (a["action"] != "c2pa.opened") continue; + if (a.contains("parameters") && a["parameters"].contains("ingredients")) { + auto &ings = a["parameters"]["ingredients"]; + ASSERT_GE(ings.size(), 1u); + ASSERT_TRUE(ings[0].contains("url")); + std::string url = ings[0]["url"]; + EXPECT_NE(url.find("c2pa.ingredient"), std::string::npos) + << "c2pa.opened should resolve to an ingredient assertion, got " << url; + found = true; + } + } + } + EXPECT_TRUE(found) << "linked c2pa.opened action not found in signed output"; +} + +TEST_F(LegacyFolderIngredient, LinkComponentOfToPlaced) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "link-comp"); + json ing = { + {"title", "link-comp.jpg"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"label", "dir-comp"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path dir = write_legacy_folder("link-comp", ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, dir / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(dir / "ingredient.json"); + + json manifest = { + {"claim_generator_info", + json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + {{"label", "c2pa.actions"}, + {"data", {{"actions", json::array({ + {{"action", "c2pa.placed"}, + {"parameters", {{"ingredientIds", json::array({"dir-comp"})}}}}, + })}}}}, + })}, + }; + + auto builder = c2pa::Builder(manifest.dump()); + builder.set_base_path(dir.string()); + std::ifstream asset_stream(dir / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("link-comp-out") / "out.jpg"; + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), out, signer)); + + auto reader = c2pa::Reader(out); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + bool found = false; + for (auto &assertion : parsed["manifests"][active]["assertions"]) { + if (assertion["label"] != "c2pa.actions.v2" && + assertion["label"] != "c2pa.actions") continue; + for (auto &a : assertion["data"]["actions"]) { + if (a["action"] != "c2pa.placed") continue; + if (a.contains("parameters") && a["parameters"].contains("ingredients")) { + auto &ings = a["parameters"]["ingredients"]; + ASSERT_GE(ings.size(), 1u); + ASSERT_TRUE(ings[0].contains("url")); + std::string url = ings[0]["url"]; + EXPECT_NE(url.find("c2pa.ingredient"), std::string::npos) + << "c2pa.placed should resolve to an ingredient assertion, got " << url; + found = true; + } + } + } + EXPECT_TRUE(found) << "linked c2pa.placed action not found in signed output"; +} + +TEST_F(LegacyFolderIngredient, LinkInputToToEdited) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "link-input"); + json ing = { + {"title", "link-input.jpg"}, + {"format", "image/jpeg"}, + {"relationship", "inputTo"}, + {"label", "dir-input"}, + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path dir = write_legacy_folder("link-input", ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, dir / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(dir / "ingredient.json"); + + json manifest = { + {"claim_generator_info", + json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + {{"label", "c2pa.actions"}, + {"data", {{"actions", json::array({ + {{"action", "c2pa.edited"}, + {"parameters", {{"ingredientIds", json::array({"dir-input"})}}}}, + })}}}}, + })}, + }; + + auto builder = c2pa::Builder(manifest.dump()); + builder.set_base_path(dir.string()); + std::ifstream asset_stream(dir / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("link-input-out") / "out.jpg"; + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), out, signer)); + + auto reader = c2pa::Reader(out); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + bool found = false; + for (auto &assertion : parsed["manifests"][active]["assertions"]) { + if (assertion["label"] != "c2pa.actions.v2" && + assertion["label"] != "c2pa.actions") continue; + for (auto &a : assertion["data"]["actions"]) { + if (a["action"] != "c2pa.edited") continue; + if (a.contains("parameters") && a["parameters"].contains("ingredients")) { + auto &ings = a["parameters"]["ingredients"]; + ASSERT_GE(ings.size(), 1u); + ASSERT_TRUE(ings[0].contains("url")); + std::string url = ings[0]["url"]; + EXPECT_NE(url.find("c2pa.ingredient"), std::string::npos) + << "c2pa.edited should resolve to an ingredient assertion, got " << url; + found = true; + } + } + } + EXPECT_TRUE(found) << "linked c2pa.edited action not found in signed output"; +} + +// After signing, the `label` used for action linking is not preserved on the +// read-back ingredient: the SDK consumes it as the linking key and rewrites it. +// An explicit `instance_id`, by contrast, stays in the ingredient data. +TEST_F(LegacyFolderIngredient, LabelNotPreservedButInstanceIdIs) { + auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "label-persist"); + const std::string my_label = "dir-link-label"; + const std::string my_iid = "xmp:iid:11111111-2222-3333-4444-555555555555"; + json ing = { + {"title", "label-persist.jpg"}, + {"format", "image/jpeg"}, + {"relationship", "componentOf"}, + {"label", my_label}, + {"instance_id", my_iid}, // explicit, caller-controlled + {"manifest_data", + {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, + }; + fs::path dir = write_legacy_folder("label-persist", ing, &seed.manifest_bytes, nullptr, ""); + fs::copy_file(seed.signed_asset, dir / "asset.jpg", + fs::copy_options::overwrite_existing); + std::string ing_json = c2pa_test::read_text_file(dir / "ingredient.json"); + + json manifest = { + {"claim_generator_info", + json::array({{{"name", "c2pa-test"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + {{"label", "c2pa.actions"}, + {"data", {{"actions", json::array({ + {{"action", "c2pa.placed"}, + {"parameters", {{"ingredientIds", json::array({my_label})}}}}, + })}}}}, + })}, + }; + + auto builder = c2pa::Builder(manifest.dump()); + builder.set_base_path(dir.string()); + std::ifstream asset_stream(dir / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + + auto signer = c2pa_test::create_test_signer(); + fs::path out = make_temp_dir("label-persist-out") / "out.jpg"; + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), out, signer)); + + auto reader = c2pa::Reader(out); + auto parsed = json::parse(reader.json()); + std::string active = parsed["active_manifest"]; + auto &ingredients = parsed["manifests"][active]["ingredients"]; + ASSERT_EQ(ingredients.size(), 1u); + const auto &out_ing = ingredients[0]; + + std::string read_label = out_ing.value("label", std::string("")); + std::string read_iid = out_ing.value("instance_id", std::string("")); + std::cerr << "[label persistence] label=" << read_label + << " instance_id=" << read_iid << std::endl; + + // The linking label is not carried through verbatim onto the read-back ingredient. + EXPECT_NE(read_label, my_label) + << "the linking label should not survive verbatim on the signed ingredient"; + // The explicit instance_id stays in the ingredient data. + EXPECT_EQ(read_iid, my_iid) + << "an explicit instance_id should be preserved on the signed ingredient"; +} From 4ae39fdf4a1cd11442f5db4825b3c68a4cd9b162 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 13 Jul 2026 08:21:09 -0700 Subject: [PATCH 04/11] fix: docs --- docs/faqs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/faqs.md b/docs/faqs.md index 623e303c..2440038b 100644 --- a/docs/faqs.md +++ b/docs/faqs.md @@ -121,5 +121,5 @@ These file-based functions not attached to an API-exposed object, `read_file`, ` | Removed function | Equivalent | | --- | --- | | `read_file(path)` | `Reader::from_asset(ctx, format, stream)` then `Reader::json()`. Pull binary resources with `Reader::get_resource(uri, dest)`. | -| `read_ingredient_file(path, data_dir)` | `Builder::add_ingredient(ingredient_json, source_path)`, which adds ingredients to the active Builder. To recover the formed ingredient JSON (if needed) and its resources, archive the working store and read it back, or move the ingredient with the dedicated `write_ingredient_archive` / `add_ingredient_from_archive` APIs. There is no single-call replacement, but the full behavior (extract the ingredient JSON, thumbnail, and manifest_data to a directory, then reuse that directory to sign a carrier, including several ingredients at once) is a short reimplementation shown with a C++ example in [Migrating from the removed read_ingredient_file](selective-manifests.md#migrating-from-the-removed-read_ingredient_file). See also [Extracting ingredients from a working store](selective-manifests.md#extracting-ingredients-from-a-working-store). | +| `read_ingredient_file(path, data_dir)` | `Builder::add_ingredient(ingredient_json, source_path)`, which adds ingredients to the active Builder. To recover the formed ingredient JSON (if needed) and its resources, archive the working store and read it back, or move the ingredient with the dedicated `write_ingredient_archive` / `add_ingredient_from_archive` APIs. There is no single-call replacement, but the full behavior (extract the ingredient JSON, thumbnail, and manifest_data to a directory, then reuse that directory to sign a carrier, including several ingredients at once) can be reimplemented as shown in examples from [Migrating from the removed read_ingredient_file](selective-manifests.md#migrating-from-the-removed-read_ingredient_file). See also [Extracting ingredients from a working store](selective-manifests.md#extracting-ingredients-from-a-working-store). | | `sign_file(src, dst, manifest, SignerInfo*, data_dir)` | `Builder::sign(source_path, dest_path, signer)` with a `Signer`. | From 654b80bf1ad200a32a7670642ff42ce1df9199f2 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 13 Jul 2026 08:23:58 -0700 Subject: [PATCH 05/11] fix: docs --- docs/faqs.md | 2 +- tests/migrations.test.cpp | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/faqs.md b/docs/faqs.md index 2440038b..23ab6fde 100644 --- a/docs/faqs.md +++ b/docs/faqs.md @@ -121,5 +121,5 @@ These file-based functions not attached to an API-exposed object, `read_file`, ` | Removed function | Equivalent | | --- | --- | | `read_file(path)` | `Reader::from_asset(ctx, format, stream)` then `Reader::json()`. Pull binary resources with `Reader::get_resource(uri, dest)`. | -| `read_ingredient_file(path, data_dir)` | `Builder::add_ingredient(ingredient_json, source_path)`, which adds ingredients to the active Builder. To recover the formed ingredient JSON (if needed) and its resources, archive the working store and read it back, or move the ingredient with the dedicated `write_ingredient_archive` / `add_ingredient_from_archive` APIs. There is no single-call replacement, but the full behavior (extract the ingredient JSON, thumbnail, and manifest_data to a directory, then reuse that directory to sign a carrier, including several ingredients at once) can be reimplemented as shown in examples from [Migrating from the removed read_ingredient_file](selective-manifests.md#migrating-from-the-removed-read_ingredient_file). See also [Extracting ingredients from a working store](selective-manifests.md#extracting-ingredients-from-a-working-store). | +| `read_ingredient_file(path, data_dir)` | `Builder::add_ingredient(ingredient_json, source_path)`, which adds ingredients to the active Builder. To recover the formed ingredient JSON (if needed) and its resources, archive the working store and read it back, or move the ingredient with the dedicated `write_ingredient_archive` / `add_ingredient_from_archive` APIs. There is no single-call replacement; but the full behavior (extract the ingredient JSON, thumbnail, and manifest_data to a directory, then reuse that directory to sign an asset using a Builder) can be reimplemented as shown in examples from [Migrating from the removed read_ingredient_file](selective-manifests.md#migrating-from-the-removed-read_ingredient_file). See also [Extracting ingredients from a working store](selective-manifests.md#extracting-ingredients-from-a-working-store). | | `sign_file(src, dst, manifest, SignerInfo*, data_dir)` | `Builder::sign(source_path, dest_path, signer)` with a `Signer`. | diff --git a/tests/migrations.test.cpp b/tests/migrations.test.cpp index 83adaa6a..69143f3d 100644 --- a/tests/migrations.test.cpp +++ b/tests/migrations.test.cpp @@ -153,7 +153,7 @@ TEST_F(LegacyApiMigrationTest, ReadFile_WithDataDir_ExtractResources) { // 3. Reader(ctx, "application/c2pa", buf) reads it back, and // Reader::get_resource(uri, path) writes the thumbnail / manifest_data to disk, // 4. Builder(ctx, {"ingredients": [...]}) + set_base_path(dir) + sign() reuses the -// extracted directory to sign a carrier. +// extracted directory to sign an asset. // This is the flow the ingredient-from-file example demonstrates, and the two tests // below exercise it end to end. // Notes: The identifier fields in the recovered ingredient JSON point at internal JUMBF @@ -231,7 +231,7 @@ TEST_F(LegacyApiMigrationTest, ReadIngredientFile_ExtractToDirThenSignCarrier) { } // --- Sign phase: load the extracted ingredient from output_dir and embed it into a - // carrier asset, resolving the thumbnail / manifest_data files via set_base_path. + // (carrier) asset, resolving the thumbnail / manifest_data files via set_base_path. json sign_manifest = {{"ingredients", json::array({ingredient})}}; auto builder = c2pa::Builder(context, sign_manifest.dump()); builder.set_base_path(output_dir.string()); @@ -256,7 +256,7 @@ TEST_F(LegacyApiMigrationTest, ReadIngredientFile_ExtractToDirThenSignCarrier) { // Extract two ingredients from the same working store into the SAME directory // and confirm their thumbnail and manifest_data files do not overwrite each other, then sign -// a carrier with both and confirm both survive the round-trip. Fixed names like +// an asset with both and confirm both survive the round-trip. Fixed names like // "thumbnail.jpg" and "manifest_data.c2pa" would fail this test; instance_id-derived // names, which are unique per ingredient, pass it. TEST_F(LegacyApiMigrationTest, ReadIngredientFile_MultipleIngredientsSameStoreNoCollision) { @@ -1124,8 +1124,8 @@ TEST_F(LegacyFolderIngredient, LegacyIngredientFolderLoadingReArchiveThenAdd) { json ingredient = json::parse(c2pa_test::read_text_file(folder / "ingredient.json")); ingredient["label"] = "ing-mig"; - // Load the legacy folder via base_path with a carrier asset, then emit it in - // the modern archive format. + // Load the legacy folder via base_path with a (carrier) asset, + // then emit it in the modern archive format. std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); { auto b = c2pa::Builder(manifest); From 47c7a3d948bc537b306ae679da1008c51c5c2158 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 13 Jul 2026 08:31:30 -0700 Subject: [PATCH 06/11] fix: docs --- tests/migrations.test.cpp | 72 +++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/tests/migrations.test.cpp b/tests/migrations.test.cpp index 69143f3d..bb84e6ea 100644 --- a/tests/migrations.test.cpp +++ b/tests/migrations.test.cpp @@ -146,8 +146,8 @@ TEST_F(LegacyApiMigrationTest, ReadFile_WithDataDir_ExtractResources) { // What it did: read an asset, returned the formed ingredient JSON string, and // wrote the ingredient's binary resources (thumbnail, and any manifest data) to // data_dir. -// Current API: there is no single-call replacement, but the behavior is a short -// reimplementation on top of Builder/Reader: +// Current API: there is no single-call replacement, but the behavior can be +// reimplemented on top of Builder/Reader: // 1. add_ingredient(json, source_path) forms the ingredient in a working store, // 2. write_ingredient_archive(id, buf) archives just that ingredient, // 3. Reader(ctx, "application/c2pa", buf) reads it back, and @@ -706,17 +706,23 @@ class LegacyFolderIngredient : public ::testing::Test { return parsed["manifests"][active]["ingredients"]; } - // Build a self-contained legacy Case-A folder (ingredient.json declaring a - // manifest_data ref + manifest_data.c2pa + the signed asset it binds to) and - // add it to `builder` via the documented Case-A route. Returns nothing; the - // ingredient is appended to the builder. - void add_legacy_caseA(c2pa::Builder &builder, const std::string &name, - const std::string &title) { + // Build a self-contained legacy folder (ingredient.json declaring a + // manifest_data ref + manifest_data.c2pa + the signed asset it binds to) on + // a throwaway builder, archive it, and merge it into `builder` via + // add_ingredient_from_archive. Keeping set_base_path scoped to the + // throwaway builder (rather than calling it on the shared `builder`) + // matters: base_path is retained as builder-level state, and merging an + // archive into a builder that has it set routes the archive's resources + // through a disk-write path keyed on their raw (colon-bearing) JUMBF URIs, + // which fails on Windows. See docs/faqs or the migration plan for detail. + void add_legacy_folder_ingredient(c2pa::Builder &builder, const std::string &name, + const std::string &title, const std::string &label) { auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), name); json ing = { {"title", title}, {"format", "image/jpeg"}, {"relationship", "componentOf"}, + {"label", label}, {"manifest_data", {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, }; @@ -724,9 +730,18 @@ class LegacyFolderIngredient : public ::testing::Test { fs::copy_file(seed.signed_asset, folder / "asset.jpg", fs::copy_options::overwrite_existing); std::string ing_json = c2pa_test::read_text_file(folder / "ingredient.json"); - builder.set_base_path(folder.string()); - std::ifstream asset_stream(folder / "asset.jpg", std::ios::binary); - ASSERT_NO_THROW(builder.add_ingredient(ing_json, "image/jpeg", asset_stream)); + + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); + { + auto scoped = c2pa::Builder(manifest); + scoped.set_base_path(folder.string()); + std::ifstream asset_stream(folder / "asset.jpg", std::ios::binary); + ASSERT_NO_THROW(scoped.add_ingredient(ing_json, "image/jpeg", asset_stream)); + scoped.write_ingredient_archive(label, archive); + } + archive.seekg(0); + ASSERT_NO_THROW(builder.add_ingredient_from_archive(archive)); } // Build a modern dedicated ingredient archive from a fixture asset and add it @@ -969,20 +984,11 @@ TEST_F(LegacyFolderIngredient, MixLegacyAndModernArchiveOnOneBuilder) { auto src = c2pa_test::get_fixture_path("A.jpg"); auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); - // Legacy folder ingredient (Case A): manifest_data.c2pa + signed asset. - auto seed = sign_seed(c2pa_test::get_fixture_path("A.jpg"), "mix-legacy"); - json legacy_ing = { - {"title", "legacy ingredient"}, - {"format", "image/jpeg"}, - {"relationship", "parentOf"}, - {"manifest_data", - {{"format", "application/c2pa"}, {"identifier", "manifest_data.c2pa"}}}, - }; - fs::path folder = - write_legacy_folder("mix-legacy", legacy_ing, &seed.manifest_bytes, nullptr, ""); - fs::copy_file(seed.signed_asset, folder / "asset.jpg", - fs::copy_options::overwrite_existing); - std::string legacy_json = c2pa_test::read_text_file(folder / "ingredient.json"); + // One builder, both routes: neither route calls set_base_path on `builder` + // itself, since add_ingredient_from_archive merges resources into whatever + // base_path state the receiving builder already carries. + auto builder = c2pa::Builder(manifest); + add_legacy_folder_ingredient(builder, "mix-legacy", "legacy ingredient", "legacy-mix"); // Modern dedicated ingredient archive built from C.jpg. std::stringstream archive(std::ios::in | std::ios::out | std::ios::binary); @@ -993,12 +999,6 @@ TEST_F(LegacyFolderIngredient, MixLegacyAndModernArchiveOnOneBuilder) { c2pa_test::get_fixture_path("C.jpg")); ab.write_ingredient_archive("ing-modern", archive); } - - // One builder, both routes. - auto builder = c2pa::Builder(manifest); - builder.set_base_path(folder.string()); - std::ifstream asset_stream(folder / "asset.jpg", std::ios::binary); - ASSERT_NO_THROW(builder.add_ingredient(legacy_json, "image/jpeg", asset_stream)); archive.seekg(0); ASSERT_NO_THROW(builder.add_ingredient_from_archive(archive)); @@ -1015,9 +1015,9 @@ TEST_F(LegacyFolderIngredient, MixMultipleLegacyAndOneModern) { auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); auto builder = c2pa::Builder(manifest); - add_legacy_caseA(builder, "m1-legacy-a", "legacy A"); - add_legacy_caseA(builder, "m1-legacy-b", "legacy B"); - add_legacy_caseA(builder, "m1-legacy-c", "legacy C"); + add_legacy_folder_ingredient(builder, "m1-legacy-a", "legacy A", "legacy-m1-a"); + add_legacy_folder_ingredient(builder, "m1-legacy-b", "legacy B", "legacy-m1-b"); + add_legacy_folder_ingredient(builder, "m1-legacy-c", "legacy C", "legacy-m1-c"); auto a1 = add_modern_archive(builder, manifest, "ing-modern", "modern X", c2pa_test::get_fixture_path("C.jpg")); @@ -1032,8 +1032,8 @@ TEST_F(LegacyFolderIngredient, MixMultipleLegacyAndMultipleModern) { auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); auto builder = c2pa::Builder(manifest); - add_legacy_caseA(builder, "m2-legacy-a", "legacy A"); - add_legacy_caseA(builder, "m2-legacy-b", "legacy B"); + add_legacy_folder_ingredient(builder, "m2-legacy-a", "legacy A", "legacy-m2-a"); + add_legacy_folder_ingredient(builder, "m2-legacy-b", "legacy B", "legacy-m2-b"); auto a1 = add_modern_archive(builder, manifest, "ing-modern-1", "modern X", c2pa_test::get_fixture_path("C.jpg")); auto a2 = add_modern_archive(builder, manifest, "ing-modern-2", "modern Y", From 14b635685ffb9f3fc42b7ab69dadc892c80eb02c Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:32:31 -0700 Subject: [PATCH 07/11] fix: Docs 3 --- docs/selective-manifests.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index b7cd2ae4..8c9ed6a3 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -1253,11 +1253,11 @@ builder.sign(source_path, output_path, signer); #### Using `add_resource` instead of `set_base_path` -`set_base_path` is deprecated: it carries a `@deprecated` note in the C++ header. `add_resource` is its replacement, and the two sections below use it throughout. This section explains the swap once so the recipes can refer back to it. +`set_base_path` carries a `@deprecated` note in the C++ header. `add_resource` is its replacement, as it puts resource on a `Builder` object instance. -An ingredient's JSON references its (binary) resources by `identifier` (a thumbnail, and a `manifest_data` store when present). `set_base_path(dir)` resolved every identifier implicitly, by matching each name against one directory on disk. `add_resource(identifier, path)` does the same job explicitly: one call per identifier the ingredient JSON declares, naming the file to load for it. +An ingredient's JSON references its (binary) resources by an `identifier`. `set_base_path(dir)` resolved every identifier implicitly, by matching each name against one directory on disk. `add_resource(identifier, path)` does the same job explicitly: one call per identifier the ingredient JSON declares, naming the file to load for it. -The two produce the same signed result. Here is a `set_base_path` sign step: +Here is a `set_base_path` sign step: ```cpp c2pa::Builder sign_builder(context, manifest.dump()); @@ -1265,7 +1265,7 @@ sign_builder.set_base_path(dir.string()); // resolves every identifier by name sign_builder.sign(carrier_path, output_path, signer); ``` -and the equivalent with `add_resource`, registering each referenced resource for every ingredient: +And the equivalent with `add_resource`, registering each referenced resource for every ingredient: ```cpp c2pa::Builder sign_builder(context, manifest.dump()); @@ -1282,12 +1282,13 @@ for (const auto& ingredient : ingredients) { sign_builder.sign(carrier_path, output_path, signer); ``` -Register a resource for every `identifier` the ingredient JSON references, or the sign call is missing one; `set_base_path` did this implicitly by resolving names against the directory. `add_resource` also has a stream overload, `add_resource(identifier, stream)`, when the resource is already in memory rather than on disk (see [Dedicated ingredient archive APIs](#dedicated-ingredient-archive-apis)). +Register a resource for every `identifier` the ingredient JSON references, or the sign call will be missing resources (and fail signing). `set_base_path` did this implicitly by resolving names against the directory set as base path. +`add_resource` also has a stream overload, `add_resource(identifier, stream)`, when the resource is already in memory rather than on disk (see [Dedicated ingredient archive APIs](#dedicated-ingredient-archive-apis)). #### Working with ingredient directories -An ingredient can live on disk as a directory: an `ingredient.json` file, an optional `manifest_data.c2pa` manifest store, and an optional thumbnail file. You may generate such a directory (see [Migrating from `read_ingredient_file`](#migrating-from-read_ingredient_file) below) or receive one from another source. Loading it onto a `Builder` instance is the same in both cases: each `ResourceRef` `identifier` in the JSON is a path relative to the directory, resolved through `set_base_path` or registered explicitly with `add_resource` (see [Using `add_resource` instead of `set_base_path`](#using-add_resource-instead-of-set_base_path)). +A legacy ingredient could live on disk as an ingredient directory containing an `ingredient.json` file, an optional `manifest_data.c2pa` manifest store, and an optional thumbnail file. You may have generated such a directory previously (see [Migrating from `read_ingredient_file`](#migrating-from-read_ingredient_file) below) or receive one from another source. To it onto a `Builder`, the ingredient needs to be added to the Builder and resolve its resources: each `ResourceRef` `identifier` in the JSON is a path relative to the directory, resolved through `set_base_path` or registered explicitly with `add_resource` (see [Using `add_resource` instead of `set_base_path`](#using-add_resource-instead-of-set_base_path)). ```text my_ingredient/ @@ -1296,7 +1297,7 @@ my_ingredient/ .jpg # optional: a thumbnail referenced by ingredient.json ``` -The asset itself is not in the directory: the directory holds metadata and a manifest store, not the image or video the ingredient describes. You supply the asset stream separately when adding the ingredient. `ingredient.json` holds only the ingredient fields (title, format, relationship) plus `ResourceRef` entries whose `identifier` is relative to the directory: +The asset itself is not in the directory: the directory holds metadata and a manifest store, not the image or video the ingredient describes. You supply the asset stream separately when adding the ingredient. `ingredient.json` holds only the ingredient fields (title, format, relationship), plus `ResourceRef` entries whose `identifier` is relative to the directory: ```json { @@ -1314,7 +1315,7 @@ The asset itself is not in the directory: the directory holds metadata and a man } ``` -Two questions pick the route: do you still have the ingredient's asset, and what does the directory hold. The asset gates the choice first, because `add_ingredient` needs an asset stream; without it you inject the ingredient into the definition instead. +Two things decide how to re-add the ingredient: whether you still have its asset, and what the directory contains. Check the asset first — `add_ingredient` needs an asset stream, so without one you inject the ingredient straight into the definition. ```mermaid flowchart TD @@ -1334,7 +1335,7 @@ flowchart TD Sign["builder.sign(source, output, signer)"] ``` -Two details cut across every route below. The `manifest_data` and a thumbnail resolve at different moments, which determine when you can delete the directory: +`manifest_data` and the thumbnail resolve at different times, and that timing decides when you can delete the ingredient directory: ```mermaid flowchart LR From 15c7a37737a9370ceeeda1c3b98a8e95cf09a4f0 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:36:18 -0700 Subject: [PATCH 08/11] fix: Further wording fixes --- docs/selective-manifests.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 8c9ed6a3..79011bd5 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -147,7 +147,7 @@ builder.sign(source_path, output_path, signer); ### Start fresh and preserve provenance -Sometimes all existing assertions and ingredients may need to be discarded but the provenance chain should be maintained nevertheless. This is done by creating a new `Builder` with a new manifest definition and adding the original signed asset as an ingredient using `add_ingredient()`. +Sometimes you need to discard all existing assertions and ingredients while still keeping the provenance chain. Do this by creating a new `Builder` with a new manifest definition and adding the original signed asset as an ingredient using `add_ingredient()`. The function `add_ingredient()` does not copy the original's assertions into the new manifest. Instead, it stores the original's entire manifest store as opaque binary data inside the ingredient record. This means: @@ -428,7 +428,7 @@ Use `label` when defining manifests in JSON. Use `instance_id` when working prog ## Working with archives -A `Builder` represents a **working store**: a manifest that is being assembled but has not yet been signed. Archives serialize this working store (definition + resources) to a `.c2pa` binary format, allowing to save, transfer, or resume the work later. For more background on working stores and archives, see [Working stores](https://opensource.contentauthenticity.org/docs/rust-sdk/docs/working-stores). +A `Builder` represents a **working store**: a manifest that is being assembled but has not yet been signed. Archives serialize this working store (definition + resources) to a `.c2pa` binary format, so you can 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 and ingredient archives. @@ -1024,7 +1024,7 @@ This section covers the **legacy** load path: producer calls `to_archive`, signi > > **Labels baked into the archive ingredient at archive-creation time do not carry through as linking keys either.** The label must be re-asserted on the signing builder's `add_ingredient` call so action and archived ingredient properly link. -Labels are build-time linking keys only. A label, as linking key, links ingredients and actions using it together: the label identifies the link. The SDK may reassign the actual label in the signed manifest. +Labels are build-time linking keys only. An action and an ingredient that share the same label string are linked. The SDK may reassign the actual label in the signed manifest. ##### Minimal archive to action linking example @@ -1063,7 +1063,7 @@ builder.add_ingredient( })", archive_path); -// Note that a signing, the SDK may reassign the labels +// Note that at signing, the SDK may reassign the labels builder.sign(source_path, output_path, signer); ``` @@ -1253,7 +1253,7 @@ builder.sign(source_path, output_path, signer); #### Using `add_resource` instead of `set_base_path` -`set_base_path` carries a `@deprecated` note in the C++ header. `add_resource` is its replacement, as it puts resource on a `Builder` object instance. +`set_base_path` carries a `@deprecated` note in the C++ header. `add_resource` is its replacement, registering each resource directly on a `Builder` instance. An ingredient's JSON references its (binary) resources by an `identifier`. `set_base_path(dir)` resolved every identifier implicitly, by matching each name against one directory on disk. `add_resource(identifier, path)` does the same job explicitly: one call per identifier the ingredient JSON declares, naming the file to load for it. @@ -1288,7 +1288,7 @@ Register a resource for every `identifier` the ingredient JSON references, or th #### Working with ingredient directories -A legacy ingredient could live on disk as an ingredient directory containing an `ingredient.json` file, an optional `manifest_data.c2pa` manifest store, and an optional thumbnail file. You may have generated such a directory previously (see [Migrating from `read_ingredient_file`](#migrating-from-read_ingredient_file) below) or receive one from another source. To it onto a `Builder`, the ingredient needs to be added to the Builder and resolve its resources: each `ResourceRef` `identifier` in the JSON is a path relative to the directory, resolved through `set_base_path` or registered explicitly with `add_resource` (see [Using `add_resource` instead of `set_base_path`](#using-add_resource-instead-of-set_base_path)). +A legacy ingredient could live on disk as an ingredient directory containing an `ingredient.json` file, an optional `manifest_data.c2pa` manifest store, and an optional thumbnail file. You may have generated such a directory previously (see [Migrating from `read_ingredient_file`](#migrating-from-read_ingredient_file) below) or receive one from another source. To use it on a `Builder`, add the ingredient and resolve its resources: each `ResourceRef` `identifier` in the JSON is a path relative to the directory, resolved through `set_base_path` or registered explicitly with `add_resource` (see [Using `add_resource` instead of `set_base_path`](#using-add_resource-instead-of-set_base_path)). ```text my_ingredient/ @@ -1565,7 +1565,7 @@ Note that some `ingredient.json` files may already carry an `instance_id` (read When an ingredient carries both a `label` and an `instance_id`, the `label` takes precedence. The SDK checks each `ingredientIds` value against labels before instance_ids, so a value that matches a `label` resolves to that ingredient even if some other ingredient has a matching `instance_id`. The `instance_id` is consulted only for an id that matches no label. -The `label` used for linking is not preserved verbatim on the ingredient after signing. The SDK consumes it as the linking (identifying) key and rewrites it to the ingredient assertion's own label: reading the signed output back, the ingredient's `label` is an SDK-assigned label, not the string that was assigned in the `label` value. An explicit `instance_id`, by contrast, stays in the ingredient data unchanged. So if you need a stable, caller-controlled identifier that survives signing and round-trips, use `instance_id`. Use `label` to identify the link itself before signing: a label identifies a link of an ingredient to an action, but is not a stable ingredient-only identifier. +The `label` used for linking is not preserved verbatim on the ingredient after signing. The SDK consumes it as the linking (identifying) key and rewrites it to the ingredient assertion's own label: reading the signed output back, the ingredient's `label` is an SDK-assigned label, not the string that was assigned in the `label` value. An explicit `instance_id`, by contrast, stays in the ingredient data unchanged. So if you need a stable, caller-controlled identifier that survives signing and round-trips, use `instance_id`. Use `label` only to link an ingredient to an action before signing; it is not a durable identifier for the ingredient itself. ```cpp // ingredient.json declares the label the action will reference: From be044feaa06033f18aa4995dd7e97ba137cc30ef Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:38:58 -0700 Subject: [PATCH 09/11] fix: Further wording fixes --- docs/selective-manifests.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 79011bd5..efed264f 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -147,7 +147,7 @@ builder.sign(source_path, output_path, signer); ### Start fresh and preserve provenance -Sometimes you need to discard all existing assertions and ingredients while still keeping the provenance chain. Do this by creating a new `Builder` with a new manifest definition and adding the original signed asset as an ingredient using `add_ingredient()`. +Sometimes all existing assertions and ingredients must be discarded while the provenance chain is kept. This is done by creating a new `Builder` with a new manifest definition and adding the original signed asset as an ingredient using `add_ingredient()`. The function `add_ingredient()` does not copy the original's assertions into the new manifest. Instead, it stores the original's entire manifest store as opaque binary data inside the ingredient record. This means: @@ -428,7 +428,7 @@ Use `label` when defining manifests in JSON. Use `instance_id` when working prog ## Working with archives -A `Builder` represents a **working store**: a manifest that is being assembled but has not yet been signed. Archives serialize this working store (definition + resources) to a `.c2pa` binary format, so you can save, transfer, or resume the work later. For more background on working stores and archives, see [Working stores](https://opensource.contentauthenticity.org/docs/rust-sdk/docs/working-stores). +A `Builder` represents a **working store**: a manifest that is being assembled but has not yet been signed. Archives serialize this working store (definition + resources) to a `.c2pa` binary format, so the work can be saved, transferred, or resumed later. For more background on working stores and archives, see [Working stores](https://opensource.contentauthenticity.org/docs/rust-sdk/docs/working-stores). There are two distinct types of archives, sharing the same binary format but being conceptually different: builder archives and ingredient archives. @@ -1315,7 +1315,7 @@ The asset itself is not in the directory: the directory holds metadata and a man } ``` -Two things decide how to re-add the ingredient: whether you still have its asset, and what the directory contains. Check the asset first — `add_ingredient` needs an asset stream, so without one you inject the ingredient straight into the definition. +Two things decide how to re-add the ingredient: whether its asset is still available, and what the directory contains. The asset is checked first — `add_ingredient` needs an asset stream, so without one the ingredient is injected straight into the definition. ```mermaid flowchart TD @@ -1335,7 +1335,7 @@ flowchart TD Sign["builder.sign(source, output, signer)"] ``` -`manifest_data` and the thumbnail resolve at different times, and that timing decides when you can delete the ingredient directory: +`manifest_data` and the thumbnail resolve at different times, and that timing decides when the ingredient directory can be deleted: ```mermaid flowchart LR @@ -1646,7 +1646,7 @@ sign_builder.sign(carrier_path, output_path, signer); To reproduce the full `read_ingredient_file` directory behavior for multiple ingredients, run the extract block once per source and append each resulting `ingredient` object to the `ingredients` array before signing. Deriving the file names from each ingredient's `instance_id` keeps them unique, so the extracted resources for every ingredient coexist in one directory without overwriting each other. To sign without `set_base_path` (deprecated), register each resource with `add_resource` instead (see [Using `add_resource` instead of `set_base_path`](#using-add_resource-instead-of-set_base_path)); to load a directory you received rather than generated, see [Working with ingredient directories](#working-with-ingredient-directories). -An ingredient formed through `add_ingredient` from an asset stream or path always has an `instance_id`: the SDK takes the asset's XMP `instanceID` when present, otherwise it synthesizes one of the form `xmp:iid:`. The stem helper splits on the last colon, so both the XMP form (`xmp.iid:`) and the synthesized form yield the UUID. Two edge cases justify the `"ingredient"` fallback in `uuid_stem`: a synthesized `instance_id` is random, so it is unique within a run but not stable across runs; and an ingredient built directly with the v2 constructor, without passing an asset stream, has no `instance_id` at all and omits the field. Set an explicit `instance_id` (or a `label`) when you need a stable, caller-controlled name. +An ingredient formed through `add_ingredient` from an asset stream or path always usually has an `instance_id`: the SDK takes the asset's XMP `instanceID` when present, otherwise it synthesizes one of the form `xmp:iid:`. The stem helper splits on the last colon, so both the XMP form (`xmp.iid:`) and the synthesized form yield the UUID. Two edge cases justify the `"ingredient"` fallback in `uuid_stem`: a synthesized `instance_id` is random, so it is unique within a run but not stable across runs; and an ingredient built directly with the v2 constructor, without passing an asset stream, has no `instance_id` at all and omits the field. Set an explicit `instance_id` (or a `label`) when you need a stable, caller-controlled name. ## Retrieving actions from a working store From bac95f37c6450cc4f263787f9bc947033a00a19d Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:42:45 -0700 Subject: [PATCH 10/11] fix: Further wording fixes --- docs/selective-manifests.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index efed264f..ef527ef8 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -147,7 +147,7 @@ builder.sign(source_path, output_path, signer); ### Start fresh and preserve provenance -Sometimes all existing assertions and ingredients must be discarded while the provenance chain is kept. This is done by creating a new `Builder` with a new manifest definition and adding the original signed asset as an ingredient using `add_ingredient()`. +Sometimes all existing assertions and ingredients may need to be discarded but the provenance chain should be maintained nevertheless. This is done by creating a new `Builder` with a new manifest definition and adding the original signed asset as an ingredient using `add_ingredient()`. The function `add_ingredient()` does not copy the original's assertions into the new manifest. Instead, it stores the original's entire manifest store as opaque binary data inside the ingredient record. This means: @@ -1024,7 +1024,7 @@ This section covers the **legacy** load path: producer calls `to_archive`, signi > > **Labels baked into the archive ingredient at archive-creation time do not carry through as linking keys either.** The label must be re-asserted on the signing builder's `add_ingredient` call so action and archived ingredient properly link. -Labels are build-time linking keys only. An action and an ingredient that share the same label string are linked. The SDK may reassign the actual label in the signed manifest. +Labels are build-time linking keys only. An action and an ingredient that share the same label string are linked (the label acts as a linking key: the label identifies the link, and is consumed at signing time). The SDK may reassign the actual label in the signed manifest. ##### Minimal archive to action linking example From b8e68ada95c45d34c0cb4e8ae33743154e493f81 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:47:43 -0700 Subject: [PATCH 11/11] fix: Further wording fixes --- docs/faqs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/faqs.md b/docs/faqs.md index 23ab6fde..b1023d18 100644 --- a/docs/faqs.md +++ b/docs/faqs.md @@ -121,5 +121,5 @@ These file-based functions not attached to an API-exposed object, `read_file`, ` | Removed function | Equivalent | | --- | --- | | `read_file(path)` | `Reader::from_asset(ctx, format, stream)` then `Reader::json()`. Pull binary resources with `Reader::get_resource(uri, dest)`. | -| `read_ingredient_file(path, data_dir)` | `Builder::add_ingredient(ingredient_json, source_path)`, which adds ingredients to the active Builder. To recover the formed ingredient JSON (if needed) and its resources, archive the working store and read it back, or move the ingredient with the dedicated `write_ingredient_archive` / `add_ingredient_from_archive` APIs. There is no single-call replacement; but the full behavior (extract the ingredient JSON, thumbnail, and manifest_data to a directory, then reuse that directory to sign an asset using a Builder) can be reimplemented as shown in examples from [Migrating from the removed read_ingredient_file](selective-manifests.md#migrating-from-the-removed-read_ingredient_file). See also [Extracting ingredients from a working store](selective-manifests.md#extracting-ingredients-from-a-working-store). | +| `read_ingredient_file(path, data_dir)` | `Builder::add_ingredient(ingredient_json, source_path)`, which adds ingredients to the active Builder. To recover the formed ingredient JSON (if needed) and its resources, archive the working store and read it back, or move the ingredient with the dedicated `write_ingredient_archive` / `add_ingredient_from_archive` APIs. There is no single-call replacement; but the full behavior (extract the ingredient JSON, thumbnail, and manifest_data to a directory, then reuse that directory to sign an asset using a Builder) can be reimplemented as shown in examples from [Migrating from `read_ingredient_file`](selective-manifests.md#migrating-from-read_ingredient_file). See also [Extracting ingredients from a working store](selective-manifests.md#extracting-ingredients-from-a-working-store). | | `sign_file(src, dst, manifest, SignerInfo*, data_dir)` | `Builder::sign(source_path, dest_path, signer)` with a `Signer`. |