diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 3fd2d538..37bb0eb7 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -557,6 +557,45 @@ with Reader("application/c2pa", archive_stream, context=ctx) as reader: new_builder.sign("image/jpeg", source, dest) ``` +### Identifying ingredients in archives + +When building an ingredient archive, you can set `instance_id` on the ingredient to give it a stable, caller-controlled identifier. This field survives archiving and signing unchanged, so it can be used to look up a specific ingredient from a catalog archive. The `description` and `informational_URI` fields also survive and can carry additional metadata about the ingredient's origin. + +`instance_id` is only for identification and catalog lookups. It cannot be used as a linking key in `ingredientIds` when linking ingredient archives to actions — use `label` for that (see [Linking an archived ingredient to an action](#linking-an-archived-ingredient-to-an-action)). + +```py +# Set instance_id when adding the ingredient to the archive builder. +builder = Builder.from_json(manifest_json) +with open("photo-A.jpg", "rb") as f: + builder.add_ingredient( + { + "title": "photo-A.jpg", + "relationship": "componentOf", + "instance_id": "catalog:photo-A", + }, + "image/jpeg", + f, + ) + +archive = io.BytesIO() +builder.to_archive(archive) +``` + +Later, when reading the archive, select ingredients by their `instance_id`: + +```py +archive.seek(0) +reader = Reader("application/c2pa", archive) +manifest_data = json.loads(reader.json()) +active = manifest_data["active_manifest"] +ingredients = manifest_data["manifests"][active]["ingredients"] + +for ing in ingredients: + if ing.get("instance_id") == "catalog:photo-A": + # Do something with the found ingredient... + pass +``` + ### Overriding ingredient properties When adding an ingredient from an archive or from a file, the JSON passed to `add_ingredient()` can override properties like `title` and `relationship`. This is useful when reusing archived ingredients in a different context: @@ -731,6 +770,111 @@ with Reader("application/c2pa", archive_stream, context=ctx) as reader: new_builder.sign("image/jpeg", source, dest) ``` +### Reading ingredient details from an ingredient archive + +An ingredient archive is a serialized `Builder` containing exactly one ingredient (see [Builder archives vs. ingredient archives](#builder-archives-vs-ingredient-archives)). Reading it with `Reader` allows the caller to inspect the ingredient before deciding whether to use it: its thumbnail, whether it carries provenance (e.g. an active manifest), validation status, relationship, etc. + +```py +# Open the ingredient archive. +with open("ingredient_archive.c2pa", "rb") as archive_file: + reader = Reader("application/c2pa", archive_file, context=ctx) + parsed = json.loads(reader.json()) + active = parsed["active_manifest"] + manifest = parsed["manifests"][active] + + # An ingredient archive has exactly one ingredient. + ingredient = manifest["ingredients"][0] + + # Relationship e.g. "parentOf", "componentOf", "inputTo". + relationship = ingredient["relationship"] + + # Instance ID (optional, can be set by caller). + instance_id = ingredient.get("instance_id") + + # Active manifest: + # When present, the ingredient had content credentials itself. + if "active_manifest" in ingredient: + ing_manifest_label = ingredient["active_manifest"] + ing_manifest = parsed["manifests"][ing_manifest_label] + # ing_manifest contains the ingredient's own assertions, actions, etc. + + # Validation status. + # The top-level "validation_status" array covers the entire manifest store, + # including this ingredient's manifest. + if "validation_status" in parsed: + for status in parsed["validation_status"]: + print(f"{status['code']}: {status['explanation']}") + + # Thumbnail + if "thumbnail" in ingredient: + thumb_id = ingredient["thumbnail"]["identifier"] + with open("thumbnail.jpg", "wb") as thumb_file: + reader.resource_to_stream(thumb_id, thumb_file) + + reader.close() +``` + +#### Linking an archived ingredient to an action + +After reading the ingredient details from an ingredient archive, the ingredient can be added to a new `Builder` and linked to an action. You must assign a `label` in the `add_ingredient()` call on the signing builder and use that label as the linking key in `ingredientIds`. Labels baked into the archive ingredient are not carried through, and `instance_id` does not work as a linking key for ingredient archives. + +Labels are only used as build-time linking keys. The SDK may reassign the actual label in the signed manifest. + +Assign a `label` in the `add_ingredient()` call and reference that same label in `ingredientIds` to link an ingredient to an action. + +```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + +# Read the ingredient archive. +with open("ingredient_archive.c2pa", "rb") as archive_file: + reader = Reader("application/c2pa", archive_file, context=ctx) + parsed = json.loads(reader.json()) + active = parsed["active_manifest"] + ingredient = parsed["manifests"][active]["ingredients"][0] + + # Use a label as the linking key. + # Any label can be used, as long as it uniquely identifies the link. + manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "parameters": { + "ingredientIds": ["archived-ingredient"] + }, + } + ] + }, + } + ], + } + + with Builder(manifest_json, context=ctx) as builder: + # The label on the ingredient must match the entry in ingredientIds on the action. + archive_file.seek(0) + builder.add_ingredient( + { + "title": ingredient["title"], + "relationship": "parentOf", + "label": "archived-ingredient", + }, + "application/c2pa", + archive_file, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "w+b") as dest: + builder.sign("image/jpeg", source, dest) + + reader.close() +``` + ### Merging multiple working stores > [!NOTE] @@ -802,3 +946,43 @@ with Builder({ # configure a dedicated Signer explicitly. builder.sign("image/jpeg", source, dest) ``` + +## Controlling manifest embedding + +By default, `sign()` embeds the manifest directly inside the output asset file. + +### Not embedding a manifest store into an asset + +Use `set_no_embed()` so the signed asset contains no embedded manifest store. The manifest store bytes are returned from `sign()` and can be stored separately (e.g. as a sidecar file). + +```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) +builder = Builder(manifest_json, context=ctx) +builder.set_no_embed() +builder.set_remote_url("<>") + +with open("source.jpg", "rb") as source, open("output.jpg", "w+b") as dest: + manifest_bytes = builder.sign("image/jpeg", source, dest) + # manifest_bytes contains the full manifest store. + # Upload manifest_bytes to the remote URL. + # The output asset has no embedded manifest. +``` + +### Checking manifest location on a Reader + +After opening an asset with `Reader`, use `is_embedded()` to check whether the manifest is embedded in the asset or stored remotely. If the manifest is remote, `get_remote_url()` returns the URL it was fetched from (the URL set via `set_remote_url()` at signing time). + +```py +reader = Reader("output.jpg", context=ctx) + +if reader.is_embedded(): + print("Manifest is embedded in the asset.") +else: + print("Manifest is not embedded.") + url = reader.get_remote_url() + if url is not None: + print(f"Remote manifest URL: {url}") +``` diff --git a/docs/working-stores.md b/docs/working-stores.md index c357b8ee..39e56c85 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -435,6 +435,136 @@ with open("new_asset.jpg", "rb") as src, open("signed_asset.jpg", "w+b") as dst: builder.sign("image/jpeg", src, dst) ``` +### Linking an ingredient archive to an action + +To link an ingredient archive to an action via `ingredientIds`, you must use a `label` set in the `add_ingredient()` call on the signing builder. Labels baked into the archive ingredient are not carried through, and `instance_id` does not work as a linking key for ingredient archives regardless of where it is set. + +```py +import io, json + +# Step 1: Create the ingredient archive. +archive_builder = Builder.from_json({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [], +}) +with open("photo.jpg", "rb") as f: + archive_builder.add_ingredient( + {"title": "photo.jpg", "relationship": "componentOf"}, + "image/jpeg", + f, + ) +archive = io.BytesIO() +archive_builder.to_archive(archive) +archive.seek(0) + +# Step 2: Build a manifest with an action that references the ingredient. +manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["my-ingredient"] + }, + } + ] + }, + } + ], +} + +ctx = Context.from_dict({"signer": signer}) +builder = Builder(manifest_json, context=ctx) + +# Step 3: Add the ingredient archive with a label matching the ingredientIds value. +# The label MUST be set here, on the signing builder's add_ingredient call. +builder.add_ingredient( + {"title": "photo.jpg", "relationship": "componentOf", "label": "my-ingredient"}, + "application/c2pa", + archive, +) + +with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign("image/jpeg", src, dst) +``` + +When linking multiple ingredient archives, give each a distinct label and reference it in the appropriate action's `ingredientIds` array. + +If each ingredient has its own action (e.g., one `c2pa.opened` for the parent and one `c2pa.placed` for a composited element), set up two actions with separate `ingredientIds`: + +```py +manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": {"ingredientIds": ["parent-photo"]}, + }, + { + "action": "c2pa.placed", + "parameters": {"ingredientIds": ["overlay-graphic"]}, + }, + ] + }, + }], +} + +builder = Builder(manifest_json, context=ctx) + +builder.add_ingredient( + {"title": "photo.jpg", "relationship": "parentOf", "label": "parent-photo"}, + "application/c2pa", + photo_archive, +) +builder.add_ingredient( + {"title": "overlay.png", "relationship": "componentOf", "label": "overlay-graphic"}, + "application/c2pa", + overlay_archive, +) +``` + +A single `c2pa.placed` action can also reference several `componentOf` ingredients composited together. List all labels in the `ingredientIds` array: + +```py +manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [{ + "label": "c2pa.actions.v2", + "data": { + "actions": [{ + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["base-layer", "overlay-layer"] + }, + }] + }, + }], +} + +builder = Builder(manifest_json, context=ctx) + +builder.add_ingredient( + {"title": "base.jpg", "relationship": "componentOf", "label": "base-layer"}, + "application/c2pa", + base_archive, +) +builder.add_ingredient( + {"title": "overlay.jpg", "relationship": "componentOf", "label": "overlay-layer"}, + "application/c2pa", + overlay_archive, +) +``` + +After signing, the action's `parameters.ingredients` array contains one resolved URL per ingredient. + ### Ingredient relationships Specify the relationship between the ingredient and the current asset: diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index d3ad730f..4d1a79af 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -1021,6 +1021,35 @@ def callback_signer_es256(data: bytes) -> bytes: return signature self.callback_signer_es256 = callback_signer_es256 + def _create_ingredient_archive(self, ingredient_json=None): + """Helper: create an ingredient archive from a single ingredient.""" + if ingredient_json is None: + ingredient_json = {"title": "photo.jpg", "relationship": "componentOf"} + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + } + ] + }, + } + ], + } + builder = Builder.from_json(manifest) + with open(self.testPath, "rb") as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + archive = io.BytesIO() + builder.to_archive(archive) + builder.close() + archive.seek(0) + return archive + def test_can_retrieve_builder_supported_mimetypes(self): result1 = Builder.get_supported_mime_types() self.assertTrue(len(result1) > 0) @@ -4151,6 +4180,456 @@ def test_builder_opened_action_multiple_ingredient_no_auto_add(self): # Make sure settings are put back to the common test defaults load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + def test_link_archive_label_on_signing_builder_placed(self): + """Label set on the signing builder's add_ingredient links an + ingredient archive to a c2pa.placed action.""" + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + archive = self._create_ingredient_archive() + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["my-ingredient"] + }, + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "photo.jpg", "relationship": "componentOf", "label": "my-ingredient"}, + "application/c2pa", + archive, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + assertions = manifest_data["manifests"][active]["assertions"] + + placed_action = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + for action in assertion["data"]["actions"]: + if action["action"] == "c2pa.placed": + placed_action = action + break + + self.assertIsNotNone(placed_action, "c2pa.placed action not found") + self.assertIn("parameters", placed_action) + self.assertIn("ingredients", placed_action["parameters"]) + self.assertEqual(len(placed_action["parameters"]["ingredients"]), 1) + self.assertIn( + "c2pa.ingredient.v3", + placed_action["parameters"]["ingredients"][0]["url"], + ) + + reader.close() + output.close() + archive.close() + builder.close() + + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + def test_link_archive_label_on_signing_builder_opened(self): + """Label set on the signing builder's add_ingredient links an + ingredient archive to a c2pa.opened action.""" + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + archive = self._create_ingredient_archive( + {"title": "photo.jpg", "relationship": "parentOf"} + ) + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": { + "ingredientIds": ["my-ingredient"] + }, + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "photo.jpg", "relationship": "parentOf", "label": "my-ingredient"}, + "application/c2pa", + archive, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + assertions = manifest_data["manifests"][active]["assertions"] + + opened_action = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + for action in assertion["data"]["actions"]: + if action["action"] == "c2pa.opened": + opened_action = action + break + + self.assertIsNotNone(opened_action, "c2pa.opened action not found") + self.assertIn("parameters", opened_action) + self.assertIn("ingredients", opened_action["parameters"]) + self.assertEqual(len(opened_action["parameters"]["ingredients"]), 1) + self.assertIn( + "c2pa.ingredient.v3", + opened_action["parameters"]["ingredients"][0]["url"], + ) + + reader.close() + output.close() + archive.close() + builder.close() + + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + def test_link_archive_two_ingredients_labels(self): + """Two ingredient archives linked to two different actions via + distinct labels. Verifies no cross-linking.""" + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + archive1 = self._create_ingredient_archive( + {"title": "photo-placed.jpg", "relationship": "componentOf"} + ) + archive2 = self._create_ingredient_archive( + {"title": "photo-opened.jpg", "relationship": "parentOf"} + ) + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["ingredient-for-placed"] + }, + }, + { + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": { + "ingredientIds": ["ingredient-for-opened"] + }, + }, + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "photo-placed.jpg", "relationship": "componentOf", "label": "ingredient-for-placed"}, + "application/c2pa", + archive1, + ) + builder.add_ingredient( + {"title": "photo-opened.jpg", "relationship": "parentOf", "label": "ingredient-for-opened"}, + "application/c2pa", + archive2, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + assertions = manifest_data["manifests"][active]["assertions"] + + placed_action = None + opened_action = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + for action in assertion["data"]["actions"]: + if action["action"] == "c2pa.placed": + placed_action = action + if action["action"] == "c2pa.opened": + opened_action = action + + self.assertIsNotNone(placed_action, "c2pa.placed action not found") + self.assertIsNotNone(opened_action, "c2pa.opened action not found") + + self.assertIn("ingredients", placed_action["parameters"]) + self.assertEqual(len(placed_action["parameters"]["ingredients"]), 1) + placed_url = placed_action["parameters"]["ingredients"][0]["url"] + + self.assertIn("ingredients", opened_action["parameters"]) + self.assertEqual(len(opened_action["parameters"]["ingredients"]), 1) + opened_url = opened_action["parameters"]["ingredients"][0]["url"] + + # Each action should link to a different ingredient (no cross-linking) + self.assertNotEqual(placed_url, opened_url, + "Each action should link to a different ingredient") + + reader.close() + output.close() + archive1.close() + archive2.close() + builder.close() + + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + def test_link_archive_multiple_ingredients_in_one_placed_action(self): + """A single c2pa.placed action references two componentOf ingredients + via ingredientIds with two labels.""" + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + archive1 = self._create_ingredient_archive( + {"title": "base-layer.jpg", "relationship": "componentOf"} + ) + archive2 = self._create_ingredient_archive( + {"title": "overlay-layer.jpg", "relationship": "componentOf"} + ) + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["base-layer", "overlay-layer"] + }, + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "base-layer.jpg", "relationship": "componentOf", "label": "base-layer"}, + "application/c2pa", + archive1, + ) + builder.add_ingredient( + {"title": "overlay-layer.jpg", "relationship": "componentOf", "label": "overlay-layer"}, + "application/c2pa", + archive2, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + assertions = manifest_data["manifests"][active]["assertions"] + + placed_action = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + for action in assertion["data"]["actions"]: + if action["action"] == "c2pa.placed": + placed_action = action + break + + self.assertIsNotNone(placed_action, "c2pa.placed action not found") + self.assertIn("parameters", placed_action) + self.assertIn("ingredients", placed_action["parameters"]) + ingredients = placed_action["parameters"]["ingredients"] + self.assertEqual(len(ingredients), 2, + "c2pa.placed should reference both ingredients") + + url0 = ingredients[0]["url"] + url1 = ingredients[1]["url"] + self.assertNotEqual(url0, url1, + "Each ingredient should have a distinct URL") + + reader.close() + output.close() + archive1.close() + archive2.close() + builder.close() + + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + def test_ingredient_fields_survive_archive(self): + archive = self._create_ingredient_archive({ + "title": "tracked-asset.jpg", + "relationship": "componentOf", + "instance_id": "tracking:project-7:asset-42", + "description": "A tracked ingredient", + "informational_URI": "https://example.com/assets/42", + }) + + reader = Reader("application/c2pa", archive) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + ingredients = manifest_data["manifests"][active]["ingredients"] + + self.assertGreaterEqual(len(ingredients), 1) + ing = ingredients[0] + + self.assertEqual(ing["title"], "tracked-asset.jpg") + self.assertIn("instance_id", ing) + self.assertEqual(ing["instance_id"], "tracking:project-7:asset-42") + + reader.close() + archive.close() + + def test_ingredient_fields_survive_archive_then_sign(self): + """instance_id set on the archive ingredient persists through + archive then sign.""" + archive = self._create_ingredient_archive({ + "title": "tracked-asset.jpg", + "relationship": "componentOf", + "instance_id": "tracking:project-7:asset-42", + "description": "A tracked ingredient", + "informational_URI": "https://example.com/assets/42", + }) + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "tracked-asset.jpg", "relationship": "componentOf"}, + "application/c2pa", + archive, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + ingredients = manifest_data["manifests"][active]["ingredients"] + + self.assertGreaterEqual(len(ingredients), 1) + ing = ingredients[0] + + self.assertIn("instance_id", ing) + self.assertEqual(ing["instance_id"], "tracking:project-7:asset-42") + + reader.close() + output.close() + archive.close() + builder.close() + + def test_instance_id_as_ingredient_identifier_in_catalog(self): + """Two ingredients with different instance_id values in one archive. + Read the archive back and select an ingredient by instance_id.""" + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + with open(self.testPath, "rb") as f: + builder.add_ingredient( + {"title": "photo-A.jpg", "relationship": "componentOf", + "instance_id": "catalog:photo-A"}, + "image/jpeg", f, + ) + with open(self.testPath, "rb") as f: + builder.add_ingredient( + {"title": "photo-B.jpg", "relationship": "componentOf", + "instance_id": "catalog:photo-B"}, + "image/jpeg", f, + ) + + archive = io.BytesIO() + builder.to_archive(archive) + archive.seek(0) + builder.close() + + reader = Reader("application/c2pa", archive) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + ingredients = manifest_data["manifests"][active]["ingredients"] + + self.assertEqual(len(ingredients), 2) + + found = None + for ing in ingredients: + if ing.get("instance_id") == "catalog:photo-B": + found = ing + break + + self.assertIsNotNone(found, + "Should find ingredient by instance_id 'catalog:photo-B' in archive") + self.assertEqual(found["title"], "photo-B.jpg") + + reader.close() + archive.close() + class TestStream(unittest.TestCase): def setUp(self):