diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6cab3c53..665e5669 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -116,7 +116,7 @@ jobs: python3 -c "from c2pa import C2paError; print('C2paError imported successfully')" - name: Run tests - run: python3 ./tests/test_unit_tests.py + run: python3 -m unittest discover -s tests -p "test_*.py" -v tests-windows: name: Unit tests for developer setup (Windows) @@ -182,7 +182,7 @@ jobs: python -c "from c2pa import C2paError; print('C2paError imported successfully')" - name: Run tests - run: python .\tests\test_unit_tests.py + run: python -m unittest discover -s tests -p "test_*.py" -v build-linux-wheel: name: Build Linux wheel @@ -254,10 +254,10 @@ jobs: source venv/bin/activate pip install dist/c2pa_python-*.whl - - name: Run unittest tests on installed wheel + - name: Run tests with unittest (venv) run: | source venv/bin/activate - python ./tests/test_unit_tests.py + python -m unittest discover -s tests -p "test_*.py" -v - name: Install pytest (in venv) run: | @@ -267,7 +267,7 @@ jobs: - name: Run tests with pytest (venv) run: | source venv/bin/activate - venv/bin/pytest tests/test_unit_tests.py -v + venv/bin/pytest tests/ -v build-windows-wheel: name: Build Windows wheel @@ -333,10 +333,10 @@ jobs: if (-not $wheel) { Write-Error "No wheel file found in dist directory"; exit 1 } pip install $wheel.FullName - - name: Run unittest tests on installed wheel + - name: Run tests with unittest (venv) run: | .\venv\Scripts\activate - python .\tests\test_unit_tests.py + python -m unittest discover -s tests -p "test_*.py" -v - name: Install pytest (in venv) run: | @@ -346,7 +346,7 @@ jobs: - name: Run tests with pytest (venv) run: | .\venv\Scripts\activate - .\venv\Scripts\pytest .\tests\test_unit_tests.py -v + .\venv\Scripts\pytest tests\ -v build-macos-wheel: name: Build macOS wheels @@ -422,10 +422,10 @@ jobs: source venv/bin/activate pip install dist/c2pa_python-*.whl - - name: Run unittest tests on installed wheel + - name: Run tests with unittest (venv) run: | source venv/bin/activate - python ./tests/test_unit_tests.py + python -m unittest discover -s tests -p "test_*.py" -v - name: Install pytest (in venv) run: | @@ -435,7 +435,7 @@ jobs: - name: Run tests with pytest (venv) run: | source venv/bin/activate - venv/bin/pytest tests/test_unit_tests.py -v + venv/bin/pytest tests/ -v sdist: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index ba70dfb3..646ea82a 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ C2PA_VERSION := $(shell cat c2pa-native-version.txt) # Start from clean env: Delete `.venv`, then `python3 -m venv .venv` # Pre-requisite: Python virtual environment is active (source .venv/bin/activate) -# Run Pytest tests in virtualenv: .venv/bin/pytest tests/test_unit_tests.py -v +# Run tests in virtualenv: .venv/bin/python -m unittest discover -s tests -p "test_*.py" -v # Removes build artifacts, distribution files, and other generated content clean: @@ -39,8 +39,8 @@ run-examples: # Runs the examples, then the unit tests test: make run-examples - python3 ./tests/test_unit_tests.py - python3 ./tests/test_unit_tests_threaded.py + python3 -m unittest discover -s tests -p "test_*.py" + python3 -m unittest tests.threaded_test # Runs benchmarks in the venv benchmark: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..306f2d40 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +import sys +import os + +sys.path.insert(0, os.path.dirname(__file__)) diff --git a/tests/test_builder_with_context.py b/tests/test_builder_with_context.py new file mode 100644 index 00000000..5fe7ae36 --- /dev/null +++ b/tests/test_builder_with_context.py @@ -0,0 +1,141 @@ +# Copyright 2023 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +import os +import unittest +import json +import tempfile + +from c2pa import Builder, C2paError as Error, Reader +from c2pa import Settings, Context + +from test_common import DEFAULT_TEST_FILE +from test_context_base import TestContextAPIs + +class TestBuilderWithContext(TestContextAPIs): + + def test_contextual_builder_with_default_context(self): + context = Context() + builder = Builder(self.test_manifest, context) + self.assertIsNotNone(builder) + builder.close() + context.close() + + def test_contextual_builder_with_settings_context(self): + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + context = Context(settings) + builder = Builder(self.test_manifest, context) + signer = self._ctx_make_signer() + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + builder.sign( + signer, "image/jpeg", source_file, dest_file, + ) + reader = Reader(dest_path) + manifest = reader.get_active_manifest() + self.assertIsNone( + manifest.get("thumbnail") + ) + reader.close() + builder.close() + context.close() + settings.close() + + def test_contextual_builder_from_json_with_context(self): + context = Context() + builder = Builder.from_json(self.test_manifest, context) + self.assertIsNotNone(builder) + builder.close() + context.close() + + def test_contextual_builder_sign_context_signer(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign_with_context( + "image/jpeg", + source_file, + dest_file, + ) + self.assertIsNotNone(manifest_bytes) + self.assertGreater(len(manifest_bytes), 0) + reader = Reader(dest_path) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + builder.close() + context.close() + + def test_contextual_builder_sign_signer_ovverride(self): + context_signer = self._ctx_make_signer() + context = Context(signer=context_signer) + builder = Builder( + self.test_manifest, context=context, + ) + explicit_signer = self._ctx_make_signer() + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign( + explicit_signer, + "image/jpeg", source_file, dest_file, + ) + self.assertIsNotNone(manifest_bytes) + self.assertGreater(len(manifest_bytes), 0) + builder.close() + explicit_signer.close() + context.close() + + def test_contextual_builder_sign_no_signer_raises(self): + context = Context() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + with self.assertRaises(Error): + builder.sign_with_context( + "image/jpeg", + source_file, + dest_file, + ) + builder.close() + context.close() + + + +if __name__ == '__main__': + unittest.main(warnings='ignore') diff --git a/tests/test_unit_tests.py b/tests/test_builder_with_signer.py similarity index 56% rename from tests/test_unit_tests.py rename to tests/test_builder_with_signer.py index 0dade99c..5c6f55aa 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_builder_with_signer.py @@ -15,3192 +15,1711 @@ import io import json import unittest -import ctypes import warnings -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.backends import default_backend import tempfile import shutil -import ctypes -import toml import threading +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.backends import default_backend -# Suppress deprecation warnings warnings.simplefilter("ignore", category=DeprecationWarning) -from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType -from c2pa import Settings, Context, ContextBuilder, ContextProvider -from c2pa.c2pa import Stream, LifecycleState, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable - - -PROJECT_PATH = os.getcwd() -FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") -DEFAULT_TEST_FILE_NAME = "C.jpg" -INGREDIENT_TEST_FILE_NAME = "A.jpg" -DEFAULT_TEST_FILE = os.path.join(FIXTURES_DIR, DEFAULT_TEST_FILE_NAME) -INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, INGREDIENT_TEST_FILE_NAME) -ALTERNATIVE_INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, "cloud.jpg") +from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, C2paBuilderIntent, C2paDigitalSourceType +from c2pa import Settings +from c2pa.c2pa import Stream, LifecycleState, sign_file, load_settings, create_signer, ed25519_sign, format_embeddable +from test_common import FIXTURES_DIR, DEFAULT_TEST_FILE, INGREDIENT_TEST_FILE, ALTERNATIVE_INGREDIENT_TEST_FILE, load_test_settings_json -def load_test_settings_json(): - """ - Load default (legacy) trust configuration test settings from a - JSON config file and return its content as JSON-compatible dict. - The return value is used to load settings (thread_local) in tests. - - Returns: - dict: The parsed JSON content as a Python dictionary (JSON-compatible). - - Raises: - FileNotFoundError: If trust_config_test_settings.json is not found. - json.JSONDecodeError: If the JSON file is malformed. - """ - # Locate the file which contains default settings for tests - tests_dir = os.path.dirname(os.path.abspath(__file__)) - settings_path = os.path.join(tests_dir, 'trust_config_test_settings.json') - - # Load the located default test settings - with open(settings_path, 'r') as f: - settings_data = json.load(f) +class TestBuilderWithSigner(unittest.TestCase): + def setUp(self): + warnings.filterwarnings("ignore", category=DeprecationWarning) + # Use the fixtures_dir fixture to set up paths + self.data_dir = FIXTURES_DIR + self.testPath = DEFAULT_TEST_FILE + self.testPath2 = INGREDIENT_TEST_FILE + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + self.certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + self.key = key_file.read() - return settings_data + # Create a local Es256 signer with certs and a timestamp server + self.signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + self.signer = Signer.from_info(self.signer_info) + self.testPath3 = os.path.join(self.data_dir, "A_thumbnail.jpg") + self.testPath4 = ALTERNATIVE_INGREDIENT_TEST_FILE -class TestC2paSdk(unittest.TestCase): - def test_sdk_version(self): - # This test verifies the native libraries used match the expected version. - self.assertIn("0.77.0", sdk_version()) + # Define a manifest as a dictionary + self.manifestDefinition = { + "claim_generator": "python_test", + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + "claim_version": 1, + "format": "image/jpeg", + "title": "Python Test Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + # Define a V2 manifest as a dictionary + self.manifestDefinitionV2 = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + # claim version 2 is the default + # "claim_version": 2, + "format": "image/jpeg", + "title": "Python Test Image V2", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } -class TestReader(unittest.TestCase): - def setUp(self): - warnings.filterwarnings("ignore", message="load_settings\\(\\) is deprecated") - self.data_dir = FIXTURES_DIR - self.testPath = DEFAULT_TEST_FILE + # Define an example ES256 callback signer + self.callback_signer_alg = "Es256" + def callback_signer_es256(data: bytes) -> bytes: + private_key = serialization.load_pem_private_key( + self.key, + password=None, + backend=default_backend() + ) + signature = private_key.sign( + data, + ec.ECDSA(hashes.SHA256()) + ) + return signature + self.callback_signer_es256 = callback_signer_es256 - def test_can_retrieve_reader_supported_mimetypes(self): - result1 = Reader.get_supported_mime_types() + def test_can_retrieve_builder_supported_mimetypes(self): + result1 = Builder.get_supported_mime_types() self.assertTrue(len(result1) > 0) - # Cache hit - result2 = Reader.get_supported_mime_types() + # Cache-hit + result2 = Builder.get_supported_mime_types() self.assertTrue(len(result2) > 0) self.assertEqual(result1, result2) - def test_stream_read_nothing_to_read(self): - # The ingredient test file has no manifest - # So if we instantiate directly, the Reader instance should throw - with open(INGREDIENT_TEST_FILE, "rb") as file: - with self.assertRaises(Error) as context: - reader = Reader("image/jpeg", file) - self.assertIn("ManifestNotFound: no JUMBF data found", str(context.exception)) - - def test_try_create_reader_nothing_to_read(self): - # The ingredient test file has no manifest - # So if we use Reader.try_create, in this case we'll get None - # And no error should be raised - with open(INGREDIENT_TEST_FILE, "rb") as file: - reader = Reader.try_create("image/jpeg", file) - self.assertIsNone(reader) - - def test_stream_read(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - json_data = reader.json() - self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) - - def test_try_create_reader_from_stream(self): - with open(self.testPath, "rb") as file: - reader = Reader.try_create("image/jpeg", file) - self.assertIsNotNone(reader) - json_data = reader.json() - self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + def test_reserve_size(self): + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + signer.reserve_size() - def test_try_create_reader_from_stream_context_manager(self): - with open(self.testPath, "rb") as file: - reader = Reader.try_create("image/jpeg", file) - self.assertIsNotNone(reader) - # Check that a Reader returned by try_create is not None, - # before using it in a context manager pattern (with) - if reader is not None: - with reader: - json_data = reader.json() - self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + def test_signer_creation_error_alg(self): + signer_info = C2paSignerInfo( + alg=b"not-an-alg", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + with self.assertRaises(Error): + Signer.from_info(signer_info) - def test_stream_read_detailed(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - json_data = reader.detailed_json() - self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + def test_signer_from_callback_error_no_cert(self): + with self.assertRaises(Error): + Signer.from_callback( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=None, + tsa_url="http://timestamp.digicert.com" + ) - def test_get_active_manifest(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - active_manifest = reader.get_active_manifest() + def test_signer_from_callback_error_wrong_url(self): + with self.assertRaises(Error): + Signer.from_callback( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=None, + tsa_url="ftp://timestamp.digicert.com" + ) - # Check the returned manifest label/key - expected_label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" - self.assertEqual(active_manifest["label"], expected_label) + def test_reserve_size_on_closed_signer(self): + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + signer.close() + # Verify signer is closed by testing that operations fail + with self.assertRaises(Error): + signer.reserve_size() - def test_get_manifest(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - - # Test getting manifest by the specific label - label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" - manifest = reader.get_manifest(label) - self.assertEqual(manifest["label"], label) - - # It should be the active manifest too, so cross-check - active_manifest = reader.get_active_manifest() - self.assertEqual(manifest, active_manifest) - - def test_stream_get_non_active_manifest_by_label(self): - video_path = os.path.join(FIXTURES_DIR, "video1.mp4") - with open(video_path, "rb") as file: - reader = Reader("video/mp4", file) - - non_active_label = "urn:uuid:54281c07-ad34-430e-bea5-112a18facf0b" - non_active_manifest = reader.get_manifest(non_active_label) - self.assertEqual(non_active_manifest["label"], non_active_label) - - # Verify it's not the active manifest - # (that test case has only one other manifest that is not the active manifest) - active_manifest = reader.get_active_manifest() - self.assertNotEqual(non_active_manifest, active_manifest) - self.assertNotEqual(non_active_manifest["label"], active_manifest["label"]) - - def test_stream_get_non_active_manifest_by_label_not_found(self): - video_path = os.path.join(FIXTURES_DIR, "video1.mp4") - with open(video_path, "rb") as file: - reader = Reader("video/mp4", file) - - # Try to get a manifest with a label that clearly doesn't exist... - non_existing_label = "urn:uuid:clearly-not-existing" - with self.assertRaises(KeyError): - reader.get_manifest(non_existing_label) - - def test_stream_read_get_validation_state(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - validation_state = reader.get_validation_state() - self.assertIsNotNone(validation_state) - self.assertEqual(validation_state, "Valid") + def test_signer_double_close(self): + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + signer.close() + # Second close should not raise an exception + signer.close() - def test_stream_read_get_validation_state_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings - result = {} - exception = {} + def test_builder_detects_malformed_json(self): + with self.assertRaises(Error): + Builder("{this is not json}") - def read_with_trust_config(): - try: - # Load trust configuration - settings_dict = load_test_settings_json() + def test_builder_does_not_allow_sign_after_close(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + builder.close() + with self.assertRaises(Error): + builder.sign(self.signer, "image/jpeg", file, output) - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) + def test_builder_does_not_allow_archiving_after_close(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + placeholder_stream = io.BytesIO(bytearray()) + builder.close() + with self.assertRaises(Error): + builder.to_archive(placeholder_stream) - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - validation_state = reader.get_validation_state() - result['validation_state'] = validation_state - except Exception as e: - exception['error'] = e + def test_builder_does_not_allow_changing_remote_url_after_close(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + builder.close() + with self.assertRaises(Error): + builder.set_remote_url("a-remote-url-that-is-not-important-in-this-tests") - # Create and start thread - thread = threading.Thread(target=read_with_trust_config) - thread.start() - thread.join() + def test_builder_does_not_allow_adding_resource_after_close(self): + builder = Builder(self.manifestDefinition) + placeholder_stream = io.BytesIO(bytearray()) + builder.close() + with self.assertRaises(Error): + builder.add_resource("a-remote-url-that-is-not-important-in-this-tests", placeholder_stream) - # Check for exceptions - if 'error' in exception: - raise exception['error'] + def test_builder_add_thumbnail_resource(self): + builder = Builder(self.manifestDefinition) + with open(self.testPath2, "rb") as thumbnail_file: + builder.add_resource("thumbnail", thumbnail_file) + builder.close() - # Assertions run in main thread - self.assertIsNotNone(result.get('validation_state')) - # With trust configuration loaded, manifest is Trusted - self.assertEqual(result.get('validation_state'), "Trusted") + def test_builder_double_close(self): + builder = Builder(self.manifestDefinition) + # First close + builder.close() + # Second close should not raise an exception + builder.close() + # Verify builder is closed + with self.assertRaises(Error): + builder.set_no_embed() - def test_stream_read_get_validation_results(self): + def test_streams_sign_recover_bytes_only(self): with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - validation_results = reader.get_validation_results() + builder = Builder(self.manifestDefinition) + manifest_bytes = builder.sign(self.signer, "image/jpeg", file) + self.assertIsNotNone(manifest_bytes) - self.assertIsNotNone(validation_results) - self.assertIsInstance(validation_results, dict) + def test_streams_sign_with_thumbnail_resource(self): + with open(self.testPath2, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) - self.assertIn("activeManifest", validation_results) - active_manifest_results = validation_results["activeManifest"] - self.assertIsInstance(active_manifest_results, dict) + with open(self.testPath2, "rb") as thumbnail_file: + builder.add_resource("thumbnail", thumbnail_file) - def test_reader_detects_unsupported_mimetype_on_stream(self): - with open(self.testPath, "rb") as file: - with self.assertRaises(Error.NotSupported): - Reader("mimetype/does-not-exist", file) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + output.close() - def test_stream_read_and_parse(self): + def test_streams_sign_with_es256_alg_v1_manifest(self): with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - manifest_store = json.loads(reader.json()) - title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] - self.assertEqual(title, DEFAULT_TEST_FILE_NAME) - - def test_stream_read_detailed_and_parse(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - manifest_store = json.loads(reader.detailed_json()) - title = manifest_store["manifests"][manifest_store["active_manifest"]]["claim"]["dc:title"] - self.assertEqual(title, DEFAULT_TEST_FILE_NAME) - - def test_stream_read_string_stream(self): - with Reader(self.testPath) as reader: - json_data = reader.json() - self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) - - def test_try_create_from_path(self): - test_path = os.path.join(self.data_dir, "C.dng") - - # Create reader with the file content - reader = Reader.try_create(test_path) - self.assertIsNotNone(reader) - # Just run and verify there is no crash - json.loads(reader.json()) - - def test_stream_read_string_stream_mimetype_not_supported(self): - with self.assertRaises(Error.NotSupported): - # xyz is actually an extension that is recognized - # as mimetype chemical/x-xyz - Reader(os.path.join(FIXTURES_DIR, "C.xyz")) - - def test_try_create_raises_mimetype_not_supported(self): - with self.assertRaises(Error.NotSupported): - # xyz is actually an extension that is recognized - # as mimetype chemical/x-xyz, but we don't support it - Reader.try_create(os.path.join(FIXTURES_DIR, "C.xyz")) - - def test_stream_read_string_stream_mimetype_not_recognized(self): - with self.assertRaises(Error.NotSupported): - Reader(os.path.join(FIXTURES_DIR, "C.test")) - - def test_try_create_raises_mimetype_not_recognized(self): - with self.assertRaises(Error.NotSupported): - Reader.try_create(os.path.join(FIXTURES_DIR, "C.test")) - - def test_stream_read_string_stream(self): - with Reader("image/jpeg", self.testPath) as reader: + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) json_data = reader.json() - self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + self.assertIn("Python Test", json_data) + self.assertIn("Valid", json_data) - def test_reader_detects_unsupported_mimetype_on_file(self): - with self.assertRaises(Error.NotSupported): - Reader("mimetype/does-not-exist", self.testPath) + # Write buffer to file + # output.seek(0) + # with open('/target_path', 'wb') as f: + # f.write(output.getbuffer()) - def test_stream_read_filepath_as_stream_and_parse(self): - with Reader("image/jpeg", self.testPath) as reader: - manifest_store = json.loads(reader.json()) - title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] - self.assertEqual(title, DEFAULT_TEST_FILE_NAME) + output.close() - def test_reader_double_close(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - reader.close() - # Second close should not raise an exception - reader.close() - # Verify reader is closed - with self.assertRaises(Error): - reader.json() + def test_streams_sign_with_es256_alg_v1_manifest_to_existing_empty_file(self): + test_file_name = os.path.join(self.data_dir, "temp_data", "temp_signing.jpg") + # Ensure tmp directory exists + os.makedirs(os.path.dirname(test_file_name), exist_ok=True) - def test_reader_streams_with_nested(self): - with open(self.testPath, "rb") as file: - with Reader("image/jpeg", file) as reader: - manifest_store = json.loads(reader.json()) - title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] - self.assertEqual(title, DEFAULT_TEST_FILE_NAME) + # Ensure the target file exists before opening it in rb+ mode + with open(test_file_name, "wb") as f: + pass # Create empty file - def test_reader_close_cleanup(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - # Close the reader - reader.close() - # Verify all resources are cleaned up - self.assertIsNone(reader._handle) - self.assertIsNone(reader._own_stream) - # Verify reader is marked as closed - self.assertEqual(reader._state, LifecycleState.CLOSED) - - def test_resource_to_stream_on_closed_reader(self): - """Test that resource_to_stream correctly raises error on closed.""" - reader = Reader("image/jpeg", self.testPath) - reader.close() - with self.assertRaises(Error): - reader.resource_to_stream("", io.BytesIO(bytearray())) + try: + with open(self.testPath, "rb") as source, open(test_file_name, "w+b") as target: + builder = Builder(self.manifestDefinition) + builder.sign(self.signer, "image/jpeg", source, target) + reader = Reader("image/jpeg", target) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertIn("Valid", json_data) - def test_read_dng_from_stream(self): - test_path = os.path.join(self.data_dir, "C.dng") - with open(test_path, "rb") as file: - file_content = file.read() + finally: + # Clean up... - with Reader("dng", io.BytesIO(file_content)) as reader: - # Just run and verify there is no crash - json.loads(reader.json()) + if os.path.exists(test_file_name): + os.remove(test_file_name) - def test_read_dng_upper_case_from_stream(self): - test_path = os.path.join(self.data_dir, "C.dng") - with open(test_path, "rb") as file: - file_content = file.read() + # Also clean up the temp directory if it's empty + temp_dir = os.path.dirname(test_file_name) + if os.path.exists(temp_dir) and not os.listdir(temp_dir): + os.rmdir(temp_dir) - with Reader("DNG", io.BytesIO(file_content)) as reader: - # Just run and verify there is no crash - json.loads(reader.json()) + def test_streams_sign_with_es256_alg_v1_manifest_to_new_dest_file(self): + test_file_name = os.path.join(self.data_dir, "temp_data", "temp_signing.jpg") + # Ensure tmp directory exists + os.makedirs(os.path.dirname(test_file_name), exist_ok=True) - def test_read_dng_file_from_path(self): - test_path = os.path.join(self.data_dir, "C.dng") + # A new target/destination file should be created during the test run + try: + with open(self.testPath, "rb") as source, open(test_file_name, "w+b") as target: + builder = Builder(self.manifestDefinition) + builder.sign(self.signer, "image/jpeg", source, target) + reader = Reader("image/jpeg", target) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertIn("Valid", json_data) - # Create reader with the file content - with Reader(test_path) as reader: - # Just run and verify there is no crash - json.loads(reader.json()) + finally: + # Clean up... - def test_read_all_files(self): - """Test reading C2PA metadata from all files in the fixtures/files-for-reading-tests directory""" - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + if os.path.exists(test_file_name): + os.remove(test_file_name) - # Map of file extensions to MIME types - mime_types = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.heic': 'image/heic', - '.heif': 'image/heif', - '.avif': 'image/avif', - '.tif': 'image/tiff', - '.tiff': 'image/tiff', - '.mp4': 'video/mp4', - '.avi': 'video/x-msvideo', - '.mp3': 'audio/mpeg', - '.m4a': 'audio/mp4', - '.wav': 'audio/wav', - '.pdf': 'application/pdf', - } + # Also clean up the temp directory if it's empty + temp_dir = os.path.dirname(test_file_name) + if os.path.exists(temp_dir) and not os.listdir(temp_dir): + os.rmdir(temp_dir) - # Skip system files - skip_files = { - '.DS_Store' - } + def test_streams_sign_with_es256_alg(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + output.close() - for filename in os.listdir(reading_dir): - if filename in skip_files: - continue + def test_streams_sign_with_es256_alg_2(self): + with open(self.testPath2, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertIn("Valid", json_data) + output.close() - file_path = os.path.join(reading_dir, filename) - if not os.path.isfile(file_path): - continue + def test_streams_sign_with_es256_alg_create_intent(self): + """Test signing with CREATE intent and empty manifest.""" - # Get file extension and corresponding MIME type - _, ext = os.path.splitext(filename) - ext = ext.lower() - if ext not in mime_types: - continue + with open(self.testPath2, "rb") as file: + # Start with an empty manifest + builder = Builder({}) + # Set the intent for creating new content + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.DIGITAL_CREATION + ) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_str = reader.json() + # Verify the manifest was created + self.assertIsNotNone(json_str) - mime_type = mime_types[ext] + # Parse the JSON to verify the structure + manifest_data = json.loads(json_str) + active_manifest_label = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_label] - try: - with open(file_path, "rb") as file: - reader = Reader(mime_type, file) - json_data = reader.json() - reader.close() - self.assertIsInstance(json_data, str) - # Verify the manifest contains expected fields - manifest = json.loads(json_data) - self.assertIn("manifests", manifest) - self.assertIn("active_manifest", manifest) - except Exception as e: - self.fail(f"Failed to read metadata from {filename}: {str(e)}") + # Check that assertions exist + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] - def test_try_create_all_files(self): - """Test reading C2PA metadata using Reader.try_create from all files in the fixtures/files-for-reading-tests directory""" - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + # Find the actions assertion + actions_assertion = None + for assertion in assertions: + if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: + actions_assertion = assertion + break - # Map of file extensions to MIME types - mime_types = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.heic': 'image/heic', - '.heif': 'image/heif', - '.avif': 'image/avif', - '.tif': 'image/tiff', - '.tiff': 'image/tiff', - '.mp4': 'video/mp4', - '.avi': 'video/x-msvideo', - '.mp3': 'audio/mpeg', - '.m4a': 'audio/mp4', - '.wav': 'audio/wav', - '.pdf': 'application/pdf', - } + self.assertIsNotNone(actions_assertion) - # Skip system files - skip_files = { - '.DS_Store' - } + # Verify c2pa.created action exists and there is only one + actions = actions_assertion["data"]["actions"] + created_actions = [ + action for action in actions + if action["action"] == "c2pa.created" + ] - for filename in os.listdir(reading_dir): - if filename in skip_files: - continue + self.assertEqual(len(created_actions), 1) - file_path = os.path.join(reading_dir, filename) - if not os.path.isfile(file_path): - continue + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertEqual(manifest_data["validation_state"], "Valid") + output.close() - # Get file extension and corresponding MIME type - _, ext = os.path.splitext(filename) - ext = ext.lower() - if ext not in mime_types: - continue + def test_streams_sign_with_es256_alg_create_intent_2(self): + """Test signing with CREATE intent and manifestDefinitionV2.""" - mime_type = mime_types[ext] + with open(self.testPath2, "rb") as file: + # Start with manifestDefinitionV2 which has predefined metadata + builder = Builder(self.manifestDefinitionV2) + # Set the intent for creating new content + # If we provided a full manifest, the digital source type from the full manifest "wins" + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.SCREEN_CAPTURE + ) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_str = reader.json() - try: - with open(file_path, "rb") as file: - reader = Reader.try_create(mime_type, file) - # try_create returns None if no manifest found, otherwise a Reader - self.assertIsNotNone(reader, f"Expected Reader for {filename}") - json_data = reader.json() - reader.close() - self.assertIsInstance(json_data, str) - # Verify the manifest contains expected fields - manifest = json.loads(json_data) - self.assertIn("manifests", manifest) - self.assertIn("active_manifest", manifest) - except Exception as e: - self.fail(f"Failed to read metadata from {filename}: {str(e)}") + # Verify the manifest was created + self.assertIsNotNone(json_str) - def test_try_create_all_files_using_extension(self): - """ - Test reading C2PA metadata using Reader.try_create - from files in the fixtures/files-for-reading-tests directory - """ - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + # Parse the JSON to verify the structure + manifest_data = json.loads(json_str) + active_manifest_label = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_label] - # Map of file extensions to MIME types - extensions = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - } + # Verify title from manifestDefinitionV2 is preserved + self.assertIn("title", active_manifest) + self.assertEqual(active_manifest["title"], "Python Test Image V2") - # Skip system files - skip_files = { - '.DS_Store' - } + # Verify claim_generator_info is present + self.assertIn("claim_generator_info", active_manifest) + claim_generator_info = active_manifest["claim_generator_info"] + self.assertIsInstance(claim_generator_info, list) + self.assertGreater(len(claim_generator_info), 0) - for filename in os.listdir(reading_dir): - if filename in skip_files: - continue + # Check for the custom claim generator info from manifestDefinitionV2 + has_python_test = any( + gen.get("name") == "python_test" and gen.get("version") == "0.0.1" + for gen in claim_generator_info + ) + self.assertTrue(has_python_test, "Should have python_test claim generator") - file_path = os.path.join(reading_dir, filename) - if not os.path.isfile(file_path): - continue + # Verify no ingredients for CREATE intent + ingredients_manifest = active_manifest.get("ingredients", []) + self.assertEqual(len(ingredients_manifest), 0, "CREATE intent should have no ingredients") - # Get file extension and corresponding MIME type - _, ext = os.path.splitext(filename) - ext = ext.lower() - if ext not in extensions: - continue + # Check that assertions exist + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] - try: - with open(file_path, "rb") as file: - # Remove the leading dot - parsed_extension = ext[1:] - reader = Reader.try_create(parsed_extension, file) - # try_create returns None if no manifest found, otherwise a Reader - self.assertIsNotNone(reader, f"Expected Reader for {filename}") - json_data = reader.json() - reader.close() - self.assertIsInstance(json_data, str) - # Verify the manifest contains expected fields - manifest = json.loads(json_data) - self.assertIn("manifests", manifest) - self.assertIn("active_manifest", manifest) - except Exception as e: - self.fail(f"Failed to read metadata from {filename}: {str(e)}") + # Find the actions assertion + actions_assertion = None + for assertion in assertions: + if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: + actions_assertion = assertion + break - def test_read_all_files_using_extension(self): - """Test reading C2PA metadata from files in the fixtures/files-for-reading-tests directory""" - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + self.assertIsNotNone(actions_assertion) - # Map of file extensions to MIME types - extensions = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - } + # Verify c2pa.created action exists and there is only one + actions = actions_assertion["data"]["actions"] + created_actions = [ + action for action in actions + if action["action"] == "c2pa.created" + ] - # Skip system files - skip_files = { - '.DS_Store' - } + self.assertEqual(len(created_actions), 1) - for filename in os.listdir(reading_dir): - if filename in skip_files: - continue - - file_path = os.path.join(reading_dir, filename) - if not os.path.isfile(file_path): - continue - - # Get file extension and corresponding MIME type - _, ext = os.path.splitext(filename) - ext = ext.lower() - if ext not in extensions: - continue - - try: - with open(file_path, "rb") as file: - # Remove the leading dot - parsed_extension = ext[1:] - reader = Reader(parsed_extension, file) - json_data = reader.json() - reader.close() - self.assertIsInstance(json_data, str) - # Verify the manifest contains expected fields - manifest = json.loads(json_data) - self.assertIn("manifests", manifest) - self.assertIn("active_manifest", manifest) - except Exception as e: - self.fail(f"Failed to read metadata from {filename}: {str(e)}") + # Verify the digitalSourceType is present in the created action + created_action = created_actions[0] + self.assertIn("digitalSourceType", created_action) + self.assertIn("digitalCreation", created_action["digitalSourceType"]) - def test_read_cached_all_files(self): - """Test reading C2PA metadata with cache functionality from all files in the fixtures/files-for-reading-tests directory""" - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertEqual(manifest_data["validation_state"], "Valid") + output.close() - # Map of file extensions to MIME types - mime_types = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.heic': 'image/heic', - '.heif': 'image/heif', - '.avif': 'image/avif', - '.tif': 'image/tiff', - '.tiff': 'image/tiff', - '.mp4': 'video/mp4', - '.avi': 'video/x-msvideo', - '.mp3': 'audio/mpeg', - '.m4a': 'audio/mp4', - '.wav': 'audio/wav', - '.pdf': 'application/pdf', - } + def test_streams_sign_with_es256_alg_edit_intent(self): + """Test signing with EDIT intent and empty manifest.""" - # Skip system files - skip_files = { - '.DS_Store' - } + with open(self.testPath2, "rb") as file: + # Start with an empty manifest + builder = Builder({}) + # Set the intent for editing existing content + builder.set_intent(C2paBuilderIntent.EDIT) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_str = reader.json() - for filename in os.listdir(reading_dir): - if filename in skip_files: - continue + # Verify the manifest was created + self.assertIsNotNone(json_str) - file_path = os.path.join(reading_dir, filename) - if not os.path.isfile(file_path): - continue + # Parse the JSON to verify the structure + manifest_data = json.loads(json_str) + active_manifest_label = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_label] - # Get file extension and corresponding MIME type - _, ext = os.path.splitext(filename) - ext = ext.lower() - if ext not in mime_types: - continue + # Check that ingredients exist in the active manifest + self.assertIn("ingredients", active_manifest) + ingredients_manifest = active_manifest["ingredients"] + self.assertIsInstance(ingredients_manifest, list) + self.assertEqual(len(ingredients_manifest), 1) - mime_type = mime_types[ext] + # Verify the ingredient has relationship "parentOf" + ingredient = ingredients_manifest[0] + self.assertIn("relationship", ingredient) + self.assertEqual( + ingredient["relationship"], + "parentOf" + ) - try: - with open(file_path, "rb") as file: - reader = Reader(mime_type, file) - - # Test 1: Verify cache variables are initially None - self.assertIsNone(reader._manifest_json_str_cache, f"JSON cache should be None initially for {filename}") - self.assertIsNone(reader._manifest_data_cache, f"Manifest data cache should be None initially for {filename}") - - # Test 2: Multiple calls to json() should return the same result and use cache - json_data_1 = reader.json() - self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache not set after first json() call for {filename}") - self.assertEqual(json_data_1, reader._manifest_json_str_cache, f"JSON cache doesn't match return value for {filename}") - - json_data_2 = reader.json() - self.assertEqual(json_data_1, json_data_2, f"JSON inconsistency for {filename}") - self.assertIsInstance(json_data_1, str) - - # Test 3: Test methods that use the cache - try: - # Test get_active_manifest() which uses _get_cached_manifest_data() - active_manifest = reader.get_active_manifest() - self.assertIsInstance(active_manifest, dict, f"Active manifest not dict for {filename}") - - # Test 4: Verify cache is set after calling cache-using methods - self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache not set after get_active_manifest for {filename}") - self.assertIsNotNone(reader._manifest_data_cache, f"Manifest data cache not set after get_active_manifest for {filename}") - - # Test 5: Multiple calls to cache-using methods should return the same result - active_manifest_2 = reader.get_active_manifest() - self.assertEqual(active_manifest, active_manifest_2, f"Active manifest cache inconsistency for {filename}") - - # Test get_validation_state() which uses the cache - validation_state = reader.get_validation_state() - # validation_state can be None, so just check it doesn't crash - - # Test get_validation_results() which uses the cache - validation_results = reader.get_validation_results() - # validation_results can be None, so just check it doesn't crash - - # Test 6: Multiple calls to validation methods should return the same result - validation_state_2 = reader.get_validation_state() - self.assertEqual(validation_state, validation_state_2, f"Validation state cache inconsistency for {filename}") - - validation_results_2 = reader.get_validation_results() - self.assertEqual(validation_results, validation_results_2, f"Validation results cache inconsistency for {filename}") - - except KeyError as e: - # Some files might not have active manifests or validation data - # This is expected for some test files, so we'll skip cache testing for those - pass - - # Test 7: Verify the manifest contains expected fields - manifest = json.loads(json_data_1) - self.assertIn("manifests", manifest) - self.assertIn("active_manifest", manifest) - - # Test 8: Test cache clearing on close - reader.close() - self.assertIsNone(reader._manifest_json_str_cache, f"JSON cache not cleared for {filename}") - self.assertIsNone(reader._manifest_data_cache, f"Manifest data cache not cleared for {filename}") + # Check that assertions exist + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] - except Exception as e: - self.fail(f"Failed to read cached metadata from {filename}: {str(e)}") + # Find the actions assertion + actions_assertion = None + for assertion in assertions: + if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: + actions_assertion = assertion + break - def test_reader_context_manager_with_exception(self): - """Test Reader state after exception in context manager.""" - try: - with Reader(self.testPath) as reader: - # Inside context - should be valid - self.assertEqual(reader._state, LifecycleState.ACTIVE) - self.assertIsNotNone(reader._handle) - self.assertIsNotNone(reader._own_stream) - self.assertIsNotNone(reader._backing_file) - raise ValueError("Test exception") - except ValueError: - pass + self.assertIsNotNone(actions_assertion) - # After exception - should still be closed - self.assertEqual(reader._state, LifecycleState.CLOSED) - self.assertIsNone(reader._handle) - self.assertIsNone(reader._own_stream) - self.assertIsNone(reader._backing_file) - - def test_reader_partial_initialization_states(self): - """Test Reader behavior with partial initialization failures.""" - # Test with _reader = None but _state = ACTIVE - reader = Reader.__new__(Reader) - reader._state = LifecycleState.ACTIVE - reader._handle = None - reader._own_stream = None - reader._backing_file = None + # Verify c2pa.opened action exists and there is only one + actions = actions_assertion["data"]["actions"] + opened_actions = [ + action for action in actions + if action["action"] == "c2pa.opened" + ] - with self.assertRaises(Error): - reader._ensure_valid_state() + self.assertEqual(len(opened_actions), 1) - def test_reader_cleanup_state_transitions(self): - """Test Reader state during cleanup operations.""" - reader = Reader(self.testPath) + # Verify the c2pa.opened action has the correct structure + opened_action = opened_actions[0] + self.assertIn("parameters", opened_action) + self.assertIn("ingredients", opened_action["parameters"]) + ingredients = opened_action["parameters"]["ingredients"] + self.assertIsInstance(ingredients, list) + self.assertGreater(len(ingredients), 0) - reader._cleanup_resources() - self.assertEqual(reader._state, LifecycleState.CLOSED) - self.assertIsNone(reader._handle) - self.assertIsNone(reader._own_stream) - self.assertIsNone(reader._backing_file) + # Verify each ingredient has url and hash + for ingredient in ingredients: + self.assertIn("url", ingredient) + self.assertIn("hash", ingredient) - def test_reader_cleanup_idempotency(self): - """Test that cleanup operations are idempotent.""" - reader = Reader(self.testPath) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertEqual(manifest_data["validation_state"], "Valid") + output.close() - # First cleanup - reader._cleanup_resources() - self.assertEqual(reader._state, LifecycleState.CLOSED) + def test_streams_sign_with_es256_alg_with_trust_config(self): + # Run in a separate thread to isolate thread-local settings + result = {} + exception = {} - # Second cleanup should not change state - reader._cleanup_resources() - self.assertEqual(reader._state, LifecycleState.CLOSED) - self.assertIsNone(reader._handle) - self.assertIsNone(reader._own_stream) - self.assertIsNone(reader._backing_file) + def sign_and_validate_with_trust_config(): + try: + # Load trust configuration + settings_dict = load_test_settings_json() - def test_reader_state_with_invalid_native_pointer(self): - """Test Reader state handling with invalid native pointer.""" - reader = Reader(self.testPath) + # Apply the settings (including trust configuration) + # Settings are thread-local, so they won't affect other tests + # And that is why we also run the test in its own thread, so tests are isolated + load_settings(settings_dict) - # Simulate invalid native pointer - reader._handle = 0 + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() - # Operations should fail gracefully - with self.assertRaises(Error): - reader.json() + # Get validation state with trust config + validation_state = reader.get_validation_state() - def test_reader_is_embedded(self): - """Test the is_embedded method returns correct values for embedded and remote manifests.""" + result['json_data'] = json_data + result['validation_state'] = validation_state + output.close() + except Exception as e: + exception['error'] = e - # Test with a fixture which has an embedded manifest - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - self.assertTrue(reader.is_embedded()) - reader.close() + # Create and start thread + thread = threading.Thread(target=sign_and_validate_with_trust_config) + thread.start() + thread.join() - # Test with cloud.jpg fixture which has a remote manifest (not embedded) - cloud_fixture_path = os.path.join(self.data_dir, "cloud.jpg") - with Reader("image/jpeg", cloud_fixture_path) as reader: - self.assertFalse(reader.is_embedded()) + # Check for exceptions + if 'error' in exception: + raise exception['error'] - def test_sign_and_read_is_not_embedded(self): - """Test the is_embedded method returns correct values for remote manifests.""" + # Assertions run in main thread + self.assertIn("Python Test", result.get('json_data', '')) + # With trust configuration loaded, validation should return "Trusted" + self.assertIsNotNone(result.get('validation_state')) + self.assertEqual(result.get('validation_state'), "Trusted") - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + def test_sign_with_ed25519_alg(self): + with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: certs = cert_file.read() - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + with open(os.path.join(self.data_dir, "ed25519.pem"), "rb") as key_file: key = key_file.read() - # Create signer info and signer signer_info = C2paSignerInfo( - alg=b"es256", + alg=b"ed25519", sign_cert=certs, private_key=key, ta_url=b"http://timestamp.digicert.com" ) signer = Signer.from_info(signer_info) - # Define a simple manifest - manifest_definition = { - "claim_generator": "python_test", - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - "format": "image/jpeg", - "title": "Python Test Image", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } - - # Create a temporary directory for the signed file - with tempfile.TemporaryDirectory() as temp_dir: - temp_file_path = os.path.join(temp_dir, "signed_test_file_no_embed.jpg") - - with open(self.testPath, "rb") as file: - builder = Builder(manifest_definition) - # Direct the Builder not to embed the manifest into the asset - builder.set_no_embed() - - - with open(temp_file_path, "wb") as temp_file: - manifest_data = builder.sign( - signer, "image/jpeg", file, temp_file) - - with Reader("image/jpeg", temp_file_path, manifest_data) as reader: - self.assertFalse(reader.is_embedded()) - - def test_reader_get_remote_url(self): - """Test the get_remote_url method returns correct values for embedded and remote manifests.""" - - # Test get_remote_url for file with embedded manifest (should return None) - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - self.assertIsNone(reader.get_remote_url()) - reader.close() - - # Test remote manifest using cloud.jpg fixture which has a remote URL - cloud_fixture_path = os.path.join(self.data_dir, "cloud.jpg") - with Reader("image/jpeg", cloud_fixture_path) as reader: - remote_url = reader.get_remote_url() - self.assertEqual(remote_url, "https://cai-manifests.adobe.com/manifests/adobe-urn-uuid-5f37e182-3687-462e-a7fb-573462780391") - self.assertFalse(reader.is_embedded()) - - def test_stream_read_and_parse_cached(self): - """Test reading and parsing with cache verification by repeating operations multiple times""" with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + output.close() - # Verify cache starts as None - self.assertIsNone(reader._manifest_json_str_cache, "JSON cache should be None initially") - self.assertIsNone(reader._manifest_data_cache, "Manifest data cache should be None initially") + def test_sign_with_ed25519_alg_with_trust_config(self): + # Run in a separate thread to isolate thread-local settings + result = {} + exception = {} - # First operation - should populate cache - manifest_store_1 = json.loads(reader.json()) - title_1 = manifest_store_1["manifests"][manifest_store_1["active_manifest"]]["title"] - self.assertEqual(title_1, DEFAULT_TEST_FILE_NAME) + def sign_and_validate_with_trust_config(): + try: + # Load trust configuration + settings_dict = load_test_settings_json() - # Verify cache is populated after first json() call - self.assertIsNotNone(reader._manifest_json_str_cache, "JSON cache should be set after first json() call") - self.assertEqual(manifest_store_1, json.loads(reader._manifest_json_str_cache), "Cached JSON should match parsed result") + # Apply the settings (including trust configuration) + # Settings are thread-local, so they won't affect other tests + # And that is why we also run the test in its own thread, so tests are isolated + load_settings(settings_dict) - # Repeat the same operation multiple times to verify cache usage - for i in range(5): - manifest_store = json.loads(reader.json()) - title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] - self.assertEqual(title, DEFAULT_TEST_FILE_NAME, f"Title should be consistent on iteration {i+1}") + with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ed25519.pem"), "rb") as key_file: + key = key_file.read() - # Verify cache is still populated and consistent - self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache should remain set on iteration {i+1}") - self.assertEqual(manifest_store, json.loads(reader._manifest_json_str_cache), f"Cached JSON should match parsed result on iteration {i+1}") + signer_info = C2paSignerInfo( + alg=b"ed25519", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) - # Test methods that use the cache - # Test get_active_manifest() which uses _get_cached_manifest_data() - active_manifest_1 = reader.get_active_manifest() - self.assertIsInstance(active_manifest_1, dict, "Active manifest should be a dict") + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() - # Verify manifest data cache is populated - self.assertIsNotNone(reader._manifest_data_cache, "Manifest data cache should be set after get_active_manifest()") + # Get validation state with trust config + validation_state = reader.get_validation_state() - # Repeat get_active_manifest() multiple times to verify cache usage - for i in range(3): - active_manifest = reader.get_active_manifest() - self.assertEqual(active_manifest_1, active_manifest, f"Active manifest should be consistent on iteration {i+1}") + result['json_data'] = json_data + result['validation_state'] = validation_state + output.close() + except Exception as e: + exception['error'] = e - # Verify cache remains populated - self.assertIsNotNone(reader._manifest_data_cache, f"Manifest data cache should remain set on iteration {i+1}") + # Create and start thread + thread = threading.Thread(target=sign_and_validate_with_trust_config) + thread.start() + thread.join() - # Test get_validation_state() and get_validation_results() with cache - validation_state_1 = reader.get_validation_state() - validation_results_1 = reader.get_validation_results() + # Check for exceptions + if 'error' in exception: + raise exception['error'] - # Repeat validation methods to verify cache usage - for i in range(3): - validation_state = reader.get_validation_state() - validation_results = reader.get_validation_results() + # Assertions run in main thread + self.assertIn("Python Test", result.get('json_data', '')) + # With trust configuration loaded, validation should return "Trusted" + self.assertIsNotNone(result.get('validation_state')) + self.assertEqual(result.get('validation_state'), "Trusted") - self.assertEqual(validation_state_1, validation_state, f"Validation state should be consistent on iteration {i+1}") - self.assertEqual(validation_results_1, validation_results, f"Validation results should be consistent on iteration {i+1}") + def test_sign_with_ed25519_alg_2(self): + with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ed25519.pem"), "rb") as key_file: + key = key_file.read() - # Verify cache clearing on close - reader.close() - self.assertIsNone(reader._manifest_json_str_cache, "JSON cache should be cleared on close") - self.assertIsNone(reader._manifest_data_cache, "Manifest data cache should be cleared on close") - - # TODO: Unskip once fixed configuration to read data is clarified - # def test_read_cawg_data_file(self): - # """Test reading C2PA metadata from C_with_CAWG_data.jpg file.""" - # file_path = os.path.join(self.data_dir, "C_with_CAWG_data.jpg") - - # with open(file_path, "rb") as file: - # reader = Reader("image/jpeg", file) - # json_data = reader.json() - # self.assertIsInstance(json_data, str) - - # # Parse the JSON and verify specific fields - # manifest_data = json.loads(json_data) - - # # Verify basic manifest structure - # self.assertIn("manifests", manifest_data) - # self.assertIn("active_manifest", manifest_data) - - # # Get the active manifest - # active_manifest_id = manifest_data["active_manifest"] - # active_manifest = manifest_data["manifests"][active_manifest_id] - - # # Verify manifest is not null or empty - # assert active_manifest is not None, "Active manifest should not be null" - # assert len(active_manifest) > 0, "Active manifest should not be empty" - - -class TestBuilderWithSigner(unittest.TestCase): - def setUp(self): - # Use the fixtures_dir fixture to set up paths - self.data_dir = FIXTURES_DIR - self.testPath = DEFAULT_TEST_FILE - self.testPath2 = INGREDIENT_TEST_FILE - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: - self.certs = cert_file.read() - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - self.key = key_file.read() - - # Create a local Es256 signer with certs and a timestamp server - self.signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=self.certs, - private_key=self.key, - ta_url=b"http://timestamp.digicert.com" - ) - self.signer = Signer.from_info(self.signer_info) - - self.testPath3 = os.path.join(self.data_dir, "A_thumbnail.jpg") - self.testPath4 = ALTERNATIVE_INGREDIENT_TEST_FILE - - # Define a manifest as a dictionary - self.manifestDefinition = { - "claim_generator": "python_test", - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - "claim_version": 1, - "format": "image/jpeg", - "title": "Python Test Image", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } - - # Define a V2 manifest as a dictionary - self.manifestDefinitionV2 = { - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - # claim version 2 is the default - # "claim_version": 2, - "format": "image/jpeg", - "title": "Python Test Image V2", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } - - # Define an example ES256 callback signer - self.callback_signer_alg = "Es256" - def callback_signer_es256(data: bytes) -> bytes: - private_key = serialization.load_pem_private_key( - self.key, - password=None, - backend=default_backend() - ) - signature = private_key.sign( - data, - ec.ECDSA(hashes.SHA256()) - ) - return signature - self.callback_signer_es256 = callback_signer_es256 - - def test_can_retrieve_builder_supported_mimetypes(self): - result1 = Builder.get_supported_mime_types() - self.assertTrue(len(result1) > 0) - - # Cache-hit - result2 = Builder.get_supported_mime_types() - self.assertTrue(len(result2) > 0) - - self.assertEqual(result1, result2) - - def test_reserve_size(self): signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=self.certs, - private_key=self.key, + alg=b"ed25519", + sign_cert=certs, + private_key=key, ta_url=b"http://timestamp.digicert.com" ) signer = Signer.from_info(signer_info) - signer.reserve_size() - - def test_signer_creation_error_alg(self): - signer_info = C2paSignerInfo( - alg=b"not-an-alg", - sign_cert=self.certs, - private_key=self.key, - ta_url=b"http://timestamp.digicert.com" - ) - with self.assertRaises(Error): - Signer.from_info(signer_info) - def test_signer_from_callback_error_no_cert(self): - with self.assertRaises(Error): - Signer.from_callback( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=None, - tsa_url="http://timestamp.digicert.com" - ) + with open(self.testPath2, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + output.close() - def test_signer_from_callback_error_wrong_url(self): - with self.assertRaises(Error): - Signer.from_callback( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=None, - tsa_url="ftp://timestamp.digicert.com" - ) + def test_sign_with_ps256_alg(self): + with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ps256.pem"), "rb") as key_file: + key = key_file.read() - def test_reserve_size_on_closed_signer(self): signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=self.certs, - private_key=self.key, + alg=b"ps256", + sign_cert=certs, + private_key=key, ta_url=b"http://timestamp.digicert.com" ) signer = Signer.from_info(signer_info) - signer.close() - # Verify signer is closed by testing that operations fail - with self.assertRaises(Error): - signer.reserve_size() - def test_signer_double_close(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + output.close() + + def test_sign_with_ps256_alg_2(self): + with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ps256.pem"), "rb") as key_file: + key = key_file.read() + signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=self.certs, - private_key=self.key, + alg=b"ps256", + sign_cert=certs, + private_key=key, ta_url=b"http://timestamp.digicert.com" ) signer = Signer.from_info(signer_info) - signer.close() - # Second close should not raise an exception - signer.close() - def test_builder_detects_malformed_json(self): - with self.assertRaises(Error): - Builder("{this is not json}") - - def test_builder_does_not_allow_sign_after_close(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) + with open(self.testPath2, "rb") as file: + builder = Builder(self.manifestDefinitionV2) output = io.BytesIO(bytearray()) - builder.close() - with self.assertRaises(Error): - builder.sign(self.signer, "image/jpeg", file, output) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted + # self.assertNotIn("validation_status", json_data) + output.close() - def test_builder_does_not_allow_archiving_after_close(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - placeholder_stream = io.BytesIO(bytearray()) - builder.close() - with self.assertRaises(Error): - builder.to_archive(placeholder_stream) + def test_sign_with_ps256_alg_2_with_trust_config(self): + # Run in a separate thread to isolate thread-local settings + result = {} + exception = {} - def test_builder_does_not_allow_changing_remote_url_after_close(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - builder.close() - with self.assertRaises(Error): - builder.set_remote_url("a-remote-url-that-is-not-important-in-this-tests") + def sign_and_validate_with_trust_config(): + try: + # Load trust configuration + settings_dict = load_test_settings_json() - def test_builder_does_not_allow_adding_resource_after_close(self): - builder = Builder(self.manifestDefinition) - placeholder_stream = io.BytesIO(bytearray()) - builder.close() - with self.assertRaises(Error): - builder.add_resource("a-remote-url-that-is-not-important-in-this-tests", placeholder_stream) + # Apply the settings (including trust configuration) + # Settings are thread-local, so they won't affect other tests + # And that is why we also run the test in its own thread, so tests are isolated + load_settings(settings_dict) - def test_builder_add_thumbnail_resource(self): - builder = Builder(self.manifestDefinition) - with open(self.testPath2, "rb") as thumbnail_file: - builder.add_resource("thumbnail", thumbnail_file) - builder.close() - - def test_builder_double_close(self): - builder = Builder(self.manifestDefinition) - # First close - builder.close() - # Second close should not raise an exception - builder.close() - # Verify builder is closed - with self.assertRaises(Error): - builder.set_no_embed() - - def test_streams_sign_recover_bytes_only(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - manifest_bytes = builder.sign(self.signer, "image/jpeg", file) - self.assertIsNotNone(manifest_bytes) - - def test_streams_sign_with_thumbnail_resource(self): - with open(self.testPath2, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - - with open(self.testPath2, "rb") as thumbnail_file: - builder.add_resource("thumbnail", thumbnail_file) - - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - output.close() - - def test_streams_sign_with_es256_alg_v1_manifest(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - self.assertIn("Valid", json_data) - - # Write buffer to file - # output.seek(0) - # with open('/target_path', 'wb') as f: - # f.write(output.getbuffer()) - - output.close() - - def test_streams_sign_with_es256_alg_v1_manifest_to_existing_empty_file(self): - test_file_name = os.path.join(self.data_dir, "temp_data", "temp_signing.jpg") - # Ensure tmp directory exists - os.makedirs(os.path.dirname(test_file_name), exist_ok=True) - - # Ensure the target file exists before opening it in rb+ mode - with open(test_file_name, "wb") as f: - pass # Create empty file - - try: - with open(self.testPath, "rb") as source, open(test_file_name, "w+b") as target: - builder = Builder(self.manifestDefinition) - builder.sign(self.signer, "image/jpeg", source, target) - reader = Reader("image/jpeg", target) - json_data = reader.json() - self.assertIn("Python Test", json_data) - self.assertIn("Valid", json_data) - - finally: - # Clean up... - - if os.path.exists(test_file_name): - os.remove(test_file_name) - - # Also clean up the temp directory if it's empty - temp_dir = os.path.dirname(test_file_name) - if os.path.exists(temp_dir) and not os.listdir(temp_dir): - os.rmdir(temp_dir) - - def test_streams_sign_with_es256_alg_v1_manifest_to_new_dest_file(self): - test_file_name = os.path.join(self.data_dir, "temp_data", "temp_signing.jpg") - # Ensure tmp directory exists - os.makedirs(os.path.dirname(test_file_name), exist_ok=True) - - # A new target/destination file should be created during the test run - try: - with open(self.testPath, "rb") as source, open(test_file_name, "w+b") as target: - builder = Builder(self.manifestDefinition) - builder.sign(self.signer, "image/jpeg", source, target) - reader = Reader("image/jpeg", target) - json_data = reader.json() - self.assertIn("Python Test", json_data) - self.assertIn("Valid", json_data) - - finally: - # Clean up... - - if os.path.exists(test_file_name): - os.remove(test_file_name) - - # Also clean up the temp directory if it's empty - temp_dir = os.path.dirname(test_file_name) - if os.path.exists(temp_dir) and not os.listdir(temp_dir): - os.rmdir(temp_dir) - - def test_streams_sign_with_es256_alg(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - output.close() - - def test_streams_sign_with_es256_alg_2(self): - with open(self.testPath2, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - self.assertIn("Valid", json_data) - output.close() - - def test_streams_sign_with_es256_alg_create_intent(self): - """Test signing with CREATE intent and empty manifest.""" - - with open(self.testPath2, "rb") as file: - # Start with an empty manifest - builder = Builder({}) - # Set the intent for creating new content - builder.set_intent( - C2paBuilderIntent.CREATE, - C2paDigitalSourceType.DIGITAL_CREATION - ) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_str = reader.json() - # Verify the manifest was created - self.assertIsNotNone(json_str) - - # Parse the JSON to verify the structure - manifest_data = json.loads(json_str) - active_manifest_label = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_label] - - # Check that assertions exist - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the actions assertion - actions_assertion = None - for assertion in assertions: - if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - - # Verify c2pa.created action exists and there is only one - actions = actions_assertion["data"]["actions"] - created_actions = [ - action for action in actions - if action["action"] == "c2pa.created" - ] - - self.assertEqual(len(created_actions), 1) - - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertEqual(manifest_data["validation_state"], "Valid") - output.close() - - def test_streams_sign_with_es256_alg_create_intent_2(self): - """Test signing with CREATE intent and manifestDefinitionV2.""" - - with open(self.testPath2, "rb") as file: - # Start with manifestDefinitionV2 which has predefined metadata - builder = Builder(self.manifestDefinitionV2) - # Set the intent for creating new content - # If we provided a full manifest, the digital source type from the full manifest "wins" - builder.set_intent( - C2paBuilderIntent.CREATE, - C2paDigitalSourceType.SCREEN_CAPTURE - ) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_str = reader.json() - - # Verify the manifest was created - self.assertIsNotNone(json_str) - - # Parse the JSON to verify the structure - manifest_data = json.loads(json_str) - active_manifest_label = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_label] - - # Verify title from manifestDefinitionV2 is preserved - self.assertIn("title", active_manifest) - self.assertEqual(active_manifest["title"], "Python Test Image V2") - - # Verify claim_generator_info is present - self.assertIn("claim_generator_info", active_manifest) - claim_generator_info = active_manifest["claim_generator_info"] - self.assertIsInstance(claim_generator_info, list) - self.assertGreater(len(claim_generator_info), 0) - - # Check for the custom claim generator info from manifestDefinitionV2 - has_python_test = any( - gen.get("name") == "python_test" and gen.get("version") == "0.0.1" - for gen in claim_generator_info - ) - self.assertTrue(has_python_test, "Should have python_test claim generator") - - # Verify no ingredients for CREATE intent - ingredients_manifest = active_manifest.get("ingredients", []) - self.assertEqual(len(ingredients_manifest), 0, "CREATE intent should have no ingredients") - - # Check that assertions exist - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the actions assertion - actions_assertion = None - for assertion in assertions: - if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - - # Verify c2pa.created action exists and there is only one - actions = actions_assertion["data"]["actions"] - created_actions = [ - action for action in actions - if action["action"] == "c2pa.created" - ] - - self.assertEqual(len(created_actions), 1) - - # Verify the digitalSourceType is present in the created action - created_action = created_actions[0] - self.assertIn("digitalSourceType", created_action) - self.assertIn("digitalCreation", created_action["digitalSourceType"]) - - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertEqual(manifest_data["validation_state"], "Valid") - output.close() - - def test_streams_sign_with_es256_alg_edit_intent(self): - """Test signing with EDIT intent and empty manifest.""" - - with open(self.testPath2, "rb") as file: - # Start with an empty manifest - builder = Builder({}) - # Set the intent for editing existing content - builder.set_intent(C2paBuilderIntent.EDIT) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_str = reader.json() - - # Verify the manifest was created - self.assertIsNotNone(json_str) - - # Parse the JSON to verify the structure - manifest_data = json.loads(json_str) - active_manifest_label = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_label] - - # Check that ingredients exist in the active manifest - self.assertIn("ingredients", active_manifest) - ingredients_manifest = active_manifest["ingredients"] - self.assertIsInstance(ingredients_manifest, list) - self.assertEqual(len(ingredients_manifest), 1) - - # Verify the ingredient has relationship "parentOf" - ingredient = ingredients_manifest[0] - self.assertIn("relationship", ingredient) - self.assertEqual( - ingredient["relationship"], - "parentOf" - ) - - # Check that assertions exist - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the actions assertion - actions_assertion = None - for assertion in assertions: - if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - - # Verify c2pa.opened action exists and there is only one - actions = actions_assertion["data"]["actions"] - opened_actions = [ - action for action in actions - if action["action"] == "c2pa.opened" - ] - - self.assertEqual(len(opened_actions), 1) - - # Verify the c2pa.opened action has the correct structure - opened_action = opened_actions[0] - self.assertIn("parameters", opened_action) - self.assertIn("ingredients", opened_action["parameters"]) - ingredients = opened_action["parameters"]["ingredients"] - self.assertIsInstance(ingredients, list) - self.assertGreater(len(ingredients), 0) - - # Verify each ingredient has url and hash - for ingredient in ingredients: - self.assertIn("url", ingredient) - self.assertIn("hash", ingredient) - - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertEqual(manifest_data["validation_state"], "Valid") - output.close() - - def test_streams_sign_with_es256_alg_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings - result = {} - exception = {} - - def sign_and_validate_with_trust_config(): - try: - # Load trust configuration - settings_dict = load_test_settings_json() - - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - - # Get validation state with trust config - validation_state = reader.get_validation_state() - - result['json_data'] = json_data - result['validation_state'] = validation_state - output.close() - except Exception as e: - exception['error'] = e - - # Create and start thread - thread = threading.Thread(target=sign_and_validate_with_trust_config) - thread.start() - thread.join() - - # Check for exceptions - if 'error' in exception: - raise exception['error'] - - # Assertions run in main thread - self.assertIn("Python Test", result.get('json_data', '')) - # With trust configuration loaded, validation should return "Trusted" - self.assertIsNotNone(result.get('validation_state')) - self.assertEqual(result.get('validation_state'), "Trusted") - - def test_sign_with_ed25519_alg(self): - with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "ed25519.pem"), "rb") as key_file: - key = key_file.read() - - signer_info = C2paSignerInfo( - alg=b"ed25519", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - output.close() - - def test_sign_with_ed25519_alg_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings - result = {} - exception = {} - - def sign_and_validate_with_trust_config(): - try: - # Load trust configuration - settings_dict = load_test_settings_json() - - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) - - with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "ed25519.pem"), "rb") as key_file: - key = key_file.read() - - signer_info = C2paSignerInfo( - alg=b"ed25519", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - - # Get validation state with trust config - validation_state = reader.get_validation_state() - - result['json_data'] = json_data - result['validation_state'] = validation_state - output.close() - except Exception as e: - exception['error'] = e - - # Create and start thread - thread = threading.Thread(target=sign_and_validate_with_trust_config) - thread.start() - thread.join() - - # Check for exceptions - if 'error' in exception: - raise exception['error'] - - # Assertions run in main thread - self.assertIn("Python Test", result.get('json_data', '')) - # With trust configuration loaded, validation should return "Trusted" - self.assertIsNotNone(result.get('validation_state')) - self.assertEqual(result.get('validation_state'), "Trusted") - - def test_sign_with_ed25519_alg_2(self): - with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "ed25519.pem"), "rb") as key_file: - key = key_file.read() - - signer_info = C2paSignerInfo( - alg=b"ed25519", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - - with open(self.testPath2, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - output.close() - - def test_sign_with_ps256_alg(self): - with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "ps256.pem"), "rb") as key_file: - key = key_file.read() - - signer_info = C2paSignerInfo( - alg=b"ps256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - output.close() - - def test_sign_with_ps256_alg_2(self): - with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "ps256.pem"), "rb") as key_file: - key = key_file.read() - - signer_info = C2paSignerInfo( - alg=b"ps256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - - with open(self.testPath2, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted - # self.assertNotIn("validation_status", json_data) - output.close() - - def test_sign_with_ps256_alg_2_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings - result = {} - exception = {} - - def sign_and_validate_with_trust_config(): - try: - # Load trust configuration - settings_dict = load_test_settings_json() - - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) - - with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "ps256.pem"), "rb") as key_file: - key = key_file.read() - - signer_info = C2paSignerInfo( - alg=b"ps256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - - with open(self.testPath2, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - - # Get validation state with trust config - validation_state = reader.get_validation_state() - - result['json_data'] = json_data - result['validation_state'] = validation_state - output.close() - except Exception as e: - exception['error'] = e - - # Create and start thread - thread = threading.Thread(target=sign_and_validate_with_trust_config) - thread.start() - thread.join() - - # Check for exceptions - if 'error' in exception: - raise exception['error'] - - # Assertions run in main thread - self.assertIn("Python Test", result.get('json_data', '')) - # With trust configuration loaded, validation should return "Trusted" - self.assertIsNotNone(result.get('validation_state')) - self.assertEqual(result.get('validation_state'), "Trusted") - - def test_archive_sign(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - archive = io.BytesIO(bytearray()) - builder.to_archive(archive) - builder = Builder.from_archive(archive) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - archive.close() - output.close() - - def test_archive_sign_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings - result = {} - exception = {} - - def sign_and_validate_with_trust_config(): - try: - # Load trust configuration - settings_dict = load_test_settings_json() - - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - archive = io.BytesIO(bytearray()) - builder.to_archive(archive) - builder = Builder.from_archive(archive) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - - # Get validation state with trust config - validation_state = reader.get_validation_state() - - result['json_data'] = json_data - result['validation_state'] = validation_state - archive.close() - output.close() - except Exception as e: - exception['error'] = e - - # Create and start thread - thread = threading.Thread(target=sign_and_validate_with_trust_config) - thread.start() - thread.join() - - # Check for exceptions - if 'error' in exception: - raise exception['error'] - - # Assertions run in main thread - self.assertIn("Python Test", result.get('json_data', '')) - # With trust configuration loaded, validation should return "Trusted" - self.assertIsNotNone(result.get('validation_state')) - self.assertEqual(result.get('validation_state'), "Trusted") - - def test_archive_sign_with_added_ingredient(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - archive = io.BytesIO(bytearray()) - builder.to_archive(archive) - builder = Builder.from_archive(archive) - output = io.BytesIO(bytearray()) - ingredient_json = '{"test": "ingredient"}' - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - archive.close() - output.close() - - def test_archive_sign_with_added_ingredient_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings - result = {} - exception = {} - - def sign_and_validate_with_trust_config(): - try: - # Load trust configuration - settings_dict = load_test_settings_json() - - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - archive = io.BytesIO(bytearray()) - builder.to_archive(archive) - builder = Builder.from_archive(archive) - output = io.BytesIO(bytearray()) - ingredient_json = '{"test": "ingredient"}' - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - - # Get validation state with trust config - validation_state = reader.get_validation_state() - - result['json_data'] = json_data - result['validation_state'] = validation_state - archive.close() - output.close() - except Exception as e: - exception['error'] = e - - # Create and start thread - thread = threading.Thread(target=sign_and_validate_with_trust_config) - thread.start() - thread.join() - - # Check for exceptions - if 'error' in exception: - raise exception['error'] - - # Assertions run in main thread - self.assertIn("Python Test", result.get('json_data', '')) - # With trust configuration loaded, validation should return "Trusted" - self.assertIsNotNone(result.get('validation_state')) - self.assertEqual(result.get('validation_state'), "Trusted") - - def test_remote_sign(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - builder.set_no_embed() - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - - output.seek(0) - # When set_no_embed() is used, no manifest should be embedded in the file - # So reading from the file should fail - with self.assertRaises(Error): - Reader("image/jpeg", output) - output.close() - - def test_remote_sign_using_returned_bytes(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - builder.set_no_embed() - with io.BytesIO() as output_buffer: - manifest_data = builder.sign( - self.signer, "image/jpeg", file, output_buffer) - output_buffer.seek(0) - read_buffer = io.BytesIO(output_buffer.getvalue()) - - with Reader("image/jpeg", read_buffer, manifest_data) as reader: - manifest_data = reader.json() - self.assertIn("Python Test", manifest_data) - - def test_remote_sign_using_returned_bytes_V2(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - builder.set_no_embed() - with io.BytesIO() as output_buffer: - manifest_data = builder.sign( - self.signer, "image/jpeg", file, output_buffer) - output_buffer.seek(0) - read_buffer = io.BytesIO(output_buffer.getvalue()) - - with Reader("image/jpeg", read_buffer, manifest_data) as reader: - manifest_data = reader.json() - self.assertIn("Python Test", manifest_data) - - def test_remote_sign_using_returned_bytes_V2_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings - result = {} - exception = {} - - def sign_and_validate_with_trust_config(): - try: - # Load trust configuration - settings_dict = load_test_settings_json() - - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - builder.set_no_embed() - with io.BytesIO() as output_buffer: - manifest_data = builder.sign( - self.signer, "image/jpeg", file, output_buffer) - output_buffer.seek(0) - read_buffer = io.BytesIO(output_buffer.getvalue()) - - with Reader("image/jpeg", read_buffer, manifest_data) as reader: - json_data = reader.json() - - # Get validation state with trust config - validation_state = reader.get_validation_state() - - result['json_data'] = json_data - result['validation_state'] = validation_state - except Exception as e: - exception['error'] = e - - # Create and start thread - thread = threading.Thread(target=sign_and_validate_with_trust_config) - thread.start() - thread.join() - - # Check for exceptions - if 'error' in exception: - raise exception['error'] - - # Assertions run in main thread - self.assertIn("Python Test", result.get('json_data', '')) - # With trust configuration loaded, validation should return "Trusted" - self.assertIsNotNone(result.get('validation_state')) - self.assertEqual(result.get('validation_state'), "Trusted") - - def test_sign_all_files(self): - """Test signing all files in both fixtures directories""" - signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") - - # Map of file extensions to MIME types - mime_types = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.heic': 'image/heic', - '.heif': 'image/heif', - '.avif': 'image/avif', - '.tif': 'image/tiff', - '.tiff': 'image/tiff', - '.mp4': 'video/mp4', - '.avi': 'video/x-msvideo', - '.mp3': 'audio/mpeg', - '.m4a': 'audio/mp4', - '.wav': 'audio/wav' - } - - # Skip files that are known to be invalid or unsupported - skip_files = { - 'sample3.invalid.wav', # Invalid file - } - - # Process both directories - for directory in [signing_dir, reading_dir]: - for filename in os.listdir(directory): - if filename in skip_files: - continue - - file_path = os.path.join(directory, filename) - if not os.path.isfile(file_path): - continue - - # Get file extension and corresponding MIME type - _, ext = os.path.splitext(filename) - ext = ext.lower() - if ext not in mime_types: - continue - - mime_type = mime_types[ext] - - try: - with open(file_path, "rb") as file: - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, mime_type, file, output) - builder.close() - output.seek(0) - reader = Reader(mime_type, output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - reader.close() - output.close() - except Error.NotSupported: - continue - except Exception as e: - self.fail(f"Failed to sign {filename}: {str(e)}") - - def test_sign_all_files_V2(self): - """Test signing all files in both fixtures directories""" - signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") - - # Map of file extensions to MIME types - mime_types = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.heic': 'image/heic', - '.heif': 'image/heif', - '.avif': 'image/avif', - '.tif': 'image/tiff', - '.tiff': 'image/tiff', - '.mp4': 'video/mp4', - '.avi': 'video/x-msvideo', - '.mp3': 'audio/mpeg', - '.m4a': 'audio/mp4', - '.wav': 'audio/wav' - } - - # Skip files that are known to be invalid or unsupported - skip_files = { - 'sample3.invalid.wav', # Invalid file - } - - # Process both directories - for directory in [signing_dir, reading_dir]: - for filename in os.listdir(directory): - if filename in skip_files: - continue - - file_path = os.path.join(directory, filename) - if not os.path.isfile(file_path): - continue - - # Get file extension and corresponding MIME type - _, ext = os.path.splitext(filename) - ext = ext.lower() - if ext not in mime_types: - continue - - mime_type = mime_types[ext] - - try: - with open(file_path, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, mime_type, file, output) - builder.close() - output.seek(0) - reader = Reader(mime_type, output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted` - self.assertIn("Valid", json_data) - reader.close() - output.close() - except Error.NotSupported: - continue - except Exception as e: - self.fail(f"Failed to sign {filename}: {str(e)}") - - def test_builder_no_added_ingredient_on_closed_builder(self): - builder = Builder(self.manifestDefinition) - - builder.close() - - with self.assertRaises(Error): - ingredient_json = '{"test": "ingredient"}' - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - def test_builder_add_ingredient(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test adding ingredient - ingredient_json = '{"test": "ingredient"}' - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - builder.close() - - def test_builder_add_ingredient_dict(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test adding ingredient with a dictionary instead of JSON string - ingredient_dict = {"test": "ingredient"} - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_dict, "image/jpeg", f) - - builder.close() - - def test_builder_add_multiple_ingredients(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test builder operations - builder.set_no_embed() - builder.set_remote_url("http://test.url") - - # Test adding ingredient - ingredient_json = '{"test": "ingredient"}' - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - # Test adding another ingredient - ingredient_json = '{"test": "ingredient2"}' - with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) - - builder.close() - - def test_builder_add_multiple_ingredients_2(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test builder operations - builder.set_no_embed() - builder.set_remote_url("http://test.url") - - # Test adding ingredient with a dictionary - ingredient_dict = {"test": "ingredient"} - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_dict, "image/jpeg", f) - - # Test adding another ingredient with a JSON string - ingredient_json = '{"test": "ingredient2"}' - with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) - - builder.close() - - def test_builder_add_multiple_ingredients_and_resources(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test builder operations - builder.set_no_embed() - builder.set_remote_url("http://test.url") - - # Test adding resource - with open(self.testPath, 'rb') as f: - builder.add_resource("test_uri_1", f) - - with open(self.testPath, 'rb') as f: - builder.add_resource("test_uri_2", f) - - with open(self.testPath, 'rb') as f: - builder.add_resource("test_uri_3", f) - - # Test adding ingredients - ingredient_json = '{"test": "ingredient"}' - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - ingredient_json = '{"test": "ingredient2"}' - with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) - - builder.close() - - def test_builder_add_multiple_ingredients_and_resources_interleaved(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - with open(self.testPath, 'rb') as f: - builder.add_resource("test_uri_1", f) - - ingredient_json = '{"test": "ingredient"}' - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - with open(self.testPath, 'rb') as f: - builder.add_resource("test_uri_2", f) - - with open(self.testPath, 'rb') as f: - builder.add_resource("test_uri_3", f) - - ingredient_json = '{"test": "ingredient2"}' - with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) - - builder.close() - - def test_builder_sign_with_ingredient(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test adding ingredient - ingredient_json = '{ "title": "Test Ingredient" }' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] + with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ps256.pem"), "rb") as key_file: + key = key_file.read() - # Verify thumbnail for manifest is here - self.assertIn("thumbnail", active_manifest) - thumbnail_data = active_manifest["thumbnail"] - self.assertIn("format", thumbnail_data) - self.assertIn("identifier", thumbnail_data) + signer_info = C2paSignerInfo( + alg=b"ps256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) + with open(self.testPath2, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual(first_ingredient["title"], "Test Ingredient") + # Get validation state with trust config + validation_state = reader.get_validation_state() - builder.close() + result['json_data'] = json_data + result['validation_state'] = validation_state + output.close() + except Exception as e: + exception['error'] = e - def test_builder_sign_with_ingredients_edit_intent(self): - """Test signing with EDIT intent and ingredient.""" - builder = Builder.from_json({}) - assert builder._handle is not None + # Create and start thread + thread = threading.Thread(target=sign_and_validate_with_trust_config) + thread.start() + thread.join() - # Set the intent for editing existing content - builder.set_intent(C2paBuilderIntent.EDIT) + # Check for exceptions + if 'error' in exception: + raise exception['error'] - # Test adding ingredient - ingredient_json = '{ "title": "Test Ingredient" }' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) + # Assertions run in main thread + self.assertIn("Python Test", result.get('json_data', '')) + # With trust configuration loaded, validation should return "Trusted" + self.assertIsNotNone(result.get('validation_state')) + self.assertEqual(result.get('validation_state'), "Trusted") - with open(self.testPath2, "rb") as file: + def test_archive_sign(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder.from_archive(archive) output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) reader = Reader("image/jpeg", output) json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists with exactly 2 ingredients - self.assertIn("ingredients", active_manifest) - ingredients_manifest = active_manifest["ingredients"] - self.assertIsInstance(ingredients_manifest, list) - self.assertEqual(len(ingredients_manifest), 2, "Should have exactly two ingredients") - - # Verify the first ingredient is the one we added manually with componentOf relationship - first_ingredient = ingredients_manifest[0] - self.assertEqual(first_ingredient["title"], "Test Ingredient") - self.assertEqual(first_ingredient["format"], "image/jpeg") - self.assertIn("instance_id", first_ingredient) - self.assertIn("thumbnail", first_ingredient) - self.assertEqual(first_ingredient["thumbnail"]["format"], "image/jpeg") - self.assertIn("identifier", first_ingredient["thumbnail"]) - self.assertEqual(first_ingredient["relationship"], "componentOf") - self.assertIn("label", first_ingredient) - - # Verify the second ingredient is the auto-created parent with parentOf relationship - second_ingredient = ingredients_manifest[1] - # Parent ingredient may not have a title field, or may have an empty one - self.assertEqual(second_ingredient["format"], "image/jpeg") - self.assertIn("instance_id", second_ingredient) - self.assertIn("thumbnail", second_ingredient) - self.assertEqual(second_ingredient["thumbnail"]["format"], "image/jpeg") - self.assertIn("identifier", second_ingredient["thumbnail"]) - self.assertEqual(second_ingredient["relationship"], "parentOf") - self.assertIn("label", second_ingredient) - - # Count ingredients with parentOf relationship - should be exactly one - parent_ingredients = [ - ing for ing in ingredients_manifest - if ing.get("relationship") == "parentOf" - ] - self.assertEqual(len(parent_ingredients), 1, "Should have exactly one parentOf ingredient") - - # Check that assertions exist - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + archive.close() + output.close() - # Find the actions assertion - actions_assertion = None - for assertion in assertions: - if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: - actions_assertion = assertion - break + def test_archive_sign_with_trust_config(self): + # Run in a separate thread to isolate thread-local settings + result = {} + exception = {} - self.assertIsNotNone(actions_assertion, "Should have c2pa.actions assertion") + def sign_and_validate_with_trust_config(): + try: + # Load trust configuration + settings_dict = load_test_settings_json() - # Verify exactly one c2pa.opened action exists for EDIT intent - actions = actions_assertion["data"]["actions"] - opened_actions = [ - action for action in actions - if action["action"] == "c2pa.opened" - ] - self.assertEqual(len(opened_actions), 1, "Should have exactly one c2pa.opened action") + # Apply the settings (including trust configuration) + # Settings are thread-local, so they won't affect other tests + # And that is why we also run the test in its own thread, so tests are isolated + load_settings(settings_dict) - # Verify the c2pa.opened action has the correct structure with parameters and ingredients - opened_action = opened_actions[0] - self.assertIn("parameters", opened_action, "c2pa.opened action should have parameters") - self.assertIn("ingredients", opened_action["parameters"], "parameters should have ingredients array") - ingredients_params = opened_action["parameters"]["ingredients"] - self.assertIsInstance(ingredients_params, list) - self.assertGreater(len(ingredients_params), 0, "Should have at least one ingredient reference") + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder.from_archive(archive) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() - # Verify each ingredient reference has url and hash - for ingredient_ref in ingredients_params: - self.assertIn("url", ingredient_ref, "Ingredient reference should have url") - self.assertIn("hash", ingredient_ref, "Ingredient reference should have hash") + # Get validation state with trust config + validation_state = reader.get_validation_state() - builder.close() + result['json_data'] = json_data + result['validation_state'] = validation_state + archive.close() + output.close() + except Exception as e: + exception['error'] = e - def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): - # The following removes the manifest's thumbnail - # Settings should be loaded before the builder is created - load_settings('{"builder": { "thumbnail": {"enabled": false}}}') + # Create and start thread + thread = threading.Thread(target=sign_and_validate_with_trust_config) + thread.start() + thread.join() - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None + # Check for exceptions + if 'error' in exception: + raise exception['error'] - # Test adding ingredient - ingredient_json = '{ "title": "Test Ingredient" }' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) + # Assertions run in main thread + self.assertIn("Python Test", result.get('json_data', '')) + # With trust configuration loaded, validation should return "Trusted" + self.assertIsNotNone(result.get('validation_state')) + self.assertEqual(result.get('validation_state'), "Trusted") - with open(self.testPath2, "rb") as file: + def test_archive_sign_with_added_ingredient(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder.from_archive(archive) output = io.BytesIO(bytearray()) + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) reader = Reader("image/jpeg", output) json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + archive.close() + output.close() - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] + def test_archive_sign_with_added_ingredient_with_trust_config(self): + # Run in a separate thread to isolate thread-local settings + result = {} + exception = {} - # There should be no thumbnail anymore here - self.assertNotIn("thumbnail", active_manifest) + def sign_and_validate_with_trust_config(): + try: + # Load trust configuration + settings_dict = load_test_settings_json() - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) + # Apply the settings (including trust configuration) + # Settings are thread-local, so they won't affect other tests + # And that is why we also run the test in its own thread, so tests are isolated + load_settings(settings_dict) - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual(first_ingredient["title"], "Test Ingredient") - self.assertNotIn("thumbnail", first_ingredient) + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder.from_archive(archive) + output = io.BytesIO(bytearray()) + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() - builder.close() + # Get validation state with trust config + validation_state = reader.get_validation_state() - # Settings are thread-local, so we reset to the default "true" here - load_settings('{"builder": { "thumbnail": {"enabled": true}}}') + result['json_data'] = json_data + result['validation_state'] = validation_state + archive.close() + output.close() + except Exception as e: + exception['error'] = e - def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): - # The following removes the manifest's thumbnail - using dict instead of string - load_settings({"builder": {"thumbnail": {"enabled": False}}}) + # Create and start thread + thread = threading.Thread(target=sign_and_validate_with_trust_config) + thread.start() + thread.join() - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None + # Check for exceptions + if 'error' in exception: + raise exception['error'] - # Test adding ingredient - ingredient_json = '{ "title": "Test Ingredient" }' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) + # Assertions run in main thread + self.assertIn("Python Test", result.get('json_data', '')) + # With trust configuration loaded, validation should return "Trusted" + self.assertIsNotNone(result.get('validation_state')) + self.assertEqual(result.get('validation_state'), "Trusted") - with open(self.testPath2, "rb") as file: + def test_remote_sign(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + builder.set_no_embed() output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) + # When set_no_embed() is used, no manifest should be embedded in the file + # So reading from the file should fail + with self.assertRaises(Error): + Reader("image/jpeg", output) + output.close() - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] + def test_remote_sign_using_returned_bytes(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + builder.set_no_embed() + with io.BytesIO() as output_buffer: + manifest_data = builder.sign( + self.signer, "image/jpeg", file, output_buffer) + output_buffer.seek(0) + read_buffer = io.BytesIO(output_buffer.getvalue()) - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] + with Reader("image/jpeg", read_buffer, manifest_data) as reader: + manifest_data = reader.json() + self.assertIn("Python Test", manifest_data) - # There should be no thumbnail anymore here - self.assertNotIn("thumbnail", active_manifest) + def test_remote_sign_using_returned_bytes_V2(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + builder.set_no_embed() + with io.BytesIO() as output_buffer: + manifest_data = builder.sign( + self.signer, "image/jpeg", file, output_buffer) + output_buffer.seek(0) + read_buffer = io.BytesIO(output_buffer.getvalue()) - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) + with Reader("image/jpeg", read_buffer, manifest_data) as reader: + manifest_data = reader.json() + self.assertIn("Python Test", manifest_data) - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual(first_ingredient["title"], "Test Ingredient") - self.assertNotIn("thumbnail", first_ingredient) + def test_remote_sign_using_returned_bytes_V2_with_trust_config(self): + # Run in a separate thread to isolate thread-local settings + result = {} + exception = {} - builder.close() + def sign_and_validate_with_trust_config(): + try: + # Load trust configuration + settings_dict = load_test_settings_json() - # Settings are thread-local, so we reset to the default "true" here - using dict instead of string - load_settings({"builder": {"thumbnail": {"enabled": True}}}) + # Apply the settings (including trust configuration) + # Settings are thread-local, so they won't affect other tests + # And that is why we also run the test in its own thread, so tests are isolated + load_settings(settings_dict) - def test_builder_sign_with_duplicate_ingredient(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + builder.set_no_embed() + with io.BytesIO() as output_buffer: + manifest_data = builder.sign( + self.signer, "image/jpeg", file, output_buffer) + output_buffer.seek(0) + read_buffer = io.BytesIO(output_buffer.getvalue()) - # Test adding ingredient - ingredient_json = '{"title": "Test Ingredient"}' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - builder.add_ingredient(ingredient_json, "image/jpeg", f) - builder.add_ingredient(ingredient_json, "image/jpeg", f) + with Reader("image/jpeg", read_buffer, manifest_data) as reader: + json_data = reader.json() - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) + # Get validation state with trust config + validation_state = reader.get_validation_state() - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] + result['json_data'] = json_data + result['validation_state'] = validation_state + except Exception as e: + exception['error'] = e - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] + # Create and start thread + thread = threading.Thread(target=sign_and_validate_with_trust_config) + thread.start() + thread.join() - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) + # Check for exceptions + if 'error' in exception: + raise exception['error'] - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual(first_ingredient["title"], "Test Ingredient") + # Assertions run in main thread + self.assertIn("Python Test", result.get('json_data', '')) + # With trust configuration loaded, validation should return "Trusted" + self.assertIsNotNone(result.get('validation_state')) + self.assertEqual(result.get('validation_state'), "Trusted") - # Verify subsequent labels are unique and have a double underscore with a monotonically inc. index - second_ingredient = active_manifest["ingredients"][1] - self.assertTrue(second_ingredient["label"].endswith("__1")) + def test_sign_all_files(self): + """Test signing all files in both fixtures directories""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") - third_ingredient = active_manifest["ingredients"][2] - self.assertTrue(third_ingredient["label"].endswith("__2")) + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav' + } - builder.close() + # Skip files that are known to be invalid or unsupported + skip_files = { + 'sample3.invalid.wav', # Invalid file + } - def test_builder_sign_with_ingredient_from_stream(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None + # Process both directories + for directory in [signing_dir, reading_dir]: + for filename in os.listdir(directory): + if filename in skip_files: + continue - # Test adding ingredient using stream - ingredient_json = '{"title": "Test Ingredient Stream"}' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient_from_stream( - ingredient_json, "image/jpeg", f) + file_path = os.path.join(directory, filename) + if not os.path.isfile(file_path): + continue - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + continue - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] + mime_type = mime_types[ext] - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] + try: + with open(file_path, "rb") as file: + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + builder.close() + output.seek(0) + reader = Reader(mime_type, output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + reader.close() + output.close() + except Error.NotSupported: + continue + except Exception as e: + self.fail(f"Failed to sign {filename}: {str(e)}") + + def test_sign_all_files_V2(self): + """Test signing all files in both fixtures directories""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav' + } + + # Skip files that are known to be invalid or unsupported + skip_files = { + 'sample3.invalid.wav', # Invalid file + } - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) + # Process both directories + for directory in [signing_dir, reading_dir]: + for filename in os.listdir(directory): + if filename in skip_files: + continue - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual( - first_ingredient["title"], - "Test Ingredient Stream") + file_path = os.path.join(directory, filename) + if not os.path.isfile(file_path): + continue - builder.close() + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + continue - def test_builder_sign_with_ingredient_dict_from_stream(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None + mime_type = mime_types[ext] - # Test adding ingredient using stream with a dictionary - ingredient_dict = {"title": "Test Ingredient Stream"} - with open(self.testPath3, 'rb') as f: - builder.add_ingredient_from_stream( - ingredient_dict, "image/jpeg", f) + try: + with open(file_path, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + builder.close() + output.seek(0) + reader = Reader(mime_type, output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + self.assertIn("Valid", json_data) + reader.close() + output.close() + except Error.NotSupported: + continue + except Exception as e: + self.fail(f"Failed to sign {filename}: {str(e)}") - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) + def test_builder_no_added_ingredient_on_closed_builder(self): + builder = Builder(self.manifestDefinition) - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] + builder.close() - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] + with self.assertRaises(Error): + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) + def test_builder_add_ingredient(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual( - first_ingredient["title"], - "Test Ingredient Stream") + # Test adding ingredient + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.close() - def test_builder_sign_with_multiple_ingredient(self): + def test_builder_add_ingredient_dict(self): builder = Builder.from_json(self.manifestDefinition) assert builder._handle is not None - # Add first ingredient - ingredient_json1 = '{"title": "Test Ingredient 1"}' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json1, "image/jpeg", f) - - # Add second ingredient - ingredient_json2 = '{"title": "Test Ingredient 2"}' - cloud_path = ALTERNATIVE_INGREDIENT_TEST_FILE - with open(cloud_path, 'rb') as f: - builder.add_ingredient(ingredient_json2, "image/jpeg", f) + # Test adding ingredient with a dictionary instead of JSON string + ingredient_dict = {"test": "ingredient"} + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_dict, "image/jpeg", f) - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) + builder.close() - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] + def test_builder_add_multiple_ingredients(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] + # Test builder operations + builder.set_no_embed() + builder.set_remote_url("http://test.url") - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertEqual(len(active_manifest["ingredients"]), 2) + # Test adding ingredient + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) - # Verify both ingredients exist in the array (order doesn't matter) - ingredient_titles = [ing["title"] - for ing in active_manifest["ingredients"]] - self.assertIn("Test Ingredient 1", ingredient_titles) - self.assertIn("Test Ingredient 2", ingredient_titles) + # Test adding another ingredient + ingredient_json = '{"test": "ingredient2"}' + with open(self.testPath2, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/png", f) builder.close() - def test_builder_sign_with_multiple_ingredients_from_stream(self): + def test_builder_add_multiple_ingredients_2(self): builder = Builder.from_json(self.manifestDefinition) assert builder._handle is not None - # Add first ingredient using stream - ingredient_json1 = '{"title": "Test Ingredient Stream 1"}' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient_from_stream( - ingredient_json1, "image/jpeg", f) - - # Add second ingredient using stream - ingredient_json2 = '{"title": "Test Ingredient Stream 2"}' - cloud_path = ALTERNATIVE_INGREDIENT_TEST_FILE - with open(cloud_path, 'rb') as f: - builder.add_ingredient_from_stream( - ingredient_json2, "image/jpeg", f) - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] + # Test builder operations + builder.set_no_embed() + builder.set_remote_url("http://test.url") - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertEqual(len(active_manifest["ingredients"]), 2) + # Test adding ingredient with a dictionary + ingredient_dict = {"test": "ingredient"} + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_dict, "image/jpeg", f) - # Verify both ingredients exist in the array (order doesn't matter) - ingredient_titles = [ing["title"] - for ing in active_manifest["ingredients"]] - self.assertIn("Test Ingredient Stream 1", ingredient_titles) - self.assertIn("Test Ingredient Stream 2", ingredient_titles) + # Test adding another ingredient with a JSON string + ingredient_json = '{"test": "ingredient2"}' + with open(self.testPath2, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/png", f) builder.close() - def test_builder_set_remote_url(self): - """Test setting the remote url of a builder.""" + def test_builder_add_multiple_ingredients_and_resources(self): builder = Builder.from_json(self.manifestDefinition) - builder.set_remote_url("http://this_does_not_exist/foo.jpg") - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - d = output.read() - self.assertIn(b'provenance="http://this_does_not_exist/foo.jpg"', d) - - def test_builder_set_remote_url_no_embed(self): - """Test setting the remote url of a builder with no embed flag.""" - - # Settings need to be loaded before the builder is created - load_settings(r'{"verify": { "remote_manifest_fetch": false} }') + assert builder._handle is not None - builder = Builder.from_json(self.manifestDefinition) + # Test builder operations builder.set_no_embed() - builder.set_remote_url("http://this_does_not_exist/foo.jpg") - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - with self.assertRaises(Error) as e: - Reader("image/jpeg", output) - - self.assertIn("http://this_does_not_exist/foo.jpg", e.exception.message) - - # Return back to default settings - load_settings(r'{"verify": { "remote_manifest_fetch": true} }') + builder.set_remote_url("http://test.url") - def test_sign_single(self): - """Test signing a file using the sign_file method.""" - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) + # Test adding resource + with open(self.testPath, 'rb') as f: + builder.add_resource("test_uri_1", f) - with open(self.testPath, "rb") as file: - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) + with open(self.testPath, 'rb') as f: + builder.add_resource("test_uri_2", f) - # Read the signed file and verify the manifest - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - output.close() + with open(self.testPath, 'rb') as f: + builder.add_resource("test_uri_3", f) - def test_sign_mp4_video_file_single(self): - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) + # Test adding ingredients + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) - with open(os.path.join(FIXTURES_DIR, "video1.mp4"), "rb") as file: - builder.sign(self.signer, "video/mp4", file, output) - output.seek(0) + ingredient_json = '{"test": "ingredient2"}' + with open(self.testPath2, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/png", f) - # Read the signed file and verify the manifest - reader = Reader("video/mp4", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - output.close() + builder.close() - def test_sign_mov_video_file_single(self): - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) + def test_builder_add_multiple_ingredients_and_resources_interleaved(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None - with open(os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), "rb") as file: - builder.sign(self.signer, "mov", file, output) - output.seek(0) + with open(self.testPath, 'rb') as f: + builder.add_resource("test_uri_1", f) - # Read the signed file and verify the manifest - reader = Reader("mov", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - output.close() + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) - def test_sign_file_video(self): - temp_dir = tempfile.mkdtemp() - try: - # Create a temporary output file path - output_path = os.path.join(temp_dir, "signed_output.mp4") + with open(self.testPath, 'rb') as f: + builder.add_resource("test_uri_2", f) - # Use the sign_file method - builder = Builder(self.manifestDefinition) - builder.sign_file( - os.path.join(FIXTURES_DIR, "video1.mp4"), - output_path, - self.signer - ) + with open(self.testPath, 'rb') as f: + builder.add_resource("test_uri_3", f) - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) + ingredient_json = '{"test": "ingredient2"}' + with open(self.testPath2, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/png", f) - # Read the signed file and verify the manifest - with open(output_path, "rb") as file: - reader = Reader("video/mp4", file) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) + builder.close() - finally: - # Clean up the temporary directory - shutil.rmtree(temp_dir) + def test_builder_sign_with_ingredient(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None - def test_sign_file_format_manifest_bytes_embeddable(self): - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) + # Test adding ingredient + ingredient_json = '{ "title": "Test Ingredient" }' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) - with open(self.testPath, "rb") as file: - manifest_bytes = builder.sign(self.signer, "image/jpeg", file, output) - res = format_embeddable("image/jpeg", manifest_bytes) + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) - output.close() - - def test_builder_sign_file_callback_signer_from_callback(self): - """Test signing a file using the sign_file method with Signer.from_callback.""" - - temp_dir = tempfile.mkdtemp() - try: + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) - output_path = os.path.join(temp_dir, "signed_output_from_callback.jpg") + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] - # Will use the sign_file method - builder = Builder(self.manifestDefinition) + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] - # Create signer with callback using Signer.from_callback - signer = Signer.from_callback( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) + # Verify thumbnail for manifest is here + self.assertIn("thumbnail", active_manifest) + thumbnail_data = active_manifest["thumbnail"] + self.assertIn("format", thumbnail_data) + self.assertIn("identifier", thumbnail_data) - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient") - # Verify results - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) + builder.close() - # Read the signed file and verify the manifest - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) + def test_builder_sign_with_ingredients_edit_intent(self): + """Test signing with EDIT intent and ingredient.""" + builder = Builder.from_json({}) + assert builder._handle is not None - # Parse the JSON and verify the signature algorithm - manifest_data = json.loads(json_data) - active_manifest_id = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_id] + # Set the intent for editing existing content + builder.set_intent(C2paBuilderIntent.EDIT) - # Verify the signature_info contains the correct algorithm - self.assertIn("signature_info", active_manifest) - signature_info = active_manifest["signature_info"] - self.assertEqual(signature_info["alg"], self.callback_signer_alg) + # Test adding ingredient + ingredient_json = '{ "title": "Test Ingredient" }' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) - finally: - shutil.rmtree(temp_dir) + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) - def test_builder_sign_file_callback_signer_from_callback_V2(self): - """Test signing a file using the sign_file method with Signer.from_callback.""" + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] - temp_dir = tempfile.mkdtemp() - try: + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] - output_path = os.path.join(temp_dir, "signed_output_from_callback.jpg") + # Verify ingredients array exists with exactly 2 ingredients + self.assertIn("ingredients", active_manifest) + ingredients_manifest = active_manifest["ingredients"] + self.assertIsInstance(ingredients_manifest, list) + self.assertEqual(len(ingredients_manifest), 2, "Should have exactly two ingredients") - # Will use the sign_file method - builder = Builder(self.manifestDefinitionV2) + # Verify the first ingredient is the one we added manually with componentOf relationship + first_ingredient = ingredients_manifest[0] + self.assertEqual(first_ingredient["title"], "Test Ingredient") + self.assertEqual(first_ingredient["format"], "image/jpeg") + self.assertIn("instance_id", first_ingredient) + self.assertIn("thumbnail", first_ingredient) + self.assertEqual(first_ingredient["thumbnail"]["format"], "image/jpeg") + self.assertIn("identifier", first_ingredient["thumbnail"]) + self.assertEqual(first_ingredient["relationship"], "componentOf") + self.assertIn("label", first_ingredient) - # Create signer with callback using Signer.from_callback - signer = Signer.from_callback( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) + # Verify the second ingredient is the auto-created parent with parentOf relationship + second_ingredient = ingredients_manifest[1] + # Parent ingredient may not have a title field, or may have an empty one + self.assertEqual(second_ingredient["format"], "image/jpeg") + self.assertIn("instance_id", second_ingredient) + self.assertIn("thumbnail", second_ingredient) + self.assertEqual(second_ingredient["thumbnail"]["format"], "image/jpeg") + self.assertIn("identifier", second_ingredient["thumbnail"]) + self.assertEqual(second_ingredient["relationship"], "parentOf") + self.assertIn("label", second_ingredient) - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) + # Count ingredients with parentOf relationship - should be exactly one + parent_ingredients = [ + ing for ing in ingredients_manifest + if ing.get("relationship") == "parentOf" + ] + self.assertEqual(len(parent_ingredients), 1, "Should have exactly one parentOf ingredient") - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) + # Check that assertions exist + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] - # Verify results - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) + # Find the actions assertion + actions_assertion = None + for assertion in assertions: + if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: + actions_assertion = assertion + break - # Read the signed file and verify the manifest - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) + self.assertIsNotNone(actions_assertion, "Should have c2pa.actions assertion") - # Parse the JSON and verify the signature algorithm - manifest_data = json.loads(json_data) - active_manifest_id = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_id] + # Verify exactly one c2pa.opened action exists for EDIT intent + actions = actions_assertion["data"]["actions"] + opened_actions = [ + action for action in actions + if action["action"] == "c2pa.opened" + ] + self.assertEqual(len(opened_actions), 1, "Should have exactly one c2pa.opened action") - # Verify the signature_info contains the correct algorithm - self.assertIn("signature_info", active_manifest) - signature_info = active_manifest["signature_info"] - self.assertEqual(signature_info["alg"], self.callback_signer_alg) + # Verify the c2pa.opened action has the correct structure with parameters and ingredients + opened_action = opened_actions[0] + self.assertIn("parameters", opened_action, "c2pa.opened action should have parameters") + self.assertIn("ingredients", opened_action["parameters"], "parameters should have ingredients array") + ingredients_params = opened_action["parameters"]["ingredients"] + self.assertIsInstance(ingredients_params, list) + self.assertGreater(len(ingredients_params), 0, "Should have at least one ingredient reference") - finally: - shutil.rmtree(temp_dir) + # Verify each ingredient reference has url and hash + for ingredient_ref in ingredients_params: + self.assertIn("url", ingredient_ref, "Ingredient reference should have url") + self.assertIn("hash", ingredient_ref, "Ingredient reference should have hash") - def test_builder_sign_with_native_ed25519_callback(self): - # Load Ed25519 private key (PEM) - ed25519_pem = os.path.join(FIXTURES_DIR, "ed25519.pem") - with open(ed25519_pem, "r") as f: - private_key_pem = f.read() + builder.close() - # Callback here uses native function - def ed25519_callback(data: bytes) -> bytes: - return ed25519_sign(data, private_key_pem) + def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): + # The following removes the manifest's thumbnail + # Settings should be loaded before the builder is created + load_settings('{"builder": { "thumbnail": {"enabled": false}}}') - # Load the certificate (PUB) - ed25519_pub = os.path.join(FIXTURES_DIR, "ed25519.pub") - with open(ed25519_pub, "r") as f: - certs_pem = f.read() + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None - # Create a Signer - # signer = create_signer( - # callback=ed25519_callback, - # alg=SigningAlg.ED25519, - # certs=certs_pem, - # tsa_url=None - # ) - signer = Signer.from_callback( - callback=ed25519_callback, - alg=SigningAlg.ED25519, - certs=certs_pem, - tsa_url=None - ) + # Test adding ingredient + ingredient_json = '{ "title": "Test Ingredient" }' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) + with open(self.testPath2, "rb") as file: output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", file, output) + builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) - builder.close() reader = Reader("image/jpeg", output) json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - reader.close() - output.close() + manifest_data = json.loads(json_data) - def test_signing_manifest_v2(self): - """Test signing and reading a V2 manifest. - V2 manifests have a slightly different structure. - """ - with open(self.testPath, "rb") as file: - # Create a builder with the V2 manifest definition using context manager - with Builder(self.manifestDefinitionV2) as builder: - output = io.BytesIO(bytearray()) + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] - # Sign as usual... - builder.sign(self.signer, "image/jpeg", file, output) + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] - output.seek(0) + # There should be no thumbnail anymore here + self.assertNotIn("thumbnail", active_manifest) - # Read the signed file and verify the manifest using context manager - with Reader("image/jpeg", output) as reader: - json_data = reader.json() + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) - # Basic verification of the manifest - self.assertIn("Python Test Image V2", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient") + self.assertNotIn("thumbnail", first_ingredient) - output.close() + builder.close() - def test_builder_does_not_sign_unsupported_format(self): - with open(self.testPath, "rb") as file: - with Builder(self.manifestDefinitionV2) as builder: - output = io.BytesIO(bytearray()) - with self.assertRaises(Error.NotSupported): - builder.sign(self.signer, "mimetype/not-supported", file, output) + # Settings are thread-local, so we reset to the default "true" here + load_settings('{"builder": { "thumbnail": {"enabled": true}}}') - def test_sign_file_mp4_video(self): - temp_dir = tempfile.mkdtemp() - try: - # Create a temporary output file path - output_path = os.path.join(temp_dir, "signed_output.mp4") + def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): + # The following removes the manifest's thumbnail - using dict instead of string + load_settings({"builder": {"thumbnail": {"enabled": False}}}) - # Use the sign_file method - builder = Builder(self.manifestDefinition) - builder.sign_file( - os.path.join(FIXTURES_DIR, "video1.mp4"), - output_path, - self.signer - ) + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) + # Test adding ingredient + ingredient_json = '{ "title": "Test Ingredient" }' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) - # Read the signed file and verify the manifest - with open(output_path, "rb") as file: - reader = Reader("video/mp4", file) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) - finally: - # Clean up the temporary directory - shutil.rmtree(temp_dir) + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] - def test_sign_file_mov_video(self): - temp_dir = tempfile.mkdtemp() - try: - # Create a temporary output file path - output_path = os.path.join(temp_dir, "signed-C-recorded-as-mov.mov") + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] - # Use the sign_file method - builder = Builder(self.manifestDefinition) - manifest_bytes = builder.sign_file( - os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), - output_path, - self.signer - ) + # There should be no thumbnail anymore here + self.assertNotIn("thumbnail", active_manifest) - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) - # Read the signed file and verify the manifest - with open(output_path, "rb") as file: - reader = Reader("mov", file) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient") + self.assertNotIn("thumbnail", first_ingredient) - # Verify also signed file using manifest bytes - with Reader("mov", output_path, manifest_bytes) as reader: - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) + builder.close() - finally: - # Clean up the temporary directory - shutil.rmtree(temp_dir) + # Settings are thread-local, so we reset to the default "true" here - using dict instead of string + load_settings({"builder": {"thumbnail": {"enabled": True}}}) - def test_sign_file_mov_video_V2(self): - temp_dir = tempfile.mkdtemp() - try: - # Create a temporary output file path - output_path = os.path.join(temp_dir, "signed-C-recorded-as-mov.mov") + def test_builder_sign_with_duplicate_ingredient(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None - # Use the sign_file method - builder = Builder(self.manifestDefinitionV2) - manifest_bytes = builder.sign_file( - os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), - output_path, - self.signer - ) + # Test adding ingredient + ingredient_json = '{"title": "Test Ingredient"}' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) - # Read the signed file and verify the manifest - with open(output_path, "rb") as file: - reader = Reader("mov", file) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) - # Verify also signed file using manifest bytes - with Reader("mov", output_path, manifest_bytes) as reader: - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient") - finally: - # Clean up the temporary directory - shutil.rmtree(temp_dir) + # Verify subsequent labels are unique and have a double underscore with a monotonically inc. index + second_ingredient = active_manifest["ingredients"][1] + self.assertTrue(second_ingredient["label"].endswith("__1")) - def test_builder_with_invalid_signer_none(self): - """Test Builder methods with None signer.""" - builder = Builder(self.manifestDefinition) + third_ingredient = active_manifest["ingredients"][2] + self.assertTrue(third_ingredient["label"].endswith("__2")) - with open(self.testPath, "rb") as file: - with self.assertRaises(Error): - builder.sign(None, "image/jpeg", file) + builder.close() - def test_builder_with_invalid_signer_closed(self): - """Test Builder methods with closed signer.""" - builder = Builder(self.manifestDefinition) + def test_builder_sign_with_ingredient_from_stream(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None - # Create and close a signer - closed_signer = Signer.from_info(self.signer_info) - closed_signer.close() + # Test adding ingredient using stream + ingredient_json = '{"title": "Test Ingredient Stream"}' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient_from_stream( + ingredient_json, "image/jpeg", f) - with open(self.testPath, "rb") as file: - with self.assertRaises(Error): - builder.sign(closed_signer, "image/jpeg", file) + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) - def test_builder_with_invalid_signer_object(self): - """Test Builder methods with invalid signer object.""" - builder = Builder(self.manifestDefinition) + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] - # Use a mock object that looks like a signer but isn't - class MockSigner: - def __init__(self): - self._handle = None + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] - mock_signer = MockSigner() + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) - with open(self.testPath, "rb") as file: - with self.assertRaises(Error): - builder.sign(mock_signer, "image/jpeg", file) + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual( + first_ingredient["title"], + "Test Ingredient Stream") - def test_builder_manifest_with_unicode_characters(self): - """Test Builder with manifest containing various Unicode characters.""" - unicode_manifest = { - "claim_generator": "python_test_unicode_テスト", - "claim_generator_info": [{ - "name": "python_test_unicode_テスト", - "version": "0.0.1", - }], - "claim_version": 1, - "format": "image/jpeg", - "title": "Python Test Image with Unicode: テスト test", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "description": "Unicode: test", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } + builder.close() - builder = Builder(unicode_manifest) + def test_builder_sign_with_ingredient_dict_from_stream(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None - with open(self.testPath, "rb") as file: + # Test adding ingredient using stream with a dictionary + ingredient_dict = {"title": "Test Ingredient Stream"} + with open(self.testPath3, 'rb') as f: + builder.add_ingredient_from_stream( + ingredient_dict, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) reader = Reader("image/jpeg", output) json_data = reader.json() + manifest_data = json.loads(json_data) - # Verify Unicode characters are preserved in title and description - self.assertIn("テスト", json_data) + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] - def test_builder_ingredient_with_special_characters(self): - """Test Builder with ingredient containing special characters.""" - special_char_ingredient = { - "title": "Test Ingredient with Special Chars: テスト", - "format": "image/jpeg", - "description": "Special characters: !@#$%^&*()_+-=[]{}|;':\",./<>?`~" - } + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] - builder = Builder(self.manifestDefinition) + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) - # Add ingredient with special characters - ingredient_json = json.dumps(special_char_ingredient) - with open(self.testPath2, "rb") as ingredient_file: - builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual( + first_ingredient["title"], + "Test Ingredient Stream") - with open(self.testPath, "rb") as file: + builder.close() + + def test_builder_sign_with_multiple_ingredient(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Add first ingredient + ingredient_json1 = '{"title": "Test Ingredient 1"}' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json1, "image/jpeg", f) + + # Add second ingredient + ingredient_json2 = '{"title": "Test Ingredient 2"}' + cloud_path = ALTERNATIVE_INGREDIENT_TEST_FILE + with open(cloud_path, 'rb') as f: + builder.add_ingredient(ingredient_json2, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) @@ -3208,578 +1727,537 @@ def test_builder_ingredient_with_special_characters(self): json_data = reader.json() manifest_data = json.loads(json_data) - # Verify special characters are preserved in ingredients - self.assertIn("ingredients", manifest_data["manifests"][manifest_data["active_manifest"]]) - ingredients = manifest_data["manifests"][manifest_data["active_manifest"]]["ingredients"] - self.assertEqual(len(ingredients), 1) + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] - ingredient = ingredients[0] - self.assertIn("テスト", ingredient["title"]) - self.assertIn("!@#$%^&*()_+-=[]{}|;':\",./<>?`~", ingredient["description"]) + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] - def test_builder_resource_uri_with_unicode(self): - """Test Builder with resource URI containing Unicode characters.""" - builder = Builder(self.manifestDefinition) + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertEqual(len(active_manifest["ingredients"]), 2) - # Test with resource URI containing Unicode characters - unicode_uri = "thumbnail_テスト.jpg" - with open(self.testPath3, "rb") as thumbnail_file: - builder.add_resource(unicode_uri, thumbnail_file) + # Verify both ingredients exist in the array (order doesn't matter) + ingredient_titles = [ing["title"] + for ing in active_manifest["ingredients"]] + self.assertIn("Test Ingredient 1", ingredient_titles) + self.assertIn("Test Ingredient 2", ingredient_titles) - with open(self.testPath, "rb") as file: + builder.close() + + def test_builder_sign_with_multiple_ingredients_from_stream(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Add first ingredient using stream + ingredient_json1 = '{"title": "Test Ingredient Stream 1"}' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient_from_stream( + ingredient_json1, "image/jpeg", f) + + # Add second ingredient using stream + ingredient_json2 = '{"title": "Test Ingredient Stream 2"}' + cloud_path = ALTERNATIVE_INGREDIENT_TEST_FILE + with open(cloud_path, 'rb') as f: + builder.add_ingredient_from_stream( + ingredient_json2, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) reader = Reader("image/jpeg", output) json_data = reader.json() + manifest_data = json.loads(json_data) - # Verify the resource was added (exact verification depends on implementation) - self.assertIsNotNone(json_data) - - def test_builder_initialization_failure_states(self): - """Test Builder state after initialization failures.""" - # Test with invalid JSON - with self.assertRaises(Error): - builder = Builder("{invalid json}") - - # Test with None manifest - with self.assertRaises(Error): - builder = Builder(None) + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] - # Test with circular reference in JSON - circular_obj = {} - circular_obj['self'] = circular_obj - with self.assertRaises(Exception) as context: - builder = Builder(circular_obj) + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] - def test_builder_state_transitions(self): - """Test Builder state transitions during lifecycle.""" - builder = Builder(self.manifestDefinition) + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertEqual(len(active_manifest["ingredients"]), 2) - # Initial state - self.assertEqual(builder._state, LifecycleState.ACTIVE) - self.assertIsNotNone(builder._handle) + # Verify both ingredients exist in the array (order doesn't matter) + ingredient_titles = [ing["title"] + for ing in active_manifest["ingredients"]] + self.assertIn("Test Ingredient Stream 1", ingredient_titles) + self.assertIn("Test Ingredient Stream 2", ingredient_titles) - # After close builder.close() - self.assertEqual(builder._state, LifecycleState.CLOSED) - self.assertIsNone(builder._handle) - def test_builder_context_manager_states(self): - """Test Builder state management in context manager.""" - with Builder(self.manifestDefinition) as builder: - # Inside context - should be valid - self.assertEqual(builder._state, LifecycleState.ACTIVE) - self.assertIsNotNone(builder._handle) - - # Placeholder operation - builder.set_no_embed() + def test_builder_set_remote_url(self): + """Test setting the remote url of a builder.""" + builder = Builder.from_json(self.manifestDefinition) + builder.set_remote_url("http://this_does_not_exist/foo.jpg") - # After context exit - should be closed - self.assertEqual(builder._state, LifecycleState.CLOSED) - self.assertIsNone(builder._handle) + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + d = output.read() + self.assertIn(b'provenance="http://this_does_not_exist/foo.jpg"', d) - def test_builder_context_manager_with_exception(self): - """Test Builder state after exception in context manager.""" - try: - with Builder(self.manifestDefinition) as builder: - # Inside context - should be valid - self.assertEqual(builder._state, LifecycleState.ACTIVE) - self.assertIsNotNone(builder._handle) - raise ValueError("Test exception") - except ValueError: - pass + def test_builder_set_remote_url_no_embed(self): + """Test setting the remote url of a builder with no embed flag.""" - # After exception - should still be closed - self.assertEqual(builder._state, LifecycleState.CLOSED) - self.assertIsNone(builder._handle) + # Settings need to be loaded before the builder is created + load_settings(r'{"verify": { "remote_manifest_fetch": false} }') - def test_builder_partial_initialization_states(self): - """Test Builder behavior with partial initialization failures.""" - # Test with _builder = None but _state = ACTIVE - builder = Builder.__new__(Builder) - builder._state = LifecycleState.ACTIVE - builder._handle = None + builder = Builder.from_json(self.manifestDefinition) + builder.set_no_embed() + builder.set_remote_url("http://this_does_not_exist/foo.jpg") - with self.assertRaises(Error): - builder._ensure_valid_state() + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + with self.assertRaises(Error) as e: + Reader("image/jpeg", output) - def test_builder_cleanup_state_transitions(self): - """Test Builder state during cleanup operations.""" - builder = Builder(self.manifestDefinition) + self.assertIn("http://this_does_not_exist/foo.jpg", e.exception.message) - # Test _cleanup_resources method - builder._cleanup_resources() - self.assertEqual(builder._state, LifecycleState.CLOSED) - self.assertIsNone(builder._handle) + # Return back to default settings + load_settings(r'{"verify": { "remote_manifest_fetch": true} }') - def test_builder_cleanup_idempotency(self): - """Test that cleanup operations are idempotent.""" + def test_sign_single(self): + """Test signing a file using the sign_file method.""" builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) - # First cleanup - builder._cleanup_resources() - self.assertEqual(builder._state, LifecycleState.CLOSED) + with open(self.testPath, "rb") as file: + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) - # Second cleanup should not change state - builder._cleanup_resources() - self.assertEqual(builder._state, LifecycleState.CLOSED) - self.assertIsNone(builder._handle) + # Read the signed file and verify the manifest + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + output.close() - def test_builder_state_after_sign_operations(self): - """Test Builder state after signing operations.""" + def test_sign_mp4_video_file_single(self): builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) - with open(self.testPath, "rb") as file: - manifest_bytes = builder.sign(self.signer, "image/jpeg", file) - - # State should still be valid after signing - self.assertEqual(builder._state, LifecycleState.ACTIVE) - self.assertIsNotNone(builder._handle) + with open(os.path.join(FIXTURES_DIR, "video1.mp4"), "rb") as file: + builder.sign(self.signer, "video/mp4", file, output) + output.seek(0) - # Should be able to sign again - with open(self.testPath, "rb") as file: - manifest_bytes2 = builder.sign(self.signer, "image/jpeg", file) + # Read the signed file and verify the manifest + reader = Reader("video/mp4", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + output.close() - def test_builder_state_after_archive_operations(self): - """Test Builder state after archive operations.""" + def test_sign_mov_video_file_single(self): builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) - # Test to_archive - with io.BytesIO() as archive_stream: - builder.to_archive(archive_stream) + with open(os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), "rb") as file: + builder.sign(self.signer, "mov", file, output) + output.seek(0) - # State should still be valid - self.assertEqual(builder._state, LifecycleState.ACTIVE) - self.assertIsNotNone(builder._handle) + # Read the signed file and verify the manifest + reader = Reader("mov", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + output.close() - def test_builder_state_after_double_close(self): - """Test Builder state after double close operations.""" - builder = Builder(self.manifestDefinition) + def test_sign_file_video(self): + temp_dir = tempfile.mkdtemp() + try: + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed_output.mp4") - # First close - builder.close() - self.assertEqual(builder._state, LifecycleState.CLOSED) - self.assertIsNone(builder._handle) + # Use the sign_file method + builder = Builder(self.manifestDefinition) + builder.sign_file( + os.path.join(FIXTURES_DIR, "video1.mp4"), + output_path, + self.signer + ) - # Second close should not change state - builder.close() - self.assertEqual(builder._state, LifecycleState.CLOSED) - self.assertIsNone(builder._handle) + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) - def test_builder_state_with_invalid_native_pointer(self): - """Test Builder state handling with invalid native pointer.""" + # Read the signed file and verify the manifest + with open(output_path, "rb") as file: + reader = Reader("video/mp4", file) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + + finally: + # Clean up the temporary directory + shutil.rmtree(temp_dir) + + def test_sign_file_format_manifest_bytes_embeddable(self): builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) - # Simulate invalid native pointer - builder._handle = 0 + with open(self.testPath, "rb") as file: + manifest_bytes = builder.sign(self.signer, "image/jpeg", file, output) + res = format_embeddable("image/jpeg", manifest_bytes) + output.seek(0) + output.close() - # Operations should fail gracefully - with self.assertRaises(Error): - builder.set_no_embed() + def test_builder_sign_file_callback_signer_from_callback(self): + """Test signing a file using the sign_file method with Signer.from_callback.""" - def test_builder_add_action_to_manifest_no_auto_add(self): - # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + temp_dir = tempfile.mkdtemp() + try: - initial_manifest_definition = { - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - # claim version 2 is the default - # "claim_version": 2, - "format": "image/jpeg", - "title": "Python Test Image V2", - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } - builder = Builder.from_json(initial_manifest_definition) + output_path = os.path.join(temp_dir, "signed_output_from_callback.jpg") - action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' - builder.add_action(action_json) + # Will use the sign_file method + builder = Builder(self.manifestDefinition) - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) + # Create signer with callback using Signer.from_callback + signer = Signer.from_callback( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] + manifest_bytes = builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) - # Verify assertions object exists in active manifest - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] + # Verify results + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) - # Find the c2pa.actions.v2 assertion to check what we added - actions_assertion = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - actions_assertion = assertion - break + # Read the signed file and verify the manifest + with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) - self.assertIsNotNone(actions_assertion) - self.assertIn("data", actions_assertion) - assertion_data = actions_assertion["data"] - # Verify the manifest now contains actions - self.assertIn("actions", assertion_data) - actions = assertion_data["actions"] - # Verify "c2pa.color_adjustments" action exists anywhere in the actions array - created_action_found = False - for action in actions: - if action.get("action") == "c2pa.color_adjustments": - created_action_found = True - break + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] - self.assertTrue(created_action_found) + # Verify the signature_info contains the correct algorithm + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) - builder.close() + finally: + shutil.rmtree(temp_dir) - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + def test_builder_sign_file_callback_signer_from_callback_V2(self): + """Test signing a file using the sign_file method with Signer.from_callback.""" - def test_builder_add_action_to_manifest_from_dict_no_auto_add(self): - # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + temp_dir = tempfile.mkdtemp() + try: - initial_manifest_definition = { - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - # claim version 2 is the default - # "claim_version": 2, - "format": "image/jpeg", - "title": "Python Test Image V2", - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } - builder = Builder.from_json(initial_manifest_definition) + output_path = os.path.join(temp_dir, "signed_output_from_callback.jpg") - # Using a dictionary instead of a JSON string - action_dict = {"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}} - builder.add_action(action_dict) + # Will use the sign_file method + builder = Builder(self.manifestDefinitionV2) - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) + # Create signer with callback using Signer.from_callback + signer = Signer.from_callback( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] + manifest_bytes = builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) - # Verify assertions object exists in active manifest - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] + # Verify results + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) - # Find the c2pa.actions.v2 assertion to check what we added - actions_assertion = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - actions_assertion = assertion - break + # Read the signed file and verify the manifest + with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) - self.assertIsNotNone(actions_assertion) - self.assertIn("data", actions_assertion) - assertion_data = actions_assertion["data"] - # Verify the manifest now contains actions - self.assertIn("actions", assertion_data) - actions = assertion_data["actions"] - # Verify "c2pa.color_adjustments" action exists anywhere in the actions array - created_action_found = False - for action in actions: - if action.get("action") == "c2pa.color_adjustments": - created_action_found = True - break + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] - self.assertTrue(created_action_found) + # Verify the signature_info contains the correct algorithm + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) - builder.close() + finally: + shutil.rmtree(temp_dir) - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + def test_builder_sign_with_native_ed25519_callback(self): + # Load Ed25519 private key (PEM) + ed25519_pem = os.path.join(FIXTURES_DIR, "ed25519.pem") + with open(ed25519_pem, "r") as f: + private_key_pem = f.read() - def test_builder_add_action_to_manifest_with_auto_add(self): - # For testing, force settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + # Callback here uses native function + def ed25519_callback(data: bytes) -> bytes: + return ed25519_sign(data, private_key_pem) - initial_manifest_definition = { - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - # claim version 2 is the default - # "claim_version": 2, - "format": "image/jpeg", - "title": "Python Test Image V2", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } - builder = Builder.from_json(initial_manifest_definition) + # Load the certificate (PUB) + ed25519_pub = os.path.join(FIXTURES_DIR, "ed25519.pub") + with open(ed25519_pub, "r") as f: + certs_pem = f.read() - action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' - builder.add_action(action_json) + # Create a Signer + # signer = create_signer( + # callback=ed25519_callback, + # alg=SigningAlg.ED25519, + # certs=certs_pem, + # tsa_url=None + # ) + signer = Signer.from_callback( + callback=ed25519_callback, + alg=SigningAlg.ED25519, + certs=certs_pem, + tsa_url=None + ) - with open(self.testPath2, "rb") as file: + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) + builder.sign(signer, "image/jpeg", file, output) output.seek(0) + builder.close() reader = Reader("image/jpeg", output) json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify assertions object exists in active manifest - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the c2pa.actions.v2 assertion to check what we added - actions_assertion = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - self.assertIn("data", actions_assertion) - assertion_data = actions_assertion["data"] - # Verify the manifest now contains actions - self.assertIn("actions", assertion_data) - actions = assertion_data["actions"] - # Verify "c2pa.color_adjustments" action exists anywhere in the actions array - created_action_found = False - for action in actions: - if action.get("action") == "c2pa.color_adjustments": - created_action_found = True - break + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + reader.close() + output.close() - self.assertTrue(created_action_found) + def test_signing_manifest_v2(self): + """Test signing and reading a V2 manifest. + V2 manifests have a slightly different structure. + """ + with open(self.testPath, "rb") as file: + # Create a builder with the V2 manifest definition using context manager + with Builder(self.manifestDefinitionV2) as builder: + output = io.BytesIO(bytearray()) - # Verify "c2pa.created" action exists only once in the actions array - created_count = 0 - for action in actions: - if action.get("action") == "c2pa.created": - created_count += 1 + # Sign as usual... + builder.sign(self.signer, "image/jpeg", file, output) - self.assertEqual(created_count, 1, "c2pa.created action should appear exactly once") + output.seek(0) - builder.close() + # Read the signed file and verify the manifest using context manager + with Reader("image/jpeg", output) as reader: + json_data = reader.json() - # Reset settings to default - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + # Basic verification of the manifest + self.assertIn("Python Test Image V2", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) - def test_builder_minimal_manifest_add_actions_and_sign_no_auto_add(self): - # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + output.close() - initial_manifest_definition = { - "claim_generator": "python_test", - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - "format": "image/jpeg", - "title": "Python Test Image V2", - } + def test_builder_does_not_sign_unsupported_format(self): + with open(self.testPath, "rb") as file: + with Builder(self.manifestDefinitionV2) as builder: + output = io.BytesIO(bytearray()) + with self.assertRaises(Error.NotSupported): + builder.sign(self.signer, "mimetype/not-supported", file, output) - builder = Builder.from_json(initial_manifest_definition) - builder.add_action('{ "action": "c2pa.created", "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}') + def test_sign_file_mp4_video(self): + temp_dir = tempfile.mkdtemp() + try: + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed_output.mp4") - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) + # Use the sign_file method + builder = Builder(self.manifestDefinition) + builder.sign_file( + os.path.join(FIXTURES_DIR, "video1.mp4"), + output_path, + self.signer + ) - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] + # Read the signed file and verify the manifest + with open(output_path, "rb") as file: + reader = Reader("video/mp4", file) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) - # Verify assertions object exists in active manifest - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] + finally: + # Clean up the temporary directory + shutil.rmtree(temp_dir) - # Find the c2pa.actions.v2 assertion to look for what we added - actions_assertion = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - actions_assertion = assertion - break + def test_sign_file_mov_video(self): + temp_dir = tempfile.mkdtemp() + try: + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed-C-recorded-as-mov.mov") - self.assertIsNotNone(actions_assertion) - self.assertIn("data", actions_assertion) - assertion_data = actions_assertion["data"] - # Verify the manifest now contains actions - self.assertIn("actions", assertion_data) - actions = assertion_data["actions"] - # Verify "c2pa.created" action exists anywhere in the actions array - created_action_found = False - for action in actions: - if action.get("action") == "c2pa.created": - created_action_found = True - break + # Use the sign_file method + builder = Builder(self.manifestDefinition) + manifest_bytes = builder.sign_file( + os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), + output_path, + self.signer + ) - self.assertTrue(created_action_found) + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) - builder.close() + # Read the signed file and verify the manifest + with open(output_path, "rb") as file: + reader = Reader("mov", file) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + # Verify also signed file using manifest bytes + with Reader("mov", output_path, manifest_bytes) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) - def test_builder_minimal_manifest_add_actions_and_sign_with_auto_add(self): - # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + finally: + # Clean up the temporary directory + shutil.rmtree(temp_dir) - initial_manifest_definition = { - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - "format": "image/jpeg", - "title": "Python Test Image V2", - } + def test_sign_file_mov_video_V2(self): + temp_dir = tempfile.mkdtemp() + try: + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed-C-recorded-as-mov.mov") - builder = Builder.from_json(initial_manifest_definition) - action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' - builder.add_action(action_json) + # Use the sign_file method + builder = Builder(self.manifestDefinitionV2) + manifest_bytes = builder.sign_file( + os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), + output_path, + self.signer + ) - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] + # Read the signed file and verify the manifest + with open(output_path, "rb") as file: + reader = Reader("mov", file) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] + # Verify also signed file using manifest bytes + with Reader("mov", output_path, manifest_bytes) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) - # Verify assertions object exists in active manifest - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] + finally: + # Clean up the temporary directory + shutil.rmtree(temp_dir) - # Find the c2pa.actions.v2 assertion to look for what we added - actions_assertion = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - actions_assertion = assertion - break + def test_builder_with_invalid_signer_none(self): + """Test Builder methods with None signer.""" + builder = Builder(self.manifestDefinition) - self.assertIsNotNone(actions_assertion) - self.assertIn("data", actions_assertion) - assertion_data = actions_assertion["data"] - # Verify the manifest now contains actions - self.assertIn("actions", assertion_data) - actions = assertion_data["actions"] - # Verify "c2pa.created" action exists anywhere in the actions array - created_action_found = False - for action in actions: - if action.get("action") == "c2pa.created": - created_action_found = True - break + with open(self.testPath, "rb") as file: + with self.assertRaises(Error): + builder.sign(None, "image/jpeg", file) - self.assertTrue(created_action_found) + def test_builder_with_invalid_signer_closed(self): + """Test Builder methods with closed signer.""" + builder = Builder(self.manifestDefinition) - # Verify "c2pa.color_adjustments" action also exists in the same actions array - color_adjustments_found = False - for action in actions: - if action.get("action") == "c2pa.color_adjustments": - color_adjustments_found = True - break + # Create and close a signer + closed_signer = Signer.from_info(self.signer_info) + closed_signer.close() - self.assertTrue(color_adjustments_found) + with open(self.testPath, "rb") as file: + with self.assertRaises(Error): + builder.sign(closed_signer, "image/jpeg", file) + + def test_builder_with_invalid_signer_object(self): + """Test Builder methods with invalid signer object.""" + builder = Builder(self.manifestDefinition) - builder.close() + # Use a mock object that looks like a signer but isn't + class MockSigner: + def __init__(self): + self._handle = None - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + mock_signer = MockSigner() - def test_builder_sign_dicts_no_auto_add(self): - # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + with open(self.testPath, "rb") as file: + with self.assertRaises(Error): + builder.sign(mock_signer, "image/jpeg", file) - initial_manifest_definition = { + def test_builder_manifest_with_unicode_characters(self): + """Test Builder with manifest containing various Unicode characters.""" + unicode_manifest = { + "claim_generator": "python_test_unicode_テスト", "claim_generator_info": [{ - "name": "python_test", + "name": "python_test_unicode_テスト", "version": "0.0.1", }], - # claim version 2 is the default - # "claim_version": 2, + "claim_version": 1, "format": "image/jpeg", - "title": "Python Test Image V2", + "title": "Python Test Image with Unicode: テスト test", + "ingredients": [], "assertions": [ { "label": "c2pa.actions", @@ -3787,6 +2265,7 @@ def test_builder_sign_dicts_no_auto_add(self): "actions": [ { "action": "c2pa.created", + "description": "Unicode: test", "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" } ] @@ -3794,755 +2273,386 @@ def test_builder_sign_dicts_no_auto_add(self): } ] } - builder = Builder.from_json(initial_manifest_definition) - # Using a dictionary instead of a JSON string - action_dict = {"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}} - builder.add_action(action_dict) + builder = Builder(unicode_manifest) - with open(self.testPath2, "rb") as file: + with open(self.testPath, "rb") as file: output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) reader = Reader("image/jpeg", output) json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify assertions object exists in active manifest - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the c2pa.actions.v2 assertion to check what we added - actions_assertion = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - self.assertIn("data", actions_assertion) - assertion_data = actions_assertion["data"] - # Verify the manifest now contains actions - self.assertIn("actions", assertion_data) - actions = assertion_data["actions"] - # Verify "c2pa.color_adjustments" action exists anywhere in the actions array - created_action_found = False - for action in actions: - if action.get("action") == "c2pa.color_adjustments": - created_action_found = True - break - - self.assertTrue(created_action_found) - - builder.close() - - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - - def test_builder_opened_action_one_ingredient_no_auto_add(self): - """Test Builder with c2pa.opened action and one ingredient, following Adobe provenance patterns""" - # Disable auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - # Instance IDs for linking ingredients and actions - # This can be any unique id so the ingredient can be uniquely identified and linked to the action - parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c" - manifestDefinition = { - "claim_generator_info": [{ - "name": "Python CAI test", - "version": "3.14.16" - }], - "title": "A title for the provenance test", - "ingredients": [ - # The parent ingredient will be added through add_ingredient - # And a properly crafted manifest json so they link - ], - "assertions": [ - { - "label": "c2pa.actions.v2", - "data": { - "actions": [ - { - "action": "c2pa.opened", - "softwareAgent": { - "name": "Opened asset", - }, - "parameters": { - "ingredientIds": [ - parent_ingredient_id - ] - }, - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" - } - ] - } - } - ] - } + # Verify Unicode characters are preserved in title and description + self.assertIn("テスト", json_data) - # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition - # Aka the unique parent_ingredient_id we rely on for linking - ingredient_json = { - "relationship": "parentOf", - "instance_id": parent_ingredient_id + def test_builder_ingredient_with_special_characters(self): + """Test Builder with ingredient containing special characters.""" + special_char_ingredient = { + "title": "Test Ingredient with Special Chars: テスト", + "format": "image/jpeg", + "description": "Special characters: !@#$%^&*()_+-=[]{}|;':\",./<>?`~" } - # An opened ingredient is always a parent, and there can only be exactly one parent ingredient - - # Read the input file (A.jpg will be signed) - with open(self.testPath2, "rb") as test_file: - file_content = test_file.read() - - builder = Builder.from_json(manifestDefinition) - # Add C.jpg as the parent "opened" ingredient - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) + builder = Builder(self.manifestDefinition) - output_buffer = io.BytesIO(bytearray()) - builder.sign( - self.signer, - "image/jpeg", - io.BytesIO(file_content), - output_buffer) - output_buffer.seek(0) + # Add ingredient with special characters + ingredient_json = json.dumps(special_char_ingredient) + with open(self.testPath2, "rb") as ingredient_file: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) - # Read and verify the manifest - reader = Reader("image/jpeg", output_buffer) + with open(self.testPath, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) json_data = reader.json() manifest_data = json.loads(json_data) - # Verify the ingredient instance ID is present - self.assertIn(parent_ingredient_id, json_data) - - # Verify c2pa.opened action is present - self.assertIn("c2pa.opened", json_data) - - builder.close() - - # 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_builder_one_opened_one_placed_action_no_auto_add(self): - """Test Builder with c2pa.opened action where asset is its own parent ingredient""" - # Disable auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + # Verify special characters are preserved in ingredients + self.assertIn("ingredients", manifest_data["manifests"][manifest_data["active_manifest"]]) + ingredients = manifest_data["manifests"][manifest_data["active_manifest"]]["ingredients"] + self.assertEqual(len(ingredients), 1) - # Instance IDs for linking ingredients and actions, - # need to be unique even if the same binary file is used, so ingredients link properly to actions - parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c" - placed_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-f3f800ebe42b" + ingredient = ingredients[0] + self.assertIn("テスト", ingredient["title"]) + self.assertIn("!@#$%^&*()_+-=[]{}|;':\",./<>?`~", ingredient["description"]) - manifestDefinition = { - "claim_generator_info": [{ - "name": "Python CAI test", - "version": "0.2.942" - }], - "title": "A title for the provenance test", - "ingredients": [ - # The parent ingredient will be added through add_ingredient - { - # Represents the bubbled up AI asset/ingredient - "format": "jpeg", - "relationship": "componentOf", - # Instance ID must be generated to match what is in parameters ingredientIds array - "instance_id": placed_ingredient_id, - } - ], - "assertions": [ - { - "label": "c2pa.actions.v2", - "data": { - "actions": [ - { - "action": "c2pa.opened", - "softwareAgent": { - "name": "Opened asset", - }, - "parameters": { - "ingredientIds": [ - parent_ingredient_id - ] - }, - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" - }, - { - "action": "c2pa.placed", - "softwareAgent": { - "name": "Placed asset", - }, - "parameters": { - "ingredientIds": [ - placed_ingredient_id - ] - }, - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" - } - ] - } - } - ] - } + def test_builder_resource_uri_with_unicode(self): + """Test Builder with resource URI containing Unicode characters.""" + builder = Builder(self.manifestDefinition) - # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition for c2pa.opened - # So that ingredients can link together. - ingredient_json = { - "relationship": "parentOf", - "when": "2025-08-07T18:01:55.934Z", - "instance_id": parent_ingredient_id - } + # Test with resource URI containing Unicode characters + unicode_uri = "thumbnail_テスト.jpg" + with open(self.testPath3, "rb") as thumbnail_file: + builder.add_resource(unicode_uri, thumbnail_file) - # Read the input file (A.jpg will be signed) - with open(self.testPath2, "rb") as test_file: - file_content = test_file.read() + with open(self.testPath, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() - builder = Builder.from_json(manifestDefinition) + # Verify the resource was added (exact verification depends on implementation) + self.assertIsNotNone(json_data) - # An asset can be its own parent ingredient! - # We add A.jpg as its own parent ingredient - with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) + def test_builder_initialization_failure_states(self): + """Test Builder state after initialization failures.""" + # Test with invalid JSON + with self.assertRaises(Error): + builder = Builder("{invalid json}") - output_buffer = io.BytesIO(bytearray()) - builder.sign( - self.signer, - "image/jpeg", - io.BytesIO(file_content), - output_buffer) - output_buffer.seek(0) + # Test with None manifest + with self.assertRaises(Error): + builder = Builder(None) - # Read and verify the manifest - reader = Reader("image/jpeg", output_buffer) - json_data = reader.json() - manifest_data = json.loads(json_data) + # Test with circular reference in JSON + circular_obj = {} + circular_obj['self'] = circular_obj + with self.assertRaises(Exception) as context: + builder = Builder(circular_obj) - # Verify both ingredient instance IDs are present - self.assertIn(parent_ingredient_id, json_data) - self.assertIn(placed_ingredient_id, json_data) + def test_builder_state_transitions(self): + """Test Builder state transitions during lifecycle.""" + builder = Builder(self.manifestDefinition) - # Verify both actions are present - self.assertIn("c2pa.opened", json_data) - self.assertIn("c2pa.placed", json_data) + # Initial state + self.assertEqual(builder._state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) + # After close builder.close() + self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) - # 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_builder_opened_action_multiple_ingredient_no_auto_add(self): - """Test Builder with c2pa.opened and c2pa.placed actions with multiple ingredients""" - # Disable auto-added actions, as what we are doing here can confuse auto-placements - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + def test_builder_context_manager_states(self): + """Test Builder state management in context manager.""" + with Builder(self.manifestDefinition) as builder: + # Inside context - should be valid + self.assertEqual(builder._state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) - # Instance IDs for linking ingredients and actions - # With multiple ingredients, we need multiple different unique ids so they each link properly - parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c" - placed_ingredient_1_id = "xmp:iid:a965983b-36fb-445a-aa80-f3f800ebe42b" - placed_ingredient_2_id = "xmp:iid:a965983b-36fb-445a-aa80-f2d712acd14c" + # Placeholder operation + builder.set_no_embed() - manifestDefinition = { - "claim_generator_info": [{ - "name": "Python CAI test", - "version": "0.2.942" - }], - "title": "A title for the provenance test with multiple ingredients", - "ingredients": [ - # More ingredients will be added using add_ingredient - { - "format": "jpeg", - "relationship": "componentOf", - # Instance ID must be generated to match what is in parameters ingredientIds array - "instance_id": placed_ingredient_1_id, - } - ], - "assertions": [ - { - "label": "c2pa.actions.v2", - "data": { - "actions": [ - { - "action": "c2pa.opened", - "softwareAgent": { - "name": "A parent opened asset", - }, - "parameters": { - "ingredientIds": [ - parent_ingredient_id - ] - }, - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" - }, - { - "action": "c2pa.placed", - "softwareAgent": { - "name": "Component placed assets", - }, - "parameters": { - "ingredientIds": [ - placed_ingredient_1_id, - placed_ingredient_2_id - ] - }, - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" - } - ] - } - } - ] - } + # After context exit - should be closed + self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) - # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition, - # so that ingredients properly link with their action - ingredient_json_parent = { - "relationship": "parentOf", - "instance_id": parent_ingredient_id - } + def test_builder_context_manager_with_exception(self): + """Test Builder state after exception in context manager.""" + try: + with Builder(self.manifestDefinition) as builder: + # Inside context - should be valid + self.assertEqual(builder._state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) + raise ValueError("Test exception") + except ValueError: + pass - # The ingredient json for the placed action needs to match the instance_id in the manifestDefinition, - # so that ingredients properly link with their action - ingredient_json_placed = { - "relationship": "componentOf", - "instance_id": placed_ingredient_2_id - } + # After exception - should still be closed + self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) - # Read the input file (A.jpg will be signed) - with open(self.testPath2, "rb") as test_file: - file_content = test_file.read() + def test_builder_partial_initialization_states(self): + """Test Builder behavior with partial initialization failures.""" + # Test with _builder = None but _state = ACTIVE + builder = Builder.__new__(Builder) + builder._state = LifecycleState.ACTIVE + builder._handle = None - builder = Builder.from_json(manifestDefinition) + with self.assertRaises(Error): + builder._ensure_valid_state() - # Add C.jpg as the parent ingredient (for c2pa.opened, it's the opened asset) - with open(self.testPath, 'rb') as f1: - builder.add_ingredient(ingredient_json_parent, "image/jpeg", f1) + def test_builder_cleanup_state_transitions(self): + """Test Builder state during cleanup operations.""" + builder = Builder(self.manifestDefinition) - # Add cloud.jpg as another placed ingredient (for instance, added on the opened asset) - with open(self.testPath4, 'rb') as f2: - builder.add_ingredient(ingredient_json_placed, "image/jpeg", f2) + # Test _cleanup_resources method + builder._cleanup_resources() + self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) - output_buffer = io.BytesIO(bytearray()) - builder.sign( - self.signer, - "image/jpeg", - io.BytesIO(file_content), - output_buffer) - output_buffer.seek(0) + def test_builder_cleanup_idempotency(self): + """Test that cleanup operations are idempotent.""" + builder = Builder(self.manifestDefinition) - # Read and verify the manifest - reader = Reader("image/jpeg", output_buffer) - json_data = reader.json() - manifest_data = json.loads(json_data) + # First cleanup + builder._cleanup_resources() + self.assertEqual(builder._state, LifecycleState.CLOSED) - # Verify all ingredient instance IDs are present - self.assertIn(parent_ingredient_id, json_data) - self.assertIn(placed_ingredient_1_id, json_data) - self.assertIn(placed_ingredient_2_id, json_data) + # Second cleanup should not change state + builder._cleanup_resources() + self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) - # Verify both actions are present - self.assertIn("c2pa.opened", json_data) - self.assertIn("c2pa.placed", json_data) + def test_builder_state_after_sign_operations(self): + """Test Builder state after signing operations.""" + builder = Builder(self.manifestDefinition) - builder.close() + with open(self.testPath, "rb") as file: + manifest_bytes = builder.sign(self.signer, "image/jpeg", file) - # 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}}}}') + # State should still be valid after signing + self.assertEqual(builder._state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) + # Should be able to sign again + with open(self.testPath, "rb") as file: + manifest_bytes2 = builder.sign(self.signer, "image/jpeg", file) -class TestStream(unittest.TestCase): - def setUp(self): - self.temp_file = io.BytesIO() - self.test_data = b"Hello, World!" - self.temp_file.write(self.test_data) - self.temp_file.seek(0) - - def tearDown(self): - self.temp_file.close() - - def test_stream_initialization(self): - stream = Stream(self.temp_file) - self.assertTrue(stream.initialized) - self.assertFalse(stream.closed) - stream.close() - - def test_stream_initialization_with_invalid_object(self): - with self.assertRaises(TypeError): - Stream("not a file-like object") - - def test_stream_read(self): - stream = Stream(self.temp_file) - try: - # Create a buffer to read into - buffer = (ctypes.c_ubyte * 13)() - # Read the data - bytes_read = stream._read_cb(None, buffer, 13) - # Verify the data - self.assertEqual(bytes_read, 13) - self.assertEqual(bytes(buffer[:bytes_read]), self.test_data) - finally: - stream.close() + def test_builder_state_after_archive_operations(self): + """Test Builder state after archive operations.""" + builder = Builder(self.manifestDefinition) - def test_stream_write(self): - output = io.BytesIO() - stream = Stream(output) - try: - # Create test data - test_data = b"Test Write" - buffer = (ctypes.c_ubyte * len(test_data))(*test_data) - # Write the data - bytes_written = stream._write_cb(None, buffer, len(test_data)) - # Verify the data - self.assertEqual(bytes_written, len(test_data)) - output.seek(0) - self.assertEqual(output.read(), test_data) - finally: - stream.close() + # Test to_archive + with io.BytesIO() as archive_stream: + builder.to_archive(archive_stream) - def test_stream_seek(self): - stream = Stream(self.temp_file) - try: - # Seek to position 7 (after "Hello, ") - new_pos = stream._seek_cb(None, 7, 0) # 0 = SEEK_SET - self.assertEqual(new_pos, 7) - # Read from new position - buffer = (ctypes.c_ubyte * 6)() - bytes_read = stream._read_cb(None, buffer, 6) - self.assertEqual(bytes(buffer[:bytes_read]), b"World!") - finally: - stream.close() + # State should still be valid + self.assertEqual(builder._state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) - def test_stream_flush(self): - output = io.BytesIO() - stream = Stream(output) - try: - # Write some data - test_data = b"Test Flush" - buffer = (ctypes.c_ubyte * len(test_data))(*test_data) - stream._write_cb(None, buffer, len(test_data)) - # Flush the stream - result = stream._flush_cb(None) - self.assertEqual(result, 0) - finally: - stream.close() + def test_builder_state_after_double_close(self): + """Test Builder state after double close operations.""" + builder = Builder(self.manifestDefinition) - def test_stream_context_manager(self): - with Stream(self.temp_file) as stream: - self.assertTrue(stream.initialized) - self.assertFalse(stream.closed) - self.assertTrue(stream.closed) + # First close + builder.close() + self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) - def test_stream_double_close(self): - stream = Stream(self.temp_file) - stream.close() - # Second close should not raise an exception - stream.close() - self.assertTrue(stream.closed) - - def test_stream_read_after_close(self): - stream = Stream(self.temp_file) - # Store callbacks before closing - read_cb = stream._read_cb - stream.close() - buffer = (ctypes.c_ubyte * 13)() - # Reading from closed stream should return -1 - self.assertEqual(read_cb(None, buffer, 13), -1) - - def test_stream_write_after_close(self): - stream = Stream(self.temp_file) - # Store callbacks before closing - write_cb = stream._write_cb - stream.close() - test_data = b"Test Write" - buffer = (ctypes.c_ubyte * len(test_data))(*test_data) - # Writing to closed stream should return -1 - self.assertEqual(write_cb(None, buffer, len(test_data)), -1) - - def test_stream_seek_after_close(self): - stream = Stream(self.temp_file) - # Store callbacks before closing - seek_cb = stream._seek_cb - stream.close() - # Seeking in closed stream should return -1 - self.assertEqual(seek_cb(None, 5, 0), -1) - - def test_stream_flush_after_close(self): - stream = Stream(self.temp_file) - # Store callbacks before closing - flush_cb = stream._flush_cb - stream.close() - # Flushing closed stream should return -1 - self.assertEqual(flush_cb(None), -1) - - -class TestLegacyAPI(unittest.TestCase): - def setUp(self): - # Filter specific deprecation warnings for legacy API tests - warnings.filterwarnings("ignore", message="The read_file function is deprecated") - warnings.filterwarnings("ignore", message="The sign_file function is deprecated") - warnings.filterwarnings("ignore", message="The read_ingredient_file function is deprecated") - warnings.filterwarnings("ignore", message="The create_signer function is deprecated") - warnings.filterwarnings("ignore", message="The create_signer_from_info function is deprecated") - warnings.filterwarnings("ignore", message="load_settings\\(\\) is deprecated") + # Second close should not change state + builder.close() + self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) - self.data_dir = FIXTURES_DIR - self.testPath = DEFAULT_TEST_FILE - self.testPath2 = INGREDIENT_TEST_FILE - self.testPath3 = os.path.join(self.data_dir, "A_thumbnail.jpg") + def test_builder_state_with_invalid_native_pointer(self): + """Test Builder state handling with invalid native pointer.""" + builder = Builder(self.manifestDefinition) - # Load test certificates and key - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: - self.certs = cert_file.read() - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - self.key = key_file.read() + # Simulate invalid native pointer + builder._handle = 0 - # Create a local ES256 signer with certs and a timestamp server - self.signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=self.certs, - private_key=self.key, - ta_url=b"http://timestamp.digicert.com" - ) - self.signer = Signer.from_info(self.signer_info) + # Operations should fail gracefully + with self.assertRaises(Error): + builder.set_no_embed() - # Define a manifest as a dictionary - self.manifestDefinition = { - "claim_generator": "python_internals_test", + def test_builder_add_action_to_manifest_no_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + initial_manifest_definition = { "claim_generator_info": [{ - "name": "python_internals_test", + "name": "python_test", "version": "0.0.1", }], - "claim_version": 1, + # claim version 2 is the default + # "claim_version": 2, "format": "image/jpeg", - "title": "Python Test Image", - "ingredients": [], + "title": "Python Test Image V2", "assertions": [ { "label": "c2pa.actions", "data": { "actions": [ { - "action": "c2pa.opened" + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" } ] } } ] } + builder = Builder.from_json(initial_manifest_definition) - # Create temp directory for tests - self.temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(self.temp_data_dir, exist_ok=True) - - # Define an example ES256 callback signer - self.callback_signer_alg = "Es256" - def callback_signer_es256(data: bytes) -> bytes: - private_key = serialization.load_pem_private_key( - self.key, - password=None, - backend=default_backend() - ) - signature = private_key.sign( - data, - ec.ECDSA(hashes.SHA256()) - ) - return signature - self.callback_signer_es256 = callback_signer_es256 - - def tearDown(self): - """Clean up temporary files after each test.""" - if os.path.exists(self.temp_data_dir): - shutil.rmtree(self.temp_data_dir) - - def test_read_ingredient_file(self): - """Test reading a C2PA ingredient from a file.""" - # Test reading ingredient from file with data_dir - temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(temp_data_dir, exist_ok=True) - - ingredient_json_with_dir = read_ingredient_file(self.testPath, temp_data_dir) - - # Verify some fields - ingredient_data = json.loads(ingredient_json_with_dir) - self.assertEqual(ingredient_data["title"], DEFAULT_TEST_FILE_NAME) - self.assertEqual(ingredient_data["format"], "image/jpeg") - self.assertIn("thumbnail", ingredient_data) - - def test_read_ingredient_file_who_has_no_manifest(self): - """Test reading a C2PA ingredient from a file.""" - # Test reading ingredient from file with data_dir - temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(temp_data_dir, exist_ok=True) - - # Load settings first, before they need to be used - load_settings('{"builder": { "thumbnail": {"enabled": false}}}') - - ingredient_json_with_dir = read_ingredient_file(self.testPath2, temp_data_dir) - - # Verify some fields - ingredient_data = json.loads(ingredient_json_with_dir) - self.assertEqual(ingredient_data["title"], INGREDIENT_TEST_FILE_NAME) - self.assertEqual(ingredient_data["format"], "image/jpeg") - self.assertNotIn("thumbnail", ingredient_data) - - # Reset setting - load_settings('{"builder": { "thumbnail": {"enabled": true}}}') - - def test_compare_read_ingredient_file_with_builder_added_ingredient(self): - """Test reading a C2PA ingredient from a file.""" - # Test reading ingredient from file with data_dir - temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(temp_data_dir, exist_ok=True) - - ingredient_json_with_dir = read_ingredient_file(self.testPath2, temp_data_dir) - - # Ingredient fields from read_ingredient_file - ingredient_data = json.loads(ingredient_json_with_dir) - - # Compare with ingredient added by Builder - builder = Builder.from_json(self.manifestDefinition) - # Only the title is needed (filename), since title not extracted or guessed from filename - ingredient_json = '{ "title" : "A.jpg" }' - with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) + action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' + builder.add_action(action_json) with open(self.testPath2, "rb") as file: output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) - - # Get ingredient fields from signed manifest reader = Reader("image/jpeg", output) json_data = reader.json() manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) active_manifest = manifest_data["manifests"][active_manifest_id] - only_ingredient = active_manifest["ingredients"][0] - - self.assertEqual(ingredient_data["title"], only_ingredient["title"]) - self.assertEqual(ingredient_data["format"], only_ingredient["format"]) - self.assertEqual(ingredient_data["document_id"], only_ingredient["document_id"]) - self.assertEqual(ingredient_data["instance_id"], only_ingredient["instance_id"]) - self.assertEqual(ingredient_data["relationship"], only_ingredient["relationship"]) - - def test_read_file(self): - """Test reading a C2PA ingredient from a file.""" - temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(temp_data_dir, exist_ok=True) - - # self.testPath has C2PA metadata to read - file_json_with_dir = read_file(self.testPath, temp_data_dir) - - # Parse the JSON and verify specific fields - file_data = json.loads(file_json_with_dir) - expected_manifest_id = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" - - # Verify some fields - self.assertEqual(file_data["active_manifest"], expected_manifest_id) - self.assertIn("manifests", file_data) - self.assertIn(expected_manifest_id, file_data["manifests"]) - - def test_sign_file(self): - """Test signing a file with C2PA manifest.""" - # Set up test paths - temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(temp_data_dir, exist_ok=True) - output_path = os.path.join(temp_data_dir, "signed_output.jpg") - - # Load test certificates and key - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - key = key_file.read() - # Create signer info - signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) + # Verify assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to check what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.color_adjustments" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + created_action_found = True + break + + self.assertTrue(created_action_found) + + builder.close() + + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - # Create a simple manifest - manifest = { - "claim_generator": "python_internals_test", + def test_builder_add_action_to_manifest_from_dict_no_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + initial_manifest_definition = { "claim_generator_info": [{ - "name": "python_internals_test", + "name": "python_test", "version": "0.0.1", }], - # Claim version has become mandatory for signing v1 claims - "claim_version": 1, + # claim version 2 is the default + # "claim_version": 2, "format": "image/jpeg", - "title": "Python Test Signed Image", - "ingredients": [], + "title": "Python Test Image V2", "assertions": [ { "label": "c2pa.actions", "data": { "actions": [ { - "action": "c2pa.opened" + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" } ] } } ] } + builder = Builder.from_json(initial_manifest_definition) + + # Using a dictionary instead of a JSON string + action_dict = {"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}} + builder.add_action(action_dict) - # Convert manifest to JSON string - manifest_json = json.dumps(manifest) + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) - try: - sign_file( - self.testPath, - output_path, - manifest_json, - signer_info - ) + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] - finally: - # Clean up - if os.path.exists(output_path): - os.remove(output_path) - - def test_sign_file_does_not_exist_errors(self): - """Test signing a file with C2PA manifest.""" - # Set up test paths - temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(temp_data_dir, exist_ok=True) - output_path = os.path.join(temp_data_dir, "signed_output.jpg") - - # Load test certificates and key - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - key = key_file.read() + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] - # Create signer info - signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) + # Verify assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to check what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.color_adjustments" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + created_action_found = True + break + + self.assertTrue(created_action_found) + + builder.close() + + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + + def test_builder_add_action_to_manifest_with_auto_add(self): + # For testing, force settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - # Create a simple manifest - manifest = { - "claim_generator": "python_internals_test", + initial_manifest_definition = { "claim_generator_info": [{ - "name": "python_internals_test", + "name": "python_test", "version": "0.0.1", }], - # Claim version has become mandatory for signing v1 claims - "claim_version": 1, + # claim version 2 is the default + # "claim_version": 2, "format": "image/jpeg", - "title": "Python Test Signed Image", + "title": "Python Test Image V2", "ingredients": [], "assertions": [ { @@ -4550,42 +2660,91 @@ def test_sign_file_does_not_exist_errors(self): "data": { "actions": [ { - "action": "c2pa.opened" + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" } ] } } ] } + builder = Builder.from_json(initial_manifest_definition) - # Convert manifest to JSON string - manifest_json = json.dumps(manifest) + action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' + builder.add_action(action_json) - try: - with self.assertRaises(Error): - sign_file( - "this-file-does-not-exist", - output_path, - manifest_json, - signer_info - ) + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to check what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.color_adjustments" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + created_action_found = True + break + + self.assertTrue(created_action_found) + + # Verify "c2pa.created" action exists only once in the actions array + created_count = 0 + for action in actions: + if action.get("action") == "c2pa.created": + created_count += 1 + + self.assertEqual(created_count, 1, "c2pa.created action should appear exactly once") + + builder.close() - finally: - # Clean up - if os.path.exists(output_path): - os.remove(output_path) + # Reset settings to default + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - def test_builder_sign_with_ingredient_from_file(self): - """Test Builder class operations with an ingredient added from file path.""" + def test_builder_minimal_manifest_add_actions_and_sign_no_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - builder = Builder.from_json(self.manifestDefinition) + initial_manifest_definition = { + "claim_generator": "python_test", + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Test Image V2", + } - # Test adding ingredient from file path - ingredient_json = '{"title": "Test Ingredient From File"}' - # Suppress the specific deprecation warning for this test, as this is a legacy method - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", self.testPath3) + builder = Builder.from_json(initial_manifest_definition) + builder.add_action('{ "action": "c2pa.created", "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}') with open(self.testPath2, "rb") as file: output = io.BytesIO(bytearray()) @@ -4604,28 +2763,53 @@ def test_builder_sign_with_ingredient_from_file(self): self.assertIn(active_manifest_id, manifest_data["manifests"]) active_manifest = manifest_data["manifests"][active_manifest_id] - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) + # Verify assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual(first_ingredient["title"], "Test Ingredient From File") + # Find the c2pa.actions.v2 assertion to look for what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.created" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.created": + created_action_found = True + break + + self.assertTrue(created_action_found) builder.close() - def test_builder_sign_with_ingredient_dict_from_file(self): - """Test Builder class operations with an ingredient added from file path using a dictionary.""" + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - builder = Builder.from_json(self.manifestDefinition) + def test_builder_minimal_manifest_add_actions_and_sign_with_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + + initial_manifest_definition = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Test Image V2", + } - # Test adding ingredient from file path with a dictionary - ingredient_dict = {"title": "Test Ingredient From File"} - # Suppress the specific deprecation warning for this test, as this is a legacy method - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - builder.add_ingredient_from_file_path(ingredient_dict, "image/jpeg", self.testPath3) + builder = Builder.from_json(initial_manifest_definition) + action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' + builder.add_action(action_json) with open(self.testPath2, "rb") as file: output = io.BytesIO(bytearray()) @@ -4644,1353 +2828,434 @@ def test_builder_sign_with_ingredient_dict_from_file(self): self.assertIn(active_manifest_id, manifest_data["manifests"]) active_manifest = manifest_data["manifests"][active_manifest_id] - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) - - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual(first_ingredient["title"], "Test Ingredient From File") - - builder.close() - - def test_builder_add_ingredient_from_file_path(self): - """Test Builder class add_ingredient_from_file_path method.""" - - # Suppress the specific deprecation warning for this test, as this is a legacy method - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - - builder = Builder.from_json(self.manifestDefinition) - - # Test adding ingredient from file path - ingredient_json = '{"test": "ingredient_from_file_path"}' - builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", self.testPath) - - builder.close() - - def test_builder_add_ingredient_from_file_path(self): - """Test Builder class add_ingredient_from_file_path method.""" - - # Suppress the specific deprecation warning for this test, as this is a legacy method - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - - builder = Builder.from_json(self.manifestDefinition) - - # Test adding ingredient from file path - ingredient_json = '{"test": "ingredient_from_file_path"}' - - with self.assertRaises(Error.FileNotFound): - builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", "this-file-path-does-not-exist") - - def test_sign_file_using_callback_signer_overloads(self): - """Test signing a file using the sign_file function with a Signer object.""" - # Create a temporary directory for the test - temp_dir = tempfile.mkdtemp() - - try: - # Create a temporary output file path - output_path = os.path.join(temp_dir, "signed_output_callback.jpg") - - # Create signer with callback - signer = Signer.from_callback( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) - - # Overload that returns a JSON string - result_json = sign_file( - self.testPath, - output_path, - self.manifestDefinition, - signer, - False - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) - - # Verify the result is JSON - self.assertIsInstance(result_json, str) - self.assertGreater(len(result_json), 0) - - manifest_data = json.loads(result_json) - self.assertIn("manifests", manifest_data) - self.assertIn("active_manifest", manifest_data) - - output_path_bytes = os.path.join(temp_dir, "signed_output_callback_bytes.jpg") - # Overload that returns bytes - result_bytes = sign_file( - self.testPath, - output_path_bytes, - self.manifestDefinition, - signer, - True - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path_bytes)) - - # Verify the result is bytes - self.assertIsInstance(result_bytes, bytes) - self.assertGreater(len(result_bytes), 0) - - # Read the signed file and verify the manifest contains expected content - with open(output_path, "rb") as file: - reader = Reader("image/jpeg", file) - file_manifest_json = reader.json() - self.assertIn("Python Test", file_manifest_json) - - finally: - shutil.rmtree(temp_dir) - - def test_sign_file_overloads(self): - """Test that the overloaded sign_file function works with both parameter types.""" - # Create a temporary directory for the test - temp_dir = tempfile.mkdtemp() - try: - # Test with C2paSignerInfo - output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") - - # Load test certificates and key - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - key = key_file.read() - - # Create signer info - signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - - # Test with C2paSignerInfo parameter - JSON return - result_1 = sign_file( - self.testPath, - output_path_1, - self.manifestDefinition, - signer_info, - False - ) - - self.assertIsInstance(result_1, str) - self.assertTrue(os.path.exists(output_path_1)) - - # Test with C2paSignerInfo parameter - bytes return - output_path_1_bytes = os.path.join(temp_dir, "signed_output_1_bytes.jpg") - result_1_bytes = sign_file( - self.testPath, - output_path_1_bytes, - self.manifestDefinition, - signer_info, - True - ) - - self.assertIsInstance(result_1_bytes, bytes) - self.assertTrue(os.path.exists(output_path_1_bytes)) - - # Test with Signer object - output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") - - # Create a signer from the signer info - signer = Signer.from_info(signer_info) - - # Test with Signer parameter - JSON return - result_2 = sign_file( - self.testPath, - output_path_2, - self.manifestDefinition, - signer, - False - ) - - self.assertIsInstance(result_2, str) - self.assertTrue(os.path.exists(output_path_2)) - - # Test with Signer parameter - bytes return - output_path_2_bytes = os.path.join(temp_dir, "signed_output_2_bytes.jpg") - result_2_bytes = sign_file( - self.testPath, - output_path_2_bytes, - self.manifestDefinition, - signer, - True - ) - - self.assertIsInstance(result_2_bytes, bytes) - self.assertTrue(os.path.exists(output_path_2_bytes)) - - # Both JSON results should be similar (same manifest structure) - manifest_1 = json.loads(result_1) - manifest_2 = json.loads(result_2) - - self.assertIn("manifests", manifest_1) - self.assertIn("manifests", manifest_2) - self.assertIn("active_manifest", manifest_1) - self.assertIn("active_manifest", manifest_2) - - # Both bytes results should be non-empty - self.assertGreater(len(result_1_bytes), 0) - self.assertGreater(len(result_2_bytes), 0) - - finally: - # Clean up the temporary directory - shutil.rmtree(temp_dir) - - def test_sign_file_callback_signer_reports_error(self): - """Test signing a file using the sign_file method with a callback that reports an error.""" - - temp_dir = tempfile.mkdtemp() - - try: - output_path = os.path.join(temp_dir, "signed_output.jpg") - - # Use the sign_file method - builder = Builder(self.manifestDefinition) - - # Define a callback that always returns None to simulate an error - def error_callback_signer(data: bytes) -> bytes: - # Could alternatively also raise an error - # raise RuntimeError("Simulated signing error") - return None - - # Create signer with error callback using create_signer function - signer = create_signer( - callback=error_callback_signer, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) - - # The signing operation should fail due to the error callback - with self.assertRaises(Error): - builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) - - finally: - shutil.rmtree(temp_dir) - - def test_sign_file_callback_signer(self): - """Test signing a file using the sign_file method.""" - - temp_dir = tempfile.mkdtemp() - - try: - output_path = os.path.join(temp_dir, "signed_output.jpg") - - # Use the sign_file method - builder = Builder(self.manifestDefinition) - - # Create signer with callback using create_signer function - signer = create_signer( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) - - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) - - # Verify results - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) - - # Read the signed file and verify the manifest - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: - json_data = reader.json() - # Needs trust configuration to be set up to validate as Trusted - # self.assertNotIn("validation_status", json_data) + # Verify assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] - # Parse the JSON and verify the signature algorithm - manifest_data = json.loads(json_data) - active_manifest_id = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_id] + # Find the c2pa.actions.v2 assertion to look for what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break - self.assertIn("signature_info", active_manifest) - signature_info = active_manifest["signature_info"] - self.assertEqual(signature_info["alg"], self.callback_signer_alg) + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.created" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.created": + created_action_found = True + break - finally: - shutil.rmtree(temp_dir) + self.assertTrue(created_action_found) - def test_sign_file_callback_signer(self): - """Test signing a file using the sign_file method.""" + # Verify "c2pa.color_adjustments" action also exists in the same actions array + color_adjustments_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + color_adjustments_found = True + break - temp_dir = tempfile.mkdtemp() + self.assertTrue(color_adjustments_found) - try: - output_path = os.path.join(temp_dir, "signed_output.jpg") + builder.close() - # Use the sign_file method - builder = Builder(self.manifestDefinition) + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - # Create signer with callback using create_signer function - signer = create_signer( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) + def test_builder_sign_dicts_no_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) + initial_manifest_definition = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + # claim version 2 is the default + # "claim_version": 2, + "format": "image/jpeg", + "title": "Python Test Image V2", + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + builder = Builder.from_json(initial_manifest_definition) - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) + # Using a dictionary instead of a JSON string + action_dict = {"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}} + builder.add_action(action_dict) - # Verify results - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) - # Read the signed file and verify the manifest - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: - json_data = reader.json() - # Needs trust configuration to be set up to validate as Trusted - # self.assertNotIn("validation_status", json_data) + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] - # Parse the JSON and verify the signature algorithm - manifest_data = json.loads(json_data) - active_manifest_id = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_id] + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] - self.assertIn("signature_info", active_manifest) - signature_info = active_manifest["signature_info"] - self.assertEqual(signature_info["alg"], self.callback_signer_alg) + # Verify assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] - finally: - shutil.rmtree(temp_dir) + # Find the c2pa.actions.v2 assertion to check what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break - def test_sign_file_callback_signer_managed_single(self): - """Test signing a file using the sign_file method with context managers.""" + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.color_adjustments" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + created_action_found = True + break - temp_dir = tempfile.mkdtemp() + self.assertTrue(created_action_found) - try: - output_path = os.path.join(temp_dir, "signed_output_managed.jpg") + builder.close() - # Create builder and signer with context managers - with Builder(self.manifestDefinition) as builder, create_signer( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) as signer: + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) + def test_builder_opened_action_one_ingredient_no_auto_add(self): + """Test Builder with c2pa.opened action and one ingredient, following Adobe provenance patterns""" + # Disable auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - # Verify results - self.assertTrue(os.path.exists(output_path)) - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) + # Instance IDs for linking ingredients and actions + # This can be any unique id so the ingredient can be uniquely identified and linked to the action + parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c" - # Verify signed data can be read - with open(output_path, "rb") as file: - with Reader("image/jpeg", file) as reader: - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted - # self.assertNotIn("validation_status", json_data) + manifestDefinition = { + "claim_generator_info": [{ + "name": "Python CAI test", + "version": "3.14.16" + }], + "title": "A title for the provenance test", + "ingredients": [ + # The parent ingredient will be added through add_ingredient + # And a properly crafted manifest json so they link + ], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "softwareAgent": { + "name": "Opened asset", + }, + "parameters": { + "ingredientIds": [ + parent_ingredient_id + ] + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + } + ] + } + } + ] + } - # Parse the JSON and verify the signature algorithm - manifest_data = json.loads(json_data) - active_manifest_id = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_id] + # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition + # Aka the unique parent_ingredient_id we rely on for linking + ingredient_json = { + "relationship": "parentOf", + "instance_id": parent_ingredient_id + } + # An opened ingredient is always a parent, and there can only be exactly one parent ingredient - # Verify the signature_info contains the correct algorithm - self.assertIn("signature_info", active_manifest) - signature_info = active_manifest["signature_info"] - self.assertEqual(signature_info["alg"], self.callback_signer_alg) + # Read the input file (A.jpg will be signed) + with open(self.testPath2, "rb") as test_file: + file_content = test_file.read() - finally: - shutil.rmtree(temp_dir) + builder = Builder.from_json(manifestDefinition) - def test_sign_file_callback_signer_managed_multiple_uses(self): - """Test that a signer can be used multiple times with context managers.""" + # Add C.jpg as the parent "opened" ingredient + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) - temp_dir = tempfile.mkdtemp() + output_buffer = io.BytesIO(bytearray()) + builder.sign( + self.signer, + "image/jpeg", + io.BytesIO(file_content), + output_buffer) + output_buffer.seek(0) - try: - # Create builder and signer with context managers - with Builder(self.manifestDefinition) as builder, create_signer( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) as signer: - - # First signing operation - output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") - manifest_bytes_1 = builder.sign_file( - source_path=self.testPath, - dest_path=output_path_1, - signer=signer - ) + # Read and verify the manifest + reader = Reader("image/jpeg", output_buffer) + json_data = reader.json() + manifest_data = json.loads(json_data) - # Verify first signing was successful - self.assertTrue(os.path.exists(output_path_1)) - self.assertIsInstance(manifest_bytes_1, bytes) - self.assertGreater(len(manifest_bytes_1), 0) - - # Second signing operation with the same signer - # This is to verify we don't free the signer or the callback too early - output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") - manifest_bytes_2 = builder.sign_file( - source_path=self.testPath, - dest_path=output_path_2, - signer=signer - ) + # Verify the ingredient instance ID is present + self.assertIn(parent_ingredient_id, json_data) - # Verify second signing was successful - self.assertTrue(os.path.exists(output_path_2)) - self.assertIsInstance(manifest_bytes_2, bytes) - self.assertGreater(len(manifest_bytes_2), 0) + # Verify c2pa.opened action is present + self.assertIn("c2pa.opened", json_data) - # Verify both files contain valid C2PA data - for output_path in [output_path_1, output_path_2]: - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted - # self.assertNotIn("validation_status", json_data) + builder.close() - # Parse the JSON and verify the signature algorithm - manifest_data = json.loads(json_data) - active_manifest_id = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_id] + # 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}}}}') - # Verify the signature_info contains the correct algorithm - self.assertIn("signature_info", active_manifest) - signature_info = active_manifest["signature_info"] - self.assertEqual(signature_info["alg"], self.callback_signer_alg) + def test_builder_one_opened_one_placed_action_no_auto_add(self): + """Test Builder with c2pa.opened action where asset is its own parent ingredient""" + # Disable auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - finally: - shutil.rmtree(temp_dir) + # Instance IDs for linking ingredients and actions, + # need to be unique even if the same binary file is used, so ingredients link properly to actions + parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c" + placed_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-f3f800ebe42b" - def test_create_signer_from_info(self): - """Create a Signer using the create_signer_from_info function""" - signer = create_signer_from_info(self.signer_info) - self.assertIsNotNone(signer) - - -class TestContextAPIs(unittest.TestCase): - """Base for context-related tests; provides test_manifest and signer helpers.""" - - test_manifest = { - "claim_generator": "c2pa_python_sdk_test/context", - "claim_generator_info": [{ - "name": "c2pa_python_sdk_contextual_test", - "version": "0.1.0", - }], - "format": "image/jpeg", - "title": "Test Image", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [{ - "action": "c2pa.created", - }] + manifestDefinition = { + "claim_generator_info": [{ + "name": "Python CAI test", + "version": "0.2.942" + }], + "title": "A title for the provenance test", + "ingredients": [ + # The parent ingredient will be added through add_ingredient + { + # Represents the bubbled up AI asset/ingredient + "format": "jpeg", + "relationship": "componentOf", + # Instance ID must be generated to match what is in parameters ingredientIds array + "instance_id": placed_ingredient_id, } - } - ] - } - - def _ctx_make_signer(self): - """Create a Signer for context tests.""" - certs_path = os.path.join( - FIXTURES_DIR, "es256_certs.pem" - ) - key_path = os.path.join( - FIXTURES_DIR, "es256_private.key" - ) - with open(certs_path, "rb") as f: - certs = f.read() - with open(key_path, "rb") as f: - key = f.read() - info = C2paSignerInfo( - alg=b"es256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com", - ) - return Signer.from_info(info) - - def _ctx_make_callback_signer(self): - """Create a callback-based Signer for context tests.""" - certs_path = os.path.join( - FIXTURES_DIR, "es256_certs.pem" - ) - key_path = os.path.join( - FIXTURES_DIR, "es256_private.key" - ) - with open(certs_path, "rb") as f: - certs = f.read() - with open(key_path, "rb") as f: - key_data = f.read() + ], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "softwareAgent": { + "name": "Opened asset", + }, + "parameters": { + "ingredientIds": [ + parent_ingredient_id + ] + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + }, + { + "action": "c2pa.placed", + "softwareAgent": { + "name": "Placed asset", + }, + "parameters": { + "ingredientIds": [ + placed_ingredient_id + ] + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + } + ] + } + } + ] + } - from cryptography.hazmat.primitives import ( - serialization, - ) - private_key = serialization.load_pem_private_key( - key_data, password=None, - backend=default_backend(), - ) + # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition for c2pa.opened + # So that ingredients can link together. + ingredient_json = { + "relationship": "parentOf", + "when": "2025-08-07T18:01:55.934Z", + "instance_id": parent_ingredient_id + } - def sign_cb(data: bytes) -> bytes: - from cryptography.hazmat.primitives.asymmetric import ( # noqa: E501 - utils as asym_utils, - ) - sig = private_key.sign( - data, ec.ECDSA(hashes.SHA256()), - ) - r, s = asym_utils.decode_dss_signature(sig) - return ( - r.to_bytes(32, byteorder='big') - + s.to_bytes(32, byteorder='big') - ) + # Read the input file (A.jpg will be signed) + with open(self.testPath2, "rb") as test_file: + file_content = test_file.read() - return Signer.from_callback( - sign_cb, - SigningAlg.ES256, - certs.decode('utf-8'), - "http://timestamp.digicert.com", - ) + builder = Builder.from_json(manifestDefinition) - def _ctx_make_ed25519_signer(self): - """Create an ED25519 Signer for context tests.""" - with open( - os.path.join(FIXTURES_DIR, "ed25519.pub"), "rb" - ) as f: - certs = f.read() - with open( - os.path.join(FIXTURES_DIR, "ed25519.pem"), "rb" - ) as f: - key = f.read() - info = C2paSignerInfo( - alg=b"ed25519", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com", - ) - return Signer.from_info(info) - - def _ctx_make_ps256_signer(self): - """Create a PS256 Signer for context tests.""" - with open( - os.path.join(FIXTURES_DIR, "ps256.pub"), "rb" - ) as f: - certs = f.read() - with open( - os.path.join(FIXTURES_DIR, "ps256.pem"), "rb" - ) as f: - key = f.read() - info = C2paSignerInfo( - alg=b"ps256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com", - ) - return Signer.from_info(info) + # An asset can be its own parent ingredient! + # We add A.jpg as its own parent ingredient + with open(self.testPath2, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + output_buffer = io.BytesIO(bytearray()) + builder.sign( + self.signer, + "image/jpeg", + io.BytesIO(file_content), + output_buffer) + output_buffer.seek(0) -class TestSettings(TestContextAPIs): + # Read and verify the manifest + reader = Reader("image/jpeg", output_buffer) + json_data = reader.json() + manifest_data = json.loads(json_data) - def test_settings_default_construction(self): - settings = Settings() - self.assertTrue(settings.is_valid) - settings.close() + # Verify both ingredient instance IDs are present + self.assertIn(parent_ingredient_id, json_data) + self.assertIn(placed_ingredient_id, json_data) - def test_settings_set_chaining(self): - settings = Settings() - result = ( - settings.set( - "builder.thumbnail.enabled", "false" - ).set( - "builder.thumbnail.enabled", "true" - ) - ) - self.assertIs(result, settings) - settings.close() + # Verify both actions are present + self.assertIn("c2pa.opened", json_data) + self.assertIn("c2pa.placed", json_data) - def test_settings_from_json(self): - settings = Settings.from_json( - '{"builder":{"thumbnail":' - '{"enabled":false}}}' - ) - self.assertTrue(settings.is_valid) - settings.close() - - def test_settings_from_dict(self): - settings = Settings.from_dict({ - "builder": { - "thumbnail": {"enabled": False} - } - }) - self.assertTrue(settings.is_valid) - settings.close() - - def test_settings_update_json(self): - settings = Settings() - result = settings.update( - '{"builder":{"thumbnail":' - '{"enabled":false}}}' - ) - self.assertIs(result, settings) - settings.close() - - def test_settings_update_dict(self): - settings = Settings() - result = settings.update({ - "builder": { - "thumbnail": {"enabled": False} - } - }) - self.assertIs(result, settings) - settings.close() - - def test_settings_is_valid_after_close(self): - settings = Settings() - settings.close() - self.assertFalse(settings.is_valid) - - def test_settings_raises_after_close(self): - settings = Settings() - settings.close() - with self.assertRaises(Error): - settings.set( - "builder.thumbnail.enabled", "false" - ) + builder.close() + # 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}}}}') -class TestContext(TestContextAPIs): + def test_builder_opened_action_multiple_ingredient_no_auto_add(self): + """Test Builder with c2pa.opened and c2pa.placed actions with multiple ingredients""" + # Disable auto-added actions, as what we are doing here can confuse auto-placements + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - def test_context_default(self): - context = Context() - self.assertTrue(context.is_valid) - self.assertFalse(context.has_signer) - context.close() + # Instance IDs for linking ingredients and actions + # With multiple ingredients, we need multiple different unique ids so they each link properly + parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c" + placed_ingredient_1_id = "xmp:iid:a965983b-36fb-445a-aa80-f3f800ebe42b" + placed_ingredient_2_id = "xmp:iid:a965983b-36fb-445a-aa80-f2d712acd14c" - def test_context_from_settings(self): - settings = Settings() - context = Context(settings) - self.assertTrue(context.is_valid) - context.close() - settings.close() + manifestDefinition = { + "claim_generator_info": [{ + "name": "Python CAI test", + "version": "0.2.942" + }], + "title": "A title for the provenance test with multiple ingredients", + "ingredients": [ + # More ingredients will be added using add_ingredient + { + "format": "jpeg", + "relationship": "componentOf", + # Instance ID must be generated to match what is in parameters ingredientIds array + "instance_id": placed_ingredient_1_id, + } + ], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "softwareAgent": { + "name": "A parent opened asset", + }, + "parameters": { + "ingredientIds": [ + parent_ingredient_id + ] + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + }, + { + "action": "c2pa.placed", + "softwareAgent": { + "name": "Component placed assets", + }, + "parameters": { + "ingredientIds": [ + placed_ingredient_1_id, + placed_ingredient_2_id + ] + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + } + ] + } + } + ] + } - def test_context_from_json(self): - context = Context.from_json( - '{"builder":{"thumbnail":' - '{"enabled":false}}}' - ) - self.assertTrue(context.is_valid) - context.close() - - def test_context_from_dict(self): - context = Context.from_dict({ - "builder": { - "thumbnail": {"enabled": False} - } - }) - self.assertTrue(context.is_valid) - context.close() - - def test_context_context_manager(self): - with Context() as context: - self.assertTrue(context.is_valid) - - def test_context_is_valid_after_close(self): - context = Context() - context.close() - self.assertFalse(context.is_valid) - - -class TestContextBuilder(TestContextAPIs): - - def test_context_builder_default(self): - context = Context.builder().build() - self.assertTrue(context.is_valid) - self.assertFalse(context.has_signer) - context.close() - - def test_context_builder_with_settings(self): - settings = Settings() - context = Context.builder().with_settings(settings).build() - self.assertTrue(context.is_valid) - context.close() - settings.close() - - def test_context_builder_with_signer(self): - signer = self._ctx_make_signer() - context = ( - Context.builder() - .with_signer(signer) - .build() - ) - self.assertTrue(context.is_valid) - self.assertTrue(context.has_signer) - context.close() - - def test_context_builder_with_settings_and_signer(self): - settings = Settings() - signer = self._ctx_make_signer() - context = ( - Context.builder() - .with_settings(settings) - .with_signer(signer) - .build() - ) - self.assertTrue(context.is_valid) - self.assertTrue(context.has_signer) - context.close() - settings.close() - - def test_context_builder_chaining_returns_self(self): - settings = Settings() - context_builder = Context.builder() - result = context_builder.with_settings(settings) - self.assertIs(result, context_builder) - context = context_builder.build() - context.close() - settings.close() - - -class TestContextWithSigner(TestContextAPIs): - - def test_context_with_signer(self): - signer = self._ctx_make_signer() - context = Context(signer=signer) - self.assertTrue(context.is_valid) - self.assertTrue(context.has_signer) - context.close() - - def test_context_with_settings_and_signer(self): - settings = Settings() - signer = self._ctx_make_signer() - context = Context(settings, signer) - self.assertTrue(context.is_valid) - self.assertTrue(context.has_signer) - context.close() - settings.close() - - def test_consumed_signer_is_closed(self): - signer = self._ctx_make_signer() - context = Context(signer=signer) - self.assertEqual(signer._state, LifecycleState.CLOSED) - context.close() - - def test_consumed_signer_raises_on_use(self): - signer = self._ctx_make_signer() - context = Context(signer=signer) - with self.assertRaises(Error): - signer._ensure_valid_state() - context.close() - - def test_context_has_signer_flag(self): - signer = self._ctx_make_signer() - context = Context(signer=signer) - self.assertTrue(context.has_signer) - context.close() - - def test_context_no_signer_flag(self): - context = Context() - self.assertFalse(context.has_signer) - context.close() - - def test_context_from_json_with_signer(self): - signer = self._ctx_make_signer() - context = Context.from_json( - '{"builder":{"thumbnail":' - '{"enabled":false}}}', - signer, - ) - self.assertTrue(context.has_signer) - self.assertEqual(signer._state, LifecycleState.CLOSED) - context.close() + # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition, + # so that ingredients properly link with their action + ingredient_json_parent = { + "relationship": "parentOf", + "instance_id": parent_ingredient_id + } + # The ingredient json for the placed action needs to match the instance_id in the manifestDefinition, + # so that ingredients properly link with their action + ingredient_json_placed = { + "relationship": "componentOf", + "instance_id": placed_ingredient_2_id + } -class TestReaderWithContext(TestContextAPIs): + # Read the input file (A.jpg will be signed) + with open(self.testPath2, "rb") as test_file: + file_content = test_file.read() - def test_reader_with_default_context(self): - context = Context() - with open(DEFAULT_TEST_FILE, "rb") as file_handle: - reader = Reader("image/jpeg", file_handle, context=context,) - data = reader.json() - self.assertIsNotNone(data) - reader.close() - context.close() - - def test_reader_with_settings_context(self): - settings = Settings() - context = Context(settings) - with open(DEFAULT_TEST_FILE, "rb") as file_handle: - reader = Reader("image/jpeg", file_handle, context=context,) - data = reader.json() - self.assertIsNotNone(data) - reader.close() - context.close() - settings.close() - - def test_reader_without_context(self): - with open(DEFAULT_TEST_FILE, "rb") as file_handle: - reader = Reader("image/jpeg", file_handle) - data = reader.json() - self.assertIsNotNone(data) - reader.close() + builder = Builder.from_json(manifestDefinition) - def test_reader_try_create_with_context(self): - context = Context() - reader = Reader.try_create(DEFAULT_TEST_FILE, context=context,) - self.assertIsNotNone(reader) - data = reader.json() - self.assertIsNotNone(data) - reader.close() - context.close() - - def test_reader_try_create_no_manifest(self): - context = Context() - reader = Reader.try_create(INGREDIENT_TEST_FILE, context=context,) - self.assertIsNone(reader) - context.close() - - def test_reader_file_path_with_context(self): - context = Context() - reader = Reader(DEFAULT_TEST_FILE, context=context,) - data = reader.json() - self.assertIsNotNone(data) - reader.close() - context.close() - - def test_reader_format_and_path_with_ctx(self): - context = Context() - reader = Reader("image/jpeg", DEFAULT_TEST_FILE, context=context) - data = reader.json() - self.assertIsNotNone(data) - reader.close() - context.close() - - -class TestBuilderWithContext(TestContextAPIs): - - def test_contextual_builder_with_default_context(self): - context = Context() - builder = Builder(self.test_manifest, context) - self.assertIsNotNone(builder) - builder.close() - context.close() - - def test_contextual_builder_with_settings_context(self): - settings = Settings.from_dict({ - "builder": { - "thumbnail": {"enabled": False} - } - }) - context = Context(settings) - builder = Builder(self.test_manifest, context) - signer = self._ctx_make_signer() - with tempfile.TemporaryDirectory() as temp_dir: - dest_path = os.path.join(temp_dir, "out.jpg") - with ( - open(DEFAULT_TEST_FILE, "rb") as source_file, - open(dest_path, "w+b") as dest_file, - ): - builder.sign( - signer, "image/jpeg", source_file, dest_file, - ) - reader = Reader(dest_path) - manifest = reader.get_active_manifest() - self.assertIsNone( - manifest.get("thumbnail") - ) - reader.close() - builder.close() - context.close() - settings.close() + # Add C.jpg as the parent ingredient (for c2pa.opened, it's the opened asset) + with open(self.testPath, 'rb') as f1: + builder.add_ingredient(ingredient_json_parent, "image/jpeg", f1) - def test_contextual_builder_from_json_with_context(self): - context = Context() - builder = Builder.from_json(self.test_manifest, context) - self.assertIsNotNone(builder) - builder.close() - context.close() + # Add cloud.jpg as another placed ingredient (for instance, added on the opened asset) + with open(self.testPath4, 'rb') as f2: + builder.add_ingredient(ingredient_json_placed, "image/jpeg", f2) - def test_contextual_builder_sign_context_signer(self): - signer = self._ctx_make_signer() - context = Context(signer=signer) - builder = Builder( - self.test_manifest, context=context, - ) - with tempfile.TemporaryDirectory() as temp_dir: - dest_path = os.path.join(temp_dir, "out.jpg") - with ( - open(DEFAULT_TEST_FILE, "rb") as source_file, - open(dest_path, "w+b") as dest_file, - ): - manifest_bytes = builder.sign_with_context( + output_buffer = io.BytesIO(bytearray()) + builder.sign( + self.signer, "image/jpeg", - source_file, - dest_file, - ) - self.assertIsNotNone(manifest_bytes) - self.assertGreater(len(manifest_bytes), 0) - reader = Reader(dest_path) - data = reader.json() - self.assertIsNotNone(data) - reader.close() - builder.close() - context.close() - - def test_contextual_builder_sign_signer_ovverride(self): - context_signer = self._ctx_make_signer() - context = Context(signer=context_signer) - builder = Builder( - self.test_manifest, context=context, - ) - explicit_signer = self._ctx_make_signer() - with tempfile.TemporaryDirectory() as temp_dir: - dest_path = os.path.join(temp_dir, "out.jpg") - with ( - open(DEFAULT_TEST_FILE, "rb") as source_file, - open(dest_path, "w+b") as dest_file, - ): - manifest_bytes = builder.sign( - explicit_signer, - "image/jpeg", source_file, dest_file, - ) - self.assertIsNotNone(manifest_bytes) - self.assertGreater(len(manifest_bytes), 0) - builder.close() - explicit_signer.close() - context.close() + io.BytesIO(file_content), + output_buffer) + output_buffer.seek(0) - def test_contextual_builder_sign_no_signer_raises(self): - context = Context() - builder = Builder( - self.test_manifest, context=context, - ) - with tempfile.TemporaryDirectory() as temp_dir: - dest_path = os.path.join(temp_dir, "out.jpg") - with ( - open(DEFAULT_TEST_FILE, "rb") as source_file, - open(dest_path, "w+b") as dest_file, - ): - with self.assertRaises(Error): - builder.sign_with_context( - "image/jpeg", - source_file, - dest_file, - ) - builder.close() - context.close() + # Read and verify the manifest + reader = Reader("image/jpeg", output_buffer) + json_data = reader.json() + manifest_data = json.loads(json_data) + # Verify all ingredient instance IDs are present + self.assertIn(parent_ingredient_id, json_data) + self.assertIn(placed_ingredient_1_id, json_data) + self.assertIn(placed_ingredient_2_id, json_data) -class TestContextIntegration(TestContextAPIs): + # Verify both actions are present + self.assertIn("c2pa.opened", json_data) + self.assertIn("c2pa.placed", json_data) - def test_sign_no_thumbnail_via_context(self): - settings = Settings.from_dict({ - "builder": { - "thumbnail": {"enabled": False} - } - }) - context = Context(settings) - signer = self._ctx_make_signer() - builder = Builder( - self.test_manifest, context=context, - ) - with tempfile.TemporaryDirectory() as temp_dir: - dest_path = os.path.join(temp_dir, "out.jpg") - with ( - open(DEFAULT_TEST_FILE, "rb") as source_file, - open(dest_path, "w+b") as dest_file, - ): - builder.sign( - signer, "image/jpeg", source_file, dest_file, - ) - reader = Reader(dest_path) - manifest = reader.get_active_manifest() - self.assertIsNone( - manifest.get("thumbnail") - ) - reader.close() - builder.close() - signer.close() - context.close() - settings.close() - - def test_sign_read_roundtrip(self): - signer = self._ctx_make_signer() - context = Context(signer=signer) - builder = Builder( - self.test_manifest, context=context, - ) - with tempfile.TemporaryDirectory() as temp_dir: - dest_path = os.path.join(temp_dir, "out.jpg") - with ( - open(DEFAULT_TEST_FILE, "rb") as source_file, - open(dest_path, "w+b") as dest_file, - ): - builder.sign_with_context( - "image/jpeg", - source_file, - dest_file, - ) - reader = Reader(dest_path) - data = reader.json() - self.assertIsNotNone(data) - self.assertIn("manifests", data) - reader.close() - builder.close() - context.close() - - def test_shared_context_multi_builders(self): - context = Context() - signer1 = self._ctx_make_signer() - signer2 = self._ctx_make_signer() - - builder1 = Builder(self.test_manifest, context) - builder2 = Builder(self.test_manifest, context) - - with tempfile.TemporaryDirectory() as temp_dir: - for index, (builder, signer) in enumerate( - [(builder1, signer1), (builder2, signer2)] - ): - dest_path = os.path.join( - temp_dir, f"out{index}.jpg" - ) - with ( - open( - DEFAULT_TEST_FILE, "rb" - ) as source_file, - open(dest_path, "w+b") as dest_file, - ): - manifest_bytes = builder.sign( - signer, "image/jpeg", - source_file, dest_file, - ) - self.assertGreater(len(manifest_bytes), 0) - - builder1.close() - builder2.close() - signer1.close() - signer2.close() - context.close() - - def test_trusted_sign_no_thumbnail_via_context(self): - trust_dict = load_test_settings_json() - trust_dict.setdefault("builder", {})["thumbnail"] = { - "enabled": False, - } - settings = Settings.from_dict(trust_dict) - context = Context(settings) - signer = self._ctx_make_signer() - builder = Builder( - self.test_manifest, context=context, - ) - with tempfile.TemporaryDirectory() as temp_dir: - dest_path = os.path.join(temp_dir, "out.jpg") - with ( - open(DEFAULT_TEST_FILE, "rb") as source_file, - open(dest_path, "w+b") as dest_file, - ): - builder.sign( - signer, "image/jpeg", - source_file, dest_file, - ) - reader = Reader(dest_path, context=context) - manifest = reader.get_active_manifest() - self.assertIsNone(manifest.get("thumbnail")) - validation_state = reader.get_validation_state() - self.assertEqual(validation_state, "Trusted") - reader.close() builder.close() - signer.close() - context.close() - settings.close() - - def test_shared_trusted_context_multi_builders(self): - trust_dict = load_test_settings_json() - settings = Settings.from_dict(trust_dict) - context = Context(settings) - signer1 = self._ctx_make_signer() - signer2 = self._ctx_make_signer() - - builder1 = Builder( - self.test_manifest, context=context, - ) - builder2 = Builder( - self.test_manifest, context=context, - ) - with tempfile.TemporaryDirectory() as temp_dir: - for index, (builder, signer) in enumerate( - [(builder1, signer1), (builder2, signer2)] - ): - dest_path = os.path.join( - temp_dir, f"out{index}.jpg" - ) - with ( - open( - DEFAULT_TEST_FILE, "rb" - ) as source_file, - open(dest_path, "w+b") as dest_file, - ): - manifest_bytes = builder.sign( - signer, "image/jpeg", - source_file, dest_file, - ) - self.assertGreater( - len(manifest_bytes), 0, - ) - reader = Reader( - dest_path, context=context, - ) - validation_state = ( - reader.get_validation_state() - ) - self.assertEqual( - validation_state, "Trusted", - ) - reader.close() - - builder1.close() - builder2.close() - signer1.close() - signer2.close() - context.close() - settings.close() - - def test_read_validation_trusted_via_context(self): - trust_dict = load_test_settings_json() - settings = Settings.from_dict(trust_dict) - context = Context(settings) - with open(DEFAULT_TEST_FILE, "rb") as f: - reader = Reader("image/jpeg", f, context=context) - validation_state = ( - reader.get_validation_state() - ) - self.assertEqual( - validation_state, "Trusted", - ) - reader.close() - context.close() - settings.close() - - def test_sign_es256_trusted_via_context(self): - trust_dict = load_test_settings_json() - settings = Settings.from_dict(trust_dict) - context = Context(settings) - signer = self._ctx_make_signer() - builder = Builder( - self.test_manifest, context=context, - ) - with tempfile.TemporaryDirectory() as temp_dir: - dest_path = os.path.join(temp_dir, "out.jpg") - with ( - open(DEFAULT_TEST_FILE, "rb") as source, - open(dest_path, "w+b") as dest, - ): - builder.sign( - signer, "image/jpeg", source, dest, - ) - reader = Reader(dest_path, context=context) - validation_state = ( - reader.get_validation_state() - ) - self.assertEqual( - validation_state, "Trusted", - ) - reader.close() - builder.close() - signer.close() - context.close() - settings.close() - - def test_sign_ed25519_trusted_via_context(self): - trust_dict = load_test_settings_json() - settings = Settings.from_dict(trust_dict) - context = Context(settings) - signer = self._ctx_make_ed25519_signer() - builder = Builder( - self.test_manifest, context=context, - ) - with tempfile.TemporaryDirectory() as temp_dir: - dest_path = os.path.join(temp_dir, "out.jpg") - with ( - open(DEFAULT_TEST_FILE, "rb") as source, - open(dest_path, "w+b") as dest, - ): - builder.sign( - signer, "image/jpeg", source, dest, - ) - reader = Reader(dest_path, context=context) - validation_state = ( - reader.get_validation_state() - ) - self.assertEqual( - validation_state, "Trusted", - ) - reader.close() - builder.close() - signer.close() - context.close() - settings.close() - - def test_sign_ps256_trusted_via_context(self): - trust_dict = load_test_settings_json() - settings = Settings.from_dict(trust_dict) - context = Context(settings) - signer = self._ctx_make_ps256_signer() - builder = Builder( - self.test_manifest, context=context, - ) - with tempfile.TemporaryDirectory() as temp_dir: - dest_path = os.path.join(temp_dir, "out.jpg") - with ( - open(DEFAULT_TEST_FILE, "rb") as source, - open(dest_path, "w+b") as dest, - ): - builder.sign( - signer, "image/jpeg", source, dest, - ) - reader = Reader(dest_path, context=context) - validation_state = ( - reader.get_validation_state() - ) - self.assertEqual( - validation_state, "Trusted", - ) - reader.close() - builder.close() - signer.close() - context.close() - settings.close() - - def test_archive_sign_trusted_via_context(self): - trust_dict = load_test_settings_json() - settings = Settings.from_dict(trust_dict) - context = Context(settings) - signer = self._ctx_make_signer() - builder = Builder( - self.test_manifest, context=context, - ) - archive = io.BytesIO(bytearray()) - builder.to_archive(archive) - builder = Builder.from_archive( - archive, context, - ) - with ( - open(DEFAULT_TEST_FILE, "rb") as source, - io.BytesIO(bytearray()) as output, - ): - builder.sign( - signer, "image/jpeg", source, output, - ) - output.seek(0) - reader = Reader( - "image/jpeg", output, context=context, - ) - validation_state = ( - reader.get_validation_state() - ) - self.assertEqual( - validation_state, "Trusted", - ) - reader.close() - archive.close() - builder.close() - signer.close() - context.close() - settings.close() - - def test_archive_sign_with_ingredient_trusted_via_context(self): - trust_dict = load_test_settings_json() - settings = Settings.from_dict(trust_dict) - context = Context(settings) - signer = self._ctx_make_signer() - builder = Builder( - self.test_manifest, context=context, - ) - archive = io.BytesIO(bytearray()) - builder.to_archive(archive) - builder = Builder.from_archive( - archive, context, - ) - ingredient_json = '{"test": "ingredient"}' - with open(DEFAULT_TEST_FILE, "rb") as f: - builder.add_ingredient( - ingredient_json, "image/jpeg", f, - ) - with ( - open(DEFAULT_TEST_FILE, "rb") as source, - io.BytesIO(bytearray()) as output, - ): - builder.sign( - signer, "image/jpeg", source, output, - ) - output.seek(0) - reader = Reader( - "image/jpeg", output, context=context, - ) - validation_state = ( - reader.get_validation_state() - ) - self.assertEqual( - validation_state, "Trusted", - ) - reader.close() - archive.close() - builder.close() - signer.close() - context.close() - settings.close() - - def test_sign_callback_signer_in_ctx(self): - signer = self._ctx_make_callback_signer() - context = Context(signer=signer) - builder = Builder( - self.test_manifest, context=context, - ) - with tempfile.TemporaryDirectory() as temp_dir: - dest_path = os.path.join(temp_dir, "out.jpg") - with ( - open(DEFAULT_TEST_FILE, "rb") as source_file, - open(dest_path, "w+b") as dest_file, - ): - manifest_bytes = builder.sign_with_context( - "image/jpeg", - source_file, - dest_file, - ) - self.assertGreater(len(manifest_bytes), 0) - reader = Reader(dest_path) - data = reader.json() - self.assertIsNotNone(data) - reader.close() - builder.close() - context.close() + # 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}}}}') + if __name__ == '__main__': diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 00000000..2bf226dd --- /dev/null +++ b/tests/test_common.py @@ -0,0 +1,66 @@ +# Copyright 2023 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +import os +import io +import json +import unittest +import ctypes +import warnings +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.backends import default_backend +import tempfile +import shutil +import toml +import threading + +# Suppress deprecation warnings +warnings.simplefilter("ignore", category=DeprecationWarning) + +from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType +from c2pa import Settings, Context, ContextBuilder, ContextProvider +from c2pa.c2pa import Stream, LifecycleState, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable + + +PROJECT_PATH = os.getcwd() +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") +DEFAULT_TEST_FILE_NAME = "C.jpg" +INGREDIENT_TEST_FILE_NAME = "A.jpg" +DEFAULT_TEST_FILE = os.path.join(FIXTURES_DIR, DEFAULT_TEST_FILE_NAME) +INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, INGREDIENT_TEST_FILE_NAME) +ALTERNATIVE_INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, "cloud.jpg") + + +def load_test_settings_json(): + """ + Load default (legacy) trust configuration test settings from a + JSON config file and return its content as JSON-compatible dict. + The return value is used to load settings (thread_local) in tests. + + Returns: + dict: The parsed JSON content as a Python dictionary (JSON-compatible). + + Raises: + FileNotFoundError: If trust_config_test_settings.json is not found. + json.JSONDecodeError: If the JSON file is malformed. + """ + # Locate the file which contains default settings for tests + tests_dir = os.path.dirname(os.path.abspath(__file__)) + settings_path = os.path.join(tests_dir, 'trust_config_test_settings.json') + + # Load the located default test settings + with open(settings_path, 'r') as f: + settings_data = json.load(f) + + return settings_data diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 00000000..7cb7142a --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,64 @@ +# Copyright 2023 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +import unittest + +from c2pa import Settings, Context + +from test_context_base import TestContextAPIs + +class TestContext(TestContextAPIs): + + def test_context_default(self): + context = Context() + self.assertTrue(context.is_valid) + self.assertFalse(context.has_signer) + context.close() + + def test_context_from_settings(self): + settings = Settings() + context = Context(settings) + self.assertTrue(context.is_valid) + context.close() + settings.close() + + def test_context_from_json(self): + context = Context.from_json( + '{"builder":{"thumbnail":' + '{"enabled":false}}}' + ) + self.assertTrue(context.is_valid) + context.close() + + def test_context_from_dict(self): + context = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + self.assertTrue(context.is_valid) + context.close() + + def test_context_context_manager(self): + with Context() as context: + self.assertTrue(context.is_valid) + + def test_context_is_valid_after_close(self): + context = Context() + context.close() + self.assertFalse(context.is_valid) + + + +if __name__ == '__main__': + unittest.main(warnings='ignore') diff --git a/tests/test_context_base.py b/tests/test_context_base.py new file mode 100644 index 00000000..5dbeea2a --- /dev/null +++ b/tests/test_context_base.py @@ -0,0 +1,145 @@ +# Copyright 2023 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +import os +import unittest + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.backends import default_backend + +from c2pa import C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer + +from test_common import FIXTURES_DIR + + +class TestContextAPIs(unittest.TestCase): + """Base for context-related tests; provides test_manifest and signer helpers.""" + + test_manifest = { + "claim_generator": "c2pa_python_sdk_test/context", + "claim_generator_info": [{ + "name": "c2pa_python_sdk_contextual_test", + "version": "0.1.0", + }], + "format": "image/jpeg", + "title": "Test Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + }] + } + } + ] + } + + def _ctx_make_signer(self): + """Create a Signer for context tests.""" + certs_path = os.path.join( + FIXTURES_DIR, "es256_certs.pem" + ) + key_path = os.path.join( + FIXTURES_DIR, "es256_private.key" + ) + with open(certs_path, "rb") as f: + certs = f.read() + with open(key_path, "rb") as f: + key = f.read() + info = C2paSignerInfo( + alg=b"es256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com", + ) + return Signer.from_info(info) + + def _ctx_make_callback_signer(self): + """Create a callback-based Signer for context tests.""" + certs_path = os.path.join( + FIXTURES_DIR, "es256_certs.pem" + ) + key_path = os.path.join( + FIXTURES_DIR, "es256_private.key" + ) + with open(certs_path, "rb") as f: + certs = f.read() + with open(key_path, "rb") as f: + key_data = f.read() + + from cryptography.hazmat.primitives import ( + serialization, + ) + private_key = serialization.load_pem_private_key( + key_data, password=None, + backend=default_backend(), + ) + + def sign_cb(data: bytes) -> bytes: + from cryptography.hazmat.primitives.asymmetric import ( # noqa: E501 + utils as asym_utils, + ) + sig = private_key.sign( + data, ec.ECDSA(hashes.SHA256()), + ) + r, s = asym_utils.decode_dss_signature(sig) + return ( + r.to_bytes(32, byteorder='big') + + s.to_bytes(32, byteorder='big') + ) + + return Signer.from_callback( + sign_cb, + SigningAlg.ES256, + certs.decode('utf-8'), + "http://timestamp.digicert.com", + ) + + def _ctx_make_ed25519_signer(self): + """Create an ED25519 Signer for context tests.""" + with open( + os.path.join(FIXTURES_DIR, "ed25519.pub"), "rb" + ) as f: + certs = f.read() + with open( + os.path.join(FIXTURES_DIR, "ed25519.pem"), "rb" + ) as f: + key = f.read() + info = C2paSignerInfo( + alg=b"ed25519", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com", + ) + return Signer.from_info(info) + + def _ctx_make_ps256_signer(self): + """Create a PS256 Signer for context tests.""" + with open( + os.path.join(FIXTURES_DIR, "ps256.pub"), "rb" + ) as f: + certs = f.read() + with open( + os.path.join(FIXTURES_DIR, "ps256.pem"), "rb" + ) as f: + key = f.read() + info = C2paSignerInfo( + alg=b"ps256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com", + ) + return Signer.from_info(info) diff --git a/tests/test_context_builder.py b/tests/test_context_builder.py new file mode 100644 index 00000000..2c3390dd --- /dev/null +++ b/tests/test_context_builder.py @@ -0,0 +1,72 @@ +# Copyright 2023 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +import unittest + +from c2pa import Settings, Context + +from test_context_base import TestContextAPIs + +class TestContextBuilder(TestContextAPIs): + + def test_context_builder_default(self): + context = Context.builder().build() + self.assertTrue(context.is_valid) + self.assertFalse(context.has_signer) + context.close() + + def test_context_builder_with_settings(self): + settings = Settings() + context = Context.builder().with_settings(settings).build() + self.assertTrue(context.is_valid) + context.close() + settings.close() + + def test_context_builder_with_signer(self): + signer = self._ctx_make_signer() + context = ( + Context.builder() + .with_signer(signer) + .build() + ) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + + def test_context_builder_with_settings_and_signer(self): + settings = Settings() + signer = self._ctx_make_signer() + context = ( + Context.builder() + .with_settings(settings) + .with_signer(signer) + .build() + ) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + settings.close() + + def test_context_builder_chaining_returns_self(self): + settings = Settings() + context_builder = Context.builder() + result = context_builder.with_settings(settings) + self.assertIs(result, context_builder) + context = context_builder.build() + context.close() + settings.close() + + + +if __name__ == '__main__': + unittest.main(warnings='ignore') diff --git a/tests/test_context_integration.py b/tests/test_context_integration.py new file mode 100644 index 00000000..2274c37d --- /dev/null +++ b/tests/test_context_integration.py @@ -0,0 +1,414 @@ +# Copyright 2023 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +import os +import io +import json +import unittest +import tempfile + +from c2pa import Builder, Reader +from c2pa import Settings, Context + +from test_common import DEFAULT_TEST_FILE, load_test_settings_json +from test_context_base import TestContextAPIs + +class TestContextIntegration(TestContextAPIs): + + def test_sign_no_thumbnail_via_context(self): + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + builder.sign( + signer, "image/jpeg", source_file, dest_file, + ) + reader = Reader(dest_path) + manifest = reader.get_active_manifest() + self.assertIsNone( + manifest.get("thumbnail") + ) + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_sign_read_roundtrip(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + builder.sign_with_context( + "image/jpeg", + source_file, + dest_file, + ) + reader = Reader(dest_path) + data = reader.json() + self.assertIsNotNone(data) + self.assertIn("manifests", data) + reader.close() + builder.close() + context.close() + + def test_shared_context_multi_builders(self): + context = Context() + signer1 = self._ctx_make_signer() + signer2 = self._ctx_make_signer() + + builder1 = Builder(self.test_manifest, context) + builder2 = Builder(self.test_manifest, context) + + with tempfile.TemporaryDirectory() as temp_dir: + for index, (builder, signer) in enumerate( + [(builder1, signer1), (builder2, signer2)] + ): + dest_path = os.path.join( + temp_dir, f"out{index}.jpg" + ) + with ( + open( + DEFAULT_TEST_FILE, "rb" + ) as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign( + signer, "image/jpeg", + source_file, dest_file, + ) + self.assertGreater(len(manifest_bytes), 0) + + builder1.close() + builder2.close() + signer1.close() + signer2.close() + context.close() + + def test_trusted_sign_no_thumbnail_via_context(self): + trust_dict = load_test_settings_json() + trust_dict.setdefault("builder", {})["thumbnail"] = { + "enabled": False, + } + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + builder.sign( + signer, "image/jpeg", + source_file, dest_file, + ) + reader = Reader(dest_path, context=context) + manifest = reader.get_active_manifest() + self.assertIsNone(manifest.get("thumbnail")) + validation_state = reader.get_validation_state() + self.assertEqual(validation_state, "Trusted") + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_shared_trusted_context_multi_builders(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer1 = self._ctx_make_signer() + signer2 = self._ctx_make_signer() + + builder1 = Builder( + self.test_manifest, context=context, + ) + builder2 = Builder( + self.test_manifest, context=context, + ) + + with tempfile.TemporaryDirectory() as temp_dir: + for index, (builder, signer) in enumerate( + [(builder1, signer1), (builder2, signer2)] + ): + dest_path = os.path.join( + temp_dir, f"out{index}.jpg" + ) + with ( + open( + DEFAULT_TEST_FILE, "rb" + ) as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign( + signer, "image/jpeg", + source_file, dest_file, + ) + self.assertGreater( + len(manifest_bytes), 0, + ) + reader = Reader( + dest_path, context=context, + ) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + + builder1.close() + builder2.close() + signer1.close() + signer2.close() + context.close() + settings.close() + + def test_read_validation_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + with open(DEFAULT_TEST_FILE, "rb") as f: + reader = Reader("image/jpeg", f, context=context) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + context.close() + settings.close() + + def test_sign_es256_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + open(dest_path, "w+b") as dest, + ): + builder.sign( + signer, "image/jpeg", source, dest, + ) + reader = Reader(dest_path, context=context) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_sign_ed25519_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_ed25519_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + open(dest_path, "w+b") as dest, + ): + builder.sign( + signer, "image/jpeg", source, dest, + ) + reader = Reader(dest_path, context=context) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_sign_ps256_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_ps256_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + open(dest_path, "w+b") as dest, + ): + builder.sign( + signer, "image/jpeg", source, dest, + ) + reader = Reader(dest_path, context=context) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_archive_sign_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder.from_archive( + archive, context, + ) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=context, + ) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + archive.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_archive_sign_with_ingredient_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder.from_archive( + archive, context, + ) + ingredient_json = '{"test": "ingredient"}' + with open(DEFAULT_TEST_FILE, "rb") as f: + builder.add_ingredient( + ingredient_json, "image/jpeg", f, + ) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=context, + ) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + archive.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_sign_callback_signer_in_ctx(self): + signer = self._ctx_make_callback_signer() + context = Context(signer=signer) + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign_with_context( + "image/jpeg", + source_file, + dest_file, + ) + self.assertGreater(len(manifest_bytes), 0) + reader = Reader(dest_path) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + builder.close() + context.close() + + + +if __name__ == '__main__': + unittest.main(warnings='ignore') diff --git a/tests/test_context_with_signer.py b/tests/test_context_with_signer.py new file mode 100644 index 00000000..51674832 --- /dev/null +++ b/tests/test_context_with_signer.py @@ -0,0 +1,78 @@ +# Copyright 2023 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +import unittest + +from c2pa import C2paError as Error +from c2pa import Settings, Context +from c2pa.c2pa import LifecycleState + +from test_context_base import TestContextAPIs + +class TestContextWithSigner(TestContextAPIs): + + def test_context_with_signer(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + + def test_context_with_settings_and_signer(self): + settings = Settings() + signer = self._ctx_make_signer() + context = Context(settings, signer) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + settings.close() + + def test_consumed_signer_is_closed(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + self.assertEqual(signer._state, LifecycleState.CLOSED) + context.close() + + def test_consumed_signer_raises_on_use(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + with self.assertRaises(Error): + signer._ensure_valid_state() + context.close() + + def test_context_has_signer_flag(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + self.assertTrue(context.has_signer) + context.close() + + def test_context_no_signer_flag(self): + context = Context() + self.assertFalse(context.has_signer) + context.close() + + def test_context_from_json_with_signer(self): + signer = self._ctx_make_signer() + context = Context.from_json( + '{"builder":{"thumbnail":' + '{"enabled":false}}}', + signer, + ) + self.assertTrue(context.has_signer) + self.assertEqual(signer._state, LifecycleState.CLOSED) + context.close() + + + +if __name__ == '__main__': + unittest.main(warnings='ignore') diff --git a/tests/test_legacy_api.py b/tests/test_legacy_api.py new file mode 100644 index 00000000..83666420 --- /dev/null +++ b/tests/test_legacy_api.py @@ -0,0 +1,848 @@ +# Copyright 2023 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +import os +import io +import json +import unittest +import warnings +import tempfile +import shutil +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.backends import default_backend + +warnings.simplefilter("ignore", category=DeprecationWarning) + +from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer +from c2pa.c2pa import read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info + +from test_common import FIXTURES_DIR, DEFAULT_TEST_FILE_NAME, INGREDIENT_TEST_FILE_NAME, DEFAULT_TEST_FILE, INGREDIENT_TEST_FILE + +class TestLegacyAPI(unittest.TestCase): + def setUp(self): + # Filter specific deprecation warnings for legacy API tests + warnings.filterwarnings("ignore", message="The read_file function is deprecated") + warnings.filterwarnings("ignore", message="The sign_file function is deprecated") + warnings.filterwarnings("ignore", message="The read_ingredient_file function is deprecated") + warnings.filterwarnings("ignore", message="The create_signer function is deprecated") + warnings.filterwarnings("ignore", message="The create_signer_from_info function is deprecated") + warnings.filterwarnings("ignore", message="load_settings\\(\\) is deprecated") + + self.data_dir = FIXTURES_DIR + self.testPath = DEFAULT_TEST_FILE + self.testPath2 = INGREDIENT_TEST_FILE + self.testPath3 = os.path.join(self.data_dir, "A_thumbnail.jpg") + + # Load test certificates and key + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + self.certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + self.key = key_file.read() + + # Create a local ES256 signer with certs and a timestamp server + self.signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + self.signer = Signer.from_info(self.signer_info) + + # Define a manifest as a dictionary + self.manifestDefinition = { + "claim_generator": "python_internals_test", + "claim_generator_info": [{ + "name": "python_internals_test", + "version": "0.0.1", + }], + "claim_version": 1, + "format": "image/jpeg", + "title": "Python Test Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.opened" + } + ] + } + } + ] + } + + # Create temp directory for tests + self.temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(self.temp_data_dir, exist_ok=True) + + # Define an example ES256 callback signer + self.callback_signer_alg = "Es256" + def callback_signer_es256(data: bytes) -> bytes: + private_key = serialization.load_pem_private_key( + self.key, + password=None, + backend=default_backend() + ) + signature = private_key.sign( + data, + ec.ECDSA(hashes.SHA256()) + ) + return signature + self.callback_signer_es256 = callback_signer_es256 + + def tearDown(self): + """Clean up temporary files after each test.""" + if os.path.exists(self.temp_data_dir): + shutil.rmtree(self.temp_data_dir) + + def test_read_ingredient_file(self): + """Test reading a C2PA ingredient from a file.""" + # Test reading ingredient from file with data_dir + temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(temp_data_dir, exist_ok=True) + + ingredient_json_with_dir = read_ingredient_file(self.testPath, temp_data_dir) + + # Verify some fields + ingredient_data = json.loads(ingredient_json_with_dir) + self.assertEqual(ingredient_data["title"], DEFAULT_TEST_FILE_NAME) + self.assertEqual(ingredient_data["format"], "image/jpeg") + self.assertIn("thumbnail", ingredient_data) + + def test_read_ingredient_file_who_has_no_manifest(self): + """Test reading a C2PA ingredient from a file.""" + # Test reading ingredient from file with data_dir + temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(temp_data_dir, exist_ok=True) + + # Load settings first, before they need to be used + load_settings('{"builder": { "thumbnail": {"enabled": false}}}') + + ingredient_json_with_dir = read_ingredient_file(self.testPath2, temp_data_dir) + + # Verify some fields + ingredient_data = json.loads(ingredient_json_with_dir) + self.assertEqual(ingredient_data["title"], INGREDIENT_TEST_FILE_NAME) + self.assertEqual(ingredient_data["format"], "image/jpeg") + self.assertNotIn("thumbnail", ingredient_data) + + # Reset setting + load_settings('{"builder": { "thumbnail": {"enabled": true}}}') + + def test_compare_read_ingredient_file_with_builder_added_ingredient(self): + """Test reading a C2PA ingredient from a file.""" + # Test reading ingredient from file with data_dir + temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(temp_data_dir, exist_ok=True) + + ingredient_json_with_dir = read_ingredient_file(self.testPath2, temp_data_dir) + + # Ingredient fields from read_ingredient_file + ingredient_data = json.loads(ingredient_json_with_dir) + + # Compare with ingredient added by Builder + builder = Builder.from_json(self.manifestDefinition) + # Only the title is needed (filename), since title not extracted or guessed from filename + ingredient_json = '{ "title" : "A.jpg" }' + with open(self.testPath2, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + # Get ingredient fields from signed manifest + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + only_ingredient = active_manifest["ingredients"][0] + + self.assertEqual(ingredient_data["title"], only_ingredient["title"]) + self.assertEqual(ingredient_data["format"], only_ingredient["format"]) + self.assertEqual(ingredient_data["document_id"], only_ingredient["document_id"]) + self.assertEqual(ingredient_data["instance_id"], only_ingredient["instance_id"]) + self.assertEqual(ingredient_data["relationship"], only_ingredient["relationship"]) + + def test_read_file(self): + """Test reading a C2PA ingredient from a file.""" + temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(temp_data_dir, exist_ok=True) + + # self.testPath has C2PA metadata to read + file_json_with_dir = read_file(self.testPath, temp_data_dir) + + # Parse the JSON and verify specific fields + file_data = json.loads(file_json_with_dir) + expected_manifest_id = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" + + # Verify some fields + self.assertEqual(file_data["active_manifest"], expected_manifest_id) + self.assertIn("manifests", file_data) + self.assertIn(expected_manifest_id, file_data["manifests"]) + + def test_sign_file(self): + """Test signing a file with C2PA manifest.""" + # Set up test paths + temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(temp_data_dir, exist_ok=True) + output_path = os.path.join(temp_data_dir, "signed_output.jpg") + + # Load test certificates and key + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + key = key_file.read() + + # Create signer info + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + + # Create a simple manifest + manifest = { + "claim_generator": "python_internals_test", + "claim_generator_info": [{ + "name": "python_internals_test", + "version": "0.0.1", + }], + # Claim version has become mandatory for signing v1 claims + "claim_version": 1, + "format": "image/jpeg", + "title": "Python Test Signed Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.opened" + } + ] + } + } + ] + } + + # Convert manifest to JSON string + manifest_json = json.dumps(manifest) + + try: + sign_file( + self.testPath, + output_path, + manifest_json, + signer_info + ) + + finally: + # Clean up + if os.path.exists(output_path): + os.remove(output_path) + + def test_sign_file_does_not_exist_errors(self): + """Test signing a file with C2PA manifest.""" + # Set up test paths + temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(temp_data_dir, exist_ok=True) + output_path = os.path.join(temp_data_dir, "signed_output.jpg") + + # Load test certificates and key + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + key = key_file.read() + + # Create signer info + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + + # Create a simple manifest + manifest = { + "claim_generator": "python_internals_test", + "claim_generator_info": [{ + "name": "python_internals_test", + "version": "0.0.1", + }], + # Claim version has become mandatory for signing v1 claims + "claim_version": 1, + "format": "image/jpeg", + "title": "Python Test Signed Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.opened" + } + ] + } + } + ] + } + + # Convert manifest to JSON string + manifest_json = json.dumps(manifest) + + try: + with self.assertRaises(Error): + sign_file( + "this-file-does-not-exist", + output_path, + manifest_json, + signer_info + ) + + finally: + # Clean up + if os.path.exists(output_path): + os.remove(output_path) + + def test_builder_sign_with_ingredient_from_file(self): + """Test Builder class operations with an ingredient added from file path.""" + + builder = Builder.from_json(self.manifestDefinition) + + # Test adding ingredient from file path + ingredient_json = '{"title": "Test Ingredient From File"}' + # Suppress the specific deprecation warning for this test, as this is a legacy method + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", self.testPath3) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient From File") + + builder.close() + + def test_builder_sign_with_ingredient_dict_from_file(self): + """Test Builder class operations with an ingredient added from file path using a dictionary.""" + + builder = Builder.from_json(self.manifestDefinition) + + # Test adding ingredient from file path with a dictionary + ingredient_dict = {"title": "Test Ingredient From File"} + # Suppress the specific deprecation warning for this test, as this is a legacy method + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + builder.add_ingredient_from_file_path(ingredient_dict, "image/jpeg", self.testPath3) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient From File") + + builder.close() + + def test_builder_add_ingredient_from_file_path(self): + """Test Builder class add_ingredient_from_file_path method.""" + + # Suppress the specific deprecation warning for this test, as this is a legacy method + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + builder = Builder.from_json(self.manifestDefinition) + + # Test adding ingredient from file path + ingredient_json = '{"test": "ingredient_from_file_path"}' + builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", self.testPath) + + builder.close() + + def test_builder_add_ingredient_from_file_path(self): + """Test Builder class add_ingredient_from_file_path method.""" + + # Suppress the specific deprecation warning for this test, as this is a legacy method + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + builder = Builder.from_json(self.manifestDefinition) + + # Test adding ingredient from file path + ingredient_json = '{"test": "ingredient_from_file_path"}' + + with self.assertRaises(Error.FileNotFound): + builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", "this-file-path-does-not-exist") + + def test_sign_file_using_callback_signer_overloads(self): + """Test signing a file using the sign_file function with a Signer object.""" + # Create a temporary directory for the test + temp_dir = tempfile.mkdtemp() + + try: + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed_output_callback.jpg") + + # Create signer with callback + signer = Signer.from_callback( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + # Overload that returns a JSON string + result_json = sign_file( + self.testPath, + output_path, + self.manifestDefinition, + signer, + False + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) + + # Verify the result is JSON + self.assertIsInstance(result_json, str) + self.assertGreater(len(result_json), 0) + + manifest_data = json.loads(result_json) + self.assertIn("manifests", manifest_data) + self.assertIn("active_manifest", manifest_data) + + output_path_bytes = os.path.join(temp_dir, "signed_output_callback_bytes.jpg") + # Overload that returns bytes + result_bytes = sign_file( + self.testPath, + output_path_bytes, + self.manifestDefinition, + signer, + True + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path_bytes)) + + # Verify the result is bytes + self.assertIsInstance(result_bytes, bytes) + self.assertGreater(len(result_bytes), 0) + + # Read the signed file and verify the manifest contains expected content + with open(output_path, "rb") as file: + reader = Reader("image/jpeg", file) + file_manifest_json = reader.json() + self.assertIn("Python Test", file_manifest_json) + + finally: + shutil.rmtree(temp_dir) + + def test_sign_file_overloads(self): + """Test that the overloaded sign_file function works with both parameter types.""" + # Create a temporary directory for the test + temp_dir = tempfile.mkdtemp() + try: + # Test with C2paSignerInfo + output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") + + # Load test certificates and key + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + key = key_file.read() + + # Create signer info + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + + # Test with C2paSignerInfo parameter - JSON return + result_1 = sign_file( + self.testPath, + output_path_1, + self.manifestDefinition, + signer_info, + False + ) + + self.assertIsInstance(result_1, str) + self.assertTrue(os.path.exists(output_path_1)) + + # Test with C2paSignerInfo parameter - bytes return + output_path_1_bytes = os.path.join(temp_dir, "signed_output_1_bytes.jpg") + result_1_bytes = sign_file( + self.testPath, + output_path_1_bytes, + self.manifestDefinition, + signer_info, + True + ) + + self.assertIsInstance(result_1_bytes, bytes) + self.assertTrue(os.path.exists(output_path_1_bytes)) + + # Test with Signer object + output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") + + # Create a signer from the signer info + signer = Signer.from_info(signer_info) + + # Test with Signer parameter - JSON return + result_2 = sign_file( + self.testPath, + output_path_2, + self.manifestDefinition, + signer, + False + ) + + self.assertIsInstance(result_2, str) + self.assertTrue(os.path.exists(output_path_2)) + + # Test with Signer parameter - bytes return + output_path_2_bytes = os.path.join(temp_dir, "signed_output_2_bytes.jpg") + result_2_bytes = sign_file( + self.testPath, + output_path_2_bytes, + self.manifestDefinition, + signer, + True + ) + + self.assertIsInstance(result_2_bytes, bytes) + self.assertTrue(os.path.exists(output_path_2_bytes)) + + # Both JSON results should be similar (same manifest structure) + manifest_1 = json.loads(result_1) + manifest_2 = json.loads(result_2) + + self.assertIn("manifests", manifest_1) + self.assertIn("manifests", manifest_2) + self.assertIn("active_manifest", manifest_1) + self.assertIn("active_manifest", manifest_2) + + # Both bytes results should be non-empty + self.assertGreater(len(result_1_bytes), 0) + self.assertGreater(len(result_2_bytes), 0) + + finally: + # Clean up the temporary directory + shutil.rmtree(temp_dir) + + def test_sign_file_callback_signer_reports_error(self): + """Test signing a file using the sign_file method with a callback that reports an error.""" + + temp_dir = tempfile.mkdtemp() + + try: + output_path = os.path.join(temp_dir, "signed_output.jpg") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + + # Define a callback that always returns None to simulate an error + def error_callback_signer(data: bytes) -> bytes: + # Could alternatively also raise an error + # raise RuntimeError("Simulated signing error") + return None + + # Create signer with error callback using create_signer function + signer = create_signer( + callback=error_callback_signer, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + # The signing operation should fail due to the error callback + with self.assertRaises(Error): + builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + finally: + shutil.rmtree(temp_dir) + + def test_sign_file_callback_signer(self): + """Test signing a file using the sign_file method.""" + + temp_dir = tempfile.mkdtemp() + + try: + output_path = os.path.join(temp_dir, "signed_output.jpg") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + + # Create signer with callback using create_signer function + signer = create_signer( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + manifest_bytes = builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) + + # Verify results + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) + + # Read the signed file and verify the manifest + with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: + json_data = reader.json() + # Needs trust configuration to be set up to validate as Trusted + # self.assertNotIn("validation_status", json_data) + + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + + finally: + shutil.rmtree(temp_dir) + + def test_sign_file_callback_signer(self): + """Test signing a file using the sign_file method.""" + + temp_dir = tempfile.mkdtemp() + + try: + output_path = os.path.join(temp_dir, "signed_output.jpg") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + + # Create signer with callback using create_signer function + signer = create_signer( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + manifest_bytes = builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) + + # Verify results + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) + + # Read the signed file and verify the manifest + with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: + json_data = reader.json() + # Needs trust configuration to be set up to validate as Trusted + # self.assertNotIn("validation_status", json_data) + + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + + finally: + shutil.rmtree(temp_dir) + + def test_sign_file_callback_signer_managed_single(self): + """Test signing a file using the sign_file method with context managers.""" + + temp_dir = tempfile.mkdtemp() + + try: + output_path = os.path.join(temp_dir, "signed_output_managed.jpg") + + # Create builder and signer with context managers + with Builder(self.manifestDefinition) as builder, create_signer( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) as signer: + + manifest_bytes = builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + # Verify results + self.assertTrue(os.path.exists(output_path)) + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) + + # Verify signed data can be read + with open(output_path, "rb") as file: + with Reader("image/jpeg", file) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted + # self.assertNotIn("validation_status", json_data) + + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify the signature_info contains the correct algorithm + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + + finally: + shutil.rmtree(temp_dir) + + def test_sign_file_callback_signer_managed_multiple_uses(self): + """Test that a signer can be used multiple times with context managers.""" + + temp_dir = tempfile.mkdtemp() + + try: + # Create builder and signer with context managers + with Builder(self.manifestDefinition) as builder, create_signer( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) as signer: + + # First signing operation + output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") + manifest_bytes_1 = builder.sign_file( + source_path=self.testPath, + dest_path=output_path_1, + signer=signer + ) + + # Verify first signing was successful + self.assertTrue(os.path.exists(output_path_1)) + self.assertIsInstance(manifest_bytes_1, bytes) + self.assertGreater(len(manifest_bytes_1), 0) + + # Second signing operation with the same signer + # This is to verify we don't free the signer or the callback too early + output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") + manifest_bytes_2 = builder.sign_file( + source_path=self.testPath, + dest_path=output_path_2, + signer=signer + ) + + # Verify second signing was successful + self.assertTrue(os.path.exists(output_path_2)) + self.assertIsInstance(manifest_bytes_2, bytes) + self.assertGreater(len(manifest_bytes_2), 0) + + # Verify both files contain valid C2PA data + for output_path in [output_path_1, output_path_2]: + with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted + # self.assertNotIn("validation_status", json_data) + + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify the signature_info contains the correct algorithm + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + + finally: + shutil.rmtree(temp_dir) + + def test_create_signer_from_info(self): + """Create a Signer using the create_signer_from_info function""" + signer = create_signer_from_info(self.signer_info) + self.assertIsNotNone(signer) + + + +if __name__ == '__main__': + unittest.main(warnings='ignore') diff --git a/tests/test_reader.py b/tests/test_reader.py new file mode 100644 index 00000000..a2f873aa --- /dev/null +++ b/tests/test_reader.py @@ -0,0 +1,896 @@ +# Copyright 2023 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +import os +import io +import json +import unittest +import warnings +import tempfile +import threading + +warnings.simplefilter("ignore", category=DeprecationWarning) + +from c2pa import Builder, C2paError as Error, Reader, C2paSignerInfo, Signer, sdk_version +from c2pa import Settings +from c2pa.c2pa import LifecycleState, load_settings + +from test_common import FIXTURES_DIR, DEFAULT_TEST_FILE_NAME, DEFAULT_TEST_FILE, INGREDIENT_TEST_FILE, load_test_settings_json + +class TestC2paSdk(unittest.TestCase): + def test_sdk_version(self): + # This test verifies the native libraries used match the expected version. + self.assertIn("0.77.0", sdk_version()) + + +class TestReader(unittest.TestCase): + def setUp(self): + warnings.filterwarnings("ignore", message="load_settings\\(\\) is deprecated") + self.data_dir = FIXTURES_DIR + self.testPath = DEFAULT_TEST_FILE + + def test_can_retrieve_reader_supported_mimetypes(self): + result1 = Reader.get_supported_mime_types() + self.assertTrue(len(result1) > 0) + + # Cache hit + result2 = Reader.get_supported_mime_types() + self.assertTrue(len(result2) > 0) + + self.assertEqual(result1, result2) + + def test_stream_read_nothing_to_read(self): + # The ingredient test file has no manifest + # So if we instantiate directly, the Reader instance should throw + with open(INGREDIENT_TEST_FILE, "rb") as file: + with self.assertRaises(Error) as context: + reader = Reader("image/jpeg", file) + self.assertIn("ManifestNotFound: no JUMBF data found", str(context.exception)) + + def test_try_create_reader_nothing_to_read(self): + # The ingredient test file has no manifest + # So if we use Reader.try_create, in this case we'll get None + # And no error should be raised + with open(INGREDIENT_TEST_FILE, "rb") as file: + reader = Reader.try_create("image/jpeg", file) + self.assertIsNone(reader) + + def test_stream_read(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + json_data = reader.json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + + def test_try_create_reader_from_stream(self): + with open(self.testPath, "rb") as file: + reader = Reader.try_create("image/jpeg", file) + self.assertIsNotNone(reader) + json_data = reader.json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + + def test_try_create_reader_from_stream_context_manager(self): + with open(self.testPath, "rb") as file: + reader = Reader.try_create("image/jpeg", file) + self.assertIsNotNone(reader) + # Check that a Reader returned by try_create is not None, + # before using it in a context manager pattern (with) + if reader is not None: + with reader: + json_data = reader.json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + + def test_stream_read_detailed(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + json_data = reader.detailed_json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + + def test_get_active_manifest(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + active_manifest = reader.get_active_manifest() + + # Check the returned manifest label/key + expected_label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" + self.assertEqual(active_manifest["label"], expected_label) + + def test_get_manifest(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + + # Test getting manifest by the specific label + label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" + manifest = reader.get_manifest(label) + self.assertEqual(manifest["label"], label) + + # It should be the active manifest too, so cross-check + active_manifest = reader.get_active_manifest() + self.assertEqual(manifest, active_manifest) + + def test_stream_get_non_active_manifest_by_label(self): + video_path = os.path.join(FIXTURES_DIR, "video1.mp4") + with open(video_path, "rb") as file: + reader = Reader("video/mp4", file) + + non_active_label = "urn:uuid:54281c07-ad34-430e-bea5-112a18facf0b" + non_active_manifest = reader.get_manifest(non_active_label) + self.assertEqual(non_active_manifest["label"], non_active_label) + + # Verify it's not the active manifest + # (that test case has only one other manifest that is not the active manifest) + active_manifest = reader.get_active_manifest() + self.assertNotEqual(non_active_manifest, active_manifest) + self.assertNotEqual(non_active_manifest["label"], active_manifest["label"]) + + def test_stream_get_non_active_manifest_by_label_not_found(self): + video_path = os.path.join(FIXTURES_DIR, "video1.mp4") + with open(video_path, "rb") as file: + reader = Reader("video/mp4", file) + + # Try to get a manifest with a label that clearly doesn't exist... + non_existing_label = "urn:uuid:clearly-not-existing" + with self.assertRaises(KeyError): + reader.get_manifest(non_existing_label) + + def test_stream_read_get_validation_state(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + validation_state = reader.get_validation_state() + self.assertIsNotNone(validation_state) + self.assertEqual(validation_state, "Valid") + + def test_stream_read_get_validation_state_with_trust_config(self): + # Run in a separate thread to isolate thread-local settings + result = {} + exception = {} + + def read_with_trust_config(): + try: + # Load trust configuration + settings_dict = load_test_settings_json() + + # Apply the settings (including trust configuration) + # Settings are thread-local, so they won't affect other tests + # And that is why we also run the test in its own thread, so tests are isolated + load_settings(settings_dict) + + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + validation_state = reader.get_validation_state() + result['validation_state'] = validation_state + except Exception as e: + exception['error'] = e + + # Create and start thread + thread = threading.Thread(target=read_with_trust_config) + thread.start() + thread.join() + + # Check for exceptions + if 'error' in exception: + raise exception['error'] + + # Assertions run in main thread + self.assertIsNotNone(result.get('validation_state')) + # With trust configuration loaded, manifest is Trusted + self.assertEqual(result.get('validation_state'), "Trusted") + + def test_stream_read_get_validation_results(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + validation_results = reader.get_validation_results() + + self.assertIsNotNone(validation_results) + self.assertIsInstance(validation_results, dict) + + self.assertIn("activeManifest", validation_results) + active_manifest_results = validation_results["activeManifest"] + self.assertIsInstance(active_manifest_results, dict) + + def test_reader_detects_unsupported_mimetype_on_stream(self): + with open(self.testPath, "rb") as file: + with self.assertRaises(Error.NotSupported): + Reader("mimetype/does-not-exist", file) + + def test_stream_read_and_parse(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, DEFAULT_TEST_FILE_NAME) + + def test_stream_read_detailed_and_parse(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + manifest_store = json.loads(reader.detailed_json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["claim"]["dc:title"] + self.assertEqual(title, DEFAULT_TEST_FILE_NAME) + + def test_stream_read_string_stream(self): + with Reader(self.testPath) as reader: + json_data = reader.json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + + def test_try_create_from_path(self): + test_path = os.path.join(self.data_dir, "C.dng") + + # Create reader with the file content + reader = Reader.try_create(test_path) + self.assertIsNotNone(reader) + # Just run and verify there is no crash + json.loads(reader.json()) + + def test_stream_read_string_stream_mimetype_not_supported(self): + with self.assertRaises(Error.NotSupported): + # xyz is actually an extension that is recognized + # as mimetype chemical/x-xyz + Reader(os.path.join(FIXTURES_DIR, "C.xyz")) + + def test_try_create_raises_mimetype_not_supported(self): + with self.assertRaises(Error.NotSupported): + # xyz is actually an extension that is recognized + # as mimetype chemical/x-xyz, but we don't support it + Reader.try_create(os.path.join(FIXTURES_DIR, "C.xyz")) + + def test_stream_read_string_stream_mimetype_not_recognized(self): + with self.assertRaises(Error.NotSupported): + Reader(os.path.join(FIXTURES_DIR, "C.test")) + + def test_try_create_raises_mimetype_not_recognized(self): + with self.assertRaises(Error.NotSupported): + Reader.try_create(os.path.join(FIXTURES_DIR, "C.test")) + + def test_stream_read_string_stream(self): + with Reader("image/jpeg", self.testPath) as reader: + json_data = reader.json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + + def test_reader_detects_unsupported_mimetype_on_file(self): + with self.assertRaises(Error.NotSupported): + Reader("mimetype/does-not-exist", self.testPath) + + def test_stream_read_filepath_as_stream_and_parse(self): + with Reader("image/jpeg", self.testPath) as reader: + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, DEFAULT_TEST_FILE_NAME) + + def test_reader_double_close(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + reader.close() + # Second close should not raise an exception + reader.close() + # Verify reader is closed + with self.assertRaises(Error): + reader.json() + + def test_reader_streams_with_nested(self): + with open(self.testPath, "rb") as file: + with Reader("image/jpeg", file) as reader: + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, DEFAULT_TEST_FILE_NAME) + + def test_reader_close_cleanup(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + # Close the reader + reader.close() + # Verify all resources are cleaned up + self.assertIsNone(reader._handle) + self.assertIsNone(reader._own_stream) + # Verify reader is marked as closed + self.assertEqual(reader._state, LifecycleState.CLOSED) + + def test_resource_to_stream_on_closed_reader(self): + """Test that resource_to_stream correctly raises error on closed.""" + reader = Reader("image/jpeg", self.testPath) + reader.close() + with self.assertRaises(Error): + reader.resource_to_stream("", io.BytesIO(bytearray())) + + def test_read_dng_from_stream(self): + test_path = os.path.join(self.data_dir, "C.dng") + with open(test_path, "rb") as file: + file_content = file.read() + + with Reader("dng", io.BytesIO(file_content)) as reader: + # Just run and verify there is no crash + json.loads(reader.json()) + + def test_read_dng_upper_case_from_stream(self): + test_path = os.path.join(self.data_dir, "C.dng") + with open(test_path, "rb") as file: + file_content = file.read() + + with Reader("DNG", io.BytesIO(file_content)) as reader: + # Just run and verify there is no crash + json.loads(reader.json()) + + def test_read_dng_file_from_path(self): + test_path = os.path.join(self.data_dir, "C.dng") + + # Create reader with the file content + with Reader(test_path) as reader: + # Just run and verify there is no crash + json.loads(reader.json()) + + def test_read_all_files(self): + """Test reading C2PA metadata from all files in the fixtures/files-for-reading-tests directory""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.pdf': 'application/pdf', + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + for filename in os.listdir(reading_dir): + if filename in skip_files: + continue + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + continue + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + reader = Reader(mime_type, file) + json_data = reader.json() + reader.close() + self.assertIsInstance(json_data, str) + # Verify the manifest contains expected fields + manifest = json.loads(json_data) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + except Exception as e: + self.fail(f"Failed to read metadata from {filename}: {str(e)}") + + def test_try_create_all_files(self): + """Test reading C2PA metadata using Reader.try_create from all files in the fixtures/files-for-reading-tests directory""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.pdf': 'application/pdf', + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + for filename in os.listdir(reading_dir): + if filename in skip_files: + continue + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + continue + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + reader = Reader.try_create(mime_type, file) + # try_create returns None if no manifest found, otherwise a Reader + self.assertIsNotNone(reader, f"Expected Reader for {filename}") + json_data = reader.json() + reader.close() + self.assertIsInstance(json_data, str) + # Verify the manifest contains expected fields + manifest = json.loads(json_data) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + except Exception as e: + self.fail(f"Failed to read metadata from {filename}: {str(e)}") + + def test_try_create_all_files_using_extension(self): + """ + Test reading C2PA metadata using Reader.try_create + from files in the fixtures/files-for-reading-tests directory + """ + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + extensions = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + for filename in os.listdir(reading_dir): + if filename in skip_files: + continue + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in extensions: + continue + + try: + with open(file_path, "rb") as file: + # Remove the leading dot + parsed_extension = ext[1:] + reader = Reader.try_create(parsed_extension, file) + # try_create returns None if no manifest found, otherwise a Reader + self.assertIsNotNone(reader, f"Expected Reader for {filename}") + json_data = reader.json() + reader.close() + self.assertIsInstance(json_data, str) + # Verify the manifest contains expected fields + manifest = json.loads(json_data) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + except Exception as e: + self.fail(f"Failed to read metadata from {filename}: {str(e)}") + + def test_read_all_files_using_extension(self): + """Test reading C2PA metadata from files in the fixtures/files-for-reading-tests directory""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + extensions = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + for filename in os.listdir(reading_dir): + if filename in skip_files: + continue + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in extensions: + continue + + try: + with open(file_path, "rb") as file: + # Remove the leading dot + parsed_extension = ext[1:] + reader = Reader(parsed_extension, file) + json_data = reader.json() + reader.close() + self.assertIsInstance(json_data, str) + # Verify the manifest contains expected fields + manifest = json.loads(json_data) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + except Exception as e: + self.fail(f"Failed to read metadata from {filename}: {str(e)}") + + def test_read_cached_all_files(self): + """Test reading C2PA metadata with cache functionality from all files in the fixtures/files-for-reading-tests directory""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.pdf': 'application/pdf', + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + for filename in os.listdir(reading_dir): + if filename in skip_files: + continue + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + continue + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + reader = Reader(mime_type, file) + + # Test 1: Verify cache variables are initially None + self.assertIsNone(reader._manifest_json_str_cache, f"JSON cache should be None initially for {filename}") + self.assertIsNone(reader._manifest_data_cache, f"Manifest data cache should be None initially for {filename}") + + # Test 2: Multiple calls to json() should return the same result and use cache + json_data_1 = reader.json() + self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache not set after first json() call for {filename}") + self.assertEqual(json_data_1, reader._manifest_json_str_cache, f"JSON cache doesn't match return value for {filename}") + + json_data_2 = reader.json() + self.assertEqual(json_data_1, json_data_2, f"JSON inconsistency for {filename}") + self.assertIsInstance(json_data_1, str) + + # Test 3: Test methods that use the cache + try: + # Test get_active_manifest() which uses _get_cached_manifest_data() + active_manifest = reader.get_active_manifest() + self.assertIsInstance(active_manifest, dict, f"Active manifest not dict for {filename}") + + # Test 4: Verify cache is set after calling cache-using methods + self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache not set after get_active_manifest for {filename}") + self.assertIsNotNone(reader._manifest_data_cache, f"Manifest data cache not set after get_active_manifest for {filename}") + + # Test 5: Multiple calls to cache-using methods should return the same result + active_manifest_2 = reader.get_active_manifest() + self.assertEqual(active_manifest, active_manifest_2, f"Active manifest cache inconsistency for {filename}") + + # Test get_validation_state() which uses the cache + validation_state = reader.get_validation_state() + # validation_state can be None, so just check it doesn't crash + + # Test get_validation_results() which uses the cache + validation_results = reader.get_validation_results() + # validation_results can be None, so just check it doesn't crash + + # Test 6: Multiple calls to validation methods should return the same result + validation_state_2 = reader.get_validation_state() + self.assertEqual(validation_state, validation_state_2, f"Validation state cache inconsistency for {filename}") + + validation_results_2 = reader.get_validation_results() + self.assertEqual(validation_results, validation_results_2, f"Validation results cache inconsistency for {filename}") + + except KeyError as e: + # Some files might not have active manifests or validation data + # This is expected for some test files, so we'll skip cache testing for those + pass + + # Test 7: Verify the manifest contains expected fields + manifest = json.loads(json_data_1) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + + # Test 8: Test cache clearing on close + reader.close() + self.assertIsNone(reader._manifest_json_str_cache, f"JSON cache not cleared for {filename}") + self.assertIsNone(reader._manifest_data_cache, f"Manifest data cache not cleared for {filename}") + + except Exception as e: + self.fail(f"Failed to read cached metadata from {filename}: {str(e)}") + + def test_reader_context_manager_with_exception(self): + """Test Reader state after exception in context manager.""" + try: + with Reader(self.testPath) as reader: + # Inside context - should be valid + self.assertEqual(reader._state, LifecycleState.ACTIVE) + self.assertIsNotNone(reader._handle) + self.assertIsNotNone(reader._own_stream) + self.assertIsNotNone(reader._backing_file) + raise ValueError("Test exception") + except ValueError: + pass + + # After exception - should still be closed + self.assertEqual(reader._state, LifecycleState.CLOSED) + self.assertIsNone(reader._handle) + self.assertIsNone(reader._own_stream) + self.assertIsNone(reader._backing_file) + + def test_reader_partial_initialization_states(self): + """Test Reader behavior with partial initialization failures.""" + # Test with _reader = None but _state = ACTIVE + reader = Reader.__new__(Reader) + reader._state = LifecycleState.ACTIVE + reader._handle = None + reader._own_stream = None + reader._backing_file = None + + with self.assertRaises(Error): + reader._ensure_valid_state() + + def test_reader_cleanup_state_transitions(self): + """Test Reader state during cleanup operations.""" + reader = Reader(self.testPath) + + reader._cleanup_resources() + self.assertEqual(reader._state, LifecycleState.CLOSED) + self.assertIsNone(reader._handle) + self.assertIsNone(reader._own_stream) + self.assertIsNone(reader._backing_file) + + def test_reader_cleanup_idempotency(self): + """Test that cleanup operations are idempotent.""" + reader = Reader(self.testPath) + + # First cleanup + reader._cleanup_resources() + self.assertEqual(reader._state, LifecycleState.CLOSED) + + # Second cleanup should not change state + reader._cleanup_resources() + self.assertEqual(reader._state, LifecycleState.CLOSED) + self.assertIsNone(reader._handle) + self.assertIsNone(reader._own_stream) + self.assertIsNone(reader._backing_file) + + def test_reader_state_with_invalid_native_pointer(self): + """Test Reader state handling with invalid native pointer.""" + reader = Reader(self.testPath) + + # Simulate invalid native pointer + reader._handle = 0 + + # Operations should fail gracefully + with self.assertRaises(Error): + reader.json() + + def test_reader_is_embedded(self): + """Test the is_embedded method returns correct values for embedded and remote manifests.""" + + # Test with a fixture which has an embedded manifest + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + self.assertTrue(reader.is_embedded()) + reader.close() + + # Test with cloud.jpg fixture which has a remote manifest (not embedded) + cloud_fixture_path = os.path.join(self.data_dir, "cloud.jpg") + with Reader("image/jpeg", cloud_fixture_path) as reader: + self.assertFalse(reader.is_embedded()) + + def test_sign_and_read_is_not_embedded(self): + """Test the is_embedded method returns correct values for remote manifests.""" + + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + key = key_file.read() + + # Create signer info and signer + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + # Define a simple manifest + manifest_definition = { + "claim_generator": "python_test", + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Test Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + + # Create a temporary directory for the signed file + with tempfile.TemporaryDirectory() as temp_dir: + temp_file_path = os.path.join(temp_dir, "signed_test_file_no_embed.jpg") + + with open(self.testPath, "rb") as file: + builder = Builder(manifest_definition) + # Direct the Builder not to embed the manifest into the asset + builder.set_no_embed() + + + with open(temp_file_path, "wb") as temp_file: + manifest_data = builder.sign( + signer, "image/jpeg", file, temp_file) + + with Reader("image/jpeg", temp_file_path, manifest_data) as reader: + self.assertFalse(reader.is_embedded()) + + def test_reader_get_remote_url(self): + """Test the get_remote_url method returns correct values for embedded and remote manifests.""" + + # Test get_remote_url for file with embedded manifest (should return None) + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + self.assertIsNone(reader.get_remote_url()) + reader.close() + + # Test remote manifest using cloud.jpg fixture which has a remote URL + cloud_fixture_path = os.path.join(self.data_dir, "cloud.jpg") + with Reader("image/jpeg", cloud_fixture_path) as reader: + remote_url = reader.get_remote_url() + self.assertEqual(remote_url, "https://cai-manifests.adobe.com/manifests/adobe-urn-uuid-5f37e182-3687-462e-a7fb-573462780391") + self.assertFalse(reader.is_embedded()) + + def test_stream_read_and_parse_cached(self): + """Test reading and parsing with cache verification by repeating operations multiple times""" + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + + # Verify cache starts as None + self.assertIsNone(reader._manifest_json_str_cache, "JSON cache should be None initially") + self.assertIsNone(reader._manifest_data_cache, "Manifest data cache should be None initially") + + # First operation - should populate cache + manifest_store_1 = json.loads(reader.json()) + title_1 = manifest_store_1["manifests"][manifest_store_1["active_manifest"]]["title"] + self.assertEqual(title_1, DEFAULT_TEST_FILE_NAME) + + # Verify cache is populated after first json() call + self.assertIsNotNone(reader._manifest_json_str_cache, "JSON cache should be set after first json() call") + self.assertEqual(manifest_store_1, json.loads(reader._manifest_json_str_cache), "Cached JSON should match parsed result") + + # Repeat the same operation multiple times to verify cache usage + for i in range(5): + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, DEFAULT_TEST_FILE_NAME, f"Title should be consistent on iteration {i+1}") + + # Verify cache is still populated and consistent + self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache should remain set on iteration {i+1}") + self.assertEqual(manifest_store, json.loads(reader._manifest_json_str_cache), f"Cached JSON should match parsed result on iteration {i+1}") + + # Test methods that use the cache + # Test get_active_manifest() which uses _get_cached_manifest_data() + active_manifest_1 = reader.get_active_manifest() + self.assertIsInstance(active_manifest_1, dict, "Active manifest should be a dict") + + # Verify manifest data cache is populated + self.assertIsNotNone(reader._manifest_data_cache, "Manifest data cache should be set after get_active_manifest()") + + # Repeat get_active_manifest() multiple times to verify cache usage + for i in range(3): + active_manifest = reader.get_active_manifest() + self.assertEqual(active_manifest_1, active_manifest, f"Active manifest should be consistent on iteration {i+1}") + + # Verify cache remains populated + self.assertIsNotNone(reader._manifest_data_cache, f"Manifest data cache should remain set on iteration {i+1}") + + # Test get_validation_state() and get_validation_results() with cache + validation_state_1 = reader.get_validation_state() + validation_results_1 = reader.get_validation_results() + + # Repeat validation methods to verify cache usage + for i in range(3): + validation_state = reader.get_validation_state() + validation_results = reader.get_validation_results() + + self.assertEqual(validation_state_1, validation_state, f"Validation state should be consistent on iteration {i+1}") + self.assertEqual(validation_results_1, validation_results, f"Validation results should be consistent on iteration {i+1}") + + # Verify cache clearing on close + reader.close() + self.assertIsNone(reader._manifest_json_str_cache, "JSON cache should be cleared on close") + self.assertIsNone(reader._manifest_data_cache, "Manifest data cache should be cleared on close") + + # TODO: Unskip once fixed configuration to read data is clarified + # def test_read_cawg_data_file(self): + # """Test reading C2PA metadata from C_with_CAWG_data.jpg file.""" + # file_path = os.path.join(self.data_dir, "C_with_CAWG_data.jpg") + + # with open(file_path, "rb") as file: + # reader = Reader("image/jpeg", file) + # json_data = reader.json() + # self.assertIsInstance(json_data, str) + + # # Parse the JSON and verify specific fields + # manifest_data = json.loads(json_data) + + # # Verify basic manifest structure + # self.assertIn("manifests", manifest_data) + # self.assertIn("active_manifest", manifest_data) + + # # Get the active manifest + # active_manifest_id = manifest_data["active_manifest"] + # active_manifest = manifest_data["manifests"][active_manifest_id] + + # # Verify manifest is not null or empty + # assert active_manifest is not None, "Active manifest should not be null" + # assert len(active_manifest) > 0, "Active manifest should not be empty" + + + +if __name__ == '__main__': + unittest.main(warnings='ignore') diff --git a/tests/test_reader_with_context.py b/tests/test_reader_with_context.py new file mode 100644 index 00000000..ede7896e --- /dev/null +++ b/tests/test_reader_with_context.py @@ -0,0 +1,86 @@ +# Copyright 2023 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +import json +import unittest + +from c2pa import Reader +from c2pa import Settings, Context + +from test_common import DEFAULT_TEST_FILE, INGREDIENT_TEST_FILE +from test_context_base import TestContextAPIs + +class TestReaderWithContext(TestContextAPIs): + + def test_reader_with_default_context(self): + context = Context() + with open(DEFAULT_TEST_FILE, "rb") as file_handle: + reader = Reader("image/jpeg", file_handle, context=context,) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + + def test_reader_with_settings_context(self): + settings = Settings() + context = Context(settings) + with open(DEFAULT_TEST_FILE, "rb") as file_handle: + reader = Reader("image/jpeg", file_handle, context=context,) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + settings.close() + + def test_reader_without_context(self): + with open(DEFAULT_TEST_FILE, "rb") as file_handle: + reader = Reader("image/jpeg", file_handle) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + + def test_reader_try_create_with_context(self): + context = Context() + reader = Reader.try_create(DEFAULT_TEST_FILE, context=context,) + self.assertIsNotNone(reader) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + + def test_reader_try_create_no_manifest(self): + context = Context() + reader = Reader.try_create(INGREDIENT_TEST_FILE, context=context,) + self.assertIsNone(reader) + context.close() + + def test_reader_file_path_with_context(self): + context = Context() + reader = Reader(DEFAULT_TEST_FILE, context=context,) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + + def test_reader_format_and_path_with_ctx(self): + context = Context() + reader = Reader("image/jpeg", DEFAULT_TEST_FILE, context=context) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + + + +if __name__ == '__main__': + unittest.main(warnings='ignore') diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 00000000..a7e0f25b --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,92 @@ +# Copyright 2023 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +import unittest + +from c2pa import C2paError as Error +from c2pa import Settings + +from test_context_base import TestContextAPIs + +class TestSettings(TestContextAPIs): + + def test_settings_default_construction(self): + settings = Settings() + self.assertTrue(settings.is_valid) + settings.close() + + def test_settings_set_chaining(self): + settings = Settings() + result = ( + settings.set( + "builder.thumbnail.enabled", "false" + ).set( + "builder.thumbnail.enabled", "true" + ) + ) + self.assertIs(result, settings) + settings.close() + + def test_settings_from_json(self): + settings = Settings.from_json( + '{"builder":{"thumbnail":' + '{"enabled":false}}}' + ) + self.assertTrue(settings.is_valid) + settings.close() + + def test_settings_from_dict(self): + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + self.assertTrue(settings.is_valid) + settings.close() + + def test_settings_update_json(self): + settings = Settings() + result = settings.update( + '{"builder":{"thumbnail":' + '{"enabled":false}}}' + ) + self.assertIs(result, settings) + settings.close() + + def test_settings_update_dict(self): + settings = Settings() + result = settings.update({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + self.assertIs(result, settings) + settings.close() + + def test_settings_is_valid_after_close(self): + settings = Settings() + settings.close() + self.assertFalse(settings.is_valid) + + def test_settings_raises_after_close(self): + settings = Settings() + settings.close() + with self.assertRaises(Error): + settings.set( + "builder.thumbnail.enabled", "false" + ) + + + +if __name__ == '__main__': + unittest.main(warnings='ignore') diff --git a/tests/test_stream.py b/tests/test_stream.py new file mode 100644 index 00000000..986dfd4f --- /dev/null +++ b/tests/test_stream.py @@ -0,0 +1,148 @@ +# Copyright 2023 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +import io +import unittest +import ctypes + +from c2pa.c2pa import Stream + + +class TestStream(unittest.TestCase): + def setUp(self): + self.temp_file = io.BytesIO() + self.test_data = b"Hello, World!" + self.temp_file.write(self.test_data) + self.temp_file.seek(0) + + def tearDown(self): + self.temp_file.close() + + def test_stream_initialization(self): + stream = Stream(self.temp_file) + self.assertTrue(stream.initialized) + self.assertFalse(stream.closed) + stream.close() + + def test_stream_initialization_with_invalid_object(self): + with self.assertRaises(TypeError): + Stream("not a file-like object") + + def test_stream_read(self): + stream = Stream(self.temp_file) + try: + # Create a buffer to read into + buffer = (ctypes.c_ubyte * 13)() + # Read the data + bytes_read = stream._read_cb(None, buffer, 13) + # Verify the data + self.assertEqual(bytes_read, 13) + self.assertEqual(bytes(buffer[:bytes_read]), self.test_data) + finally: + stream.close() + + def test_stream_write(self): + output = io.BytesIO() + stream = Stream(output) + try: + # Create test data + test_data = b"Test Write" + buffer = (ctypes.c_ubyte * len(test_data))(*test_data) + # Write the data + bytes_written = stream._write_cb(None, buffer, len(test_data)) + # Verify the data + self.assertEqual(bytes_written, len(test_data)) + output.seek(0) + self.assertEqual(output.read(), test_data) + finally: + stream.close() + + def test_stream_seek(self): + stream = Stream(self.temp_file) + try: + # Seek to position 7 (after "Hello, ") + new_pos = stream._seek_cb(None, 7, 0) # 0 = SEEK_SET + self.assertEqual(new_pos, 7) + # Read from new position + buffer = (ctypes.c_ubyte * 6)() + bytes_read = stream._read_cb(None, buffer, 6) + self.assertEqual(bytes(buffer[:bytes_read]), b"World!") + finally: + stream.close() + + def test_stream_flush(self): + output = io.BytesIO() + stream = Stream(output) + try: + # Write some data + test_data = b"Test Flush" + buffer = (ctypes.c_ubyte * len(test_data))(*test_data) + stream._write_cb(None, buffer, len(test_data)) + # Flush the stream + result = stream._flush_cb(None) + self.assertEqual(result, 0) + finally: + stream.close() + + def test_stream_context_manager(self): + with Stream(self.temp_file) as stream: + self.assertTrue(stream.initialized) + self.assertFalse(stream.closed) + self.assertTrue(stream.closed) + + def test_stream_double_close(self): + stream = Stream(self.temp_file) + stream.close() + # Second close should not raise an exception + stream.close() + self.assertTrue(stream.closed) + + def test_stream_read_after_close(self): + stream = Stream(self.temp_file) + # Store callbacks before closing + read_cb = stream._read_cb + stream.close() + buffer = (ctypes.c_ubyte * 13)() + # Reading from closed stream should return -1 + self.assertEqual(read_cb(None, buffer, 13), -1) + + def test_stream_write_after_close(self): + stream = Stream(self.temp_file) + # Store callbacks before closing + write_cb = stream._write_cb + stream.close() + test_data = b"Test Write" + buffer = (ctypes.c_ubyte * len(test_data))(*test_data) + # Writing to closed stream should return -1 + self.assertEqual(write_cb(None, buffer, len(test_data)), -1) + + def test_stream_seek_after_close(self): + stream = Stream(self.temp_file) + # Store callbacks before closing + seek_cb = stream._seek_cb + stream.close() + # Seeking in closed stream should return -1 + self.assertEqual(seek_cb(None, 5, 0), -1) + + def test_stream_flush_after_close(self): + stream = Stream(self.temp_file) + # Store callbacks before closing + flush_cb = stream._flush_cb + stream.close() + # Flushing closed stream should return -1 + self.assertEqual(flush_cb(None), -1) + + + +if __name__ == '__main__': + unittest.main(warnings='ignore') diff --git a/tests/test_unit_tests_threaded.py b/tests/test_threaded.py similarity index 88% rename from tests/test_unit_tests_threaded.py rename to tests/test_threaded.py index eab6de8d..7b3b95b8 100644 --- a/tests/test_unit_tests_threaded.py +++ b/tests/test_threaded.py @@ -15,12 +15,15 @@ import io import json import unittest +import warnings import threading import concurrent.futures import time import asyncio import random +warnings.simplefilter("ignore", category=DeprecationWarning) + from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version # noqa: E501 from c2pa import Context, Settings from c2pa.c2pa import Stream @@ -159,8 +162,7 @@ def process_file(filename): errors.append(error) except Exception as e: errors.append( - f"Unexpected error processing {filename}: { - str(e)}") + f"Unexpected error processing {filename}: {str(e)}") # If any errors occurred, fail the test with all error messages if errors: @@ -694,8 +696,7 @@ def sign_file(filename, thread_id): active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] # Verify the correct manifest was used - expected_claim_generator = f"python_test_{ - 2 if thread_id % 2 == 0 else 1}/0.0.1" + expected_claim_generator = f"python_test_{2 if thread_id % 2 == 0 else 1}/0.0.1" self.assertEqual( active_manifest["claim_generator"], expected_claim_generator) @@ -713,8 +714,7 @@ def sign_file(filename, thread_id): except Error.NotSupported: return None except Exception as e: - return f"Failed to sign { - filename} in thread {thread_id}: {str(e)}" + return f"Failed to sign {filename} in thread {thread_id}: {str(e)}" # Create a thread pool with 6 workers with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: @@ -738,8 +738,7 @@ def sign_file(filename, thread_id): if error: errors.append(error) except Exception as e: - errors.append(f"Unexpected error processing { - filename} in thread {thread_id}: {str(e)}") + errors.append(f"Unexpected error processing {filename} in thread {thread_id}: {str(e)}") # If any errors occurred, fail the test with all error messages if errors: @@ -809,8 +808,7 @@ async def async_sign_file(filename, thread_id): active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] # Verify the correct manifest was used - expected_claim_generator = f"python_test_{ - 2 if thread_id % 2 == 0 else 1}/0.0.1" + expected_claim_generator = f"python_test_{2 if thread_id % 2 == 0 else 1}/0.0.1" self.assertEqual( active_manifest["claim_generator"], expected_claim_generator) @@ -828,8 +826,7 @@ async def async_sign_file(filename, thread_id): except Error.NotSupported: return None except Exception as e: - return f"Failed to sign { - filename} in thread {thread_id}: {str(e)}" + return f"Failed to sign {filename} in thread {thread_id}: {str(e)}" async def run_async_tests(): # Get all files from both directories @@ -893,8 +890,7 @@ def write_manifest(manifest_def, output_stream, thread_id): if assertion["label"] == "com.unit.test": author_name = assertion["data"]["author"][0]["name"] self.assertEqual( - author_name, f"Tester { - 'One' if thread_id == 1 else 'Two'}") + author_name, f"Tester {'One' if thread_id == 1 else 'Two'}") break return active_manifest @@ -1037,8 +1033,7 @@ def sign_file(filename, thread_id): if thread_id % 3 == 0: expected_claim_generator = "python_test/0.0.1" else: - expected_claim_generator = f"python_test_{ - expected_thread}/0.0.1" + expected_claim_generator = f"python_test_{expected_thread}/0.0.1" self.assertEqual( active_manifest["claim_generator"], @@ -1057,8 +1052,7 @@ def sign_file(filename, thread_id): except Error.NotSupported: return None except Exception as e: - return f"Failed to sign { - filename} in thread {thread_id}: {str(e)}" + return f"Failed to sign {filename} in thread {thread_id}: {str(e)}" # Create a thread pool with 3 workers with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: @@ -1082,8 +1076,7 @@ def sign_file(filename, thread_id): if error: errors.append(error) except Exception as e: - errors.append(f"Unexpected error processing { - filename} in thread {thread_id}: {str(e)}") + errors.append(f"Unexpected error processing {filename} in thread {thread_id}: {str(e)}") # Verify thread interleaving # Check that we don't have long sequences of the same thread @@ -1096,8 +1089,7 @@ def sign_file(filename, thread_id): if thread_execution_order[i][1] == current_thread: current_sequence += 1 if current_sequence > max_same_thread_sequence: - self.fail(f"Thread {current_thread} executed { - current_sequence} times in sequence, indicating poor interleaving") + self.fail(f"Thread {current_thread} executed {current_sequence} times in sequence, indicating poor interleaving") else: current_sequence = 1 current_thread = thread_execution_order[i][1] @@ -1747,8 +1739,9 @@ def test_resource_contention_read_parallel_async(self): active_readers = 0 readers_lock = asyncio.Lock() # Lock for reader count stream_lock = asyncio.Lock() # Lock for stream access - # Barrier to synchronize task starts - start_barrier = asyncio.Barrier(reader_count) + # Event + counter to synchronize task starts (asyncio.Barrier requires 3.11+) + ready_count = 0 + all_ready = asyncio.Event() # First write some data to read with open(self.testPath, "rb") as file: @@ -1757,13 +1750,16 @@ def test_resource_contention_read_parallel_async(self): output.seek(0) async def read_manifest(reader_id): - nonlocal active_readers + nonlocal active_readers, ready_count try: async with readers_lock: active_readers += 1 + ready_count += 1 + if ready_count == reader_count: + all_ready.set() # Wait for all tasks to be ready - await start_barrier.wait() + await all_ready.wait() # Read the manifest async with stream_lock: # Ensure exclusive access to stream @@ -1818,203 +1814,6 @@ async def run_async_tests(): # Verify all readers completed self.assertEqual(active_readers, 0, "Not all readers completed") - def test_builder_sign_with_multiple_ingredients_from_stream(self): - """Test Builder class operations with multiple ingredients using streams.""" - # Test creating builder from JSON - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Thread synchronization - add_errors = [] - add_lock = threading.Lock() - completed_threads = 0 - completion_lock = threading.Lock() - - def add_ingredient_from_stream(ingredient_json, file_path, thread_id): - nonlocal completed_threads - try: - with open(file_path, 'rb') as f: - builder.add_ingredient_from_stream( - ingredient_json, "image/jpeg", f) - with add_lock: - add_errors.append(None) # Success case - except Exception as e: - with add_lock: - add_errors.append(f"Thread {thread_id} error: {str(e)}") - finally: - with completion_lock: - completed_threads += 1 - - # Create and start two threads for parallel ingredient addition - thread1 = threading.Thread( - target=add_ingredient_from_stream, - args=('{"title": "Test Ingredient Stream 1"}', self.testPath3, 1) - ) - thread2 = threading.Thread( - target=add_ingredient_from_stream, - args=('{"title": "Test Ingredient Stream 2"}', self.testPath4, 2) - ) - - # Start both threads - thread1.start() - thread2.start() - - # Wait for both threads to complete - thread1.join() - thread2.join() - - # Check for errors during ingredient addition - if any(error for error in add_errors if error is not None): - self.fail( - "\n".join( - error for error in add_errors if error is not None)) - - # Verify both ingredients were added successfully - self.assertEqual( - completed_threads, - 2, - "Both threads should have completed") - self.assertEqual( - len(add_errors), - 2, - "Both threads should have completed without errors") - - # Now sign the manifest with the added ingredients - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertEqual(len(active_manifest["ingredients"]), 2) - - # Verify both ingredients exist in the array (order doesn't matter) - ingredient_titles = [ing["title"] - for ing in active_manifest["ingredients"]] - self.assertIn("Test Ingredient Stream 1", ingredient_titles) - self.assertIn("Test Ingredient Stream 2", ingredient_titles) - - builder.close() - - def test_builder_sign_with_same_ingredient_multiple_times(self): - """Test Builder class operations with the same ingredient added multiple times from different threads.""" - # Test creating builder from JSON - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Thread synchronization - add_errors = [] - add_lock = threading.Lock() - completed_threads = 0 - completion_lock = threading.Lock() - - def add_ingredient(ingredient_json, thread_id): - nonlocal completed_threads - try: - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - with add_lock: - add_errors.append(None) # Success case - except Exception as e: - with add_lock: - add_errors.append(f"Thread {thread_id} error: {str(e)}") - finally: - with completion_lock: - completed_threads += 1 - - # Create and start 5 threads for parallel ingredient addition - threads = [] - for i in range(1, 6): - # Create unique manifest JSON for each thread - ingredient_json = json.dumps({ - "title": f"Test Ingredient Thread {i}" - }) - - thread = threading.Thread( - target=add_ingredient, - args=(ingredient_json, i) - ) - threads.append(thread) - thread.start() - - # Wait for all threads to complete - for thread in threads: - thread.join() - - # Check for errors during ingredient addition - if any(error for error in add_errors if error is not None): - self.fail( - "\n".join( - error for error in add_errors if error is not None)) - - # Verify all ingredients were added successfully - self.assertEqual( - completed_threads, - 5, - "All 5 threads should have completed") - self.assertEqual( - len(add_errors), - 5, - "All 5 threads should have completed without errors") - - # Now sign the manifest with the added ingredients - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertEqual(len(active_manifest["ingredients"]), 5) - - # Verify all ingredients exist in the array with correct thread IDs - # and unique metadata - ingredient_titles = [ing["title"] - for ing in active_manifest["ingredients"]] - - # Check that we have 5 unique titles - self.assertEqual(len(set(ingredient_titles)), 5, - "Should have 5 unique ingredient titles") - - # Verify each thread's ingredient exists with correct metadata - for i in range(1, 6): - # Find ingredients with this thread ID - thread_ingredients = [ing for ing in active_manifest["ingredients"] - if ing["title"] == f"Test Ingredient Thread {i}"] - self.assertEqual( - len(thread_ingredients), - 1, - f"Should find exactly one ingredient for thread {i}") - - builder.close() - def test_builder_sign_with_multiple_ingredient_random_many_threads(self): """Test Builder class operations with 12 threads, each adding 3 specific ingredients and signing a file.""" # Number of threads to use in the test @@ -2815,7 +2614,9 @@ def test_resource_contention_read_parallel_async(self): active_readers = 0 readers_lock = asyncio.Lock() stream_lock = asyncio.Lock() - start_barrier = asyncio.Barrier(reader_count) + # Event + counter to synchronize task starts (asyncio.Barrier requires 3.11+) + ready_count = 0 + all_ready = asyncio.Event() ctx = Context() with open(self.testPath, "rb") as file: @@ -2824,11 +2625,14 @@ def test_resource_contention_read_parallel_async(self): output.seek(0) async def read_manifest(reader_id): - nonlocal active_readers + nonlocal active_readers, ready_count try: async with readers_lock: active_readers += 1 - await start_barrier.wait() + ready_count += 1 + if ready_count == reader_count: + all_ready.set() + await all_ready.wait() async with stream_lock: output.seek(0) read_ctx = Context() @@ -2857,118 +2661,6 @@ async def run_async_tests(): self.fail("\n".join(read_errors)) self.assertEqual(active_readers, 0) - def test_builder_sign_with_multiple_ingredients_from_stream(self): - """Test Builder with multiple ingredients from streams using context APIs""" - ctx = Context() - builder = Builder.from_json(self.manifestDefinition, context=ctx) - assert builder._handle is not None - add_errors = [] - add_lock = threading.Lock() - completed_threads = 0 - completion_lock = threading.Lock() - - def add_ingredient_from_stream(ingredient_json, file_path, thread_id): - nonlocal completed_threads - try: - with open(file_path, 'rb') as f: - builder.add_ingredient_from_stream(ingredient_json, "image/jpeg", f) - with add_lock: - add_errors.append(None) - except Exception as e: - with add_lock: - add_errors.append(f"Thread {thread_id} error: {str(e)}") - finally: - with completion_lock: - completed_threads += 1 - - thread1 = threading.Thread(target=add_ingredient_from_stream, args=('{"title": "Test Ingredient Stream 1"}', self.testPath3, 1)) - thread2 = threading.Thread(target=add_ingredient_from_stream, args=('{"title": "Test Ingredient Stream 2"}', self.testPath4, 2)) - thread1.start() - thread2.start() - thread1.join() - thread2.join() - if any(e for e in add_errors if e is not None): - self.fail("\n".join(e for e in add_errors if e is not None)) - self.assertEqual(completed_threads, 2) - self.assertEqual(len(add_errors), 2) - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - read_ctx = Context() - reader = Reader("image/jpeg", output, context=read_ctx) - json_data = reader.json() - manifest_data = json.loads(json_data) - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - self.assertIn("ingredients", active_manifest) - self.assertEqual(len(active_manifest["ingredients"]), 2) - ingredient_titles = [ing["title"] for ing in active_manifest["ingredients"]] - self.assertIn("Test Ingredient Stream 1", ingredient_titles) - self.assertIn("Test Ingredient Stream 2", ingredient_titles) - builder.close() - - def test_builder_sign_with_same_ingredient_multiple_times(self): - """Test Builder with same ingredient added multiple times from different threads using context APIs""" - ctx = Context() - builder = Builder.from_json(self.manifestDefinition, context=ctx) - assert builder._handle is not None - add_errors = [] - add_lock = threading.Lock() - completed_threads = 0 - completion_lock = threading.Lock() - - def add_ingredient(ingredient_json, thread_id): - nonlocal completed_threads - try: - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - with add_lock: - add_errors.append(None) - except Exception as e: - with add_lock: - add_errors.append(f"Thread {thread_id} error: {str(e)}") - finally: - with completion_lock: - completed_threads += 1 - - threads = [] - for i in range(1, 6): - ingredient_json = json.dumps({"title": f"Test Ingredient Thread {i}"}) - thread = threading.Thread(target=add_ingredient, args=(ingredient_json, i)) - threads.append(thread) - thread.start() - for thread in threads: - thread.join() - if any(e for e in add_errors if e is not None): - self.fail("\n".join(e for e in add_errors if e is not None)) - self.assertEqual(completed_threads, 5) - self.assertEqual(len(add_errors), 5) - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - read_ctx = Context() - reader = Reader("image/jpeg", output, context=read_ctx) - json_data = reader.json() - manifest_data = json.loads(json_data) - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - self.assertIn("ingredients", active_manifest) - self.assertEqual(len(active_manifest["ingredients"]), 5) - ingredient_titles = [ing["title"] for ing in active_manifest["ingredients"]] - self.assertEqual(len(set(ingredient_titles)), 5) - for i in range(1, 6): - thread_ingredients = [ing for ing in active_manifest["ingredients"] if ing["title"] == f"Test Ingredient Thread {i}"] - self.assertEqual(len(thread_ingredients), 1) - builder.close() - def test_builder_sign_with_multiple_ingredient_random_many_threads(self): """Test Builder with 12 threads adding ingredients and signing using context APIs""" TOTAL_THREADS_USED = 12