From 138ff74b3fbe9a5a41e5a2142ddf734ed60bf559 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Fri, 31 Oct 2025 17:14:14 -0700 Subject: [PATCH 01/29] feat: c2pa-rs and c2pa_c_ffi 0.68.0 update (#189) * feat: Prepare 0.68.0 update * fix: Load trust for some tests * fix: Trust settings * fix: Update tests * fix: Restore deleted file * fix: Update the examples * fix: Clarify comment * fix: CLean up old tmp test * fix: Switch to json * fix: Add sign all files test with V2 spec * fix: Remove toml example --- c2pa-native-version.txt | 2 +- examples/sign.py | 10 +- examples/sign_info.py | 3 + pyproject.toml | 2 +- tests/__init__.py | 1 + tests/test_unit_tests.py | 626 +++++++++++++++++++++++--- tests/test_unit_tests_threaded.py | 10 - tests/trust_config_test_settings.json | 7 + 8 files changed, 582 insertions(+), 79 deletions(-) create mode 100644 tests/trust_config_test_settings.json diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index c9951732..1c50da98 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.67.1 +c2pa-v0.68.0 diff --git a/examples/sign.py b/examples/sign.py index 7182f99a..3df9fd5b 100644 --- a/examples/sign.py +++ b/examples/sign.py @@ -26,7 +26,7 @@ fixtures_dir = os.path.join(os.path.dirname(__file__), "../tests/fixtures/") output_dir = os.path.join(os.path.dirname(__file__), "../output/") -# Ensure the output directory exists +# Ensure the output directory exists. if not os.path.exists(output_dir): os.makedirs(output_dir) @@ -43,7 +43,7 @@ with open(fixtures_dir + "es256_private.key", "rb") as key_file: key = key_file.read() -# Define a callback signer function +# Define a callback signer function. def callback_signer_es256(data: bytes) -> bytes: """Callback function that signs data using ES256 algorithm.""" private_key = serialization.load_pem_private_key( @@ -60,7 +60,6 @@ def callback_signer_es256(data: bytes) -> bytes: # Create a manifest definition as a dictionary. # This manifest follows the V2 manifest format. manifest_definition = { - "claim_generator": "python_example", "claim_generator_info": [{ "name": "python_example", "version": "0.0.1", @@ -87,7 +86,7 @@ def callback_signer_es256(data: bytes) -> bytes: } # Sign the image with the signer created above, -# which will use the callback signer +# which will use the callback signer. print("\nSigning the image file...") with c2pa.Signer.from_callback( @@ -107,6 +106,9 @@ def callback_signer_es256(data: bytes) -> bytes: print("\nReading signed image metadata:") with open(output_dir + "A_signed.jpg", "rb") as file: with c2pa.Reader("image/jpeg", file) as reader: + # The validation state will depend on loaded trust settings. + # Without loaded trust settings, + # the manifest validation_state will be "Invalid". print(reader.json()) print("\nExample completed successfully!") diff --git a/examples/sign_info.py b/examples/sign_info.py index 0efa68d8..6b256647 100644 --- a/examples/sign_info.py +++ b/examples/sign_info.py @@ -100,6 +100,9 @@ print("\nReading signed image metadata:") with open(output_dir + "C_signed.jpg", "rb") as file: with c2pa.Reader("image/jpeg", file) as reader: + # The validation state will depend on loaded trust settings. + # Without loaded trust settings, + # the manifest validation_state will be "Invalid". print(reader.json()) print("\nExample completed successfully!") diff --git a/pyproject.toml b/pyproject.toml index d4722df1..dc370403 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.27.1" +version = "0.28.0" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..867e2c84 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Placeholder \ No newline at end of file diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index e7f2d778..5bd5960a 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -23,6 +23,8 @@ import tempfile import shutil import ctypes +import toml +import threading # Suppress deprecation warnings warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -38,9 +40,34 @@ 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 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. + + 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 + + class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.67.1", sdk_version()) + self.assertIn("0.68.0", sdk_version()) class TestReader(unittest.TestCase): @@ -122,7 +149,44 @@ def test_stream_read_get_validation_state(self): reader = Reader("image/jpeg", file) validation_state = reader.get_validation_state() self.assertIsNotNone(validation_state) - self.assertEqual(validation_state, "Valid") + # Needs trust configuration to be set up to validate as Trusted, otherwise manifest is Invalid + self.assertEqual(validation_state, "Invalid") + + 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 from test_settings.toml + 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: @@ -937,7 +1001,6 @@ def test_streams_sign_with_thumbnail_resource(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) output.close() def test_streams_sign_with_es256_alg_v1_manifest(self): @@ -949,7 +1012,10 @@ def test_streams_sign_with_es256_alg_v1_manifest(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) # Write buffer to file # output.seek(0) @@ -974,7 +1040,10 @@ def test_streams_sign_with_es256_alg_v1_manifest_to_existing_empty_file(self): reader = Reader("image/jpeg", target) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) finally: # Clean up... @@ -1000,7 +1069,10 @@ def test_streams_sign_with_es256_alg_v1_manifest_to_new_dest_file(self): reader = Reader("image/jpeg", target) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) finally: # Clean up... @@ -1022,7 +1094,10 @@ def test_streams_sign_with_es256_alg(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) output.close() def test_streams_sign_with_es256_alg_2(self): @@ -1034,9 +1109,60 @@ def test_streams_sign_with_es256_alg_2(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) 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 from test_settings.toml + 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() @@ -1059,9 +1185,72 @@ def test_sign_with_ed25519_alg(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", 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 from test_settings.toml + 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() @@ -1084,7 +1273,10 @@ def test_sign_with_ed25519_alg_2(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) output.close() def test_sign_with_ps256_alg(self): @@ -1109,7 +1301,10 @@ def test_sign_with_ps256_alg(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) output.close() def test_sign_with_ps256_alg_2(self): @@ -1134,9 +1329,70 @@ def test_sign_with_ps256_alg_2(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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 from test_settings.toml + 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) @@ -1149,10 +1405,64 @@ def test_archive_sign(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", 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 from test_settings.toml + 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) @@ -1168,10 +1478,67 @@ def test_archive_sign_with_added_ingredient(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", 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 from test_settings.toml + 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) @@ -1199,7 +1566,6 @@ def test_remote_sign_using_returned_bytes(self): with Reader("image/jpeg", read_buffer, manifest_data) as reader: manifest_data = reader.json() self.assertIn("Python Test", manifest_data) - self.assertNotIn("validation_status", manifest_data) def test_remote_sign_using_returned_bytes_V2(self): with open(self.testPath, "rb") as file: @@ -1214,7 +1580,56 @@ def test_remote_sign_using_returned_bytes_V2(self): with Reader("image/jpeg", read_buffer, manifest_data) as reader: manifest_data = reader.json() self.assertIn("Python Test", manifest_data) - self.assertNotIn("validation_status", 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 from test_settings.toml + 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""" @@ -1273,7 +1688,78 @@ def test_sign_all_files(self): reader = Reader(mime_type, output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", 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` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) reader.close() output.close() except Error.NotSupported: @@ -1804,7 +2290,10 @@ def test_sign_single(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` (which makes the manifest Invalid) + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) output.close() def test_sign_mp4_video_file_single(self): @@ -1819,7 +2308,10 @@ def test_sign_mp4_video_file_single(self): reader = Reader("video/mp4", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) output.close() def test_sign_mov_video_file_single(self): @@ -1834,37 +2326,12 @@ def test_sign_mov_video_file_single(self): reader = Reader("mov", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) output.close() - def test_sign_file_tmn_wip(self): - temp_dir = tempfile.mkdtemp() - try: - # Create a temporary output file path - output_path = os.path.join(temp_dir, "signed_output.jpg") - - # Use the sign_file method - builder = Builder(self.manifestDefinition) - builder.sign_file( - self.testPath, - output_path, - self.signer - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) - - # Read the signed file and verify the manifest - with open(output_path, "rb") as file: - reader = Reader("image/jpeg", file) - json_data = reader.json() - self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) - - finally: - # Clean up the temporary directory - shutil.rmtree(temp_dir) - def test_sign_file_video(self): temp_dir = tempfile.mkdtemp() try: @@ -1887,7 +2354,10 @@ def test_sign_file_video(self): reader = Reader("video/mp4", file) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) finally: # Clean up the temporary directory @@ -1939,7 +2409,10 @@ def test_builder_sign_file_callback_signer_from_callback(self): with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) # Parse the JSON and verify the signature algorithm manifest_data = json.loads(json_data) @@ -1990,7 +2463,10 @@ def test_builder_sign_file_callback_signer_from_callback_V2(self): with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) # Parse the JSON and verify the signature algorithm manifest_data = json.loads(json_data) @@ -2043,7 +2519,10 @@ def ed25519_callback(data: bytes) -> bytes: reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) reader.close() output.close() @@ -2067,7 +2546,10 @@ def test_signing_manifest_v2(self): # Basic verification of the manifest self.assertIn("Python Test Image V2", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) output.close() @@ -2100,7 +2582,10 @@ def test_sign_file_mp4_video(self): reader = Reader("video/mp4", file) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) finally: # Clean up the temporary directory @@ -2128,13 +2613,19 @@ def test_sign_file_mov_video(self): reader = Reader("mov", file) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) # 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) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) finally: # Clean up the temporary directory @@ -2162,13 +2653,19 @@ def test_sign_file_mov_video_V2(self): reader = Reader("mov", file) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) # 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) - self.assertNotIn("validation_status", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + # which makes the manifest validation_state become Invalid. + self.assertIn("Invalid", json_data) finally: # Clean up the temporary directory @@ -3516,7 +4013,6 @@ def test_sign_file_using_callback_signer_overloads(self): reader = Reader("image/jpeg", file) file_manifest_json = reader.json() self.assertIn("Python Test", file_manifest_json) - self.assertNotIn("validation_status", file_manifest_json) finally: shutil.rmtree(temp_dir) @@ -3687,7 +4183,8 @@ def test_sign_file_callback_signer(self): # 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.assertNotIn("validation_status", 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) @@ -3736,7 +4233,8 @@ def test_sign_file_callback_signer(self): # 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.assertNotIn("validation_status", 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) @@ -3782,7 +4280,8 @@ def test_sign_file_callback_signer_managed_single(self): with Reader("image/jpeg", file) as reader: json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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) @@ -3843,7 +4342,8 @@ def test_sign_file_callback_signer_managed_multiple_uses(self): with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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) diff --git a/tests/test_unit_tests_threaded.py b/tests/test_unit_tests_threaded.py index 9a00dd6a..14ef48fe 100644 --- a/tests/test_unit_tests_threaded.py +++ b/tests/test_unit_tests_threaded.py @@ -1429,10 +1429,6 @@ def sign_file(output_stream, manifest_def, thread_id): active_manifest1["title"], active_manifest2["title"]) - # Verify both outputs have valid signatures - self.assertNotIn("validation_status", manifest_store1) - self.assertNotIn("validation_status", manifest_store2) - # Clean up output1.close() output2.close() @@ -1511,12 +1507,6 @@ async def read_manifest(): self.assertTrue(author_found, "Author assertion not found in manifest") - # Verify no validation errors - self.assertNotIn( - "validation_status", - manifest_store, - "Manifest should not have validation errors") - except Exception as e: read_errors.append(f"Read error: {str(e)}") diff --git a/tests/trust_config_test_settings.json b/tests/trust_config_test_settings.json new file mode 100644 index 00000000..82422751 --- /dev/null +++ b/tests/trust_config_test_settings.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "trust": { + "trust_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCAcWgAwIBAgIUW4fUnS38162x10PCnB8qFsrQuZgwBQYDK2VwMHcxCzAJ\nBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdoZXJlMRowGAYD\nVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRFU1RJTkdfT05M\nWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2NDFaFw0zMjA2MDcxODQ2\nNDFaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdo\nZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRF\nU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAqMAUGAytlcAMhAGPUgK9q1H3D\neKMGqLGjTXJSpsrLpe0kpxkaFMe7KUAuo2MwYTAdBgNVHQ4EFgQUXuZWArP1jiRM\nfgye6ZqRyGupTowwHwYDVR0jBBgwFoAUXuZWArP1jiRMfgye6ZqRyGupTowwDwYD\nVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwBQYDK2VwA0EA8E79g54u2fUy\ndfVLPyqKmtjenOUMvVQD7waNbetLY7kvUJZCd5eaDghk30/Q1RaNjiP/2RfA/it8\nzGxQnM2hCA==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIC2jCCAjygAwIBAgIUYm+LFaltpWbS9kED6RRAamOdUHowCgYIKoZIzj0EAwQw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIGbMBAGByqGSM49AgEG\nBSuBBAAjA4GGAAQBaifSYJBkf5fgH3FWPxRdV84qwIsLd7RcIDcRJrRkan0xUYP5\nzco7R4fFGaQ9YJB8dauyqiNg00LVuPajvKmhgEMAT4eSfEhYC25F2ggXQlBIK3Q7\nmkXwJTIJSObnbw4S9Jy3W6OVKq351VpgWUcmhvGRRejW7S/D8L2tzqRW7JPI2uSj\nYzBhMB0GA1UdDgQWBBS6OykommTmfYoLJuPN4OU83wjPqjAfBgNVHSMEGDAWgBS6\nOykommTmfYoLJuPN4OU83wjPqjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQE\nAwIBhjAKBggqhkjOPQQDBAOBiwAwgYcCQV4B6uKKoCWecEDlzj2xQLFPmnBQIOzD\nnyiSEcYyrCKwMV+HYS39oM+T53NvukLKUTznHwdWc9++HNaqc+IjsDl6AkIB2lXd\n5+s3xf0ioU91GJ4E13o5rpAULDxVSrN34A7BlsaXYQLnSkLMqva6E7nq2JBYjkqf\niwNQm1DDcQPtPTnddOs=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICkTCCAhagAwIBAgIUIngKvNC/BMF3TRIafgweprIbGgAwCgYIKoZIzj0EAwMw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEX3FzSTnCcEAP3wteNaiy4GZzZ+ABd2Y7gJpfyZf3kkCuX/I3psFq\nQBRvb3/FEBaDT4VbDNlZ0WLwtw5d3PI42Zufgpxemgfjf31d8H51eU3/IfAz5AFX\ny/OarhObHgVvo2MwYTAdBgNVHQ4EFgQUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wHwYD\nVR0jBBgwFoAUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wDwYDVR0TAQH/BAUwAwEB/zAO\nBgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAPOgmJbVdhDh9KlgQXqE\nFzHiCt347JG4strk22MXzOgxQ0LnXStIh+viC3S1INzuBgIxAI1jiUBX/V7Gg0y6\nY/p6a63Xp2w+ia7vlUaUBWsR3ex9NNSTPLNoDkoTCSDOE2O20w==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICUzCCAfmgAwIBAgIUdmkq4byvgk2FSnddHqB2yjoD68gwCgYIKoZIzj0EAwIw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMFkwEwYHKoZIzj0CAQYI\nKoZIzj0DAQcDQgAEre/KpcWwGEHt+mD4xso3xotRnRx2IEsMoYwVIKI7iEJrDEye\nPcvJuBywA0qiMw2yvAvGOzW/fqUTu1jABrFIk6NjMGEwHQYDVR0OBBYEFF6ZuIbh\neBvZVxVadQBStikOy6iMMB8GA1UdIwQYMBaAFF6ZuIbheBvZVxVadQBStikOy6iM\nMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0gA\nMEUCIHBC1xLwkCWSGhVXFlSnQBx9cGZivXzCbt8BuwRqPSUoAiEAteZQDk685yh9\njgOTkp4H8oAmM1As+qlkRK2b+CHAQ3k=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIGezCCBC+gAwIBAgIUDAG5+sfGspprX+hlkn1SuB2f5VQwQQYJKoZIhvcNAQEK\nMDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEF\nAKIDAgEgMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t\nZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S\nIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2MjVa\nFw0zMjA2MDcxODQ2MjVaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG\nA1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG\nA1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ\nKoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglg\nhkgBZQMEAgEFAKIDAgEgA4ICDwAwggIKAoICAQC4q3t327HRHDs7Y9NR+ZqernwU\nbZ1EiEBR8vKTZ9StXmSfkzgSnvVfsFanvrKuZvFIWq909t/gH2z0klI2ZtChwLi6\nTFYXQjzQt+x5CpRcdWnB9zfUhOpdUHAhRd03Q14H2MyAiI98mqcVreQOiLDydlhP\nDla7Ign4PqedXBH+NwUCEcbQIEr2LvkZ5fzX1GzBtqymClT/Gqz75VO7zM1oV4gq\nElFHLsTLgzv5PR7pydcHauoTvFWhZNgz5s3olXJDKG/n3h0M3vIsjn11OXkcwq99\nNe5Nm9At2tC1w0Huu4iVdyTLNLIAfM368ookf7CJeNrVJuYdERwLwICpetYvOnid\nVTLSDt/YK131pR32XCkzGnrIuuYBm/k6IYgNoWqUhojGJai6o5hI1odAzFIWr9T0\nsa9f66P6RKl4SUqa/9A/uSS8Bx1gSbTPBruOVm6IKMbRZkSNN/O8dgDa1OftYCHD\nblCCQh9DtOSh6jlp9I6iOUruLls7d4wPDrstPefi0PuwsfWAg4NzBtQ3uGdzl/lm\nyusq6g94FVVq4RXHN/4QJcitE9VPpzVuP41aKWVRM3X/q11IH80rtaEQt54QMJwi\nsIv4eEYW3TYY9iQtq7Q7H9mcz60ClJGYQJvd1DR7lA9LtUrnQJIjNY9v6OuHVXEX\nEFoDH0viraraHozMdwIDAQABo2MwYTAdBgNVHQ4EFgQURW8b4nQuZgIteSw5+foy\nTZQrGVAwHwYDVR0jBBgwFoAURW8b4nQuZgIteSw5+foyTZQrGVAwDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB\nZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEFAKIDAgEgA4ICAQBB\nWnUOG/EeQoisgC964H5+ns4SDIYFOsNeksJM3WAd0yG2L3CEjUksUYugQzB5hgh4\nBpsxOajrkKIRxXN97hgvoWwbA7aySGHLgfqH1vsGibOlA5tvRQX0WoQ+GMnuliVM\npLjpHdYE2148DfgaDyIlGnHpc4gcXl7YHDYcvTN9NV5Y4P4x/2W/Lh11NC/VOSM9\naT+jnFE7s7VoiRVfMN2iWssh2aihecdE9rs2w+Wt/E/sCrVClCQ1xaAO1+i4+mBS\na7hW+9lrQKSx2bN9c8K/CyXgAcUtutcIh5rgLm2UWOaB9It3iw0NVaxwyAgWXC9F\nqYJsnia4D3AP0TJL4PbpNUaA4f2H76NODtynMfEoXSoG3TYYpOYKZ65lZy3mb26w\nfvBfrlASJMClqdiEFHfGhP/dTAZ9eC2cf40iY3ta84qSJybSYnqst8Vb/Gn+dYI9\nqQm0yVHtJtvkbZtgBK5Vg6f5q7I7DhVINQJUVlWzRo6/Vx+/VBz5tC5aVDdqtBAs\nq6ZcYS50ECvK/oGnVxjpeOafGvaV2UroZoGy7p7bEoJhqOPrW2yZ4JVNp9K6CCRg\nzR6jFN/gUe42P1lIOfcjLZAM1GHixtjP5gLAp6sJS8X05O8xQRBtnOsEwNLj5w0y\nMAdtwAzT/Vfv7b08qfx4FfQPFmtjvdu4s82gNatxSA==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\nxPd7wFhjRZHfuWb2cs63xjAGjQ==\n-----END CERTIFICATE-----\n", + "trust_config": "//id-kp-emailProtection\n1.3.6.1.5.5.7.3.4\n//id-kp-documentSigning\n1.3.6.1.5.5.7.3.36\n//id-kp-timeStamping\n1.3.6.1.5.5.7.3.8\n//id-kp-OCSPSigning\n1.3.6.1.5.5.7.3.9\n// MS C2PA Signing\n1.3.6.1.4.1.311.76.59.1.9\n// c2pa-kp-claimSigning\n1.3.6.1.4.1.62558.2.1\n" + } +} \ No newline at end of file From b272adbee2aa25a75a2bed10e864f3a0497ee0a2 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 5 Nov 2025 08:21:03 -0800 Subject: [PATCH 02/29] chore: Update c2pa version from v0.68.0 to v0.69.0 (#190) * Update c2pa version from v0.68.0 to v0.69.0 * fix: Test version number check --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 1c50da98..52ba598b 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.68.0 +c2pa-v0.69.0 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 5bd5960a..488c5901 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.68.0", sdk_version()) + self.assertIn("0.69.0", sdk_version()) class TestReader(unittest.TestCase): From 43b460e23febf4767a436686dc72e883b31caa53 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:07:23 -0800 Subject: [PATCH 03/29] fix: Version bump from c2pa-rs and c2pa-c-ffi from v0.69.0 to v0.70.0 (#191) --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 52ba598b..3fecf232 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.69.0 +c2pa-v0.70.0 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 488c5901..a97c1ba7 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.69.0", sdk_version()) + self.assertIn("0.70.0", sdk_version()) class TestReader(unittest.TestCase): From e14197871e599aa069f8cb5140a7e34c3fcb3b6c Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Fri, 7 Nov 2025 18:59:51 -0800 Subject: [PATCH 04/29] fix: Version bump from c2pa-rs v0.70.0 to v0.71.0 (#193) --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 3fecf232..b002316e 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.70.0 +c2pa-v0.71.0 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index a97c1ba7..97b5bb8a 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.70.0", sdk_version()) + self.assertIn("0.71.0", sdk_version()) class TestReader(unittest.TestCase): From 4ed3ce84c7c1b77200443ea2b89f53d1d1db4c0d Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Tue, 18 Nov 2025 18:55:50 -0800 Subject: [PATCH 05/29] feat: Add set_intent APIs (#194) * fix: Version bump * fix: New bindings * fix: Initial bindings * fix: Few more tests * fix: Add a test * fix: Renamings * chore: c2pa-v0.71.1 * chore: c2pa-v0.71.1 * fix: Update tests * fix: Clean up logs * fix: Clean up logs from native lib --- c2pa-native-version.txt | 2 +- src/c2pa/__init__.py | 4 + src/c2pa/c2pa.py | 77 +++++++++ tests/test_unit_tests.py | 331 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 402 insertions(+), 12 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index b002316e..ff0544b3 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.71.0 +c2pa-v0.71.2 diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index c2a89d73..4791b225 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -22,6 +22,8 @@ C2paError, Reader, C2paSigningAlg, + C2paDigitalSourceType, + C2paBuilderIntent, C2paSignerInfo, Signer, Stream, @@ -35,6 +37,8 @@ 'C2paError', 'Reader', 'C2paSigningAlg', + 'C2paDigitalSourceType', + 'C2paBuilderIntent', 'C2paSignerInfo', 'Signer', 'Stream', diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index b0252279..82cad35c 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -48,6 +48,7 @@ 'c2pa_builder_free', 'c2pa_builder_set_no_embed', 'c2pa_builder_set_remote_url', + 'c2pa_builder_set_intent', 'c2pa_builder_add_resource', 'c2pa_builder_add_ingredient_from_stream', 'c2pa_builder_add_action', @@ -158,6 +159,37 @@ class C2paSigningAlg(enum.IntEnum): ED25519 = 6 +class C2paDigitalSourceType(enum.IntEnum): + """List of possible digital source types.""" + EMPTY = 0 + TRAINED_ALGORITHMIC_DATA = 1 + DIGITAL_CAPTURE = 2 + COMPUTATIONAL_CAPTURE = 3 + NEGATIVE_FILM = 4 + POSITIVE_FILM = 5 + PRINT = 6 + HUMAN_EDITS = 7 + COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA = 8 + ALGORITHMICALLY_ENHANCED = 9 + DIGITAL_CREATION = 10 + DATA_DRIVEN_MEDIA = 11 + TRAINED_ALGORITHMIC_MEDIA = 12 + ALGORITHMIC_MEDIA = 13 + SCREEN_CAPTURE = 14 + VIRTUAL_RECORDING = 15 + COMPOSITE = 16 + COMPOSITE_CAPTURE = 17 + COMPOSITE_SYNTHETIC = 18 + + +class C2paBuilderIntent(enum.IntEnum): + """Builder intent enumeration. + """ + CREATE = 0 # New digital creation with specified digital source type + EDIT = 1 # Edit of a pre-existing parent asset + UPDATE = 2 # Restricted version of Edit for non-editorial changes + + # Mapping from C2paSigningAlg enum to string representation, # as the enum value currently maps by default to an integer value. _ALG_TO_STRING_BYTES_MAPPING = { @@ -258,6 +290,7 @@ def _clear_error_state(): # Free the error to clear the state _lib.c2pa_string_free(error) + class C2paSignerInfo(ctypes.Structure): """Configuration for a Signer.""" _fields_ = [ @@ -414,6 +447,10 @@ def _setup_function(func, argtypes, restype=None): _setup_function( _lib.c2pa_builder_set_remote_url, [ ctypes.POINTER(C2paBuilder), ctypes.c_char_p], ctypes.c_int) +_setup_function( + _lib.c2pa_builder_set_intent, + [ctypes.POINTER(C2paBuilder), ctypes.c_uint, ctypes.c_uint], + ctypes.c_int) _setup_function(_lib.c2pa_builder_add_resource, [ctypes.POINTER( C2paBuilder), ctypes.c_char_p, ctypes.POINTER(C2paStream)], ctypes.c_int) _setup_function(_lib.c2pa_builder_add_ingredient_from_stream, @@ -2570,6 +2607,46 @@ def set_remote_url(self, remote_url: str): raise C2paError( Builder._ERROR_MESSAGES['url_error'].format("Unknown error")) + def set_intent( + self, + intent: C2paBuilderIntent, + digital_source_type: C2paDigitalSourceType = ( + C2paDigitalSourceType.EMPTY + ) + ): + """Set the intent for the manifest. + + The intent specifies what kind of manifest to create: + - CREATE: New with specified digital source type. + Must not have a parent ingredient. + - EDIT: Edit of a pre-existing parent asset. + Must have a parent ingredient. + - UPDATE: Restricted version of Edit for non-editorial changes. + Must have only one ingredient as a parent. + + Args: + intent: The builder intent (C2paBuilderIntent enum value) + digital_source_type: The digital source type (required + for CREATE intent). Defaults to C2paDigitalSourceType.EMPTY + (for all other cases). + + Raises: + C2paError: If there was an error setting the intent + """ + self._ensure_valid_state() + + result = _lib.c2pa_builder_set_intent( + self._builder, + ctypes.c_uint(intent), + ctypes.c_uint(digital_source_type), + ) + + if result != 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError("Error setting intent for Builder: Unknown error") + def add_resource(self, uri: str, stream: Any): """Add a resource to the builder. diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 97b5bb8a..f808126a 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -29,7 +29,7 @@ # Suppress deprecation warnings warnings.filterwarnings("ignore", category=DeprecationWarning) -from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version +from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable PROJECT_PATH = os.getcwd() @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.71.0", sdk_version()) + self.assertIn("0.71.2", sdk_version()) class TestReader(unittest.TestCase): @@ -1115,6 +1115,215 @@ def test_streams_sign_with_es256_alg_2(self): self.assertIn("Invalid", 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` + # which makes the manifest validation_state become Invalid. + self.assertEqual(manifest_data["validation_state"], "Invalid") + 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` + # which makes the manifest validation_state become Invalid. + self.assertEqual(manifest_data["validation_state"], "Invalid") + 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` + # which makes the manifest validation_state become Invalid. + self.assertEqual(manifest_data["validation_state"], "Invalid") + output.close() + def test_streams_sign_with_es256_alg_with_trust_config(self): # Run in a separate thread to isolate thread-local settings result = {} @@ -1162,7 +1371,6 @@ def sign_and_validate_with_trust_config(): 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() @@ -1934,6 +2142,107 @@ def test_builder_sign_with_ingredient(self): builder.close() + def test_builder_sign_with_ingredients_edit_intent(self): + """Test signing with EDIT intent and ingredient.""" + builder = Builder.from_json({}) + assert builder._builder is not None + + # Set the intent for editing existing content + builder.set_intent(C2paBuilderIntent.EDIT) + + # 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] + + # 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"] + + # 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, "Should have c2pa.actions assertion") + + # 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 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") + + # 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") + + builder.close() + def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): builder = Builder.from_json(self.manifestDefinition) assert builder._builder is not None @@ -3029,7 +3338,7 @@ def test_builder_add_action_to_manifest_no_auto_add(self): builder.close() # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + 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_from_dict_no_auto_add(self): # For testing, remove auto-added actions @@ -3110,11 +3419,11 @@ def test_builder_add_action_to_manifest_from_dict_no_auto_add(self): builder.close() # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + 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}}}}') + 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": [{ @@ -3199,7 +3508,7 @@ def test_builder_add_action_to_manifest_with_auto_add(self): builder.close() # Reset settings to default - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + 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_minimal_manifest_add_actions_and_sign_no_auto_add(self): # For testing, remove auto-added actions @@ -3264,11 +3573,11 @@ def test_builder_minimal_manifest_add_actions_and_sign_no_auto_add(self): builder.close() # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + 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_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}}}}') + 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": [{ @@ -3338,7 +3647,7 @@ def test_builder_minimal_manifest_add_actions_and_sign_with_auto_add(self): builder.close() # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + 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_dicts_no_auto_add(self): # For testing, remove auto-added actions @@ -3419,7 +3728,7 @@ def test_builder_sign_dicts_no_auto_add(self): builder.close() # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + 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"}}}}') class TestStream(unittest.TestCase): From dde66091781b53afcd1c0a9e7c3f7fc95d2f9bdf Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:13:13 -0800 Subject: [PATCH 06/29] chore: Update c2pa version to v0.72.0 (#198) * chore: Update c2pa version to v0.72.0 * chore: Update expected SDK version in unit tests --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index ff0544b3..541747ad 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.71.2 +c2pa-v0.72.0 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index f808126a..1c0ced7f 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.71.2", sdk_version()) + self.assertIn("0.72.0", sdk_version()) class TestReader(unittest.TestCase): From 838639c634deb3a66ac1b726616af2b2ae8148fb Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:33:00 -0800 Subject: [PATCH 07/29] chore: Update c2pa version to v0.72.1 (#200) * chore: Update c2pa version to v0.72.1 * chore: Update c2pa version to v0.72.1 * fix: Makefile indent typo * fix: Update the tests --- Makefile | 2 +- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 143 +++++++++++++++------------------------ 3 files changed, 55 insertions(+), 92 deletions(-) diff --git a/Makefile b/Makefile index 8fa23345..eca23ade 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ run-examples: # Runs the examples, then the unit tests test: - make run-examples + make run-examples python3 ./tests/test_unit_tests.py python3 ./tests/test_unit_tests_threaded.py diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 541747ad..0ca86fe2 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.72.0 +c2pa-v0.72.1 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 1c0ced7f..756588ff 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.72.0", sdk_version()) + self.assertIn("0.72.1", sdk_version()) class TestReader(unittest.TestCase): @@ -149,8 +149,7 @@ def test_stream_read_get_validation_state(self): reader = Reader("image/jpeg", file) validation_state = reader.get_validation_state() self.assertIsNotNone(validation_state) - # Needs trust configuration to be set up to validate as Trusted, otherwise manifest is Invalid - self.assertEqual(validation_state, "Invalid") + 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 @@ -1012,10 +1011,7 @@ def test_streams_sign_with_es256_alg_v1_manifest(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + self.assertIn("Valid", json_data) # Write buffer to file # output.seek(0) @@ -1040,10 +1036,7 @@ def test_streams_sign_with_es256_alg_v1_manifest_to_existing_empty_file(self): reader = Reader("image/jpeg", target) 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + self.assertIn("Valid", json_data) finally: # Clean up... @@ -1069,10 +1062,7 @@ def test_streams_sign_with_es256_alg_v1_manifest_to_new_dest_file(self): reader = Reader("image/jpeg", target) 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + self.assertIn("Valid", json_data) finally: # Clean up... @@ -1095,9 +1085,8 @@ def test_streams_sign_with_es256_alg(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # 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): @@ -1109,10 +1098,7 @@ def test_streams_sign_with_es256_alg_2(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + self.assertIn("Valid", json_data) output.close() def test_streams_sign_with_es256_alg_create_intent(self): @@ -1162,9 +1148,8 @@ def test_streams_sign_with_es256_alg_create_intent(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertEqual(manifest_data["validation_state"], "Invalid") + # 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): @@ -1242,9 +1227,8 @@ def test_streams_sign_with_es256_alg_create_intent_2(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertEqual(manifest_data["validation_state"], "Invalid") + # 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): @@ -1319,9 +1303,8 @@ def test_streams_sign_with_es256_alg_edit_intent(self): self.assertIn("hash", ingredient) # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted` - # which makes the manifest validation_state become Invalid. - self.assertEqual(manifest_data["validation_state"], "Invalid") + # 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): @@ -1394,9 +1377,8 @@ def test_sign_with_ed25519_alg(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # 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): @@ -1482,9 +1464,8 @@ def test_sign_with_ed25519_alg_2(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) output.close() def test_sign_with_ps256_alg(self): @@ -1510,9 +1491,8 @@ def test_sign_with_ps256_alg(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) output.close() def test_sign_with_ps256_alg_2(self): @@ -1614,9 +1594,8 @@ def test_archive_sign(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) archive.close() output.close() @@ -1687,9 +1666,8 @@ def test_archive_sign_with_added_ingredient(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) archive.close() output.close() @@ -1897,9 +1875,8 @@ def test_sign_all_files(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) reader.close() output.close() except Error.NotSupported: @@ -1966,8 +1943,7 @@ def test_sign_all_files_V2(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + self.assertIn("Valid", json_data) reader.close() output.close() except Error.NotSupported: @@ -2600,9 +2576,8 @@ def test_sign_single(self): 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` (which makes the manifest Invalid) - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) output.close() def test_sign_mp4_video_file_single(self): @@ -2618,9 +2593,8 @@ def test_sign_mp4_video_file_single(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) output.close() def test_sign_mov_video_file_single(self): @@ -2636,9 +2610,8 @@ def test_sign_mov_video_file_single(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) output.close() def test_sign_file_video(self): @@ -2664,9 +2637,8 @@ def test_sign_file_video(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) finally: # Clean up the temporary directory @@ -2719,9 +2691,8 @@ def test_builder_sign_file_callback_signer_from_callback(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) # Parse the JSON and verify the signature algorithm manifest_data = json.loads(json_data) @@ -2773,9 +2744,8 @@ def test_builder_sign_file_callback_signer_from_callback_V2(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) # Parse the JSON and verify the signature algorithm manifest_data = json.loads(json_data) @@ -2829,9 +2799,8 @@ def ed25519_callback(data: bytes) -> bytes: 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) reader.close() output.close() @@ -2856,9 +2825,8 @@ def test_signing_manifest_v2(self): # 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) output.close() @@ -2892,9 +2860,8 @@ def test_sign_file_mp4_video(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) finally: # Clean up the temporary directory @@ -2923,18 +2890,16 @@ def test_sign_file_mov_video(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) # 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) finally: # Clean up the temporary directory @@ -2963,18 +2928,16 @@ def test_sign_file_mov_video_V2(self): 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) # 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` - # which makes the manifest validation_state become Invalid. - self.assertIn("Invalid", json_data) + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) finally: # Clean up the temporary directory From 35471a58ade540824bb2cab76f6cd3a641b11c1b Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:01:55 -0800 Subject: [PATCH 08/29] chore: Update c2pa version to v0.73.0 (#201) * chore: Update c2pa version to v0.73.0 * chore: Update expected SDK version in unit test * fix: Remove retired runner * fix: Update retired runner * fix: Update retired runner 2 * fix: Update retired runner 3 --- .github/workflows/build.yml | 4 ++-- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3bb6e130..6cab3c53 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -368,7 +368,7 @@ jobs: - target: arm64 runs-on: macos-latest - target: x86_64 - runs-on: macos-13 + runs-on: macos-15-intel if: | github.event_name != 'pull_request' || @@ -387,7 +387,7 @@ jobs: - target: arm64 runs-on: macos-latest - target: x86_64 - runs-on: macos-13 + runs-on: macos-15-intel if: | github.event_name != 'pull_request' || diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 0ca86fe2..c8f95fe2 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.72.1 +c2pa-v0.73.0 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 756588ff..b82eda80 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.72.1", sdk_version()) + self.assertIn("0.73.0", sdk_version()) class TestReader(unittest.TestCase): From 125341a93dfc3586979e278274ecc236c450398d Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Fri, 19 Dec 2025 20:57:52 -0800 Subject: [PATCH 09/29] chore: Update c2pa version to v0.73.1 (#204) * chore: Update c2pa version to v0.73.1 * fix: Update expected SDK version in unit test --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index c8f95fe2..560aef5c 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.73.0 +c2pa-v0.73.1 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 6fa4326e..1c3bfbed 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.73.0", sdk_version()) + self.assertIn("0.73.1", sdk_version()) class TestReader(unittest.TestCase): From 3d8387f5c454d103c8b3952ab20e5b9dfd9e7256 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Mon, 5 Jan 2026 09:45:20 -0800 Subject: [PATCH 10/29] chore: Update to native version 0.73.2 (#210) * chore: Update expected SDK version in unit test * chore: Update c2pa version to v0.73.2 --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 560aef5c..e3872c66 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.73.1 +c2pa-v0.73.2 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 1c3bfbed..81389ce8 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.73.1", sdk_version()) + self.assertIn("0.73.2", sdk_version()) class TestReader(unittest.TestCase): From f187beeebd02484a8962af6ae5eff047480829ab Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:40:20 -0800 Subject: [PATCH 11/29] fix: Improve typings for errors (#213) * fix: Clarify version docs * fix: Add the test that verifies direct instance throws * fix: Add the tests for the factory method * fix: Add tests for from_asset * fix: Add tests * fix: Typos in docs * fix: Add docs * fix: Docs * fix: Typo * fix: Rename method * fix: Exception classes hierarchy * fix: COmment typos... * fix: Typos in comments * fix: In examples, add link to the app repo example * fix: In examples, add link to the app repo example * fix: In examples, add link to the app repo example * fix: Add docs link * fix: Add docs link * fix: Clean up exception handling --- README.md | 4 +- examples/README.md | 6 +- src/c2pa/c2pa.py | 269 ++++++++++++++++++++++++++------------- tests/test_unit_tests.py | 8 ++ 4 files changed, 197 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index d33472ee..c2d20851 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,9 @@ See the [`examples` directory](https://github.com/contentauth/c2pa-python/tree/m ## API reference documentation -See [the section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation). +Documentation is published at [github.io/c2pa-python/api/c2pa](https://contentauth.github.io/c2pa-python/api/c2pa/index.html). + +To build documentation locally, refer to [this section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation). ## Contributing diff --git a/examples/README.md b/examples/README.md index ce8003b9..027da526 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,4 +1,4 @@ -# Python example code +# Python example code The `examples` directory contains some small examples of using the Python library. The examples use asset files from the `tests/fixtures` directory, save the resulting signed assets to the temporary `output` directory, and display manifest store data and other output to the console. @@ -96,3 +96,7 @@ In this example, `SignerInfo` creates a `Signer` object that signs the manifest. ```bash python examples/sign_info.py ``` + +## Backend application example + +[c2pa-python-example](https://github.com/contentauth/c2pa-python-example) is an example of a simple application that accepts an uploaded JPEG image file, attaches a C2PA manifest, and signs it using a certificate. The app uses the CAI Python library and the Flask Python framework to implement a back-end REST endpoint; it does not have an HTML front-end, so you have to use something like curl to access it. This example is a development setup and should not be deployed as-is to a production environment. diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 82cad35c..7f2f4325 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -71,10 +71,6 @@ 'c2pa_reader_remote_url', ] -# TODO Bindings: -# c2pa_reader_is_embedded -# c2pa_reader_remote_url - def _validate_library_exports(lib): """Validate that all required functions are present in the loaded library. @@ -537,71 +533,118 @@ def _setup_function(func, argtypes, restype=None): class C2paError(Exception): - """Exception raised for C2PA errors.""" + """Exception raised for C2PA errors. + + This is the base class for all C2PA exceptions. Catching C2paError will + catch all typed C2PA exceptions (e.g., C2paError.ManifestNotFound). + """ def __init__(self, message: str = ""): self.message = message super().__init__(message) - class Assertion(Exception): - """Exception raised for assertion errors.""" - pass - class AssertionNotFound(Exception): - """Exception raised when an assertion is not found.""" - pass +# Define typed exception subclasses that inherit from C2paError +# These are attached to C2paError as class attributes for backward compatibility +# (eg., C2paError.ManifestNotFound), and also to ensure properly inheritance hierarchy + +class _C2paAssertion(C2paError): + """Exception raised for assertion errors.""" + pass + + +class _C2paAssertionNotFound(C2paError): + """Exception raised when an assertion is not found.""" + pass + + +class _C2paDecoding(C2paError): + """Exception raised for decoding errors.""" + pass + + +class _C2paEncoding(C2paError): + """Exception raised for encoding errors.""" + pass + + +class _C2paFileNotFound(C2paError): + """Exception raised when a file is not found.""" + pass + + +class _C2paIo(C2paError): + """Exception raised for IO errors.""" + pass + + +class _C2paJson(C2paError): + """Exception raised for JSON errors.""" + pass + + +class _C2paManifest(C2paError): + """Exception raised for manifest errors.""" + pass + + +class _C2paManifestNotFound(C2paError): + """ + Exception raised when a manifest is not found, + aka there is no C2PA metadata to read + aka there is no JUMBF data to read. + """ + pass + - class Decoding(Exception): - """Exception raised for decoding errors.""" - pass +class _C2paNotSupported(C2paError): + """Exception raised for unsupported operations.""" + pass - class Encoding(Exception): - """Exception raised for encoding errors.""" - pass - class FileNotFound(Exception): - """Exception raised when a file is not found.""" - pass +class _C2paOther(C2paError): + """Exception raised for other errors.""" + pass - class Io(Exception): - """Exception raised for IO errors.""" - pass - class Json(Exception): - """Exception raised for JSON errors.""" - pass +class _C2paRemoteManifest(C2paError): + """Exception raised for remote manifest errors.""" + pass - class Manifest(Exception): - """Exception raised for manifest errors.""" - pass - class ManifestNotFound(Exception): - """Exception raised when a manifest is not found.""" - pass +class _C2paResourceNotFound(C2paError): + """Exception raised when a resource is not found.""" + pass - class NotSupported(Exception): - """Exception raised for unsupported operations.""" - pass - class Other(Exception): - """Exception raised for other errors.""" - pass +class _C2paSignature(C2paError): + """Exception raised for signature errors.""" + pass - class RemoteManifest(Exception): - """Exception raised for remote manifest errors.""" - pass - class ResourceNotFound(Exception): - """Exception raised when a resource is not found.""" - pass +class _C2paVerify(C2paError): + """Exception raised for verification errors.""" + pass - class Signature(Exception): - """Exception raised for signature errors.""" - pass - class Verify(Exception): - """Exception raised for verification errors.""" - pass +# Attach exception subclasses to C2paError for backward compatibility +# Preserves behavior for exception catching like except C2paError.ManifestNotFound, +# also reduces imports (think of it as an alias of sorts) +C2paError.Assertion = _C2paAssertion +C2paError.AssertionNotFound = _C2paAssertionNotFound +C2paError.Decoding = _C2paDecoding +C2paError.Encoding = _C2paEncoding +C2paError.FileNotFound = _C2paFileNotFound +C2paError.Io = _C2paIo +C2paError.Json = _C2paJson +C2paError.Manifest = _C2paManifest +C2paError.ManifestNotFound = _C2paManifestNotFound +C2paError.NotSupported = _C2paNotSupported +C2paError.Other = _C2paOther +C2paError.RemoteManifest = _C2paRemoteManifest +C2paError.ResourceNotFound = _C2paResourceNotFound +C2paError.Signature = _C2paSignature +C2paError.Verify = _C2paVerify class _StringContainer: @@ -656,10 +699,83 @@ def _convert_to_py_string(value) -> str: return py_string +def _raise_typed_c2pa_error(error_str: str) -> None: + """Parse an error string and raise the appropriate typed C2paError. + + Error strings from the native library have the format "ErrorType: message". + This function parses the error type and raises the corresponding + C2paError subclass with the full original error string as the message. + + Args: + error_str: The error string from the native library + + Raises: + C2paError subclass: The appropriate typed exception based on error_str + """ + # Error format from native library is "ErrorType: message" or "ErrorType message" + # Try splitting on ": " first (colon-space), then fall back to space only + if ': ' in error_str: + parts = error_str.split(': ', 1) + else: + parts = error_str.split(' ', 1) + if len(parts) > 1: + error_type = parts[0] + # Use the full error string as the message for backward compatibility + if error_type == "Assertion": + raise C2paError.Assertion(error_str) + elif error_type == "AssertionNotFound": + raise C2paError.AssertionNotFound(error_str) + elif error_type == "Decoding": + raise C2paError.Decoding(error_str) + elif error_type == "Encoding": + raise C2paError.Encoding(error_str) + elif error_type == "FileNotFound": + raise C2paError.FileNotFound(error_str) + elif error_type == "Io": + raise C2paError.Io(error_str) + elif error_type == "Json": + raise C2paError.Json(error_str) + elif error_type == "Manifest": + raise C2paError.Manifest(error_str) + elif error_type == "ManifestNotFound": + raise C2paError.ManifestNotFound(error_str) + elif error_type == "NotSupported": + raise C2paError.NotSupported(error_str) + elif error_type == "Other": + raise C2paError.Other(error_str) + elif error_type == "RemoteManifest": + raise C2paError.RemoteManifest(error_str) + elif error_type == "ResourceNotFound": + raise C2paError.ResourceNotFound(error_str) + elif error_type == "Signature": + raise C2paError.Signature(error_str) + elif error_type == "Verify": + raise C2paError.Verify(error_str) + # If no recognized error type, raise base C2paError + raise C2paError(error_str) + + def _parse_operation_result_for_error( result: ctypes.c_void_p | None, check_error: bool = True) -> Optional[str]: - """Helper function to handle string results from C2PA functions.""" + """Helper function to handle string results from C2PA functions. + + When result is falsy and check_error is True, this function retrieves the + error from the native library, parses it, and raises a typed C2paError. + + When result is truthy (a pointer to an error string), this function + converts it to a Python string, parses it, and raises a typed C2paError. + + Args: + result: A pointer to a result string, or None/falsy on error + check_error: Whether to check for errors when result is falsy + + Returns: + None if no error occurred + + Raises: + C2paError subclass: The appropriate typed exception if an error occurred + """ if not result: # pragma: no cover if check_error: error = _lib.c2pa_error() @@ -667,49 +783,22 @@ def _parse_operation_result_for_error( error_str = ctypes.cast( error, ctypes.c_char_p).value.decode('utf-8') _lib.c2pa_string_free(error) - parts = error_str.split(' ', 1) - if len(parts) > 1: - error_type, message = parts - if error_type == "Assertion": - raise C2paError.Assertion(message) - elif error_type == "AssertionNotFound": - raise C2paError.AssertionNotFound(message) - elif error_type == "Decoding": - raise C2paError.Decoding(message) - elif error_type == "Encoding": - raise C2paError.Encoding(message) - elif error_type == "FileNotFound": - raise C2paError.FileNotFound(message) - elif error_type == "Io": - raise C2paError.Io(message) - elif error_type == "Json": - raise C2paError.Json(message) - elif error_type == "Manifest": - raise C2paError.Manifest(message) - elif error_type == "ManifestNotFound": - raise C2paError.ManifestNotFound(message) - elif error_type == "NotSupported": - raise C2paError.NotSupported(message) - elif error_type == "Other": - raise C2paError.Other(message) - elif error_type == "RemoteManifest": - raise C2paError.RemoteManifest(message) - elif error_type == "ResourceNotFound": - raise C2paError.ResourceNotFound(message) - elif error_type == "Signature": - raise C2paError.Signature(message) - elif error_type == "Verify": - raise C2paError.Verify(message) - return error_str + _raise_typed_c2pa_error(error_str) return None # In the case result would be a string already (error message) - return _convert_to_py_string(result) + error_str = _convert_to_py_string(result) + if error_str: + _raise_typed_c2pa_error(error_str) + return None def sdk_version() -> str: """ Returns the underlying c2pa-rs/c2pa-c-ffi version string + c2pa-rs and c2pa-c-ffi versions are in lockstep release, + so the version string is the same for both and we return + the shared semantic version number. """ vstr = version() # Example: "c2pa-c/0.60.1 c2pa-rs/0.60.1" @@ -721,7 +810,11 @@ def sdk_version() -> str: def version() -> str: - """Get the C2PA library version.""" + """ + Get the C2PA library version with the fully qualified names + of the native core libraries (library names and semantic version + numbers). + """ result = _lib.c2pa_version() return _convert_to_py_string(result) @@ -2622,7 +2715,7 @@ def set_intent( - EDIT: Edit of a pre-existing parent asset. Must have a parent ingredient. - UPDATE: Restricted version of Edit for non-editorial changes. - Must have only one ingredient as a parent. + Must have only one ingredient, as a parent. Args: intent: The builder intent (C2paBuilderIntent enum value) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 81389ce8..6215adbc 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -85,6 +85,14 @@ def test_can_retrieve_reader_supported_mimetypes(self): 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_stream_read(self): with open(self.testPath, "rb") as file: reader = Reader("image/jpeg", file) From a01dd37480c8ffd1008d6c9a57cd6dfb464eddbf Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:52:37 -0800 Subject: [PATCH 12/29] feat: Add a factory method to the Reader class (#214) * fix: Clarify version docs * fix: Add the test that verifies direct instance throws * fix: Add the tests for the factory method * fix: Add tests for from_asset * fix: Add tests * fix: Typos in docs * fix: Add docs * fix: Docs * fix: Typo * fix: Rename method * fix: Exception classes hierarchy * fix: COmment typos... * fix: Typos in comments * fix: In examples, add link to the app repo example * fix: In examples, add link to the app repo example * fix: In examples, add link to the app repo example * fix: Add docs link * fix: Add docs link * fix: Clean up exception handling * fix: try_read --- src/c2pa/c2pa.py | 33 +++++++++ tests/test_unit_tests.py | 155 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 7f2f4325..02980dc9 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1514,6 +1514,39 @@ def get_supported_mime_types(cls) -> list[str]: return cls._supported_mime_types_cache + @classmethod + def try_create(cls, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None) -> Optional["Reader"]: + """This is a factory method to create a new Reader, + returning None if no manifest/c2pa data/JUMBF data could be read + (instead of raising a ManifestNotFound: no JUMBF data found exception). + + Returns None instead of raising C2paError.ManifestNotFound if no + C2PA manifest data is found in the asset. This is useful when you + want to check if an asset contains C2PA data without handling + exceptions for the expected case of no manifest. + + Args: + format_or_path: The format or path to read from + stream: Optional stream to read from (Python stream-like object) + manifest_data: Optional manifest data in bytes + + Returns: + Reader instance if the asset contains C2PA data, + None if no manifest found (ManifestNotFound: no JUMBF data found) + + Raises: + C2paError: If there was an error other than ManifestNotFound + """ + try: + # Reader creations checks deferred to the constructor __init__ method + return cls(format_or_path, stream, manifest_data) + except C2paError.ManifestNotFound: + # Nothing to read, so no Reader returned + return None + def __init__(self, format_or_path: Union[str, Path], diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 6215adbc..75b3fee1 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -93,12 +93,38 @@ def test_stream_read_nothing_to_read(self): 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) @@ -231,16 +257,35 @@ def test_stream_read_string_stream(self): 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() @@ -375,6 +420,116 @@ def test_read_all_files(self): 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") From 99ab6d9ee74fd9d752c7305f3e5887f4ea15ddfa Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:25:24 -0800 Subject: [PATCH 13/29] chore: Bump to c2pa-rs v0.74.0 (#215) * fix: Clarify version docs * fix: Add the test that verifies direct instance throws * fix: Add the tests for the factory method * fix: Add tests for from_asset * fix: Add tests * fix: Typos in docs * fix: Add docs * fix: Docs * fix: Typo * fix: Rename method * fix: Exception classes hierarchy * fix: COmment typos... * fix: Typos in comments * fix: In examples, add link to the app repo example * fix: In examples, add link to the app repo example * fix: In examples, add link to the app repo example * fix: Add docs link * fix: Add docs link * fix: Clean up exception handling * fix: try_read * fix: Bump version * fix: Load settings order --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 21 +++++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index e3872c66..6b7a4e95 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.73.2 +c2pa-v0.74.0 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 75b3fee1..d2cd24ab 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.73.2", sdk_version()) + self.assertIn("0.74.0", sdk_version()) class TestReader(unittest.TestCase): @@ -2384,12 +2384,13 @@ def test_builder_sign_with_ingredients_edit_intent(self): builder.close() def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None - # The following removes the manifest's thumbnail + # Settings should be loaded before the builder is created load_settings('{"builder": { "thumbnail": {"enabled": false}}}') + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + # Test adding ingredient ingredient_json = '{ "title": "Test Ingredient" }' with open(self.testPath3, 'rb') as f: @@ -2431,12 +2432,12 @@ def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): load_settings('{"builder": { "thumbnail": {"enabled": true}}}') def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None - # The following removes the manifest's thumbnail - using dict instead of string load_settings({"builder": {"thumbnail": {"enabled": False}}}) + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + # Test adding ingredient ingredient_json = '{ "title": "Test Ingredient" }' with open(self.testPath3, 'rb') as f: @@ -2709,8 +2710,11 @@ def test_builder_set_remote_url(self): def test_builder_set_remote_url_no_embed(self): """Test setting the remote url of a builder with no embed flag.""" - builder = Builder.from_json(self.manifestDefinition) + + # Settings need to be loaded before the builder is created load_settings(r'{"verify": { "remote_manifest_fetch": false} }') + + builder = Builder.from_json(self.manifestDefinition) builder.set_no_embed() builder.set_remote_url("http://this_does_not_exist/foo.jpg") @@ -4394,6 +4398,7 @@ def test_read_ingredient_file_who_has_no_manifest(self): 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) From 9f5dd8b86dca6bf9e545ddd1b34871d1df0caf5a Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Thu, 8 Jan 2026 19:14:45 -0800 Subject: [PATCH 14/29] fix: Retrigger tests --- examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index 027da526..53793845 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ # Python example code -The `examples` directory contains some small examples of using the Python library. +The `examples` directory contains some small examples of using this Python library. The examples use asset files from the `tests/fixtures` directory, save the resulting signed assets to the temporary `output` directory, and display manifest store data and other output to the console. ## Signing and verifying assets From fb54175ffe40ce0a905a9199f6054c44c22415cd Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:11:18 -0800 Subject: [PATCH 15/29] chore: Update c2pa version to v0.75.2 --- c2pa-native-version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 6b7a4e95..3fb3c77b 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.74.0 +c2pa-v0.75.2 From 9f2c9f6962b798fd752d2cd992d8bdb377bce7fa Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:11:46 -0800 Subject: [PATCH 16/29] chore: Update SDK version in unit tests --- tests/test_unit_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index d2cd24ab..7134bacc 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.74.0", sdk_version()) + self.assertIn("0.75.2", sdk_version()) class TestReader(unittest.TestCase): From 16058b3bb4a34d46bb4eef739da507da02ac5eb0 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 15 Jan 2026 20:06:18 -0800 Subject: [PATCH 17/29] fix: export C2paBuilderIntent --- src/c2pa/c2pa.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 02980dc9..d2f9d98e 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -3328,6 +3328,7 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'C2paSeekMode', 'C2paSigningAlg', 'C2paSignerInfo', + 'C2paBuilderIntent', 'Stream', 'Reader', 'Builder', From 659399c352e84eae6ef70994095aeb7fd256fc10 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:27:43 -0800 Subject: [PATCH 18/29] fix: c2pa v0.75.4 (#217) * fix: Update expected SDK version in unit tests * fix: Update c2pa version to v0.75.3 * fix: Update SDK version in unit tests to 0.75.4 * fix: Update c2pa version to v0.75.4 * fix: Update comment to clarify legacy settings loading --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 3fb3c77b..a6b3410d 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.75.2 +c2pa-v0.75.4 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 7134bacc..9c9e7960 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -43,7 +43,7 @@ def load_test_settings_json(): """ - Load default trust configuration test settings from a + 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. @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.75.2", sdk_version()) + self.assertIn("0.75.4", sdk_version()) class TestReader(unittest.TestCase): From 732993f804e67a435652883a8be7496c0d4b40bf Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:35:25 -0800 Subject: [PATCH 19/29] chore: Update c2pa version to v0.75.6 (#219) * Update c2pa version to v0.75.6 * fix: Update SDK version in unit test --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index a6b3410d..ebea9f00 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.75.4 +c2pa-v0.75.6 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 9c9e7960..e38a7de7 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.75.4", sdk_version()) + self.assertIn("0.75.6", sdk_version()) class TestReader(unittest.TestCase): From 682f0395581921d34271069ca4d09a40c71f0c5b Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:45:27 -0800 Subject: [PATCH 20/29] chore: Update c2pa version to v0.75.7 (#220) * Update c2pa version to v0.75.7 * Update SDK version in unit tests --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index ebea9f00..40de7a80 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.75.6 +c2pa-v0.75.7 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index e38a7de7..a00a0b08 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.75.6", sdk_version()) + self.assertIn("0.75.7", sdk_version()) class TestReader(unittest.TestCase): From d5583120d540ade7b590f89622ada67421ee9778 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:21:30 -0800 Subject: [PATCH 21/29] fix: Bump c2pa version to v0.75.8 (#222) * fix: Bump c2pa version to v0.75.8 * fix: Update SDK version in unit tests --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 40de7a80..d3b18900 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.75.7 +c2pa-v0.75.8 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index a00a0b08..c11635fa 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,7 +67,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.75.7", sdk_version()) + self.assertIn("0.75.8", sdk_version()) class TestReader(unittest.TestCase): From 86d6ca1394a502d349cc012956cffd9c3323ab5e Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:21:23 -0800 Subject: [PATCH 22/29] fix: Export types moved files (#221) --- src/c2pa/c2pa.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index d2f9d98e..d4ff669c 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -3327,6 +3327,7 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'C2paError', 'C2paSeekMode', 'C2paSigningAlg', + 'C2paDigitalSourceType', 'C2paSignerInfo', 'C2paBuilderIntent', 'Stream', From f19695d731d55aac5731aef94130138bde609617 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:22:40 -0800 Subject: [PATCH 23/29] chore: Bump c2pa-rs version to c2pa-v0.75.10 (#223) * Update c2pa-native-version.txt * Update test_unit_tests.py * Update test_unit_tests.py * Update c2pa-native-version.txt * Retrigger all tests at once --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index d3b18900..d63d5cc6 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.75.8 +c2pa-v0.75.10 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index c11635fa..4d23211e 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,7 +67,8 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.75.8", sdk_version()) + # This test verifies the native libraries used match the expected version + self.assertIn("0.75.10", sdk_version()) class TestReader(unittest.TestCase): From 6104a0da65e51bd53c244d31ea5f513d60d90dac Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:39:38 -0800 Subject: [PATCH 24/29] chore: Update c2pa-native-version.txt to use c2pa v0.75.16 (#224) * chore: Update c2pa-native-version.txt * fix: Update test_unit_tests.py * Update test_unit_tests.py * Update c2pa-native-version.txt * Update test_unit_tests.py * Update c2pa-native-version.txt * fix: Remove obsolete test * fix: Prep for 0.75.16 * fix: Retrigger all tests --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index d63d5cc6..34e79839 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.75.10 +c2pa-v0.75.16 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 4d23211e..f3368051 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -45,7 +45,7 @@ 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. + The return value is used to load settings (thread_local) in tests. Returns: dict: The parsed JSON content as a Python dictionary (JSON-compatible). @@ -68,7 +68,7 @@ def 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.75.10", sdk_version()) + self.assertIn("0.75.16", sdk_version()) class TestReader(unittest.TestCase): @@ -4374,11 +4374,6 @@ def tearDown(self): if os.path.exists(self.temp_data_dir): shutil.rmtree(self.temp_data_dir) - def test_invalid_settings_str(self): - """Test loading a malformed settings string.""" - with self.assertRaises(Error): - load_settings(r'{"verify": { "remote_manifest_fetch": false }') - def test_read_ingredient_file(self): """Test reading a C2PA ingredient from a file.""" # Test reading ingredient from file with data_dir From 14aabfcaa273c4413e60519998a5cdbb57085004 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:19:35 -0800 Subject: [PATCH 25/29] chore: Up to c2pa-v0.75.19 (#226) * chore: Up to c2pa-v0.75.18 * chore: Update test_unit_tests.py to check c2pa-v0.75.18 * Update test_unit_tests.py * Update c2pa-native-version.txt * Update test_unit_tests.py --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 34e79839..20d8c27f 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.75.16 +c2pa-v0.75.19 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index f3368051..88a4499c 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,8 +67,8 @@ def 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.75.16", sdk_version()) + # This test verifies the native libraries used match the expected version. + self.assertIn("0.75.19", sdk_version()) class TestReader(unittest.TestCase): From 6adc1ba1f5a3c4cd84063b09925200d419a49c35 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:59:29 -0800 Subject: [PATCH 26/29] chore Update c2pa-native-version.txt to c2pa-v0.75.21 (#227) * Update c2pa-native-version.txt * chore: Update test_unit_tests.py --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 20d8c27f..27f9930a 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.75.19 +c2pa-v0.75.21 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 88a4499c..18f6b817 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -68,7 +68,7 @@ def 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.75.19", sdk_version()) + self.assertIn("0.75.21", sdk_version()) class TestReader(unittest.TestCase): From 7ecfb0b4228aee3b7cff66eb4ec3ddd7e1329298 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:57:40 -0800 Subject: [PATCH 27/29] chore(deps): bump cryptography from 45.0.6 to 46.0.5 (#229) Bumps [cryptography](https://github.com/pyca/cryptography) from 45.0.6 to 46.0.5. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/45.0.6...46.0.5) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.5 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 26e511c1..e45dda87 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -16,7 +16,7 @@ autopep8==2.0.4 # For automatic code formatting flake8==7.3.0 # Test dependencies (for callback signers) -cryptography==45.0.6 +cryptography==46.0.5 # Documentation Sphinx>=7.3.0 From 63db2670a66929199ce3e00281b8946e950eb6d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:55:59 -0700 Subject: [PATCH 28/29] chore(deps): bump cryptography from 45.0.6 to 46.0.5 (#236) Bumps [cryptography](https://github.com/pyca/cryptography) from 45.0.6 to 46.0.5. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/45.0.6...46.0.5) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.5 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 4b6dc473d0e0540e03a87e53648ed7bda3bb756b Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:56:30 -0700 Subject: [PATCH 29/29] feat: Context+Settings APIs (#230) * feat: Context and Settings WIP * fix: Docs 1 * fix: WIP * fix: WIP * Delete docs/faqs.md * Update settings.md * Update usage instructions for context managers Clarified usage of context managers with Builder, Reader, and Signer classes. * fix: WIP * fix: Docs * fix: Clean up * fix: Move docs out * Delete tests/test_docs.py * fix: Switch to Interface and not protocol * fix: Updates * fix: Update things * fix: CLean up notes * fix: Require funcs * fix: Examples * fix: Refactor * fix: Refactor * fix: Unnest example * fix: The exampels * fix: The useless refactors * fix: Refactor * fix: Refactor 2 * fix: Refactor 3 * fix: Refactor * fix: Refactor 4 * fix: Version bump * fix: Deprec warning * fix: Test also the contextual APIs in threading * fix: Refactor * fix: Native handles handling refactoring (#232) * fix: Native handles handling * fix: Clean up notes * fix: Native handles handling --------- Co-authored-by: Tania Mathern * fix: Format * fix: Tests * fix: Test with trust * Clean up comments in read.py Removed unnecessary comments about trust anchor configuration. * Update read.py * fix: Typos * fix: Typos * fix: Notes for WIP * fix: Refactor * Remove version vNext details from release notes Removed details about new features and deprecations for version vNext. * fix: docs * fix:signfile * fix: Clean up * fix: Clean up * fix: Remove unused APIs * fix: Remove unused APIs, with_archive added * fix: Clean up notes and tests * fix: Double free * fix: Wording and examples * fix: Ownership handling * fix: Improve resources handling * fix: Docs * fix: refactor & docs * fix: refactor & docs * fix: refactor & docs * fix: refactor * fix: refactor * feat: Fragment APIs (e.g. for video) (#237) * fix: Fragment API * fix: Refactor * fix: Refactor * docs: Managed resources (#238) * fix: Managed resources docs * fix: Docs * fix: Docs * fix: Docs * fix: Docs * fix: Update docs --------- Co-authored-by: Tania Mathern * fix: refactor * fix: refactor * fix: refactor * fix: refactor * fix: refactor * fix: refactor * fix: refactor * fix: refactor * fix: Memory handling * fix: Remote manifest support in Reader with Context (#233) * fix: New API * fix: Review feedback on c2pa-rs PR * fix: Docs * v0.77.1 of c2pa-rs is released * Update c2pa version to v0.77.1 * fix: Merge conflict mistake --------- Co-authored-by: Tania Mathern * fix: Master of typos * fix: Docs * fix: Fix a typo * chore: Update c2pa version to v0.78.2 (#239) * Update c2pa version to v0.78.0 * Update expected SDK version in unit test * Update expected SDK version in unit test * Update c2pa version from v0.78.0 to v0.78.1 * Update expected SDK version in unit test * Update c2pa version to v0.78.2 * fix: Lifecycle handling handler leaks * fix: Review comments 2 * fix: Lifecycle handling handler leaks * fix: Docs * fix: Docs * fix: Docs --------- Co-authored-by: Tania Mathern --- Makefile | 1 + c2pa-native-version.txt | 2 +- docs/native-resources-management.md | 362 ++++ docs/usage.md | 437 ++++- examples/README.md | 3 - examples/no_thumbnails.py | 110 ++ examples/read.py | 16 +- examples/sign.py | 48 +- examples/training.py | 44 +- src/c2pa/__init__.py | 8 + src/c2pa/c2pa.py | 2556 +++++++++++++++++---------- src/c2pa/lib.py | 2 +- tests/fixtures/dash1.m4s | Bin 0 -> 71111 bytes tests/fixtures/dashinit.mp4 | Bin 0 -> 4765 bytes tests/fixtures/settings.toml | 230 --- tests/test_unit_tests.py | 1423 +++++++++++++-- tests/test_unit_tests_threaded.py | 1282 +++++++++++--- 17 files changed, 4818 insertions(+), 1706 deletions(-) create mode 100644 docs/native-resources-management.md create mode 100644 examples/no_thumbnails.py create mode 100644 tests/fixtures/dash1.m4s create mode 100644 tests/fixtures/dashinit.mp4 delete mode 100644 tests/fixtures/settings.toml diff --git a/Makefile b/Makefile index eca23ade..ba70dfb3 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ rebuild: clean-c2pa-env install-deps download-native-artifacts build-python run-examples: python3 ./examples/sign.py python3 ./examples/sign_info.py + python3 ./examples/no_thumbnails.py python3 ./examples/training.py rm -rf output/ diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 27f9930a..f8bb7ede 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.75.21 +c2pa-v0.78.2 diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md new file mode 100644 index 00000000..100936d0 --- /dev/null +++ b/docs/native-resources-management.md @@ -0,0 +1,362 @@ +# Native resource management (ManagedResource class) + +`ManagedResource` is the internal base class used by the C2PA Python SDK to wrap native (Rust/FFI) pointers. When adding new wrappers around native resources `ManagedResource` should be subclassed and follow the documented lifecycle rules. + +## Why `ManagedResource`? + +`ManagedResource` is the internal base class responsible for managing native pointers owned by the C2PA Python SDK. It guarantees: + +- Native memory is freed exactly once (no double-free). +- Resources are cleaned up deterministically via context managers or explicit `close()`. +- Ownership transfers (e.g. signer to context) are handled so the same pointer is not freed twice (and the objects/classes know which one owns what). +- Cleanup never raises (trade-off to avoid raising errors on clean-up only, but errors are logged). + +Developers wrapping new native resources must inherit from `ManagedResource` and follow the documented lifecycle rules. + +## Why is native resources management needed? + +### Native pointers in a Python wrapper + +The C2PA Python SDK is a wrapper around a native Rust library that exposes a C FFI. When the SDK creates a `Reader`, `Builder`, `Signer`, `Context`, or `Settings` object, that object holds a **pointer** to memory allocated on the native side (by the native library). + +### How Python's garbage collector works + +Python manages its own objects' memory automatically through garbage collection. In CPython (the standard interpreter), this works primarily through reference counting: each object has a counter tracking how many references point to it, and when that counter reaches zero the object is deallocated. A secondary cycle-detecting collector handles the case where objects reference each other in a loop and their counts never reach zero on their own. + +### Why garbage collection is not enough for native memory + +This system works well for pure Python objects, but native memory sits outside of it entirely. The garbage collector sees the Python wrapper object (e.g. a `Reader` instance) and tracks references to it, but it has no visibility into the native memory that the wrapper's `_handle` attribute points to. Memory allocated by native libraries is invisible to the garbage collector: it does not know the size of that native allocation, cannot tell when it is no longer needed, and will not call the native library's `c2pa_free` function to release it. If the Python wrapper of those native resources is collected without first calling `c2pa_free`, the native memory is never released and leaks. + +### Why `__del__` is not reliable enough + +Python does offer `__del__` as a hook that runs when an object is collected (finalizer), and `ManagedResource` uses it as a fallback to possibly clean up leftover resources at that point. But `__del__` cannot be relied on as the primary cleanup mechanism: its timing is unpredictable (due to being called when the garbage collection runs, which is non-deterministic itself), it may not run at all during interpreter shutdown, and other Python implementations (PyPy, GraalPy) that do not use reference counting make its behavior even less deterministic. + +In CPython, `__del__` runs synchronously when the last reference to an object disappears, which in simple cases happens at a predictable point (e.g. when a local variable goes out of scope). But if the object is part of a reference cycle, its reference count never reaches zero on its own. The cycle collector must discover and break the cycle first, and it runs periodically rather than immediately. An object caught in a cycle might sit in memory for an arbitrary amount of time before `__del__` fires. CPython's cycle collector does not guarantee an order when finalizing groups of objects in a cycle, so `__del__` methods that depend on other objects in the same cycle may find those objects already partially torn down. During interpreter shutdown, the situation is even less reliable: CPython clears module globals and may collect objects in an arbitrary order, and `__del__` methods that reference global state (like the `_lib` handle to the native library) can fail silently because those globals have already been set to `None`. PyPy and GraalPy use tracing garbage collectors (which periodically walk the object graph to find unreachable objects, rather than tracking individual reference counts) instead of reference counting, so `__del__` does not run when the last reference disappears. It runs at some later point when the GC happens to trace that region of the heap, which could be seconds or minutes later, or not at all if the process exits first. + +`ManagedResource` is the internal base class that handles managed resources, especially their lifecycle and clean-up. Every class that holds a native pointer should inherit from it. + +## Class hierarchy + +```mermaid +classDiagram + class ManagedResource { + <> + } + + class ContextProvider { + <> + } + + ManagedResource <|-- Settings + ManagedResource <|-- Context + ManagedResource <|-- Reader + ManagedResource <|-- Builder + ManagedResource <|-- Signer + + ContextProvider <|-- Context +``` + +Notes: + +- `Context` inherits from both `ManagedResource` and `ContextProvider` (Python supports multiple inheritance). +- `Settings` inherits from `ManagedResource` only. +- `ContextProvider` is an ABC (abstract base class) that requires two properties: `is_valid` and `execution_context`. The `is_valid` implementation lives on `ManagedResource`, so `Context` satisfies that part of the `ContextProvider` contract without duplicating the property. + +> [!NOTE] +> **How `is_valid` resolves across both parents for Context** +> +> Python's MRO (Method Resolution Order) is the order in which Python searches parent classes when looking up a method or property. For `Context(ManagedResource, ContextProvider)`, the MRO is `Context then ManagedResource then ContextProvider then ABC then object (base class)`. When `context.is_valid` is accessed, Python walks the MRO left-to-right and finds `ManagedResource.is_valid` first. Since `ContextProvider.is_valid` is abstract (it declares the requirement but has no implementation), `ManagedResource`'s concrete version both provides the behavior and satisfies the ABC contract. +> +> The MRO is computed using C3 linearization, which enforces two rules: children appear before their parents, and left-to-right order from the class definition is preserved. For `class Context(ManagedResource, ContextProvider)`: +> +> 1. `Context`: the class itself always comes first. +> 2. `ManagedResource` :first listed parent, nothing else requires it to appear later. +> 3. `ContextProvider`: second listed parent, must come after `ManagedResource` to preserve declaration order. +> 4. `ABC`: parent of `ContextProvider`, must come after its child. +> 5. `object`: root of everything (all objects), always last. +> +> Putting `ManagedResource` first in the declaration matters: the concrete `is_valid` implementation is found immediately during lookup, rather than hitting the abstract declaration on `ContextProvider` first. + +## Guarantees provided by ManagedResource + +`ManagedResource` provides the following guarantees, invariants must be maintained when subclassing the `ManagedResource` class in new implementation/new native resources handlers: + +| Guarantee | Description | +| --- | --- | +| **Pointer freed exactly once** | Each native pointer is passed to `c2pa_free` at most once. No leak (zero frees) and no double-free. | +| **Cleanup is idempotent** | Calling `close()` (or exiting a `with` block) multiple times is safe; after the first successful cleanup, further calls do nothing. | +| **Cleanup never raises** | The cleanup path (including `_release()` and `c2pa_free`) is wrapped so that exceptions are caught and logged, never re-raised. The original exception from the `with` block (if any) is never masked. | +| **State transitions are one-way** | Lifecycle moves only from UNINITIALIZED → ACTIVE → CLOSED. A closed resource cannot be reactivated. | +| **Ownership transfer is safe** | When a pointer is transferred elsewhere (e.g. via `_mark_consumed()`), the object stops managing it and does not call `c2pa_free` on it. | +| **Public methods validate lifecycle state** | Every public API calls `_ensure_valid_state()` before use; closed or invalid state yields `C2paError` instead of undefined behavior or crashes. | + +## Preventing garbage collection of live references + +When a Python object passes a callback or pointer to the native library, that reference must stay alive for as long as the native side might use it. Python's garbage collector has no way to know that native code is still holding a reference to a Python callback. + +The SDK solves this by storing these references as instance attributes on the owning object. For example, `Stream` stores its four callback objects (`_read_cb`, `_seek_cb`, `_write_cb`, `_flush_cb`) as instance attributes. As long as the `Stream` object is alive, its callbacks have a nonzero reference count and will not be collected. Similarly, when a `Signer` is consumed by a `Context`, the Context copies the signer's `_callback_cb` to its own `_signer_callback_cb` attribute so the callback survives even though the Signer object is now closed. + +During cleanup, `_release()` sets these attributes to `None`, which drops the reference count on the callback objects and allows them to be collected. In the cleanup sequence, `_release()` runs first, then `c2pa_free` frees the native pointer. `_release()` goes first so that subclass-specific resources (open file handles, stream wrappers) are torn down before the native pointer they depend on is freed. + +## How native memory is freed + +The native Rust library exposes a single C FFI function, `c2pa_free`, that deallocates memory it previously allocated. `ManagedResource` wraps this in a static method: + +```python +@staticmethod +def _free_native_ptr(ptr): + _lib.c2pa_free(ctypes.cast(ptr, ctypes.c_void_p)) +``` + +All native pointers are freed through this single path, regardless of which constructor created them (`c2pa_reader_from_stream`, `c2pa_builder_from_json`, `c2pa_signer_from_info`, etc.). The `ctypes.cast` to `c_void_p` is needed because the C function accepts a generic void pointer regardless of the original type. + +`ManagedResource` guarantees that `c2pa_free` is called exactly once per pointer: not zero times (leak), not twice (double-free). + +## Lifecycle states + +Each `ManagedResource` tracks its state with a `LifecycleState` enum: + +```mermaid +stateDiagram-v2 + direction LR + [*] --> UNINITIALIZED : __init__() + UNINITIALIZED --> ACTIVE : native pointer created + ACTIVE --> CLOSED : close() / __exit__ / __del__ / _mark_consumed() +``` + +- `UNINITIALIZED`: The Python object exists but the native pointer has not been set yet. This is a transient state during construction. +- `ACTIVE`: The native pointer is valid. The object can be used. +- `CLOSED`: The native pointer has been freed (or ownership was transferred). Any further use raises `C2paError`. + +The transition from ACTIVE to CLOSED is one-way. Once closed, an object cannot be reactivated. + +Every public method calls `_ensure_valid_state()` before doing any work. Besides checking the lifecycle state, this method also calls `_clear_error_state()`, which resets any stale error left over from a previous native library call. Without this, an error from one operation could leak into the next one and produce a misleading error message. + +## Ways to clean up + +### Context manager (`with` statement) + +```python +with Reader("image.jpg") as reader: + print(reader.json()) +# reader is automatically closed here, even if an exception occurs +``` + +When the `with` block exits, `__exit__` calls `close()`, which frees the native pointer. This is the safest approach because cleanup happens even if the code inside the block raises an exception. + +### Explicit `.close()` + +```python +reader = Reader("image.jpg") +try: + print(reader.json()) +finally: + reader.close() +``` + +Calling `.close()` directly is equivalent to exiting a `with` block. It is idempotent: calling it multiple times is safe and does nothing after the first call. + +### Destructor fallback (`__del__`) + +If neither of the above is used, `__del__` attempts to free the native pointer when Python garbage-collects the object. As described above, `__del__` timing is unpredictable and it may not run at all, so it is a safety net rather than a primary cleanup mechanism. + +## Error handling during cleanup + +Cleanup must never raise an exception. A failure during cleanup (for example, the native library crashing on free) should not mask the original exception that caused the `with` block to exit. `ManagedResource` enforces this: + +- `close()` delegates to `_cleanup_resources()`, which wraps the entire cleanup sequence in a try/except that catches and silences all exceptions. +- If freeing the native pointer fails, the error is logged via Python's `logging` module but not re-raised. +- The state is set to `CLOSED` as the very first step, before attempting to free anything. If cleanup fails halfway, the object is still marked closed, preventing a second attempt from doing further damage. +- Cleanup is idempotent. Calling `close()` on an already-closed object returns immediately. + +## Nesting resources + +When multiple native resources are in play at once, they can share a single `with` statement or use nested blocks. Either way, Python cleans them up in reverse order (right to left, or inner to outer). + +```python +with open("photo.jpg", "rb") as file, Reader("image/jpeg", file) as reader: + manifest = reader.json() +# reader is closed first, then file +``` + +The same can be written with nested blocks if readability is better: + +```python +with open("photo.jpg", "rb") as file: + with Reader("image/jpeg", file) as reader: + manifest = reader.json() +``` + +The order matters because resources often depend on each other. In the example above, the `Reader` holds a native pointer that references the file's data through a `Stream` wrapper. If the file handle were closed first, the native library would still hold a pointer into the stream's read callbacks, and any subsequent access (including cleanup) could read freed memory or trigger a segfault. By closing the Reader first, the native pointer is freed while the underlying file is still open and valid. Python's `with` statement guarantees this ordering: resources listed later (or nested deeper) are torn down first. + +## Reader lifecycle + +A `Reader` wraps a stream (or opens a file), passes it to the native library, and holds the returned pointer. While active, callers can use `.json()`, `.detailed_json()`, `.resource_to_stream()`, and other methods. Each of these checks state via `_ensure_valid_state()` before making the FFI call. + +```mermaid +stateDiagram-v2 + direction LR + [*] --> UNINITIALIZED : __init__() + UNINITIALIZED --> ACTIVE : Reader("image.jpg") + ACTIVE --> CLOSED : close() / exit with block + CLOSED --> [*] +``` + +While `ACTIVE`, callers can use `.json()`, `.detailed_json()`, etc. repeatedly without changing state. Calling `.close()` on an already-closed Reader is a no-op. Any other method call on a closed Reader raises `C2paError`. + +When the Reader is closed, it first releases its own resources (open file handles, stream wrappers) via `_release()`, then frees the native pointer via `c2pa_free`. + +## Builder lifecycle + +A `Builder` follows the same pattern as Reader, with one difference: **signing consumes the builder**. The native library takes ownership of the builder's pointer during the sign operation. After signing, the builder is closed and cannot be reused. + +```mermaid +stateDiagram-v2 + direction LR + [*] --> UNINITIALIZED : __init__() + UNINITIALIZED --> ACTIVE : Builder.from_json(manifest) + ACTIVE --> CLOSED : .sign() or close() + CLOSED --> [*] + + note left of CLOSED + .sign() consumes the pointer + close() frees it + end note +``` + +While `ACTIVE`, callers can use `.add_ingredient()`, `.add_action()`, etc. repeatedly. `.sign()` consumes the native pointer (ownership transfers to the native library), so the Builder cannot be reused afterward. Closing without signing frees the pointer normally. + +After `.sign()`, the builder calls `_mark_consumed()`, which sets the handle to `None` and the state to `CLOSED`. Because the native library now owns the pointer, `ManagedResource` does not call `c2pa_free`. That would double-free memory the native library already manages. + +## Ownership transfer + +Some operations transfer a native pointer from one object to another. When this happens, the original object must stop managing the pointer (e.g. so it is not freed twice). + +`_mark_consumed()` handles this. It sets `_handle = None` and `_lifecycle_state = CLOSED` in one step. + +There are two cases where this is relevant: + +- When a `Signer` is passed to a `Context`, the Context takes ownership of the Signer's native pointer. The Signer is marked consumed and must not be used again. + +- When `Builder.sign()` is called, the native library consumes the Builder's pointer. The Builder marks itself consumed regardless of whether the sign operation succeeds or fails, because in both cases the native library has taken the pointer. + +## Consume-and-return + +`_mark_consumed()` closes an object permanently. A different pattern is needed when the native library must replace an object's internal state without discarding the Python-side object. This happens with fragmented media: `Reader.with_fragment()` feeds a new BMFF fragment (used in DASH/HLS streaming) into an existing Reader, and the native library must rebuild its internal representation to account for the new data. The native API does this by consuming the old pointer and returning a new one. Creating a fresh `Reader` from scratch would not work because the native library needs the accumulated state from prior fragments. + +`Builder.with_archive()` follows the same pattern: it loads an archive into an existing Builder, replacing the manifest definition while preserving the Builder's context and settings. + +In both cases the FFI call consumes the current pointer and returns a replacement: + +```mermaid +stateDiagram-v2 + state "ACTIVE (ptr A)" as A + state "ACTIVE (ptr B)" as B + + A --> B : C FFI call consumes ptr A, returns ptr B + note right of B + Same Python object, + new native pointer + end note +``` + +```python +# Reader.with_fragment() internally does: +new_ptr = _lib.c2pa_reader_with_fragment(self._handle, ...) +# self._handle (old pointer) is now invalid +self._handle = new_ptr +``` + +The object stays `ACTIVE` throughout because the Python-side object is still valid: it has a live native pointer, its public methods still work, and callers may continue using it (e.g. reading the updated manifest or feeding in another fragment). The lifecycle state does not change because from `ManagedResource`'s perspective nothing has closed. Only the underlying native pointer has been swapped. This is different from `_mark_consumed()`, where the object transitions to `CLOSED` and becomes unusable. The old pointer must not be freed by `ManagedResource` because the native library already consumed it as part of the FFI call. + +## Subclass-specific cleanup with `_release()` + +Each subclass can override `_release()` to clean up its own resources before the native pointer is freed. The base implementation does nothing. + +Examples from the codebase: + +| Class | What `_release()` cleans up | +| --- | --- | +| Reader | Closes owned file handles and stream wrappers | +| Context | Drops the reference to the signer callback | +| Signer | Drops the reference to the signing callback | +| Settings | (no override, nothing extra to clean up) | +| Builder | (no override, nothing extra to clean up) | + +The cleanup order matters: `_release()` runs first (closing streams, dropping callbacks), then `c2pa_free` frees the native pointer. This order prevents the native library from accessing Python objects that no longer exist. + +## Why is `Stream` not a `ManagedResource`? + +`Stream` wraps a Python stream-like object (file stream or memory stream) so the native library can read from and write to it via callbacks. It does not inherit from `ManagedResource`, and it uses `c2pa_release_stream()` instead of `c2pa_free()` for cleanup. + +The reason is that ownership runs in the opposite direction. A `Reader` or `Builder` holds a native resource that Python code calls methods on. A `Stream` holds a native handle that the native library calls *back into* (read, seek, write, flush). The native library needs a different release function to tear down the callback machinery. + +`Stream` tracks its own state with `_closed` and `_initialized` flags rather than `LifecycleState`, but it supports the same three cleanup paths: context manager, explicit `.close()`, and `__del__` fallback. + +## Implementing a subclass of `ManagedResource` + +To wrap a new native resource, inherit from `ManagedResource` and follow these rules: + +```python +class NativeResource(ManagedResource): + def __init__(self, arg): + super().__init__() + + # 1. Initialize ALL instance attributes before any code + # that can raise. If __init__ fails partway through, + # __del__ will call _release(), which accesses these + # attributes. If they don't exist, _release() raises AttributeError. + self._my_stream = None + self._my_cache = None + + # 2. Create the native pointer. + ptr = _lib.c2pa_my_resource_new(arg) + _check_ffi_operation_result(ptr, "Failed to create MyResource") + + # 3. Only set _handle and activate AFTER the FFI call + # succeeded. If it raised, _lifecycle_state stays + # UNINITIALIZED and cleanup won't try to free a + # pointer that doesn't exist. + self._handle = ptr + self._lifecycle_state = LifecycleState.ACTIVE + + def _release(self): + # 4. Clean up class-specific resources. + # Never let this method raise. Must be idempotent. + # + # Consider defining a simple lifecycle for native resources + # so _release() can check whether they are releasable + # before attempting cleanup. The if-guard below + # verifies the stream exists and has not + # already been released. The try/except is a fallback + # that silences unexpected errors from .close(). + if self._my_stream: + try: + self._my_stream.close() + except Exception: + logger.error("Failed to close MyResource stream") + finally: + self._my_stream = None + + def do_something(self): + # 5. Check state at the start of every public method. + # This raises C2paError if the resource is closed. + self._ensure_valid_state() + return _lib.c2pa_my_resource_do_something(self._handle) +``` + +### Troubleshooting + +- If `self._my_callback = None` is set after the FFI call that can raise, and the call fails, `_release()` will try to access `self._my_callback` and crash with `AttributeError`. Always initialize attributes right after `super().__init__()`. + +- If `_lifecycle_state = ACTIVE` is set before the FFI call and the call fails, cleanup will try to free a null or invalid pointer. Activation should happen only after a valid handle exists. + +- If `_release()` raises, the exception is silently swallowed by `_cleanup_resources()`. It will not be visible unless logs are checked. Define a lifecycle for managed resources so `_release()` can check whether they need releasing. Wrap the actual release call in try/except as a fallback for unexpected failures. + +- `_release()` can be called more than once (via `close()` then `__del__`, or multiple `close()` calls). Make sure it handles being called on an already-cleaned-up object. Setting attributes to `None` after closing them is the standard pattern. + +- Calling `c2pa_free` directly is not recommended. `ManagedResource` handles this. If the pointer is freed manually and `ManagedResource` frees it again, the process crashes (double-free). + +- If a subclass inherits from both `ManagedResource` and an ABC like `ContextProvider`, and both define a property with the same name (e.g. `is_valid`), Python resolves it using the MRO. The parent listed first in the class definition wins. If the ABC is listed first, Python finds the abstract property before the concrete one and raises `TypeError: Can't instantiate abstract class`. Always list the class with the concrete implementation first (e.g. `class Context(ManagedResource, ContextProvider)`, not `class Context(ContextProvider, ManagedResource)`). + +- If two parent classes define the same method or property with different concrete implementations, the MRO silently picks the first one. This can cause subtle bugs where the wrong implementation is used. When combining multiple inheritance with shared property names, verify the MRO with `ClassName.__mro__` or `ClassName.mro()` to confirm the expected resolution order. diff --git a/docs/usage.md b/docs/usage.md index aeec23a4..7964bde3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -12,8 +12,13 @@ Import the objects needed from the API: from c2pa import Builder, Reader, Signer, C2paSigningAlg, C2paSignerInfo ``` -You can use both `Builder`, `Reader` and `Signer` classes with context managers by using a `with` statement. -Doing this is recommended to ensure proper resource and memory cleanup. +If you want to use per-instance configuration with `Context` and `Settings`: + +```py +from c2pa import Settings, Context, ContextBuilder, ContextProvider +``` + +All of `Builder`, `Reader`, `Signer`, `Context`, and `Settings` support context managers (the `with` statement) for automatic resource cleanup. ## Define manifest JSON @@ -53,45 +58,68 @@ An asset file may contain many manifests in a manifest store. The most recent ma NOTE: For a comprehensive reference to the JSON manifest structure, see the [Manifest store reference](https://opensource.contentauthenticity.org/docs/manifest/manifest-ref). +#### Reading without Context + ```py try: - # Create a reader from a file path + # Create a Reader from a file path. with Reader("path/to/media_file.jpg") as reader: - # Print manifest store as JSON + # Print manifest store as JSON. print("Manifest store:", reader.json()) # Get the active manifest. manifest = json.loads(reader.json()) active_manifest = manifest["manifests"][manifest["active_manifest"]] if active_manifest: - # Get the uri to the manifest's thumbnail and write it to a file + # Get the uri to the manifest's thumbnail and write it to a file. uri = active_manifest["thumbnail"]["identifier"] - with open("thumbnail_v2.jpg", "wb") as f: + with open("thumbnail.jpg", "wb") as f: reader.resource_to_stream(uri, f) except Exception as err: print(err) ``` +#### Reading with Context + +Pass a `Context` to apply custom settings to the Reader, such as trust anchors or verification flags. + +```py +try: + settings = Settings.from_dict({ + "verify": {"verify_cert_anchors": True}, + "trust": {"trust_anchors": anchors_pem} + }) + + with Context(settings) as ctx: + with Reader("path/to/media_file.jpg", context=ctx) as reader: + print("Manifest store:", reader.json()) + +except Exception as err: + print(err) +``` + ### Add a signed manifest **WARNING**: This example accesses the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as show in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). -Use a `Builder` to add a manifest to an asset: +#### Signing without Context + +Use a `Builder` and `Signer` to add a manifest to an asset: ```py try: - # Create a signer from certificate and key files + # Load certificate and key files with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: cert_data = cert_file.read() key_data = key_file.read() - # Create signer info using cert and key info + # Create signer info with the correct field names signer_info = C2paSignerInfo( alg=C2paSigningAlg.PS256, - cert=cert_data, - key=key_data, - timestamp_url="http://timestamp.digicert.com" + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" ) # Create signer using the defined SignerInfo @@ -99,14 +127,13 @@ try: # Create builder with manifest and add ingredients with Builder(manifest_json) as builder: - # Add any ingredients if needed with open("path/to/ingredient.jpg", "rb") as ingredient_file: ingredient_json = json.dumps({"title": "Ingredient Image"}) builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) - # Sign the file - with open("path/to/source.jpg", "rb") as source_file, open("path/to/output.jpg", "wb") as dest_file: - manifest_bytes = builder.sign(signer, "image/jpeg", source_file, dest_file) + # Sign the file (dest must be opened in w+b mode) + with open("path/to/source.jpg", "rb") as source, open("path/to/output.jpg", "w+b") as dest: + builder.sign(signer, "image/jpeg", source, dest) # Verify the signed file by reading data from the signed output file with Reader("path/to/output.jpg") as reader: @@ -118,74 +145,368 @@ except Exception as e: print("Failed to sign manifest store: " + str(e)) ``` -## Stream-based operation +#### Signing with Context + +Pass a `Context` to the Builder to apply custom settings during signing. The signer is still passed explicitly to `builder.sign()`. + +```py +try: + with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: + cert_data = cert_file.read() + key_data = key_file.read() + + signer_info = C2paSignerInfo( + alg=C2paSigningAlg.PS256, + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" + ) + + with Context() as ctx: + with Signer.from_info(signer_info) as signer: + with Builder(manifest_json, ctx) as builder: + with open("path/to/ingredient.jpg", "rb") as ingredient_file: + ingredient_json = json.dumps({"title": "Ingredient Image"}) + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) + + # Sign using file paths + builder.sign_file("path/to/source.jpg", "path/to/output.jpg", signer) + + # Verify the signed file with the same context + with Reader("path/to/output.jpg", context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + print("Signed manifest:", active_manifest) + +except Exception as e: + print("Failed to sign manifest store: " + str(e)) +``` + +## Settings, Context, and ContextProvider + +The `Settings` and `Context` classes provide per-instance configuration for Reader and Builder operations. This replaces the global `load_settings()` function, which is now deprecated. + +```mermaid +classDiagram + class ContextProvider { + <> + +is_valid bool + +execution_context + } + + class Settings { + +set(path, value) Settings + +update(data) Settings + +from_json(json_str)$ Settings + +from_dict(config)$ Settings + +close() + } + + class Context { + +has_signer bool + +builder()$ ContextBuilder + +from_json(json_str, signer)$ Context + +from_dict(config, signer)$ Context + +close() + } + + class ContextBuilder { + +with_settings(settings) ContextBuilder + +with_signer(signer) ContextBuilder + +build() Context + } + + class Signer { + +from_info(signer_info)$ Signer + +from_callback(callback, alg, certs, tsa_url)$ Signer + +close() + } + + class Reader { + +json() str + +resource_to_stream(uri, stream) + +close() + } + + class Builder { + +add_ingredient(json, format, stream) + +sign(signer, format, source, dest) bytes + +close() + } + + ContextProvider <|-- Context + ContextBuilder --> Context : builds + Context o-- Settings : optional + Context o-- Signer : optional, consumed + Reader ..> ContextProvider : uses + Builder ..> ContextProvider : uses +``` + +### Settings + +`Settings` controls behavior such as thumbnail generation, trust lists, and verification flags. + +```py +from c2pa import Settings + +# Create with defaults +settings = Settings() + +# Set individual values by dot-notation path +settings.set("builder.thumbnail.enabled", "false") + +# Method chaining +settings.set("builder.thumbnail.enabled", "false").set("verify.remote_manifest_fetch", "true") + +# Dict-like access +settings["builder.thumbnail.enabled"] = "false" + +# Create from JSON string +settings = Settings.from_json('{"builder": {"thumbnail": {"enabled": false}}}') + +# Create from a dictionary +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) + +# Merge additional configuration +settings.update({"verify": {"remote_manifest_fetch": True}}) +``` + +### Context + +A `Context` can carry `Settings` and a `Signer`, and is passed to `Reader` or `Builder` to control their behavior through settings propagation. + +```py +from c2pa import Context, Settings, Reader, Builder, Signer + +# Default context (no custom settings) +ctx = Context() + +# Context with settings +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +ctx = Context(settings=settings) + +# Create from JSON or dict directly +ctx = Context.from_json('{"builder": {"thumbnail": {"enabled": false}}}') +ctx = Context.from_dict({"builder": {"thumbnail": {"enabled": False}}}) + +# Use with Reader (keyword argument) +reader = Reader("path/to/media_file.jpg", context=ctx) + +# Use with Builder (positional or keyword argument) +builder = Builder(manifest_json, ctx) +``` + +### ContextBuilder (fluent API) + +`ContextBuilder` provides a fluent interface for constructing a `Context`, matching the c2pa-rs `ContextBuilder` pattern. Use `Context.builder()` to get started. + +```py +from c2pa import Context, ContextBuilder, Settings, Signer + +# Fluent construction with settings and signer +ctx = ( + Context.builder() + .with_settings(settings) + .with_signer(signer) + .build() +) + +# Settings only +ctx = Context.builder().with_settings(settings).build() + +# Default context (equivalent to Context()) +ctx = Context.builder().build() +``` + +You can call `with_settings()` multiple times. This is useful when different code paths each need to configure settings before the context is built. Each call replaces the previous `Settings` object entirely (the last one wins): + +```py +# Only settings_b is used, settings_a is replaced +ctx = ( + Context.builder() + .with_settings(settings_a) + .with_settings(settings_b) + .build() +) +``` + +To merge multiple configurations into one, use `Settings.update()` on a single `Settings` object, and then pass the built Settings object to the context: + +```py +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +settings.update({"verify": {"remote_manifest_fetch": True}}) + +ctx = Context.builder().with_settings(settings).build() +``` + +### Context with a Signer + +When a `Signer` is passed to `Context`, the `Signer` object is consumed and must not be reused directly. The `Context` takes ownership of the underlying native signer. This allows signing without passing an explicit signer to `Builder.sign()`. + +```py +from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg + +# Create a signer +signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" +) +signer = Signer.from_info(signer_info) + +# Create context with signer (signer is consumed) +ctx = Context(settings=settings, signer=signer) +# The signer object is now invalid and must not be used directly again + +# Build and sign without passing a signer, since the signer is in the context +builder = Builder(manifest_json, ctx) +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + manifest_bytes = builder.sign(format="image/jpeg", source=src, dest=dst) +``` + +If both an explicit signer and a context signer are available, the explicit signer takes precedence: + +```py +# Explicit signer wins over context signer +manifest_bytes = builder.sign(explicit_signer, "image/jpeg", source, dest) +``` + +### ContextProvider (abstract base class) + +`ContextProvider` is an abstract base class (ABC) that defines the interface `Reader` and `Builder` use to access a context. It requires two properties: + +- `is_valid` (bool): Whether the provider is in a usable state. `Reader` and `Builder` check this before every operation. +- `execution_context`: The raw native context pointer (`C2paContext` handle). `Reader` and `Builder` pass this to the native library for FFI calls. + +The built-in `Context` class is the standard implementation to provide context: + +```py +from c2pa import ContextProvider, Context + +ctx = Context() +assert isinstance(ctx, ContextProvider) +``` + +Any class can become a `ContextProvider` by inheriting from `ContextProvider` and implementing both properties. The two properties can live on any object through multiple inheritance, but a dedicated context class (as done in the SDK with `Context`) is preferred because it handles native memory management, lifecycle states, and signer ownership. + +In practice, `execution_context` must return a pointer that the native C2PA library understands, so custom providers will likely wrap a compatible native resource, rather than constructing native pointers independently: + +```py +from c2pa import ContextProvider, Context, Settings + +class MyContextProvider(ContextProvider): + """Custom provider that wraps a Context with application-specific logic.""" + + def __init__(self, config: dict): + self._ctx = Context(settings=Settings.from_dict(config)) + + @property + def is_valid(self) -> bool: + return self._ctx.is_valid + + @property + def execution_context(self): + return self._ctx.execution_context + + def close(self): + self._ctx.close() +``` + +`Settings` is not a `ContextProvider`. It inherits from `ManagedResource` only and cannot be passed directly as the `context` parameter to `Reader` or `Builder`. + +### Migrating from load_settings + +The `load_settings()` function that set settings in a thread-local fashion is deprecated. +Replace it with `Settings` and `Context` usage to propagate configurations (do not mix legacy and new APIs): + +```py +# Before: +from c2pa import load_settings +load_settings({"builder": {"thumbnail": {"enabled": False}}}) +reader = Reader("file.jpg") + +# After: +from c2pa import Settings, Context, Reader + +# Settings are on the context, and move with the context +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +ctx = Context(settings=settings) +reader = Reader("file.jpg", context=ctx) +``` + +## Stream-based operations Instead of working with files, you can read, validate, and add a signed manifest to streamed data. This example is similar to what the file-based example does. ### Read and validate C2PA data using streams +#### Stream reading without Context + ```py try: - # Create a reader from a format and stream with open("path/to/media_file.jpg", "rb") as stream: - # First parameter should be the type of the file (here, we use the mimetype) - # But in any case we need something to identify the file type with Reader("image/jpeg", stream) as reader: - # Print manifest store as JSON, as extracted by the Reader - print("manifest store:", reader.json()) + print("Manifest store:", reader.json()) - # Get the active manifest manifest = json.loads(reader.json()) active_manifest = manifest["manifests"][manifest["active_manifest"]] if active_manifest: - # get the uri to the manifest's thumbnail and write it to a file uri = active_manifest["thumbnail"]["identifier"] - with open("thumbnail_v2.jpg", "wb") as f: + with open("thumbnail.jpg", "wb") as f: reader.resource_to_stream(uri, f) except Exception as err: print(err) ``` +#### Stream reading with Context + +```py +try: + settings = Settings.from_dict({"verify": {"verify_cert_anchors": True}}) + + with Context(settings) as ctx: + with open("path/to/media_file.jpg", "rb") as stream: + with Reader("image/jpeg", stream, context=ctx) as reader: + print("Manifest store:", reader.json()) + +except Exception as err: + print(err) +``` + ### Add a signed manifest to a stream -**WARNING**: This example accesses the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as show in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). +**WARNING**: These examples access the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as shown in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). -Use a `Builder` to add a manifest to an asset: +#### Stream signing without Context ```py try: - # Create a signer from certificate and key files with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: cert_data = cert_file.read() key_data = key_file.read() - # Create signer info using the read certificate and key data signer_info = C2paSignerInfo( alg=C2paSigningAlg.PS256, - cert=cert_data, - key=key_data, - timestamp_url="http://timestamp.digicert.com" + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" ) - # Create a Signer using the SignerInfo defined previously signer = Signer.from_info(signer_info) - # Create a Builder with manifest and add ingredients with Builder(manifest_json) as builder: - # Add any ingredients as needed with open("path/to/ingredient.jpg", "rb") as ingredient_file: ingredient_json = json.dumps({"title": "Ingredient Image"}) - # Here the ingredient is added using streams builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) - # Sign using streams - with open("path/to/source.jpg", "rb") as source_file, open("path/to/output.jpg", "wb") as dest_file: - manifest_bytes = builder.sign(signer, "image/jpeg", source_file, dest_file) + # Sign using streams (dest must be opened in w+b mode) + with open("path/to/source.jpg", "rb") as source, open("path/to/output.jpg", "w+b") as dest: + builder.sign(signer, "image/jpeg", source, dest) # Verify the signed file with open("path/to/output.jpg", "rb") as stream: - # Create a Reader to read data with Reader("image/jpeg", stream) as reader: manifest_store = json.loads(reader.json()) active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] @@ -194,3 +515,39 @@ try: except Exception as e: print("Failed to sign manifest store: " + str(e)) ``` + +#### Stream signing with Context + +```py +try: + with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: + cert_data = cert_file.read() + key_data = key_file.read() + + signer_info = C2paSignerInfo( + alg=C2paSigningAlg.PS256, + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" + ) + + with Context() as ctx: + with Signer.from_info(signer_info) as signer: + with Builder(manifest_json, ctx) as builder: + with open("path/to/ingredient.jpg", "rb") as ingredient_file: + ingredient_json = json.dumps({"title": "Ingredient Image"}) + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) + + with open("path/to/source.jpg", "rb") as source, open("path/to/output.jpg", "w+b") as dest: + builder.sign(signer, "image/jpeg", source, dest) + + # Verify the signed file with the same context + with open("path/to/output.jpg", "rb") as stream: + with Reader("image/jpeg", stream, context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + print("Signed manifest:", active_manifest) + +except Exception as e: + print("Failed to sign manifest store: " + str(e)) +``` diff --git a/examples/README.md b/examples/README.md index da7733b7..191e88f4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,9 +7,6 @@ The examples use asset files from the `tests/fixtures` directory, save the resul The [`examples/sign.py`](https://github.com/contentauth/c2pa-python/blob/main/examples/sign.py) script shows how to sign an asset with a C2PA manifest and verify the asset. - -The `examples/sign.py` script shows how to sign an asset with a C2PA manifest and verify it using a callback signer. Callback signers let you define signing logic, for example where to load keys from. - The `examples/sign_info.py` script shows how to sign an asset with a C2PA manifest and verify it using a "default" signer created with the needed signer information. These statements create a `builder` object with the specified manifest JSON (omitted in the snippet below), call `builder.sign()` to sign and attach the manifest to the source file, `tests/fixtures/C.jpg`, and save the signed asset to the output file, `output/C_signed.jpg`: diff --git a/examples/no_thumbnails.py b/examples/no_thumbnails.py new file mode 100644 index 00000000..2886bc43 --- /dev/null +++ b/examples/no_thumbnails.py @@ -0,0 +1,110 @@ +# Copyright 2026 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. + +# Shows how to use Context+Settings API to control +# thumbnails added to the manifest. +# +# This example uses Settings to explicitly turn off +# thumbnail addition when signing. + +import json +import os +import c2pa +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.backends import default_backend + +fixtures_dir = os.path.join(os.path.dirname(__file__), "../tests/fixtures/") +output_dir = os.path.join(os.path.dirname(__file__), "../output/") + +# Ensure the output directory exists. +if not os.path.exists(output_dir): + os.makedirs(output_dir) + +# Load certificates and private key (here from the unit test fixtures). +with open(fixtures_dir + "es256_certs.pem", "rb") as cert_file: + certs = cert_file.read() +with open(fixtures_dir + "es256_private.key", "rb") as key_file: + key = key_file.read() + +# Define a callback signer function. +def callback_signer_es256(data: bytes) -> bytes: + """Callback function that signs data using ES256 algorithm.""" + private_key = serialization.load_pem_private_key( + key, + password=None, + backend=default_backend() + ) + signature = private_key.sign( + data, + ec.ECDSA(hashes.SHA256()) + ) + return signature + +# Create a manifest definition. +manifest_definition = { + "claim_generator_info": [{ + "name": "python_no_thumbnail_example", + "version": "0.1.0", + }], + "format": "image/jpeg", + "title": "No Thumbnail Example", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] +} + +# Use Settings to disable thumbnail generation, +# Settings are JSON matching the C2PA SDK settings schema +settings = c2pa.Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } +}) + +print("Signing image with thumbnails disabled through settings...") +with c2pa.Context(settings) as context: + with c2pa.Signer.from_callback( + callback_signer_es256, + c2pa.C2paSigningAlg.ES256, + certs.decode('utf-8'), + "http://timestamp.digicert.com" + ) as signer: + with c2pa.Builder(manifest_definition, context) as builder: + builder.sign_file( + fixtures_dir + "A.jpg", + output_dir + "A_no_thumbnail.jpg", + signer + ) + + # Read the signed image and verify no thumbnail is present. + with c2pa.Reader(output_dir + "A_no_thumbnail.jpg", context=context) as reader: + manifest_store = json.loads(reader.json()) + manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + if manifest.get("thumbnail") is None: + print("No thumbnail in the manifest as per settings.") + else: + print("Thumbnail found in the manifest.") + +print("\nExample completed successfully!") diff --git a/examples/read.py b/examples/read.py index ea30126b..e4b718a9 100644 --- a/examples/read.py +++ b/examples/read.py @@ -11,27 +11,32 @@ def load_trust_anchors(): + """Load trust anchors and return a Settings object holding trust configuration.""" try: with urllib.request.urlopen(TRUST_ANCHORS_URL) as response: anchors = response.read().decode('utf-8') - settings = { + return c2pa.Settings.from_dict({ "verify": { "verify_cert_anchors": True }, "trust": { "trust_anchors": anchors } - } - c2pa.load_settings(settings) + }) except Exception as e: print(f"Warning: Could not load trust anchors from {TRUST_ANCHORS_URL}: {e}") + return None def read_c2pa_data(media_path: str): print(f"Reading {media_path}") try: - with c2pa.Reader(media_path) as reader: - print(reader.detailed_json()) + settings = load_trust_anchors() + # Settings are put into the context, to make sure they propagate. + # All objects using this context will have trust configured. + with c2pa.Context(settings) as context: + with c2pa.Reader(media_path, context=context) as reader: + print(reader.detailed_json()) except Exception as e: print(f"Error reading C2PA data from {media_path}: {e}") sys.exit(1) @@ -43,5 +48,4 @@ def read_c2pa_data(media_path: str): else: media_path = sys.argv[1] - load_trust_anchors() read_c2pa_data(media_path) diff --git a/examples/sign.py b/examples/sign.py index 3df9fd5b..e6c14859 100644 --- a/examples/sign.py +++ b/examples/sign.py @@ -19,9 +19,8 @@ from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.backends import default_backend -# Note: Builder, Reader, and Signer support being used as context managers -# (with 'with' statements), but this example shows manual usage which requires -# explicitly calling the close() function to clean up resources. +# Note: Builder, Reader, Signer, and Context support being used as context managers +# (with 'with' statements) to automatically clean up resources. fixtures_dir = os.path.join(os.path.dirname(__file__), "../tests/fixtures/") output_dir = os.path.join(os.path.dirname(__file__), "../output/") @@ -89,27 +88,28 @@ def callback_signer_es256(data: bytes) -> bytes: # which will use the callback signer. print("\nSigning the image file...") -with c2pa.Signer.from_callback( - callback=callback_signer_es256, - alg=c2pa.C2paSigningAlg.ES256, - certs=certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" -) as signer: - with c2pa.Builder(manifest_definition) as builder: - builder.sign_file( - source_path=fixtures_dir + "A.jpg", - dest_path=output_dir + "A_signed.jpg", - signer=signer - ) +# Use default Context and Settings. +with c2pa.Context() as context: + with c2pa.Signer.from_callback( + callback_signer_es256, + c2pa.C2paSigningAlg.ES256, + certs.decode('utf-8'), + "http://timestamp.digicert.com" + ) as signer: + with c2pa.Builder(manifest_definition, context) as builder: + builder.sign_file( + fixtures_dir + "A.jpg", + output_dir + "A_signed.jpg", + signer + ) -# Re-Read the signed image to verify -print("\nReading signed image metadata:") -with open(output_dir + "A_signed.jpg", "rb") as file: - with c2pa.Reader("image/jpeg", file) as reader: - # The validation state will depend on loaded trust settings. - # Without loaded trust settings, - # the manifest validation_state will be "Invalid". - print(reader.json()) + # Re-Read the signed image to verify + print("\nReading signed image metadata:") + with open(output_dir + "A_signed.jpg", "rb") as file: + with c2pa.Reader("image/jpeg", file, context=context) as reader: + # The validation state will depend on loaded trust settings. + # Without loaded trust settings, + # the manifest validation_state will be "Invalid". + print(reader.json()) print("\nExample completed successfully!") - diff --git a/examples/training.py b/examples/training.py index b07d47ab..2bb446ce 100644 --- a/examples/training.py +++ b/examples/training.py @@ -90,7 +90,7 @@ def getitem(d, key): } } -# V2 signing API example +# Signing API example (v2 claims) try: # Read the private key and certificate files with open(keyFile, "rb") as key_file: @@ -106,26 +106,29 @@ def getitem(d, key): ta_url=b"http://timestamp.digicert.com" ) - with c2pa.Signer.from_info(signer_info) as signer: - with c2pa.Builder(manifest_json) as builder: - # Add the thumbnail resource using a stream - with open(fixtures_dir + "A_thumbnail.jpg", "rb") as thumbnail_file: - builder.add_resource("thumbnail", thumbnail_file) + with ( + c2pa.Context() as context, + c2pa.Signer.from_info(signer_info) as signer, + c2pa.Builder(manifest_json, context) as builder, + ): + # Add the thumbnail resource using a stream + with open(fixtures_dir + "A_thumbnail.jpg", "rb") as thumbnail_file: + builder.add_resource("thumbnail", thumbnail_file) - # Add the ingredient using the correct method - with open(fixtures_dir + "A_thumbnail.jpg", "rb") as ingredient_file: - builder.add_ingredient(json.dumps(ingredient_json), "image/jpeg", ingredient_file) + # Add the ingredient to the working store (Builder) + with open(fixtures_dir + "A_thumbnail.jpg", "rb") as ingredient_file: + builder.add_ingredient(json.dumps(ingredient_json), "image/jpeg", ingredient_file) - if os.path.exists(testOutputFile): - os.remove(testOutputFile) + if os.path.exists(testOutputFile): + os.remove(testOutputFile) - # Sign the file using the stream-based sign method - with open(testFile, "rb") as source_file: - with open(testOutputFile, "w+b") as dest_file: - result = builder.sign(signer, "image/jpeg", source_file, dest_file) + # Sign the file using the stream-based sign method + with open(testFile, "rb") as source_file: + with open(testOutputFile, "w+b") as dest_file: + result = builder.sign(signer, "image/jpeg", source_file, dest_file) - # As an alternative, you can also use file paths directly during signing: - # builder.sign_file(testFile, testOutputFile, signer) + # As an alternative, you can also use file paths directly during signing: + # builder.sign_file(testFile, testOutputFile, signer) except Exception as err: print(f"Exception during signing: {err}") @@ -136,8 +139,11 @@ def getitem(d, key): allowed = True # opt out model, assume training is ok if the assertion doesn't exist try: - # Create reader using the Reader API - with c2pa.Reader(testOutputFile) as reader: + # Create reader using the Reader API with default Context + with ( + c2pa.Context() as context, + c2pa.Reader(testOutputFile, context=context) as reader, + ): # Retrieve the manifest store manifest_store = json.loads(reader.json()) diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index 8f0c8fe1..5a5bfe78 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -27,6 +27,10 @@ C2paSignerInfo, Signer, Stream, + Settings, + Context, + ContextBuilder, + ContextProvider, sdk_version, read_ingredient_file, load_settings @@ -43,6 +47,10 @@ 'C2paSignerInfo', 'Signer', 'Stream', + 'Settings', + 'Context', + 'ContextBuilder', + 'ContextProvider', 'sdk_version', 'read_ingredient_file', 'load_settings' diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index d4ff669c..0ba07318 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -18,6 +18,7 @@ import sys import os import warnings +from abc import ABC, abstractmethod from pathlib import Path from typing import Optional, Union, Callable, Any, overload import io @@ -31,21 +32,33 @@ # Define required function names _REQUIRED_FUNCTIONS = [ + # Version 'c2pa_version', + # Error retriever and parser 'c2pa_error', - 'c2pa_string_free', + # Legacy APIs, deprecated 'c2pa_load_settings', 'c2pa_read_file', 'c2pa_read_ingredient_file', + # Stream + 'c2pa_create_stream', + 'c2pa_release_stream', + # Reader bindings 'c2pa_reader_from_stream', 'c2pa_reader_from_manifest_data_and_stream', - 'c2pa_reader_free', 'c2pa_reader_json', 'c2pa_reader_detailed_json', 'c2pa_reader_resource_to_stream', + 'c2pa_reader_from_context', + 'c2pa_reader_with_stream', + 'c2pa_reader_with_fragment', + 'c2pa_reader_with_manifest_data_and_stream', + 'c2pa_reader_is_embedded', + 'c2pa_reader_remote_url', + 'c2pa_reader_supported_mime_types', + # Builder bindings 'c2pa_builder_from_json', 'c2pa_builder_from_archive', - 'c2pa_builder_free', 'c2pa_builder_set_no_embed', 'c2pa_builder_set_remote_url', 'c2pa_builder_set_intent', @@ -54,21 +67,33 @@ 'c2pa_builder_add_action', 'c2pa_builder_to_archive', 'c2pa_builder_sign', - 'c2pa_manifest_bytes_free', - 'c2pa_builder_data_hashed_placeholder', - 'c2pa_builder_sign_data_hashed_embeddable', + 'c2pa_builder_sign_context', + 'c2pa_builder_from_context', + 'c2pa_builder_with_definition', + 'c2pa_builder_with_archive', + 'c2pa_builder_supported_mime_types', 'c2pa_format_embeddable', + # Signer bindings 'c2pa_signer_create', 'c2pa_signer_from_info', 'c2pa_signer_reserve_size', - 'c2pa_signer_free', 'c2pa_ed25519_sign', 'c2pa_signature_free', + # Settings bindings + 'c2pa_settings_new', + 'c2pa_settings_set_value', + 'c2pa_settings_update_from_string', + # Context bindings + 'c2pa_context_builder_new', + 'c2pa_context_builder_set_settings', + 'c2pa_context_builder_build', + 'c2pa_context_builder_set_signer', + 'c2pa_context_new', + # Free bindings + 'c2pa_string_free', 'c2pa_free_string_array', - 'c2pa_reader_supported_mime_types', - 'c2pa_builder_supported_mime_types', - 'c2pa_reader_is_embedded', - 'c2pa_reader_remote_url', + 'c2pa_manifest_bytes_free', + 'c2pa_free', ] @@ -186,6 +211,115 @@ class C2paBuilderIntent(enum.IntEnum): UPDATE = 2 # Restricted version of Edit for non-editorial changes +class LifecycleState(enum.IntEnum): + """Internal state for lifecycle management. + Object transitions: UNINITIALIZED -> ACTIVE -> CLOSED + """ + UNINITIALIZED = 0 + ACTIVE = 1 + CLOSED = 2 + + +class ManagedResource: + """Base class for objects that hold a native (C FFI) resource. + This is an internal base class that provides lifecycle management + for native resources (e.g. pointers). + + Subclasses must: + - Set `self._handle` to the native pointer after creation. + - Set `self._lifecycle_state = LifecycleState.ACTIVE` once initialized. + - Override `_release()` to free class-specific resources + (streams, caches, callbacks, etc.), called before the + native pointer is freed. + + The native pointer is freed automatically via `_free_native_ptr`. + """ + + def __init__(self): + self._lifecycle_state = LifecycleState.UNINITIALIZED + self._handle = None + _clear_error_state() + + @staticmethod + def _free_native_ptr(ptr): + """Free a native pointer by casting it to c_void_p and calling c2pa_free.""" + _lib.c2pa_free(ctypes.cast(ptr, ctypes.c_void_p)) + + def _ensure_valid_state(self): + """Raise if the resource is closed or uninitialized.""" + name = type(self).__name__ + if self._lifecycle_state == LifecycleState.CLOSED: + raise C2paError(f"{name} is closed") + if self._lifecycle_state != LifecycleState.ACTIVE: + raise C2paError(f"{name} is not properly initialized") + if not self._handle: + raise C2paError(f"{name} has an invalid internal state (active but no handle)") + _clear_error_state() + + def _release(self): + """Override to free class-specific resources (streams, caches, etc.). + + Called during cleanup before the native handle is freed. + The default implementation does nothing. + """ + + def _mark_consumed(self): + """Mark as consumed by an FFI call that took ownership + of native resources e.g. pointers. This means we should not + call clean-up here anymore, and leave it to the new owner. + """ + + self._handle = None + self._lifecycle_state = LifecycleState.CLOSED + + def _cleanup_resources(self): + """Release native resources idempotently.""" + try: + if ( + hasattr(self, '_lifecycle_state') + and self._lifecycle_state != LifecycleState.CLOSED + ): + self._lifecycle_state = LifecycleState.CLOSED + self._release() + if hasattr(self, '_handle') and self._handle: + try: + ManagedResource._free_native_ptr(self._handle) + except Exception: + logger.error( + "Failed to free native %s resources", + type(self).__name__, + ) + finally: + self._handle = None + except Exception: + pass + + @property + def is_valid(self) -> bool: + """Check if the resource is in a valid (active) state.""" + return ( + self._lifecycle_state == LifecycleState.ACTIVE + and self._handle is not None + ) + + def close(self) -> None: + """Release the resource (idempotent, never raises).""" + self._cleanup_resources() + + def __enter__(self): + """For classes with context manager (with) pattern""" + self._ensure_valid_state() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """For classes with context manager (with) pattern""" + self.close() + + def __del__(self): + """Free native resources if close() was not called.""" + self._cleanup_resources() + + # Mapping from C2paSigningAlg enum to string representation, # as the enum value currently maps by default to an integer value. _ALG_TO_STRING_BYTES_MAPPING = { @@ -360,6 +494,21 @@ class C2paBuilder(ctypes.Structure): """Opaque structure for builder context.""" _fields_ = [] # Empty as it's opaque in the C API + +class C2paSettings(ctypes.Structure): + """Opaque structure for settings context.""" + _fields_ = [] # Empty as it's opaque in the C API + + +class C2paContextBuilder(ctypes.Structure): + """Opaque structure for context builder.""" + _fields_ = [] # Empty as it's opaque in the C API + + +class C2paContext(ctypes.Structure): + """Opaque structure for context.""" + _fields_ = [] # Empty as it's opaque in the C API + # Helper function to set function prototypes @@ -405,7 +554,6 @@ def _setup_function(func, argtypes, restype=None): _lib.c2pa_reader_from_manifest_data_and_stream, [ ctypes.c_char_p, ctypes.POINTER(C2paStream), ctypes.POINTER( ctypes.c_ubyte), ctypes.c_size_t], ctypes.POINTER(C2paReader)) -_setup_function(_lib.c2pa_reader_free, [ctypes.POINTER(C2paReader)], None) _setup_function( _lib.c2pa_reader_json, [ ctypes.POINTER(C2paReader)], ctypes.c_void_p) @@ -437,7 +585,10 @@ def _setup_function(func, argtypes, restype=None): _setup_function(_lib.c2pa_builder_from_archive, [ctypes.POINTER(C2paStream)], ctypes.POINTER(C2paBuilder)) -_setup_function(_lib.c2pa_builder_free, [ctypes.POINTER(C2paBuilder)], None) +_setup_function( + _lib.c2pa_builder_with_archive, + [ctypes.POINTER(C2paBuilder), ctypes.POINTER(C2paStream)], + ctypes.POINTER(C2paBuilder)) _setup_function(_lib.c2pa_builder_set_no_embed, [ ctypes.POINTER(C2paBuilder)], None) _setup_function( @@ -473,21 +624,6 @@ def _setup_function(func, argtypes, restype=None): _lib.c2pa_manifest_bytes_free, [ ctypes.POINTER( ctypes.c_ubyte)], None) -_setup_function( - _lib.c2pa_builder_data_hashed_placeholder, [ - ctypes.POINTER(C2paBuilder), ctypes.c_size_t, ctypes.c_char_p, - ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)) - ], - ctypes.c_int64, -) -_setup_function(_lib.c2pa_builder_sign_data_hashed_embeddable, - [ctypes.POINTER(C2paBuilder), - ctypes.POINTER(C2paSigner), - ctypes.c_char_p, - ctypes.c_char_p, - ctypes.POINTER(C2paStream), - ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], - ctypes.c_int64) _setup_function( _lib.c2pa_format_embeddable, [ ctypes.c_char_p, ctypes.POINTER( @@ -515,7 +651,6 @@ def _setup_function(func, argtypes, restype=None): _setup_function( _lib.c2pa_signer_reserve_size, [ ctypes.POINTER(C2paSigner)], ctypes.c_int64) -_setup_function(_lib.c2pa_signer_free, [ctypes.POINTER(C2paSigner)], None) _setup_function( _lib.c2pa_ed25519_sign, [ ctypes.POINTER( @@ -531,6 +666,89 @@ def _setup_function(func, argtypes, restype=None): ctypes.POINTER(ctypes.c_char_p) ) +# Set up Settings function prototypes +_setup_function(_lib.c2pa_settings_new, [], ctypes.POINTER(C2paSettings)) +_setup_function( + _lib.c2pa_settings_set_value, + [ctypes.POINTER(C2paSettings), ctypes.c_char_p, ctypes.c_char_p], + ctypes.c_int +) +_setup_function( + _lib.c2pa_settings_update_from_string, + [ctypes.POINTER(C2paSettings), ctypes.c_char_p, ctypes.c_char_p], + ctypes.c_int +) + +# Set up ContextBuilder function prototypes +_setup_function( + _lib.c2pa_context_builder_new, + [], + ctypes.POINTER(C2paContextBuilder) +) +_setup_function( + _lib.c2pa_context_builder_set_settings, + [ctypes.POINTER(C2paContextBuilder), ctypes.POINTER(C2paSettings)], + ctypes.c_int +) +_setup_function( + _lib.c2pa_context_builder_build, + [ctypes.POINTER(C2paContextBuilder)], + ctypes.POINTER(C2paContext) +) + +# Set up Context function prototypes +_setup_function(_lib.c2pa_context_new, [], ctypes.POINTER(C2paContext)) +_setup_function( + _lib.c2pa_reader_from_context, + [ctypes.POINTER(C2paContext)], + ctypes.POINTER(C2paReader) +) +_setup_function( + _lib.c2pa_reader_with_stream, + [ctypes.POINTER(C2paReader), ctypes.c_char_p, + ctypes.POINTER(C2paStream)], + ctypes.POINTER(C2paReader) +) +_setup_function( + _lib.c2pa_reader_with_manifest_data_and_stream, + [ctypes.POINTER(C2paReader), ctypes.c_char_p, + ctypes.POINTER(C2paStream), + ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t], + ctypes.POINTER(C2paReader), +) +_setup_function( + _lib.c2pa_reader_with_fragment, + [ctypes.POINTER(C2paReader), ctypes.c_char_p, + ctypes.POINTER(C2paStream), ctypes.POINTER(C2paStream)], + ctypes.POINTER(C2paReader) +) +_setup_function( + _lib.c2pa_builder_from_context, + [ctypes.POINTER(C2paContext)], + ctypes.POINTER(C2paBuilder) +) +_setup_function( + _lib.c2pa_builder_with_definition, + [ctypes.POINTER(C2paBuilder), ctypes.c_char_p], + ctypes.POINTER(C2paBuilder) +) +_setup_function(_lib.c2pa_free, [ctypes.c_void_p], ctypes.c_int) + +_setup_function( + _lib.c2pa_context_builder_set_signer, + [ctypes.POINTER(C2paContextBuilder), ctypes.POINTER(C2paSigner)], + ctypes.c_int +) +_setup_function( + _lib.c2pa_builder_sign_context, + [ctypes.POINTER(C2paBuilder), + ctypes.c_char_p, + ctypes.POINTER(C2paStream), + ctypes.POINTER(C2paStream), + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64 +) + class C2paError(Exception): """Exception raised for C2PA errors. @@ -793,6 +1011,62 @@ def _parse_operation_result_for_error( return None +def _check_ffi_operation_result(result, fallback_msg, *, check=lambda r: not r): + """Check an FFI native call result and raise C2paError if it indicates failure. + + Args: + result: The return value from the FFI call + fallback_msg: Error message if the native library has no error details + check: Predicate that returns True when the result indicates failure. + Defaults to `not r` (for pointer-returning calls). + Use `lambda r: r != 0` for status-code-returning calls. + Use `lambda r: r < 0` for signed-result calls. + + Returns: + The result unchanged, if the check passed. + + Raises: + C2paError: If the check indicates failure + """ + if check(result): + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError(fallback_msg) + return result + + +def _to_utf8_bytes(data: Union[str, dict], error_context: str = "input") -> bytes: + """Convert a string or dict to UTF-8 bytes. + + If data is a dict, it is serialized to JSON first. + + Args: + data: String or dict to encode. + error_context: Description for error messages. + + Returns: + UTF-8 encoded bytes. + + Raises: + C2paError.Json: If dict serialization fails. + C2paError.Encoding: If UTF-8 encoding fails or data is not a supported type. + """ + if isinstance(data, dict): + try: + data = json.dumps(data) + except (TypeError, ValueError) as e: + raise C2paError.Json(f"Failed to serialize {error_context}: {e}") + if not isinstance(data, str): + raise C2paError.Encoding( + f"Expected str or dict for {error_context}, got {type(data).__name__}" + ) + try: + return data.encode('utf-8') + except UnicodeError as e: + raise C2paError.Encoding(f"Invalid UTF-8 in {error_context}: {e}") + + def sdk_version() -> str: """ Returns the underlying c2pa-rs/c2pa-c-ffi version string @@ -830,7 +1104,14 @@ def load_settings(settings: dict) -> None: def load_settings(settings: Union[str, dict], format: str = "json") -> None: - """Load C2PA settings from a string or dict. + """Load C2PA settings into thread-local storage from a string or dict. + + .. deprecated:: + Use :class:`Settings` and :class:`Context` for + per-instance configuration instead. Settings and + Context will propagate configurations through object instances, + no thread-local configurations. Avoid mixing Context APIs + and legacy load_settings usage. Args: settings: The settings string or dict to load @@ -840,6 +1121,12 @@ def load_settings(settings: Union[str, dict], format: str = "json") -> None: Raises: C2paError: If there was an error loading the settings """ + warnings.warn( + "load_settings() is deprecated. Use Settings" + " and Context for per-instance configuration.", + DeprecationWarning, + stacklevel=2, + ) _clear_error_state() # Convert to JSON string as necessary @@ -859,11 +1146,7 @@ def load_settings(settings: Union[str, dict], format: str = "json") -> None: raise C2paError(f"Failed to encode settings to UTF-8: {e}") result = _lib.c2pa_load_settings(settings_bytes, format_bytes) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error loading settings") + _check_ffi_operation_result(result, "Error loading settings", check=lambda r: r != 0) return result @@ -936,13 +1219,7 @@ def read_ingredient_file( result = _lib.c2pa_read_ingredient_file( container._path_str, container._data_dir_str) - if result is None: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - "Error reading ingredient file {}".format(path) - ) + _check_ffi_operation_result(result, "Error reading ingredient file {}".format(path)) return _convert_to_py_string(result) @@ -981,13 +1258,7 @@ def read_file(path: Union[str, Path], container._data_dir_str = str(data_dir).encode('utf-8') result = _lib.c2pa_read_file(container._path_str, container._data_dir_str) - if result is None: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error is not None: - raise C2paError(error) - raise C2paError.Other( - "Error during read of manifest from file {}".format(path) - ) + _check_ffi_operation_result(result, "Error during read of manifest from file {}".format(path)) return _convert_to_py_string(result) @@ -1107,120 +1378,456 @@ def sign_file( signer.close() -class Stream: - # Class-level somewhat atomic counter for generating - # unique stream IDs (useful for tracing streams usage in debug) - _stream_id_counter = count(start=0, step=1) - - # Maximum value for a 32-bit signed integer (2^31 - 1) - _MAX_STREAM_ID = 2**31 - 1 +class ContextProvider(ABC): + """Abstract base class for types that provide a C2PA context. - # Class-level error messages to avoid multiple creation - _ERROR_MESSAGES = { - 'stream_error': "Error cleaning up stream: {}", - 'callback_error': "Error cleaning up callback {}: {}", - 'cleanup_error': "Error during cleanup: {}", - 'read': "Stream is closed or not initialized during read operation", - 'memory_error': "Memory error during stream operation: {}", - 'read_error': "Error during read operation: {}", - 'seek': "Stream is closed or not initialized during seek operation", - 'seek_error': "Error during seek operation: {}", - 'write': "Stream is closed or not initialized during write operation", - 'write_error': "Error during write operation: {}", - 'flush': "Stream is closed or not initialized during flush operation", - 'flush_error': "Error during flush operation: {}" - } + Subclass to implement a custom context provider. + The built-in Context class is the standard implementation. + """ - def __init__(self, file_like_stream): - """Initialize a new Stream wrapper around a file-like object - (or an in-memory stream). + @property + @abstractmethod + def is_valid(self) -> bool: + """Whether this provider is in a usable state. - Args: - file_like_stream: A file-like stream object or an in-memory stream - that implements read, write, seek, tell, and flush methods + Return True when the underlying native context is active + and its handle has not been freed or consumed. Return + False after the provider has been closed or invalidated. - Raises: - TypeError: The file stream object doesn't - implement all required methods + The ManagedResource base class provides a standard + implementation that checks lifecycle state and handle + presence. """ - # Initialize _closed first to prevent AttributeError - # during garbage collection - self._closed = False - self._initialized = False - self._stream = None + ... - # Generate unique stream ID using object ID and counter - stream_counter = next(Stream._stream_id_counter) + @property + @abstractmethod + def execution_context(self): + """Return the raw native C2paContext pointer. - # Handle counter overflow by resetting the counter - if stream_counter >= Stream._MAX_STREAM_ID: # pragma: no cover - # Reset the counter to 0 and get the next value - Stream._stream_id_counter = count(start=0, step=1) - stream_counter = next(Stream._stream_id_counter) + The returned pointer must be valid for the duration of any + FFI call that uses it. Callers should check is_valid before + accessing this property. + """ + ... - self._stream_id = f"{id(self)}-{stream_counter}" - # Rest of the existing initialization code... - required_methods = ['read', 'write', 'seek', 'tell', 'flush'] - missing_methods = [ - method for method in required_methods if not hasattr( - file_like_stream, method)] - if missing_methods: - raise TypeError( - "Object must be a stream-like object with methods: {}. " - "Missing: {}".format( - ", ".join(required_methods), - ", ".join(missing_methods), - ) - ) +class Settings(ManagedResource): + """Configuration for C2PA operations. - self._file_like_stream = file_like_stream + Settings configure SDK behavior. Use with Context class to + apply settings to Reader/Builder operations. + """ - def read_callback(ctx, data, length): - """Callback function for reading data from the Python stream. - This function is called by C2PA library when it needs to read data. - It handles: - - Stream state validation - - Memory safety - - Error handling - - Buffer management + def __init__(self): + """Create new Settings with default values.""" + super().__init__() - Args: - ctx: The stream context (unused) - data: Pointer to the buffer to read into - length: Maximum number of bytes to read + ptr = _lib.c2pa_settings_new() + _check_ffi_operation_result(ptr, "Failed to create Settings") - Returns: - Number of bytes read, or -1 on error - """ - if not self._initialized or self._closed: - return -1 - try: - if not data or length <= 0: - return -1 + self._handle = ptr + self._lifecycle_state = LifecycleState.ACTIVE - buffer = self._file_like_stream.read(length) - if not buffer: # EOF - return 0 + @classmethod + def from_json(cls, json_str: str) -> 'Settings': + """Create Settings from a serialized JSON configuration string. - # Ensure we don't write beyond the allocated memory - actual_length = min(len(buffer), length) - # Direct memory copy - ctypes.memmove(data, buffer, actual_length) + Args: + json_str: JSON string with settings configuration. - return actual_length - except Exception: - return -1 + Returns: + A new Settings instance with the given configuration. + """ + settings = cls() + settings.update(json_str) + return settings - def seek_callback(ctx, offset, whence): - """Callback function for seeking in the Python stream. + @classmethod + def from_dict(cls, config: dict) -> 'Settings': + """Create Settings from a (JSON-based)dictionary. - This function is called by the C2PA library when it needs to change - the stream position. It handles: - - Stream state validation - - Position validation - - Error handling + Args: + config: Dictionary with settings configuration. + + Returns: + A new Settings instance with the given configuration. + """ + return cls.from_json(json.dumps(config)) + + def set(self, path: str, value: str) -> 'Settings': + """Set a configuration value by dot-notation path. + + Args: + path: Dot-notation path (e.g. "builder.thumbnail.enabled"). + value: The value to set. + + Returns: + self, for method chaining. + """ + self._ensure_valid_state() + + path_bytes = _to_utf8_bytes(path, "settings path") + value_bytes = _to_utf8_bytes(value, "settings value") + + result = _lib.c2pa_settings_set_value( + self._handle, path_bytes, value_bytes + ) + if result != 0: + _parse_operation_result_for_error(None) + + return self + + def update( + self, data: Union[str, dict], + ) -> 'Settings': + """Update current configuration from a JSON string or dict. + If the updated string overwrite an existing settings value, + the last setting value set for that property wins. + + Args: + data: A JSON string or dict with configuration to merge. + + Returns: + self, for method chaining. + """ + self._ensure_valid_state() + + data_bytes = _to_utf8_bytes(data, "settings data") + + result = _lib.c2pa_settings_update_from_string( + self._handle, data_bytes, b"json" + ) + if result != 0: + _parse_operation_result_for_error(None) + + return self + + @property + def _c_settings(self): + """Expose the raw pointer for Context to consume.""" + self._ensure_valid_state() + return self._handle + + + +class ContextBuilder: + """Fluent builder for Context. + + Use Context.builder() to create an instance. + """ + + def __init__(self): + self._settings = None + self._signer = None + + def with_settings( + self, settings: 'Settings', + ) -> 'ContextBuilder': + """Attach Settings to the Context being built. + + Can be called multiple times, but each call replaces + the previous Settings object entirely (the last one wins). + To merge multiple configurations, use Settings.update() + on a single Settings instance before passing it in. + + Args: + settings: The Settings instance to use. + + Returns: + self, for method chaining. + """ + self._settings = settings + return self + + def with_signer( + self, signer: 'Signer', + ) -> 'ContextBuilder': + """Attach a Signer (will be consumed on build).""" + self._signer = signer + return self + + def build(self) -> 'Context': + """Build and return a configured Context.""" + return Context( + settings=self._settings, + signer=self._signer, + ) + + +class Context(ManagedResource, ContextProvider): + """Per-instance context for C2PA operations. + + A Context may carry Settings and a Signer, + and is passed to Reader or Builder to control their behavior, + thus propagating settings and configurations by passing + the Context object (+settings on it) as parameter. + + When a Signer is provided, the Signer object is consumed, + as it becomes included into the Context, and must not be + used directly again after that. + """ + + + def __init__( + self, + settings: Optional['Settings'] = None, + signer: Optional['Signer'] = None, + ): + """Create a Context. + + Args: + settings: Optional Settings for configuration. + If None, default SDK settings are used. + signer: Optional Signer. If provided it is consumed + and must not be used directly again after that call. + + Raises: + C2paError: If creation fails + """ + super().__init__() + self._has_signer = False + self._signer_callback_cb = None + + if settings is None and signer is None: + # Simple default context + ptr = _lib.c2pa_context_new() + _check_ffi_operation_result( + ptr, "Failed to create Context" + ) + self._handle = ptr + else: + # Use ContextBuilder for settings/signer + builder_ptr = _lib.c2pa_context_builder_new() + _check_ffi_operation_result( + builder_ptr, "Failed to create ContextBuilder" + ) + + try: + if settings is not None: + result = ( + _lib.c2pa_context_builder_set_settings( + builder_ptr, settings._c_settings, + ) + ) + if result != 0: + _parse_operation_result_for_error(None) + + if signer is not None: + signer._ensure_valid_state() + result = ( + _lib.c2pa_context_builder_set_signer( + builder_ptr, signer._handle, + ) + ) + if result != 0: + _parse_operation_result_for_error(None) + + # Build consumes builder_ptr + ptr = ( + _lib.c2pa_context_builder_build(builder_ptr) + ) + builder_ptr = None + self._handle = ptr + + _check_ffi_operation_result( + ptr, "Failed to build Context" + ) + + # Build succeeded, consume the Signer. + # Keep its callback ref alive on this Context, + # then mark it so it won't double-free the + # pointer the Context now owns. + if signer is not None: + self._signer_callback_cb = signer._callback_cb + signer._mark_consumed() + self._has_signer = True + except Exception: + # Free builder if build was not reached + if builder_ptr is not None: + try: + ManagedResource._free_native_ptr(builder_ptr) + except Exception: + pass + raise + + self._lifecycle_state = LifecycleState.ACTIVE + + def _release(self): + """Release Context-specific resources.""" + self._signer_callback_cb = None + + @classmethod + def builder(cls) -> 'ContextBuilder': + """Return a fluent ContextBuilder.""" + return ContextBuilder() + + @classmethod + def from_json( + cls, + json_str: str, + signer: Optional['Signer'] = None, + ) -> 'Context': + """Create Context from a JSON configuration string. + + Args: + json_str: JSON string with settings config. + signer: Optional Signer (consumed if provided). + + Returns: + A new Context instance. + """ + settings = Settings.from_json(json_str) + try: + return cls(settings=settings, signer=signer) + finally: + settings.close() + + @classmethod + def from_dict( + cls, + config: dict, + signer: Optional['Signer'] = None, + ) -> 'Context': + """Create Context from a dictionary. + + Args: + config: Dictionary with settings configuration. + signer: Optional Signer (consumed if provided). + + Returns: + A new Context instance. + """ + return cls.from_json(json.dumps(config), signer=signer) + + @property + def has_signer(self) -> bool: + """Whether this context was created with a signer.""" + return self._has_signer + + @property + def execution_context(self): + """Return the raw C2paContext pointer.""" + self._ensure_valid_state() + return self._handle + + + +class Stream: + # Class-level somewhat atomic counter for generating + # unique stream IDs (useful for tracing streams usage in debug) + _stream_id_counter = count(start=0, step=1) + + # Maximum value for a 32-bit signed integer (2^31 - 1) + _MAX_STREAM_ID = 2**31 - 1 + + # Class-level error messages to avoid multiple creation + _ERROR_MESSAGES = { + 'stream_error': "Error cleaning up stream: {}", + 'callback_error': "Error cleaning up callback {}: {}", + 'cleanup_error': "Error during cleanup: {}", + 'read': "Stream is closed or not initialized during read operation", + 'memory_error': "Memory error during stream operation: {}", + 'read_error': "Error during read operation: {}", + 'seek': "Stream is closed or not initialized during seek operation", + 'seek_error': "Error during seek operation: {}", + 'write': "Stream is closed or not initialized during write operation", + 'write_error': "Error during write operation: {}", + 'flush': "Stream is closed or not initialized during flush operation", + 'flush_error': "Error during flush operation: {}" + } + + def __init__(self, file_like_stream): + """Initialize a new Stream wrapper around a file-like object + (or an in-memory stream). + + Args: + file_like_stream: A file-like stream object or an in-memory stream + that implements read, write, seek, tell, and flush methods + + Raises: + TypeError: The file stream object doesn't + implement all required methods + """ + # Initialize _closed first to prevent AttributeError + # during garbage collection + self._closed = False + self._initialized = False + self._stream = None + + # Generate unique stream ID using object ID and counter + stream_counter = next(Stream._stream_id_counter) + + # Handle counter overflow by resetting the counter + if stream_counter >= Stream._MAX_STREAM_ID: # pragma: no cover + # Reset the counter to 0 and get the next value + Stream._stream_id_counter = count(start=0, step=1) + stream_counter = next(Stream._stream_id_counter) + + self._stream_id = f"{id(self)}-{stream_counter}" + + # Rest of the existing initialization code... + required_methods = ['read', 'write', 'seek', 'tell', 'flush'] + missing_methods = [ + method for method in required_methods if not hasattr( + file_like_stream, method)] + if missing_methods: + raise TypeError( + "Object must be a stream-like object with methods: {}. " + "Missing: {}".format( + ", ".join(required_methods), + ", ".join(missing_methods), + ) + ) + + self._file_like_stream = file_like_stream + + def read_callback(ctx, data, length): + """Callback function for reading data from the Python stream. + + This function is called by C2PA library when it needs to read data. + It handles: + - Stream state validation + - Memory safety + - Error handling + - Buffer management + + Args: + ctx: The stream context (unused) + data: Pointer to the buffer to read into + length: Maximum number of bytes to read + + Returns: + Number of bytes read, or -1 on error + """ + if not self._initialized or self._closed: + return -1 + try: + if not data or length <= 0: + return -1 + + buffer = self._file_like_stream.read(length) + if not buffer: # EOF + return 0 + + # Ensure we don't write beyond the allocated memory + actual_length = min(len(buffer), length) + # Direct memory copy + ctypes.memmove(data, buffer, actual_length) + + return actual_length + except Exception: + return -1 + + def seek_callback(ctx, offset, whence): + """Callback function for seeking in the Python stream. + + This function is called by the C2PA library when it needs to change + the stream position. It handles: + - Stream state validation + - Position validation + - Error handling Args: ctx: The stream context (unused) @@ -1306,6 +1913,7 @@ def flush_callback(ctx): self._flush_cb = FlushCallback(flush_callback) # Create the stream + _clear_error_state() self._stream = _lib.c2pa_create_stream( None, self._read_cb, @@ -1423,7 +2031,89 @@ def initialized(self) -> bool: return self._initialized -class Reader: +def _get_supported_mime_types(ffi_func, cache): + """Shared helper to retrieve supported MIME types from the native library. + + Args: + ffi_func: The FFI function to call (e.g. _lib.c2pa_reader_supported_mime_types) + cache: The current cache value (frozenset or None) + + Returns: + A tuple of (list of MIME type strings, updated cache value) + """ + if cache is not None: + return list(cache), cache + + _clear_error_state() + count = ctypes.c_size_t() + arr = ffi_func(ctypes.byref(count)) + + if not arr: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(f"Failed to get supported MIME types: {error}") + return [], cache + + if count.value <= 0: + try: + _lib.c2pa_free_string_array(arr, count.value) + except Exception: + pass + return [], cache + + try: + result = [] + for i in range(count.value): + try: + if arr[i] is None: + continue + mime_type = arr[i].decode("utf-8", errors='replace') + if mime_type: + result.append(mime_type) + except Exception: + continue + finally: + try: + _lib.c2pa_free_string_array(arr, count.value) + except Exception: + pass + + if result: + cache = frozenset(result) + + if cache: + return list(cache), cache + return [], cache + + +def _validate_and_encode_format( + format_str: str, supported_types: list[str], class_name: str +) -> bytes: + """Validate a MIME type / format string and encode it to UTF-8 bytes. + + Args: + format_str: The MIME type or format string to validate + supported_types: List of supported MIME types + class_name: Name of the calling class (for error messages) + + Returns: + UTF-8 encoded format bytes + + Raises: + C2paError.NotSupported: If the format is not supported + C2paError.Encoding: If the string contains invalid UTF-8 characters + """ + if format_str.lower() not in supported_types: + raise C2paError.NotSupported( + f"{class_name} does not support {format_str}") + try: + return format_str.encode('utf-8') + except UnicodeError as e: + raise C2paError.Encoding( + f"Invalid UTF-8 characters in input: {e}") + + +class Reader(ManagedResource): """High-level wrapper for C2PA Reader operations. Example: @@ -1434,6 +2124,7 @@ class Reader: Where `output` is either an in-memory stream or an opened file. """ + # Supported mimetypes cache _supported_mime_types_cache = None @@ -1448,7 +2139,8 @@ class Reader: 'file_error': "Error cleaning up file: {}", 'reader_cleanup_error': "Error cleaning up reader: {}", 'encoding_error': "Invalid UTF-8 characters in input: {}", - 'closed_error': "Reader is closed" + 'closed_error': "Reader is closed", + 'fragment_error': "Failed to process fragment: {}" } @classmethod @@ -1462,63 +2154,52 @@ def get_supported_mime_types(cls) -> list[str]: Raises: C2paError: If there was an error retrieving the MIME types """ - if cls._supported_mime_types_cache is not None: - return cls._supported_mime_types_cache - - count = ctypes.c_size_t() - arr = _lib.c2pa_reader_supported_mime_types(ctypes.byref(count)) - - # Validate the returned array pointer - if not arr: - # If no array returned, check for errors - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(f"Failed to get supported MIME types: {error}") - # Return empty list if no error but no array - return [] + result, cls._supported_mime_types_cache = _get_supported_mime_types( + _lib.c2pa_reader_supported_mime_types, cls._supported_mime_types_cache + ) + return result - # Validate count value - if count.value <= 0: - # Free the array even if count is invalid - try: - _lib.c2pa_free_string_array(arr, count.value) - except Exception: - pass - return [] + @classmethod + def _is_mime_type_supported(cls, mime_type: str) -> bool: + """Check if a MIME type is supported. - try: - result = [] - for i in range(count.value): - try: - # Validate each array element before accessing - if arr[i] is None: - continue + Args: + mime_type: The MIME type to check - mime_type = arr[i].decode("utf-8", errors='replace') - if mime_type: - result.append(mime_type) - except Exception: - # Ignore cleanup errors - continue - finally: - # Always free the native memory, even if string extraction fails - try: - _lib.c2pa_free_string_array(arr, count.value) - except Exception: - # Ignore cleanup errors - pass + Returns: + True if the MIME type is supported + """ + if cls._supported_mime_types_cache is None: + cls.get_supported_mime_types() + return mime_type in cls._supported_mime_types_cache - # Cache the result - if result: - cls._supported_mime_types_cache = result + @classmethod + @overload + def try_create( + cls, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None, + ) -> Optional["Reader"]: ... - return cls._supported_mime_types_cache + @classmethod + @overload + def try_create( + cls, + format_or_path: Union[str, Path], + stream: Optional[Any], + manifest_data: Optional[Any], + context: 'ContextProvider', + ) -> Optional["Reader"]: ... @classmethod - def try_create(cls, - format_or_path: Union[str, Path], - stream: Optional[Any] = None, - manifest_data: Optional[Any] = None) -> Optional["Reader"]: + def try_create( + cls, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None, + context: Optional['ContextProvider'] = None, + ) -> Optional["Reader"]: """This is a factory method to create a new Reader, returning None if no manifest/c2pa data/JUMBF data could be read (instead of raising a ManifestNotFound: no JUMBF data found exception). @@ -1532,6 +2213,7 @@ def try_create(cls, format_or_path: The format or path to read from stream: Optional stream to read from (Python stream-like object) manifest_data: Optional manifest data in bytes + context: Optional ContextProvider for settings Returns: Reader instance if the asset contains C2PA data, @@ -1541,49 +2223,78 @@ def try_create(cls, C2paError: If there was an error other than ManifestNotFound """ try: - # Reader creations checks deferred to the constructor __init__ method - return cls(format_or_path, stream, manifest_data) + return cls( + format_or_path, stream, manifest_data, + context=context, + ) except C2paError.ManifestNotFound: - # Nothing to read, so no Reader returned return None - def __init__(self, - format_or_path: Union[str, - Path], - stream: Optional[Any] = None, - manifest_data: Optional[Any] = None): + @overload + def __init__( + self, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None, + ) -> None: ... + + @overload + def __init__( + self, + format_or_path: Union[str, Path], + stream: Optional[Any], + manifest_data: Optional[Any], + context: 'ContextProvider', + ) -> None: ... + + def __init__( + self, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None, + context: Optional['ContextProvider'] = None, + ): """Create a new Reader. Args: format_or_path: The format or path to read from stream: Optional stream to read from (Python stream-like object) manifest_data: Optional manifest data in bytes + context: Optional context implementing ContextProvider with settings Raises: C2paError: If there was an error creating the reader C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters """ - # Native libs plumbing: - # Clear any stale error state from previous operations - _clear_error_state() - - self._closed = False - self._initialized = False + super().__init__() - self._reader = None self._own_stream = None # This is used to keep track of a file # we may have opened ourselves, and that we need to close later self._backing_file = None - # Caches for manifest JSON string and parsed data + # Caches for manifest JSON string and parsed data. + # These are invalidated when with_fragment() is called, because each + # new BMFF fragment can refine or update the manifest content as the + # reader progressively builds its understanding of the fragmented stream. + # They are also cleared on close() to release memory. self._manifest_json_str_cache = None self._manifest_data_cache = None + self._context = context + + if context is not None: + self._init_from_context( + context, format_or_path, stream, + manifest_data, + ) + return + + supported = Reader.get_supported_mime_types() + if stream is None: - # If we don't get a stream as param: # Create a stream from the file path in format_or_path path = str(format_or_path) mime_type = _get_mime_type_from_path(path) @@ -1592,237 +2303,186 @@ def __init__(self, raise C2paError.NotSupported( f"Could not determine MIME type for file: {path}") - if mime_type not in Reader.get_supported_mime_types(): - raise C2paError.NotSupported( - f"Reader does not support {mime_type}") - - try: - mime_type_str = mime_type.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Reader._ERROR_MESSAGES['encoding_error'].format( - str(e))) - - try: - with open(path, 'rb') as file: - self._own_stream = Stream(file) - - self._reader = _lib.c2pa_reader_from_stream( - mime_type_str, - self._own_stream._stream - ) - - if not self._reader: - self._own_stream.close() - error = _parse_operation_result_for_error( - _lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Reader._ERROR_MESSAGES['reader_error'].format( - "Unknown error" - ) - ) - - # Store the file to close it later - self._backing_file = file - self._initialized = True + format_bytes = _validate_and_encode_format( + mime_type, supported, "Reader") + self._init_from_file(path, format_bytes) - except Exception as e: - # File automatically closed by context manager - if self._own_stream: - self._own_stream.close() - if hasattr(self, '_backing_file') and self._backing_file: - self._backing_file.close() - raise C2paError.Io( - Reader._ERROR_MESSAGES['io_error'].format( - str(e))) elif isinstance(stream, str): - # We may have gotten format + a file path - # If stream is a string, treat it as a path and try to open it - - # format_or_path is a format - format_lower = format_or_path.lower() - if format_lower not in Reader.get_supported_mime_types(): - raise C2paError.NotSupported( - f"Reader does not support {format_or_path}") - - try: - with open(stream, 'rb') as file: - self._own_stream = Stream(file) - - format_str = str(format_or_path) - format_bytes = format_str.encode('utf-8') - - if manifest_data is None: - self._reader = _lib.c2pa_reader_from_stream( - format_bytes, self._own_stream._stream) - else: - if not isinstance(manifest_data, bytes): - raise TypeError( - Reader._ERROR_MESSAGES['manifest_error']) - manifest_array = ( - ctypes.c_ubyte * - len(manifest_data))( - * - manifest_data) - self._reader = ( - _lib.c2pa_reader_from_manifest_data_and_stream( - format_bytes, - self._own_stream._stream, - manifest_array, - len(manifest_data), - ) - ) - - if not self._reader: - self._own_stream.close() - error = _parse_operation_result_for_error( - _lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Reader._ERROR_MESSAGES['reader_error'].format( - "Unknown error" - ) - ) + # stream is a file path, format_or_path is the format + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") + self._init_from_file( + stream, format_bytes, manifest_data) - self._backing_file = file - self._initialized = True - except Exception as e: - # File closed by context manager - if self._own_stream: - self._own_stream.close() - if hasattr(self, '_backing_file') and self._backing_file: - self._backing_file.close() - raise C2paError.Io( - Reader._ERROR_MESSAGES['io_error'].format( - str(e))) else: - # format_or_path is a format string - format_str = str(format_or_path) - if format_str.lower() not in Reader.get_supported_mime_types(): - raise C2paError.NotSupported( - f"Reader does not support {format_str}") - - # Use the provided stream - self._format_str = format_str.encode('utf-8') + # format_or_path is a format string, stream is a stream object + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") with Stream(stream) as stream_obj: - if manifest_data is None: - self._reader = _lib.c2pa_reader_from_stream( - self._format_str, stream_obj._stream) - else: - if not isinstance(manifest_data, bytes): - raise TypeError( - Reader._ERROR_MESSAGES['manifest_error']) - manifest_array = ( - ctypes.c_ubyte * - len(manifest_data))( - * - manifest_data) - self._reader = ( - _lib.c2pa_reader_from_manifest_data_and_stream( - self._format_str, - stream_obj._stream, - manifest_array, - len(manifest_data) - ) - ) - - if not self._reader: - error = _parse_operation_result_for_error( - _lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Reader._ERROR_MESSAGES['reader_error'].format( - "Unknown error" - ) - ) + self._create_reader( + format_bytes, stream_obj, manifest_data) + self._lifecycle_state = LifecycleState.ACTIVE - self._initialized = True + def _create_reader(self, format_bytes, stream_obj, + manifest_data=None): + """Create a Reader from a Stream. - def __enter__(self): - self._ensure_valid_state() - return self + Args: + format_bytes: UTF-8 encoded format/MIME type + stream_obj: A Stream instance + manifest_data: Optional manifest bytes + """ + if manifest_data is None: + self._handle = _lib.c2pa_reader_from_stream( + format_bytes, stream_obj._stream) + else: + if not isinstance(manifest_data, bytes): + raise TypeError(Reader._ERROR_MESSAGES['manifest_error']) + manifest_array = ( + ctypes.c_ubyte * + len(manifest_data))( + *manifest_data) + self._handle = ( + _lib.c2pa_reader_from_manifest_data_and_stream( + format_bytes, + stream_obj._stream, + manifest_array, + len(manifest_data), + ) + ) - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() + _check_ffi_operation_result(self._handle, + Reader._ERROR_MESSAGES['reader_error'].format("Unknown error") + ) - def __del__(self): - """Ensure resources are cleaned up if close() wasn't called. + def _init_from_file(self, path, format_bytes, + manifest_data=None): + """Open a file and create a reader from it. - This destructor handles cleanup without causing double frees. - It only cleans up if the object hasn't been explicitly closed. + Args: + path: File path to open + format_bytes: UTF-8 encoded format/MIME type + manifest_data: Optional manifest bytes """ - self._cleanup_resources() - - def _ensure_valid_state(self): - """Ensure the reader is in a valid state for operations. - - Raises: - C2paError: If the reader is closed, not initialized, or invalid + try: + self._backing_file = open(path, 'rb') + self._own_stream = Stream(self._backing_file) + self._create_reader(format_bytes, self._own_stream, manifest_data) + self._lifecycle_state = LifecycleState.ACTIVE + except C2paError: + self._close_streams() + raise + except Exception as e: + self._close_streams() + raise C2paError.Io( + Reader._ERROR_MESSAGES['io_error'].format(str(e))) + + def _init_from_context(self, context, format_or_path, + stream, manifest_data=None): + """Initialize Reader from a Context object implementing + the ContextProvider interface/abstract base class. """ - if self._closed: - raise C2paError(Reader._ERROR_MESSAGES['closed_error']) - if not self._initialized: - raise C2paError("Reader is not properly initialized") - if not self._reader: - raise C2paError(Reader._ERROR_MESSAGES['closed_error']) + if not context.is_valid: + raise C2paError("Context is not valid") - def _cleanup_resources(self): - """Internal cleanup method that releases native resources. + # Determine format and open stream + supported = Reader.get_supported_mime_types() - This method handles the actual cleanup logic and can be called - from both close() and __del__ without causing double frees. - """ - try: - # Only cleanup if not already closed and we have a valid reader - if hasattr(self, '_closed') and not self._closed: - self._closed = True + if stream is None: + path = str(format_or_path) + mime_type = _get_mime_type_from_path(path) + if not mime_type: + raise C2paError.NotSupported( + f"Could not determine MIME type for file: {path}") + format_bytes = _validate_and_encode_format( + mime_type, supported, "Reader") + self._backing_file = open(path, 'rb') + self._own_stream = Stream(self._backing_file) + elif isinstance(stream, str): + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") + self._backing_file = open(stream, 'rb') + self._own_stream = Stream(self._backing_file) + else: + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") + self._own_stream = Stream(stream) - # Clean up reader - if hasattr(self, '_reader') and self._reader: - try: - _lib.c2pa_reader_free(self._reader) - except Exception: - # Cleanup failure doesn't raise exceptions - logger.error( - "Failed to free native Reader resources" - ) - pass - finally: - self._reader = None + try: + # Create reader from context + reader_ptr = _lib.c2pa_reader_from_context( + context.execution_context, + ) + _check_ffi_operation_result(reader_ptr, + Reader._ERROR_MESSAGES[ + 'reader_error' + ].format("Unknown error") + ) - # Clean up stream - if hasattr(self, '_own_stream') and self._own_stream: - try: - self._own_stream.close() - except Exception: - # Cleanup failure doesn't raise exceptions - logger.error("Failed to close Reader stream") - pass - finally: - self._own_stream = None + if manifest_data is not None: + if not isinstance(manifest_data, bytes): + raise TypeError( + Reader._ERROR_MESSAGES[ + 'manifest_error']) + manifest_array = ( + ctypes.c_ubyte * + len(manifest_data))( + *manifest_data) + # Consume current reader, + # with manifest data and stream (C FFI pattern), + # to create a new one (switch out) + new_ptr = ( + _lib.c2pa_reader_with_manifest_data_and_stream( + reader_ptr, + format_bytes, + self._own_stream._stream, + manifest_array, + len(manifest_data), + ) + ) + # reader_ptr has been invalidated(consumed) + else: + # Consume reader with stream + new_ptr = _lib.c2pa_reader_with_stream( + reader_ptr, format_bytes, + self._own_stream._stream, + ) + # reader_ptr has been invalidated(consumed) - # Clean up backing file (if needed) - if self._backing_file: - try: - self._backing_file.close() - except Exception: - # Cleanup failure doesn't raise exceptions - logger.warning("Failed to close Reader backing file") - pass - finally: - self._backing_file = None + self._handle = new_ptr - # Reset initialized state after cleanup - self._initialized = False + _check_ffi_operation_result(new_ptr, + Reader._ERROR_MESSAGES[ + 'reader_error' + ].format("Unknown error") + ) + self._lifecycle_state = LifecycleState.ACTIVE except Exception: - # Ensure we don't raise exceptions during cleanup - pass + self._close_streams() + raise + + def _close_streams(self): + """Close owned stream and backing file if present.""" + if getattr(self, '_own_stream', None): + try: + self._own_stream.close() + except Exception: + logger.error("Failed to close Reader stream") + finally: + self._own_stream = None + if getattr(self, '_backing_file', None): + try: + self._backing_file.close() + except Exception: + logger.warning("Failed to close Reader backing file") + finally: + self._backing_file = None + + def _release(self): + """Release Reader-specific resources (stream, backing file).""" + self._close_streams() def _get_cached_manifest_data(self) -> Optional[dict]: """Get the cached manifest data, fetching and parsing if not cached. @@ -1851,30 +2511,61 @@ def _get_cached_manifest_data(self) -> Optional[dict]: return self._manifest_data_cache - def close(self): - """Release the reader resources. + def with_fragment(self, format: str, stream, + fragment_stream) -> "Reader": + """Process a BMFF fragment stream with this reader. - This method ensures all resources are properly cleaned up, - even if errors occur during cleanup. - Errors during cleanup are logged but not raised to ensure cleanup. - Multiple calls to close() are handled gracefully. + Used for fragmented BMFF media (DASH/HLS streaming) where + content is split into init segments and fragment files. + + Args: + format: MIME type of the media (e.g., "video/mp4") + stream: Stream-like object with the main/init segment data + fragment_stream: Stream-like object with the fragment data + + Returns: + This reader instance, for method chaining. + + Raises: + C2paError: If there was an error processing the fragment """ - if self._closed: - return + self._ensure_valid_state() - try: - # Use the internal cleanup method - self._cleanup_resources() - except Exception as e: - # Log any unexpected errors during close - logger.error( - Reader._ERROR_MESSAGES['cleanup_error'].format( - str(e))) - finally: - # Clear the cache when closing - self._manifest_json_str_cache = None - self._manifest_data_cache = None - self._closed = True + supported = Reader.get_supported_mime_types() + format_bytes = _validate_and_encode_format( + format, supported, "Reader" + ) + + with Stream(stream) as main_obj, Stream(fragment_stream) as frag_obj: + new_ptr = _lib.c2pa_reader_with_fragment( + self._handle, + format_bytes, + main_obj._stream, + frag_obj._stream, + ) + + if not new_ptr: + self._mark_consumed() + _check_ffi_operation_result(new_ptr, + Reader._ERROR_MESSAGES[ + 'fragment_error' + ].format("Unknown error")) + self._handle = new_ptr + + # Invalidate caches: processing a new BMFF fragment updates the native + # reader's state, which can change the manifest data it returns. + # The cached JSON string and parsed dict may now be stale, so clear + # them to force a fresh read from the native layer on next access. + self._manifest_json_str_cache = None + self._manifest_data_cache = None + + return self + + def close(self): + """Release the reader resources.""" + self._manifest_json_str_cache = None + self._manifest_data_cache = None + super().close() def json(self) -> str: """Get the manifest store as a JSON string. @@ -1892,13 +2583,9 @@ def json(self) -> str: if self._manifest_json_str_cache is not None: return self._manifest_json_str_cache - result = _lib.c2pa_reader_json(self._reader) - - if result is None: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error during manifest parsing in Reader") + result = _lib.c2pa_reader_json(self._handle) + _check_ffi_operation_result(result, + "Error during manifest parsing in Reader") # Cache the result and return it self._manifest_json_str_cache = _convert_to_py_string(result) @@ -1922,16 +2609,30 @@ def detailed_json(self) -> str: self._ensure_valid_state() - result = _lib.c2pa_reader_detailed_json(self._reader) - - if result is None: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error during detailed manifest parsing in Reader") + result = _lib.c2pa_reader_detailed_json(self._handle) + _check_ffi_operation_result(result, + "Error during detailed manifest parsing in Reader") return _convert_to_py_string(result) + def _get_manifest_field(self, extractor): + """Extract a field from (cached) manifest data, or None if unavailable. + + Args: + extractor: A callable that takes the parsed manifest dict + and returns the desired field value. + + Returns: + Extracted field value, or None if not available. + """ + try: + data = self._get_cached_manifest_data() + if data is None: + return None + return extractor(data) + except C2paError.ManifestNotFound: + return None + def get_active_manifest(self) -> Optional[dict]: """Get the active manifest from the manifest store. @@ -1946,30 +2647,18 @@ def get_active_manifest(self) -> Optional[dict]: Raises: KeyError: If the active_manifest key is missing from the JSON """ - try: - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() - if manifest_data is None: - # raise C2paError("Failed to parse manifest JSON") - return None - - # Get the active manfiest id/label - if "active_manifest" not in manifest_data: + def _extract(data): + if "active_manifest" not in data: raise KeyError("No 'active_manifest' key found") - - active_manifest_id = manifest_data["active_manifest"] - - # Retrieve the active manifest data using manifest id/label - if "manifests" not in manifest_data: + active_manifest_id = data["active_manifest"] + if "manifests" not in data: raise KeyError("No 'manifests' key found in manifest data") - - manifests = manifest_data["manifests"] + manifests = data["manifests"] if active_manifest_id not in manifests: raise KeyError("Active manifest not found in manifest store") - return manifests[active_manifest_id] - except C2paError.ManifestNotFound: - return None + + return self._get_manifest_field(_extract) def get_manifest(self, label: str) -> Optional[dict]: """Get a specific manifest from the manifest store by its label. @@ -1988,23 +2677,15 @@ def get_manifest(self, label: str) -> Optional[dict]: Raises: KeyError: If the manifests key is missing from the JSON """ - try: - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() - if manifest_data is None: - # raise C2paError("Failed to parse manifest JSON") - return None - - if "manifests" not in manifest_data: + def _extract(data): + if "manifests" not in data: raise KeyError("No 'manifests' key found in manifest data") - - manifests = manifest_data["manifests"] + manifests = data["manifests"] if label not in manifests: raise KeyError(f"Manifest {label} not found in manifest store") - return manifests[label] - except C2paError.ManifestNotFound: - return None + + return self._get_manifest_field(_extract) def get_validation_state(self) -> Optional[str]: """Get the validation state of the manifest store. @@ -2018,15 +2699,7 @@ def get_validation_state(self) -> Optional[str]: or None if the validation_state field is not present or if no manifest is found or if there was an error parsing the JSON. """ - try: - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() - if manifest_data is None: - return None - - return manifest_data.get("validation_state") - except C2paError.ManifestNotFound: - return None + return self._get_manifest_field(lambda d: d.get("validation_state")) def get_validation_results(self) -> Optional[dict]: """Get the validation results of the manifest store. @@ -2041,15 +2714,7 @@ def get_validation_results(self) -> Optional[dict]: field is not present or if no manifest is found or if there was an error parsing the JSON. """ - try: - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() - if manifest_data is None: - return None - - return manifest_data.get("validation_results") - except C2paError.ManifestNotFound: - return None + return self._get_manifest_field(lambda d: d.get("validation_results")) def resource_to_stream(self, uri: str, stream: Any) -> int: """Write a resource to a stream. @@ -2069,15 +2734,11 @@ def resource_to_stream(self, uri: str, stream: Any) -> int: uri_str = uri.encode('utf-8') with Stream(stream) as stream_obj: result = _lib.c2pa_reader_resource_to_stream( - self._reader, uri_str, stream_obj._stream) - - if result < 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError.Other( - "Error during resource {} to stream conversion".format(uri) - ) + self._handle, uri_str, stream_obj._stream) + + _check_ffi_operation_result(result, + "Error during resource {} to stream conversion".format(uri), + check=lambda r: r < 0) return result @@ -2093,7 +2754,7 @@ def is_embedded(self) -> bool: """ self._ensure_valid_state() - result = _lib.c2pa_reader_is_embedded(self._reader) + result = _lib.c2pa_reader_is_embedded(self._handle) return bool(result) @@ -2110,7 +2771,7 @@ def get_remote_url(self) -> Optional[str]: """ self._ensure_valid_state() - result = _lib.c2pa_reader_remote_url(self._reader) + result = _lib.c2pa_reader_remote_url(self._handle) if result is None: # No remote URL set (manifest is embedded) @@ -2121,9 +2782,10 @@ def get_remote_url(self) -> Optional[str]: return url_str -class Signer: +class Signer(ManagedResource): """High-level wrapper for C2PA Signer operations.""" + # Class-level error messages to avoid multiple creation _ERROR_MESSAGES = { 'closed_error': "Signer is closed", @@ -2156,13 +2818,8 @@ def from_info(cls, signer_info: C2paSignerInfo) -> 'Signer': signer_ptr = _lib.c2pa_signer_from_info(ctypes.byref(signer_info)) - if not signer_ptr: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - # More detailed error message when possible - raise C2paError(error) - raise C2paError( - "Failed to create signer from configured signer_info") + _check_ffi_operation_result(signer_ptr, + "Failed to create signer from configured signer_info") return cls(signer_ptr) @@ -2295,130 +2952,43 @@ def wrapped_callback( tsa_url_bytes ) - if not signer_ptr: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to create signer") + _check_ffi_operation_result(signer_ptr, + "Failed to create signer") # Create and return the signer instance with the callback - signer_instance = cls(signer_ptr) - - # Keep callback alive on the object to prevent gc of the callback - # So the callback will live as long as the signer leaves, - # and there is a 1:1 relationship between signer and callback. - signer_instance._callback_cb = callback_cb - - return signer_instance - - def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): - """Initialize a new Signer instance. - - Note: This constructor is not meant to be called directly. - Use from_info() or from_callback() instead. - - Args: - signer_ptr: Pointer to the native C2PA signer - - Raises: - C2paError: If the signer pointer is invalid - """ - # Native libs plumbing: - # Clear any stale error state from previous operations - _clear_error_state() - - # Validate pointer before assignment - if not signer_ptr: - raise C2paError("Invalid signer pointer: pointer is null") - - self._signer = signer_ptr - self._closed = False - - # Set only for signers which are callback signers - self._callback_cb = None - - def __enter__(self): - """Context manager entry.""" - self._ensure_valid_state() - - if not self._signer: - raise C2paError("Invalid signer pointer: pointer is null") - - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit.""" - self.close() - - def _cleanup_resources(self): - """Internal cleanup method that releases native resources. - - This method handles the actual cleanup logic and can be called - from both close() and __del__ without causing double frees. - """ - try: - if not self._closed and self._signer: - self._closed = True - - try: - _lib.c2pa_signer_free(self._signer) - except Exception: - # Log cleanup errors but don't raise exceptions - logger.error("Failed to free native Signer resources") - finally: - self._signer = None + signer_instance = cls(signer_ptr) - # Clean up callback reference - if self._callback_cb: - self._callback_cb = None + # Keep callback alive on the object to prevent gc of the callback + # So the callback will live as long as the signer leaves, + # and there is a 1:1 relationship between signer and callback. + signer_instance._callback_cb = callback_cb - except Exception: - # Ensure we don't raise exceptions during cleanup - pass + return signer_instance - def _ensure_valid_state(self): - """Ensure the signer is in a valid state for operations. + def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): + """Initialize a new Signer instance. This constructor is not meant + to be called directly. Use from_info() or from_callback() instead. + + Args: + signer_ptr: Pointer to the native C2PA signer Raises: - C2paError: If the signer is closed or invalid + C2paError: If the signer pointer is invalid """ - if self._closed: - raise C2paError(Signer._ERROR_MESSAGES['closed_error']) - if not self._signer: - raise C2paError(Signer._ERROR_MESSAGES['closed_error']) - - def close(self): - """Release the signer resources. + super().__init__() - This method ensures all resources are properly cleaned up, - even if errors occur during cleanup. + self._callback_cb = None - Note: - Multiple calls to close() are handled gracefully. - Errors during cleanup are logged but not raised - to ensure cleanup. - """ - if self._closed: - return + if not signer_ptr: + raise C2paError("Invalid signer pointer: pointer is null") - try: - # Validate pointer before cleanup if it exists - if self._signer and self._signer != 0: - # Use the internal cleanup method - self._cleanup_resources() - else: - # Make sure to release the callback - if self._callback_cb: - self._callback_cb = None + self._handle = signer_ptr + self._lifecycle_state = LifecycleState.ACTIVE - except Exception as e: - # Log any unexpected errors during close - logger.error( - Signer._ERROR_MESSAGES['cleanup_error'].format( - str(e))) - finally: - # Always mark as closed, regardless of cleanup success - self._closed = True + def _release(self): + """Release Signer-specific resources (callback reference).""" + if self._callback_cb: + self._callback_cb = None def reserve_size(self) -> int: """Get the size to reserve for signatures from this signer. @@ -2431,20 +3001,18 @@ def reserve_size(self) -> int: """ self._ensure_valid_state() - result = _lib.c2pa_signer_reserve_size(self._signer) + result = _lib.c2pa_signer_reserve_size(self._handle) - if result < 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to get reserve size") + _check_ffi_operation_result(result, + "Failed to get reserve size", check=lambda r: r < 0) return result -class Builder: +class Builder(ManagedResource): """High-level wrapper for C2PA Builder operations.""" + # Supported mimetypes cache _supported_mime_types_cache = None @@ -2476,64 +3044,37 @@ def get_supported_mime_types(cls) -> list[str]: Raises: C2paError: If there was an error retrieving the MIME types """ - if cls._supported_mime_types_cache is not None: - return cls._supported_mime_types_cache - - count = ctypes.c_size_t() - arr = _lib.c2pa_builder_supported_mime_types(ctypes.byref(count)) - - # Validate the returned array pointer - if not arr: - # If no array returned, check for errors - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(f"Failed to get supported MIME types: {error}") - # Return empty list if no error but no array - return [] - - # Validate count value - if count.value <= 0: - # Free the array even if count is invalid - try: - _lib.c2pa_free_string_array(arr, count.value) - except Exception: - pass - return [] - - try: - result = [] - for i in range(count.value): - try: - # Validate each array element before accessing - if arr[i] is None: - continue - - mime_type = arr[i].decode("utf-8", errors='replace') - if mime_type: - result.append(mime_type) - except Exception: - # Ignore decoding failures - continue - finally: - # Always free the native memory, even if string extraction fails - try: - _lib.c2pa_free_string_array(arr, count.value) - except Exception: - # Ignore cleanup errors - pass + result, cls._supported_mime_types_cache = _get_supported_mime_types( + _lib.c2pa_builder_supported_mime_types, cls._supported_mime_types_cache + ) + return result - # Cache the result - if result: - cls._supported_mime_types_cache = result + @classmethod + @overload + def from_json( + cls, + manifest_json: Any, + ) -> 'Builder': ... - return cls._supported_mime_types_cache + @classmethod + @overload + def from_json( + cls, + manifest_json: Any, + context: 'ContextProvider', + ) -> 'Builder': ... @classmethod - def from_json(cls, manifest_json: Any) -> 'Builder': + def from_json( + cls, + manifest_json: Any, + context: Optional['ContextProvider'] = None, + ) -> 'Builder': """Create a new Builder from a JSON manifest. Args: manifest_json: The JSON manifest definition + context: Optional ContextProvider for settings Returns: A new Builder instance @@ -2541,163 +3082,132 @@ def from_json(cls, manifest_json: Any) -> 'Builder': Raises: C2paError: If there was an error creating the builder """ - return cls(manifest_json) + return cls(manifest_json, context=context) @classmethod - def from_archive(cls, stream: Any) -> 'Builder': + def from_archive( + cls, + stream: Any, + ) -> 'Builder': """Create a new Builder from an archive stream. + This creates builder without a context. To use a context, + create a Builder with a context first, then call with_archive() on it. + Args: stream: The stream containing the archive (any Python stream-like object) Returns: - A new Builder instance + A new Builder instance (without any context) Raises: - C2paError: If there was an error creating the builder from archive + C2paError: If there was an error creating the builder + from archive """ - builder = cls({}) + # Handle builder._handle lifecycle somewhat manually + builder = object.__new__(cls) + ManagedResource.__init__(builder) + builder._context = None + builder._has_context_signer = False + stream_obj = Stream(stream) - builder._builder = _lib.c2pa_builder_from_archive(stream_obj._stream) + try: + builder._handle = ( + _lib.c2pa_builder_from_archive(stream_obj._stream) + ) + + _check_ffi_operation_result(builder._handle, + "Failed to create builder from archive" + ) - if not builder._builder: - # Clean up the stream object if builder creation fails + builder._lifecycle_state = LifecycleState.ACTIVE + return builder + finally: stream_obj.close() - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to create builder from archive") + @overload + def __init__( + self, + manifest_json: Any, + ) -> None: ... - builder._initialized = True - return builder + @overload + def __init__( + self, + manifest_json: Any, + context: 'ContextProvider', + ) -> None: ... - def __init__(self, manifest_json: Any): + def __init__( + self, + manifest_json: Any, + context: Optional['ContextProvider'] = None, + ): """Initialize a new Builder instance. Args: manifest_json: The manifest JSON definition (string or dict) + context: Optional Context (ContextProvider) for settings Raises: C2paError: If there was an error creating the builder C2paError.Encoding: If manifest JSON contains invalid UTF-8 chars C2paError.Json: If the manifest JSON cannot be serialized """ - # Native libs plumbing: - # Clear any stale error state from previous operations - _clear_error_state() - - self._closed = False - self._initialized = False - self._builder = None + super().__init__() - if not isinstance(manifest_json, str): - try: - manifest_json = json.dumps(manifest_json) - except (TypeError, ValueError) as e: - raise C2paError.Json( - Builder._ERROR_MESSAGES['json_error'].format( - str(e))) + self._context = context + self._has_context_signer = ( + context is not None + and hasattr(context, 'has_signer') + and context.has_signer + ) - try: - json_str = manifest_json.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Builder._ERROR_MESSAGES['encoding_error'].format( - str(e))) + json_str = _to_utf8_bytes(manifest_json, "manifest JSON") - self._builder = _lib.c2pa_builder_from_json(json_str) + if context is not None: + self._init_from_context(context, json_str) + else: + self._handle = _lib.c2pa_builder_from_json(json_str) - if not self._builder: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( + _check_ffi_operation_result(self._handle, Builder._ERROR_MESSAGES['builder_error'].format( "Unknown error" ) ) - self._initialized = True - - def __del__(self): - """Ensure resources are cleaned up if close() wasn't called.""" - self._cleanup_resources() - - def __enter__(self): - self._ensure_valid_state() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() - - def _ensure_valid_state(self): - """Ensure the builder is in a valid state for operations. + self._lifecycle_state = LifecycleState.ACTIVE - Raises: - C2paError: If the builder is closed, not initialized, or invalid - """ - if self._closed: - raise C2paError(Builder._ERROR_MESSAGES['closed_error']) - if not self._initialized: - raise C2paError("Builder is not properly initialized") - if not self._builder: - raise C2paError(Builder._ERROR_MESSAGES['closed_error']) + def _init_from_context(self, context, json_str): + """Initialize Builder from a ContextProvider. - def _cleanup_resources(self): - """Internal cleanup method that releases native resources. - - This method handles the actual cleanup logic and can be called - from both close() and __del__ without causing double frees. + Uses c2pa_builder_from_context + + c2pa_builder_with_definition (consume-and-return). """ - try: - # Only cleanup if not already closed and we have a valid builder - if hasattr(self, '_closed') and not self._closed: - self._closed = True + if not context.is_valid: + raise C2paError("Context is not valid") - if hasattr( - self, - '_builder') and self._builder and self._builder != 0: - try: - _lib.c2pa_builder_free(self._builder) - except Exception: - # Log cleanup errors but don't raise exceptions - logger.error( - "Failed to release native Builder resources" - ) - pass - finally: - self._builder = None - - # Reset initialized state after cleanup - self._initialized = False - except Exception: - # Ensure we don't raise exceptions during cleanup - pass - - def close(self): - """Release the builder resources. + builder_ptr = _lib.c2pa_builder_from_context( + context.execution_context, + ) + _check_ffi_operation_result(builder_ptr, + Builder._ERROR_MESSAGES[ + 'builder_error' + ].format("Unknown error") + ) - This method ensures all resources are properly cleaned up, - even if errors occur during cleanup. - Errors during cleanup are logged but not raised to ensure cleanup. - Multiple calls to close() are handled gracefully. - """ - if self._closed: - return + # Consume-and-return: builder_ptr is consumed, + # new_ptr is the valid pointer going forward + new_ptr = _lib.c2pa_builder_with_definition(builder_ptr, json_str) + self._handle = new_ptr - try: - # Use the internal cleanup method - self._cleanup_resources() - except Exception as e: - # Log any unexpected errors during close - logger.error( - Builder._ERROR_MESSAGES['cleanup_error'].format( - str(e))) - finally: - self._closed = True + _check_ffi_operation_result(new_ptr, + Builder._ERROR_MESSAGES[ + 'builder_error' + ].format("Unknown error") + ) def set_no_embed(self): """Set the no-embed flag. @@ -2707,7 +3217,7 @@ def set_no_embed(self): This is useful when creating cloud or sidecar manifests. """ self._ensure_valid_state() - _lib.c2pa_builder_set_no_embed(self._builder) + _lib.c2pa_builder_set_no_embed(self._handle) def set_remote_url(self, remote_url: str): """Set the remote URL. @@ -2723,15 +3233,12 @@ def set_remote_url(self, remote_url: str): """ self._ensure_valid_state() - url_str = remote_url.encode('utf-8') - result = _lib.c2pa_builder_set_remote_url(self._builder, url_str) + url_bytes = _to_utf8_bytes(remote_url, "remote URL") + result = _lib.c2pa_builder_set_remote_url(self._handle, url_bytes) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Builder._ERROR_MESSAGES['url_error'].format("Unknown error")) + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES['url_error'].format("Unknown error"), + check=lambda r: r != 0) def set_intent( self, @@ -2762,16 +3269,14 @@ def set_intent( self._ensure_valid_state() result = _lib.c2pa_builder_set_intent( - self._builder, + self._handle, ctypes.c_uint(intent), ctypes.c_uint(digital_source_type), ) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error setting intent for Builder: Unknown error") + _check_ffi_operation_result(result, + "Error setting intent for Builder: Unknown error", + check=lambda r: r != 0) def add_resource(self, uri: str, stream: Any): """Add a resource to the builder. @@ -2786,20 +3291,16 @@ def add_resource(self, uri: str, stream: Any): """ self._ensure_valid_state() - uri_str = uri.encode('utf-8') + uri_bytes = _to_utf8_bytes(uri, "resource URI") with Stream(stream) as stream_obj: result = _lib.c2pa_builder_add_resource( - self._builder, uri_str, stream_obj._stream) + self._handle, uri_bytes, stream_obj._stream) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Builder._ERROR_MESSAGES['resource_error'].format( - "Unknown error" - ) - ) + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES['resource_error'].format( + "Unknown error" + ), + check=lambda r: r != 0) def add_ingredient( self, ingredient_json: Union[str, dict], format: str, source: Any @@ -2850,36 +3351,24 @@ def add_ingredient_from_stream( """ self._ensure_valid_state() - if isinstance(ingredient_json, dict): - ingredient_json = json.dumps(ingredient_json) - - try: - ingredient_str = ingredient_json.encode('utf-8') - format_str = format.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Builder._ERROR_MESSAGES['encoding_error'].format( - str(e))) + ingredient_str = _to_utf8_bytes(ingredient_json, "ingredient JSON") + format_str = _to_utf8_bytes(format, "ingredient format") with Stream(source) as source_stream: result = ( _lib.c2pa_builder_add_ingredient_from_stream( - self._builder, + self._handle, ingredient_str, format_str, source_stream._stream ) ) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Builder._ERROR_MESSAGES['ingredient_error'].format( - "Unknown error" - ) - ) + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES['ingredient_error'].format( + "Unknown error" + ), + check=lambda r: r != 0) def add_ingredient_from_file_path( self, @@ -2941,27 +3430,14 @@ def add_action(self, action_json: Union[str, dict]) -> None: """ self._ensure_valid_state() - if isinstance(action_json, dict): - action_json = json.dumps(action_json) + action_str = _to_utf8_bytes(action_json, "action JSON") + result = _lib.c2pa_builder_add_action(self._handle, action_str) - try: - action_str = action_json.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Builder._ERROR_MESSAGES['encoding_error'].format(str(e)) - ) - - result = _lib.c2pa_builder_add_action(self._builder, action_str) - - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Builder._ERROR_MESSAGES['action_error'].format( - "Unknown error" - ) - ) + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES['action_error'].format( + "Unknown error" + ), + check=lambda r: r != 0) def to_archive(self, stream: Any) -> None: """Write an archive of the builder to a stream. @@ -2977,33 +3453,63 @@ def to_archive(self, stream: Any) -> None: with Stream(stream) as stream_obj: result = _lib.c2pa_builder_to_archive( - self._builder, stream_obj._stream) + self._handle, stream_obj._stream) + + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES["archive_error"].format( + "Unknown error" + ), + check=lambda r: r != 0) + + def with_archive(self, stream: Any) -> 'Builder': + """Load an archive into this Builder instance, replacing its + manifest definition. The archive carries only the + definition, not settings. Settings come from the context that + was configured to be used with this Builder instance. - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) + Args: + stream: The stream containing the archive + + Returns: + This builder instance, for method chaining. + + Raises: + C2paError: If there was an error loading the archive + """ + self._ensure_valid_state() + + with Stream(stream) as stream_obj: + try: + new_ptr = _lib.c2pa_builder_with_archive(self._handle, stream_obj._stream) + except Exception as e: + self._mark_consumed() raise C2paError( - Builder._ERROR_MESSAGES["archive_error"].format( - "Unknown error" - ) + f"Error loading archive: {e}" ) + # Old handle consumed by FFI + self._handle = new_ptr + _check_ffi_operation_result(new_ptr, "Failed to load archive into builder") + + return self def _sign_internal( self, - signer: Signer, format: str, source_stream: Stream, - dest_stream: Stream) -> bytes: - """Internal signing logic shared between sign() and sign_file() methods - to use same native calls but expose different API surface. + dest_stream: Stream, + signer: Optional[Signer] = None) -> bytes: + """Internal signing implementation. + When `signer` is provided, calls `c2pa_builder_sign` (explicit + signer). When `signer` is `None`, calls + `c2pa_builder_sign_context` (context-based signer). Args: - signer: The signer to use format: The MIME type or extension of the content source_stream: The source stream dest_stream: The destination stream, - opened in w+b (write+read binary) mode. + opened in w+b (write+read binary) mode. + signer: Signer to use. When None the context + signer is used instead. Returns: Manifest bytes @@ -3013,37 +3519,40 @@ def _sign_internal( """ self._ensure_valid_state() - # Validate signer pointer before use - if not signer or not hasattr(signer, '_signer') or not signer._signer: - raise C2paError("Invalid or closed signer") - - format_lower = format.lower() - if format_lower not in Builder.get_supported_mime_types(): - raise C2paError.NotSupported( - f"Builder does not support {format}") + if signer is not None: + if not hasattr(signer, '_handle') or not signer._handle: + raise C2paError("Invalid or closed signer") - format_str = format.encode('utf-8') + format_bytes = _validate_and_encode_format( + format, Builder.get_supported_mime_types(), "Builder") manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() - # c2pa_builder_sign uses streams try: - result = _lib.c2pa_builder_sign( - self._builder, - format_str, - source_stream._stream, - dest_stream._stream, - signer._signer, - ctypes.byref(manifest_bytes_ptr) - ) + if signer is not None: + result = _lib.c2pa_builder_sign( + self._handle, + format_bytes, + source_stream._stream, + dest_stream._stream, + signer._handle, + ctypes.byref(manifest_bytes_ptr) + ) + else: + result = _lib.c2pa_builder_sign_context( + self._handle, + format_bytes, + source_stream._stream, + dest_stream._stream, + ctypes.byref(manifest_bytes_ptr), + ) + # Builder pointer consumed by Rust FFI at this point + self._mark_consumed() except Exception as e: - # Handle errors during the C function call - raise C2paError(f"Error calling c2pa_builder_sign: {str(e)}") + self._mark_consumed() + raise C2paError(f"Error during signing: {e}") - if result < 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error during signing") + _check_ffi_operation_result(result, + "Error during signing", check=lambda r: r < 0) # Capture the manifest bytes if available manifest_bytes = b"" @@ -3064,24 +3573,94 @@ def _sign_internal( logger.error( "Failed to release native manifest bytes memory" ) - pass return manifest_bytes + def _sign_common( + self, + signer: Optional[Signer], + format: str, + source: Any, + dest: Any = None, + ) -> bytes: + """Shared signing logic for sign(). + + Args: + signer: The signer to use, or None for context signer. + format: The MIME type of the content. + source: The source stream. + dest: The destination stream (optional). + + Returns: + Manifest bytes + """ + source_stream = Stream(source) + try: + if dest: + dest_stream = Stream(dest) + else: + mem_buffer = io.BytesIO() + dest_stream = Stream(mem_buffer) + + try: + if signer is not None: + manifest_bytes = self._sign_internal( + format, source_stream, dest_stream, + signer=signer, + ) + elif self._has_context_signer: + manifest_bytes = self._sign_internal(format, source_stream, dest_stream) + else: + raise C2paError( + "No signer provided. Either pass a" + " signer parameter or create the" + " Builder with a Context that has" + " a signer." + ) + finally: + dest_stream.close() + finally: + source_stream.close() + + return manifest_bytes + + @overload def sign( - self, - signer: Signer, - format: str, - source: Any, - dest: Any = None) -> bytes: - """Sign the builder's content and write to a destination stream. + self, + signer: Signer, + format: str, + source: Any, + dest: Any = None, + ) -> bytes: ... + + @overload + def sign( + self, + format: str, + source: Any, + dest: Any = None, + ) -> bytes: ... + + def sign( + self, + signer_or_format: Union[Signer, str], + format_or_source: Any = None, + source_or_dest: Any = None, + dest: Any = None, + ) -> bytes: + """Sign the builder's content. + + Can be called with or without an explicit signer. + If no signer is provided, the context's signer is + used (builder must have been created with a Context + that has a signer). Args: - format: The MIME type or extension of the content - source: The source stream (any Python stream-like object) - dest: The destination stream (any Python stream-like object), - opened in w+b (write+read binary) mode. - signer: The signer to use + signer: The signer to use. If not provided, the + context's signer is used. + format: The MIME type of the content. + source: The source stream. + dest: The destination stream (optional). Returns: Manifest bytes @@ -3089,46 +3668,58 @@ def sign( Raises: C2paError: If there was an error during signing """ - # Convert Python streams to Stream objects - source_stream = Stream(source) - - if dest: - # dest is optional, only if we write back somewhere - dest_stream = Stream(dest) + if isinstance(signer_or_format, Signer): + return self._sign_common( + signer_or_format, + format_or_source, + source_or_dest, + dest, + ) + elif isinstance(signer_or_format, str): + return self._sign_common( + None, + signer_or_format, + format_or_source, + source_or_dest, + ) else: - # no destination? - # we keep things in-memory for validation and processing - mem_buffer = io.BytesIO() - dest_stream = Stream(mem_buffer) - - # Use the internal stream-base signing logic - manifest_bytes = self._sign_internal( - signer, - format, - source_stream, - dest_stream - ) + raise C2paError( + "First argument must be a Signer or a format string (MIME type)." + ) - if not dest: - # Close temporary in-memory stream since we own it - dest_stream.close() + @overload + def sign_file( + self, + source_path: Union[str, Path], + dest_path: Union[str, Path], + signer: Signer, + ) -> bytes: ... - return manifest_bytes + @overload + def sign_file( + self, + source_path: Union[str, Path], + dest_path: Union[str, Path], + ) -> bytes: ... + + def sign_file( + self, + source_path: Union[str, Path], + dest_path: Union[str, Path], + signer: Optional[Signer] = None, + ) -> bytes: + """Sign a file and write signed data to output. - def sign_file(self, - source_path: Union[str, - Path], - dest_path: Union[str, - Path], - signer: Signer) -> bytes: - """Sign a file and write the signed data to an output file. + Can be called with or without an explicit signer. + If no signer is provided, the context's signer is + used (builder must have been created with a Context + that has a signer). Args: - source_path: Path to the source file. We will attempt - to guess the mimetype of the source file based on - the extension. - dest_path: Path to write the signed file to - signer: The signer to use + source_path: Path to the source file. + dest_path: Path to write the signed file to. + signer: The signer to use. If None, the + context's signer is used. Returns: Manifest bytes @@ -3136,14 +3727,17 @@ def sign_file(self, Raises: C2paError: If there was an error during signing """ - # Get the MIME type from the file extension mime_type = _get_mime_type_from_path(source_path) try: - # Open source file and destination file, then use the sign method - with open(source_path, 'rb') as source_file, \ - open(dest_path, 'w+b') as dest_file: - return self.sign(signer, mime_type, source_file, dest_file) + with ( + open(source_path, 'rb') as source_file, + open(dest_path, 'w+b') as dest_file, + ): + if signer is not None: + return self.sign(signer, mime_type, source_file, dest_file) + # else: + return self.sign(mime_type, source_file, dest_file) except Exception as e: raise C2paError(f"Error signing file: {str(e)}") from e @@ -3174,16 +3768,19 @@ def format_embeddable(format: str, manifest_bytes: bytes) -> tuple[int, bytes]: ctypes.byref(result_bytes_ptr) ) - if result < 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to format embeddable manifest") + _check_ffi_operation_result(result, + "Failed to format embeddable manifest", check=lambda r: r < 0) # Convert the result bytes to a Python bytes object size = result - result_bytes = bytes(result_bytes_ptr[:size]) - _lib.c2pa_manifest_bytes_free(result_bytes_ptr) + try: + result_bytes = bytes(result_bytes_ptr[:size]) + except Exception as e: + raise C2paError( + f"Failed to convert embeddable manifest bytes: {e}" + ) from e + finally: + _lib.c2pa_manifest_bytes_free(result_bytes_ptr) return size, result_bytes @@ -3200,7 +3797,7 @@ def create_signer( This function is deprecated and will be removed in a future version. Please use the Signer class method instead. Example: - ```python + ``` signer = Signer.from_callback(callback, alg, certs, tsa_url) ``` @@ -3235,7 +3832,7 @@ def create_signer_from_info(signer_info: C2paSignerInfo) -> Signer: This function is deprecated and will be removed in a future version. Please use the Signer class method instead. Example: - ```python + ``` signer = Signer.from_info(signer_info) ``` @@ -3303,11 +3900,8 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: key_bytes ) - if not signature_ptr: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to sign data with Ed25519") + _check_ffi_operation_result(signature_ptr, + "Failed to sign data with Ed25519") try: # Ed25519 signatures are always 64 bytes @@ -3330,6 +3924,10 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'C2paDigitalSourceType', 'C2paSignerInfo', 'C2paBuilderIntent', + 'ContextBuilder', + 'ContextProvider', + 'Settings', + 'Context', 'Stream', 'Reader', 'Builder', diff --git a/src/c2pa/lib.py b/src/c2pa/lib.py index 82b6148b..e14f3d6b 100644 --- a/src/c2pa/lib.py +++ b/src/c2pa/lib.py @@ -239,7 +239,7 @@ def dynamically_load_library( logger.info(f"Using library name from env var C2PA_LIBRARY_NAME: {env_lib_name}") try: possible_paths = _get_possible_search_paths() - lib = _load_single_library(env_lib_name, possible_paths) + lib, load_errors = _load_single_library(env_lib_name, possible_paths) if lib: return lib else: diff --git a/tests/fixtures/dash1.m4s b/tests/fixtures/dash1.m4s new file mode 100644 index 0000000000000000000000000000000000000000..1a9a99644b833105a4ee54e958f4a762442ca7b1 GIT binary patch literal 71111 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∋D0KiSPO4Ys;jH*$ELgMQjX-+?)IdIfGAXNI=; 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/settings.toml b/tests/fixtures/settings.toml deleted file mode 100644 index 31eccf31..00000000 --- a/tests/fixtures/settings.toml +++ /dev/null @@ -1,230 +0,0 @@ -# This sample c2pa settings file enables a trust list. -# We use the toml format here because it does a good job of containing the PEM formatted certificates. -# In practice you should update the trust anchors from a remote source as needed. -# Many other settings are available, see the c2pa documentation for more information. -[verify] -trusted = true - -[trust] -trust_config = """ -//id-kp-emailProtection -1.3.6.1.5.5.7.3.4 -//id-kp-documentSigning -1.3.6.1.5.5.7.3.36 -//id-kp-timeStamping -1.3.6.1.5.5.7.3.8 -//id-kp-OCSPSigning -1.3.6.1.5.5.7.3.9 -// MS C2PA Signing -1.3.6.1.4.1.311.76.59.1.9 -""" -trust_anchors = """ ------BEGIN CERTIFICATE----- -MIICEzCCAcWgAwIBAgIUW4fUnS38162x10PCnB8qFsrQuZgwBQYDK2VwMHcxCzAJ -BgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdoZXJlMRowGAYD -VQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRFU1RJTkdfT05M -WTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2NDFaFw0zMjA2MDcxODQ2 -NDFaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdo -ZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRF -U1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAqMAUGAytlcAMhAGPUgK9q1H3D -eKMGqLGjTXJSpsrLpe0kpxkaFMe7KUAuo2MwYTAdBgNVHQ4EFgQUXuZWArP1jiRM -fgye6ZqRyGupTowwHwYDVR0jBBgwFoAUXuZWArP1jiRMfgye6ZqRyGupTowwDwYD -VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwBQYDK2VwA0EA8E79g54u2fUy -dfVLPyqKmtjenOUMvVQD7waNbetLY7kvUJZCd5eaDghk30/Q1RaNjiP/2RfA/it8 -zGxQnM2hCA== ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIIC2jCCAjygAwIBAgIUYm+LFaltpWbS9kED6RRAamOdUHowCgYIKoZIzj0EAwQw -dzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx -GjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO -R19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw -NzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT -b21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG -T1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIGbMBAGByqGSM49AgEG -BSuBBAAjA4GGAAQBaifSYJBkf5fgH3FWPxRdV84qwIsLd7RcIDcRJrRkan0xUYP5 -zco7R4fFGaQ9YJB8dauyqiNg00LVuPajvKmhgEMAT4eSfEhYC25F2ggXQlBIK3Q7 -mkXwJTIJSObnbw4S9Jy3W6OVKq351VpgWUcmhvGRRejW7S/D8L2tzqRW7JPI2uSj -YzBhMB0GA1UdDgQWBBS6OykommTmfYoLJuPN4OU83wjPqjAfBgNVHSMEGDAWgBS6 -OykommTmfYoLJuPN4OU83wjPqjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQE -AwIBhjAKBggqhkjOPQQDBAOBiwAwgYcCQV4B6uKKoCWecEDlzj2xQLFPmnBQIOzD -nyiSEcYyrCKwMV+HYS39oM+T53NvukLKUTznHwdWc9++HNaqc+IjsDl6AkIB2lXd -5+s3xf0ioU91GJ4E13o5rpAULDxVSrN34A7BlsaXYQLnSkLMqva6E7nq2JBYjkqf -iwNQm1DDcQPtPTnddOs= ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIICkTCCAhagAwIBAgIUIngKvNC/BMF3TRIafgweprIbGgAwCgYIKoZIzj0EAwMw -dzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx -GjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO -R19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw -NzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT -b21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG -T1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMHYwEAYHKoZIzj0CAQYF -K4EEACIDYgAEX3FzSTnCcEAP3wteNaiy4GZzZ+ABd2Y7gJpfyZf3kkCuX/I3psFq -QBRvb3/FEBaDT4VbDNlZ0WLwtw5d3PI42Zufgpxemgfjf31d8H51eU3/IfAz5AFX -y/OarhObHgVvo2MwYTAdBgNVHQ4EFgQUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wHwYD -VR0jBBgwFoAUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wDwYDVR0TAQH/BAUwAwEB/zAO -BgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAPOgmJbVdhDh9KlgQXqE -FzHiCt347JG4strk22MXzOgxQ0LnXStIh+viC3S1INzuBgIxAI1jiUBX/V7Gg0y6 -Y/p6a63Xp2w+ia7vlUaUBWsR3ex9NNSTPLNoDkoTCSDOE2O20w== ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIICUzCCAfmgAwIBAgIUdmkq4byvgk2FSnddHqB2yjoD68gwCgYIKoZIzj0EAwIw -dzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx -GjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO -R19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw -NzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT -b21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG -T1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMFkwEwYHKoZIzj0CAQYI -KoZIzj0DAQcDQgAEre/KpcWwGEHt+mD4xso3xotRnRx2IEsMoYwVIKI7iEJrDEye -PcvJuBywA0qiMw2yvAvGOzW/fqUTu1jABrFIk6NjMGEwHQYDVR0OBBYEFF6ZuIbh -eBvZVxVadQBStikOy6iMMB8GA1UdIwQYMBaAFF6ZuIbheBvZVxVadQBStikOy6iM -MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0gA -MEUCIHBC1xLwkCWSGhVXFlSnQBx9cGZivXzCbt8BuwRqPSUoAiEAteZQDk685yh9 -jgOTkp4H8oAmM1As+qlkRK2b+CHAQ3k= ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -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 ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -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 ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -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 ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIIF3zCCA8egAwIBAgIUfPyUDhze4auMF066jChlB9aD2yIwDQYJKoZIhvcNAQEL -BQAwdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hl -cmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVT -VElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTI0MDczMTE5MDUwMVoXDTM0 -MDcyOTE5MDUwMVowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQH -DAlTb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQL -DBBGT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIICIjANBgkqhkiG -9w0BAQEFAAOCAg8AMIICCgKCAgEAkBSlOCwlWBgbqLxFu99ERwU23D/V7qBs7GsA -ZPaAvwCKf7FgVTpkzz6xsgArQU6MVo8n1tXUWWThB81xTXwqbWINP0pl5RnZKFxH -TmloE2VEMrEK3q4W6gqMjyiG+hPkwUK450WdJGkUkYi2rp6YF9YWJHv7YqYodz+u -mkIRcsczwRPDaJ7QA6pu3V4YlwrFXZu7jMHHMju02emNoiI8n7QZBJXpRr4C87jT -Ad+aNJQZ1DJ/S/QfiYpaXQ2xNH/Wq7zNXXIMs/LU0kUCggFIj+k6tmaYIAYKJR6o -dmV3anBTF8iSuAqcUXvM4IYMXSqMgzot3MYPYPdC+rj+trQ9bCPOkMAp5ySx8pYr -Upo79FOJvG8P9JzuFRsHBobYjtQqJnn6OczM69HVXCQn4H4tBpotASjT2gc6sHYv -a7YreKCbtFLpJhslNysIzVOxlnDbsugbq1gK8mAwG48ttX15ZUdX10MDTpna1FWu -Jnqa6K9NUfrvoW97ff9itca5NDRmm/K5AVA801NHFX1ApVty9lilt+DFDtaJd7zy -9w0+8U1sZ4+sc8moFRPqvEZZ3gdFtDtVjShcwdbqHZdSNU2lNbVCiycjLs/5EMRO -WfAxNZaKUreKGfOZkvQNqBhuebF3AfgmP6iP1qtO8aSilC1/43DjVRx3SZ1eecO6 -n0VGjgcCAwEAAaNjMGEwHQYDVR0OBBYEFBTOcmBU5xp7Jfn4Nzyw+kIc73yHMB8G -A1UdIwQYMBaAFBTOcmBU5xp7Jfn4Nzyw+kIc73yHMA8GA1UdEwEB/wQFMAMBAf8w -DgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQCLexj0luEpQh/LEB14 -ARG/yQ8iqW2FMonQsobrDQSI4BhrQ4ak5I892MQX9xIoUpRAVp8GkJ/eXM6ChmXa -wMJSkfrPGIvES4TY2CtmXDNo0UmHD1GDfHKQ06FJtRJWpn9upT/9qTclTNtvwxQ8 -bKl/y7lrFsn+fQsKL2i5uoQ9nGpXG7WPirJEt9jcld2yylWSStTS4MXJIZSlALIA -mBTkbzEpzBOLHRRezdfoV4hyL/tWyiXa799436kO48KtwEzvYzC5cZ4bqvM5BXQf -6aiIYZT7VypFwJQtpTgnfrsjr2Y8q/+N7FoMpLfFO4eeqtwWPiP/47/lb9np/WQq -iO/yyIwYVwiqVG0AyzA5Z4pdke1t93y3UuhXgxevJ7GqGXuLCM0iMqFrAkPlLJzI -84THLJzFy+wEKH+/L1Zi94cHNj3WvablAMG5v/Kfr6k+KueNQzrY4jZrQPUEdxjv -xk/1hyZg+khAPVKRxhWeIr6/KIuQYu6kJeTqmXKafx5oHAS6OqcK7G1KbEa1bWMV -K0+GGwenJOzSTKWKtLO/6goBItGnhyQJCjwiBKOvcW5yfEVjLT+fJ7dkvlSzFMaM -OZIbev39n3rQTWb4ORq1HIX2JwNsEQX+gBv6aGjMT2a88QFS0TsAA5LtFl8xeVgt -xPd7wFhjRZHfuWb2cs63xjAGjQ== ------END CERTIFICATE----- -""" \ No newline at end of file diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 18f6b817..61847223 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -27,10 +27,12 @@ import threading # Suppress deprecation warnings -warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.simplefilter("ignore", category=DeprecationWarning) from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType -from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable +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") @@ -68,11 +70,12 @@ def 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.75.21", sdk_version()) + self.assertIn("0.78.2", 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 @@ -193,7 +196,7 @@ def test_stream_read_get_validation_state_with_trust_config(self): def read_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -253,7 +256,7 @@ def test_stream_read_detailed_and_parse(self): 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): + def test_stream_read_string_stream_path_only(self): with Reader(self.testPath) as reader: json_data = reader.json() self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) @@ -325,10 +328,10 @@ def test_reader_close_cleanup(self): # Close the reader reader.close() # Verify all resources are cleaned up - self.assertIsNone(reader._reader) + self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) # Verify reader is marked as closed - self.assertTrue(reader._closed) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) def test_resource_to_stream_on_closed_reader(self): """Test that resource_to_stream correctly raises error on closed.""" @@ -690,9 +693,8 @@ def test_reader_context_manager_with_exception(self): try: with Reader(self.testPath) as reader: # Inside context - should be valid - self.assertFalse(reader._closed) - self.assertTrue(reader._initialized) - self.assertIsNotNone(reader._reader) + self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(reader._handle) self.assertIsNotNone(reader._own_stream) self.assertIsNotNone(reader._backing_file) raise ValueError("Test exception") @@ -700,19 +702,17 @@ def test_reader_context_manager_with_exception(self): pass # After exception - should still be closed - self.assertTrue(reader._closed) - self.assertFalse(reader._initialized) - self.assertIsNone(reader._reader) + self.assertEqual(reader._lifecycle_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 _initialized = True + # Test with _reader = None but lifecycle state = ACTIVE reader = Reader.__new__(Reader) - reader._closed = False - reader._initialized = True - reader._reader = None + reader._lifecycle_state = LifecycleState.ACTIVE + reader._handle = None reader._own_stream = None reader._backing_file = None @@ -724,9 +724,8 @@ def test_reader_cleanup_state_transitions(self): reader = Reader(self.testPath) reader._cleanup_resources() - self.assertTrue(reader._closed) - self.assertFalse(reader._initialized) - self.assertIsNone(reader._reader) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) @@ -736,13 +735,12 @@ def test_reader_cleanup_idempotency(self): # First cleanup reader._cleanup_resources() - self.assertTrue(reader._closed) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) # Second cleanup should not change state reader._cleanup_resources() - self.assertTrue(reader._closed) - self.assertFalse(reader._initialized) - self.assertIsNone(reader._reader) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) @@ -751,7 +749,7 @@ def test_reader_state_with_invalid_native_pointer(self): reader = Reader(self.testPath) # Simulate invalid native pointer - reader._reader = 0 + reader._handle = 0 # Operations should fail gracefully with self.assertRaises(Error): @@ -1479,7 +1477,7 @@ def test_streams_sign_with_es256_alg_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1553,7 +1551,7 @@ def test_sign_with_ed25519_alg_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1693,7 +1691,7 @@ def test_sign_with_ps256_alg_2_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1771,7 +1769,7 @@ def test_archive_sign_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1843,7 +1841,7 @@ def test_archive_sign_with_added_ingredient_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1939,7 +1937,7 @@ def test_remote_sign_using_returned_bytes_V2_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -2128,7 +2126,7 @@ def test_builder_no_added_ingredient_on_closed_builder(self): def test_builder_add_ingredient(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{"test": "ingredient"}' @@ -2139,7 +2137,7 @@ def test_builder_add_ingredient(self): def test_builder_add_ingredient_dict(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient with a dictionary instead of JSON string ingredient_dict = {"test": "ingredient"} @@ -2150,7 +2148,7 @@ def test_builder_add_ingredient_dict(self): def test_builder_add_multiple_ingredients(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test builder operations builder.set_no_embed() @@ -2170,7 +2168,7 @@ def test_builder_add_multiple_ingredients(self): def test_builder_add_multiple_ingredients_2(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test builder operations builder.set_no_embed() @@ -2190,7 +2188,7 @@ def test_builder_add_multiple_ingredients_2(self): def test_builder_add_multiple_ingredients_and_resources(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test builder operations builder.set_no_embed() @@ -2219,7 +2217,7 @@ def test_builder_add_multiple_ingredients_and_resources(self): def test_builder_add_multiple_ingredients_and_resources_interleaved(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None with open(self.testPath, 'rb') as f: builder.add_resource("test_uri_1", f) @@ -2242,7 +2240,7 @@ def test_builder_add_multiple_ingredients_and_resources_interleaved(self): def test_builder_sign_with_ingredient(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{ "title": "Test Ingredient" }' @@ -2286,7 +2284,7 @@ def test_builder_sign_with_ingredient(self): def test_builder_sign_with_ingredients_edit_intent(self): """Test signing with EDIT intent and ingredient.""" builder = Builder.from_json({}) - assert builder._builder is not None + assert builder._handle is not None # Set the intent for editing existing content builder.set_intent(C2paBuilderIntent.EDIT) @@ -2390,7 +2388,7 @@ def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): load_settings('{"builder": { "thumbnail": {"enabled": false}}}') builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{ "title": "Test Ingredient" }' @@ -2437,7 +2435,7 @@ def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): load_settings({"builder": {"thumbnail": {"enabled": False}}}) builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{ "title": "Test Ingredient" }' @@ -2481,7 +2479,7 @@ def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): def test_builder_sign_with_duplicate_ingredient(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{"title": "Test Ingredient"}' @@ -2527,7 +2525,7 @@ def test_builder_sign_with_duplicate_ingredient(self): def test_builder_sign_with_ingredient_from_stream(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient using stream ingredient_json = '{"title": "Test Ingredient Stream"}' @@ -2567,7 +2565,7 @@ def test_builder_sign_with_ingredient_from_stream(self): def test_builder_sign_with_ingredient_dict_from_stream(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient using stream with a dictionary ingredient_dict = {"title": "Test Ingredient Stream"} @@ -2607,7 +2605,7 @@ def test_builder_sign_with_ingredient_dict_from_stream(self): def test_builder_sign_with_multiple_ingredient(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Add first ingredient ingredient_json1 = '{"title": "Test Ingredient 1"}' @@ -2652,7 +2650,7 @@ def test_builder_sign_with_multiple_ingredient(self): def test_builder_sign_with_multiple_ingredients_from_stream(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Add first ingredient using stream ingredient_json1 = '{"title": "Test Ingredient Stream 1"}' @@ -3139,7 +3137,7 @@ def test_builder_with_invalid_signer_object(self): # Use a mock object that looks like a signer but isn't class MockSigner: def __init__(self): - self._signer = None + self._handle = None mock_signer = MockSigner() @@ -3259,56 +3257,49 @@ def test_builder_state_transitions(self): builder = Builder(self.manifestDefinition) # Initial state - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) # After close builder.close() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_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.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) # Placeholder operation builder.set_no_embed() # After context exit - should be closed - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) 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.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) raise ValueError("Test exception") except ValueError: pass # After exception - should still be closed - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_partial_initialization_states(self): """Test Builder behavior with partial initialization failures.""" - # Test with _builder = None but _initialized = True + # Test with _builder = None but _state = ACTIVE builder = Builder.__new__(Builder) - builder._closed = False - builder._initialized = True - builder._builder = None + builder._lifecycle_state = LifecycleState.ACTIVE + builder._handle = None with self.assertRaises(Error): builder._ensure_valid_state() @@ -3319,9 +3310,8 @@ def test_builder_cleanup_state_transitions(self): # Test _cleanup_resources method builder._cleanup_resources() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_cleanup_idempotency(self): """Test that cleanup operations are idempotent.""" @@ -3329,13 +3319,12 @@ def test_builder_cleanup_idempotency(self): # First cleanup builder._cleanup_resources() - self.assertTrue(builder._closed) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) # Second cleanup should not change state builder._cleanup_resources() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_state_after_sign_operations(self): """Test Builder state after signing operations.""" @@ -3344,14 +3333,9 @@ def test_builder_state_after_sign_operations(self): with open(self.testPath, "rb") as file: manifest_bytes = builder.sign(self.signer, "image/jpeg", file) - # State should still be valid after signing - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) - - # Should be able to sign again - with open(self.testPath, "rb") as file: - manifest_bytes2 = builder.sign(self.signer, "image/jpeg", file) + # Builder is consumed by sign — pointer ownership transferred to Rust + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_state_after_archive_operations(self): """Test Builder state after archive operations.""" @@ -3362,9 +3346,8 @@ def test_builder_state_after_archive_operations(self): builder.to_archive(archive_stream) # State should still be valid - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) def test_builder_state_after_double_close(self): """Test Builder state after double close operations.""" @@ -3372,22 +3355,20 @@ def test_builder_state_after_double_close(self): # First close builder.close() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) # Second close should not change state builder.close() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_state_with_invalid_native_pointer(self): """Test Builder state handling with invalid native pointer.""" builder = Builder(self.manifestDefinition) # Simulate invalid native pointer - builder._builder = 0 + builder._handle = 0 # Operations should fail gracefully with self.assertRaises(Error): @@ -4304,6 +4285,7 @@ def setUp(self): 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 @@ -4684,7 +4666,7 @@ def test_builder_add_ingredient_from_file_path(self): builder.close() - def test_builder_add_ingredient_from_file_path(self): + def test_builder_add_ingredient_from_file_path_not_found(self): """Test Builder class add_ingredient_from_file_path method.""" # Suppress the specific deprecation warning for this test, as this is a legacy method @@ -4943,56 +4925,6 @@ def test_sign_file_callback_signer(self): 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.""" @@ -5068,10 +5000,12 @@ def test_sign_file_callback_signer_managed_multiple_uses(self): 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 + # Second signing operation with a new builder but same signer + # Builder is consumed by sign, so we need a fresh one. + # This verifies we don't free the signer or the callback too early. + builder2 = Builder(self.manifestDefinition) output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") - manifest_bytes_2 = builder.sign_file( + manifest_bytes_2 = builder2.sign_file( source_path=self.testPath, dest_path=output_path_2, signer=signer @@ -5109,5 +5043,1196 @@ def test_create_signer_from_info(self): 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", + }] + } + } + ] + } + + 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) + + +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" + ) + + +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) + + +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() + + def test_context_builder_with_settings_last_wins(self): + """The last with_settings call determines the settings used. + + Toggles thumbnails on, off, on, off across four calls. + The last call disables thumbnails, so the signed manifest + should have no thumbnail. + """ + settings_on_1 = Settings.from_dict({ + "builder": {"thumbnail": {"enabled": True}}, + }) + settings_off_1 = Settings.from_dict({ + "builder": {"thumbnail": {"enabled": False}}, + }) + settings_on_2 = Settings.from_dict({ + "builder": {"thumbnail": {"enabled": True}}, + }) + settings_off_2 = Settings.from_dict({ + "builder": {"thumbnail": {"enabled": False}}, + }) + context = ( + Context.builder() + .with_settings(settings_on_1) + .with_settings(settings_off_1) + .with_settings(settings_on_2) + .with_settings(settings_off_2) + .build() + ) + signer = self._ctx_make_signer() + builder = Builder(self.test_manifest, 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() + # Last settings disabled thumbnails + self.assertIsNone(manifest.get("thumbnail")) + reader.close() + builder.close() + context.close() + settings_on_1.close() + settings_off_1.close() + settings_on_2.close() + settings_off_2.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._lifecycle_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._lifecycle_state, LifecycleState.CLOSED) + context.close() + + +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() + + def test_with_fragment_on_closed_reader_raises(self): + context = Context() + reader = Reader(DEFAULT_TEST_FILE, context=context) + reader.close() + with self.assertRaises(Error): + reader.with_fragment( + "video/mp4", + io.BytesIO(b"\x00" * 100), + io.BytesIO(b"\x00" * 100), + ) + context.close() + + def test_with_fragment_unsupported_format_raises(self): + context = Context() + reader = Reader(DEFAULT_TEST_FILE, context=context) + with self.assertRaises(Error): + reader.with_fragment( + "text/plain", + io.BytesIO(b"\x00" * 100), + io.BytesIO(b"\x00" * 100), + ) + reader.close() + context.close() + + def test_with_fragment_with_dash_fixtures(self): + context = Context() + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + with open(init_path, "rb") as init_fragment: + reader = Reader("video/mp4", init_fragment, context=context) + frag_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + with open(init_path, "rb") as init_fragment, \ + open(frag_path, "rb") as next_fragment: + reader.with_fragment("video/mp4", init_fragment, next_fragment) + 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() + + 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( + "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( + "image/jpeg", + source_file, + dest_file, + ) + builder.close() + context.close() + + def test_sign_file_with_context_signer_no_explicit_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") + manifest_bytes = builder.sign_file( + source_path=DEFAULT_TEST_FILE, + dest_path=dest_path, + ) + 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_sign_file_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 self.assertRaises(Error): + builder.sign_file( + source_path=DEFAULT_TEST_FILE, + dest_path=dest_path, + ) + builder.close() + context.close() + + def test_with_archive_preserves_settings(self): + """with_archive() preserves the builder's context settings. + + Settings live on the builder's context, not in the archive. + The archive only carries the manifest definition. This test + proves that a builder created with no-thumbnail settings + keeps those settings after loading an archive. + """ + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + + # Context provides the no-thumbnail setting; + # with_archive only loads the manifest definition. + builder2 = Builder({}, context) + builder2.with_archive(archive) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder2.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=Context(), + ) + manifest = reader.get_active_manifest() + self.assertIsNone( + manifest.get("thumbnail"), + "with_archive should preserve no-thumbnail setting", + ) + reader.close() + archive.close() + builder2.close() + signer.close() + context.close() + settings.close() + + def test_with_archive_replaces_definition(self): + """with_archive() restores the original builder's + manifest definition, even if something set on new Builder.""" + context = Context() + signer = self._ctx_make_signer() + original_manifest = dict(self.test_manifest) + original_manifest["title"] = "Original Title" + builder = Builder(original_manifest, context) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + + replaced_manifest = dict(self.test_manifest) + replaced_manifest["title"] = "Replaced Title" + builder2 = Builder(replaced_manifest, context) + builder2.with_archive(archive) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder2.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=Context(), + ) + json_data = reader.json() + self.assertIn("Original Title", json_data) + self.assertNotIn("Replaced Title", json_data) + reader.close() + archive.close() + builder2.close() + signer.close() + context.close() + + def test_with_archive_on_closed_builder_raises(self): + """with_archive() on a closed builder raises C2paError.""" + context = Context() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder.close() + with self.assertRaises(Error): + builder.with_archive(archive) + context.close() + + def test_from_archive_roundtrip(self): + """from_archive() can't propagate contexts.""" + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + + # from_archive creates a context-free builder + builder2 = Builder.from_archive(archive) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder2.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=Context(), + ) + manifest = reader.get_active_manifest() + # from_archive can't propagate contexts + self.assertIsNotNone( + manifest.get("thumbnail"), + "from_archive should lose settings and generate thumbnail", + ) + reader.close() + archive.close() + builder2.close() + signer.close() + context.close() + settings.close() + + +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( + "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({}, context=context) + builder.with_archive(archive) + 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({}, context=context) + builder.with_archive(archive) + 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_remote_sign_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings=settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + builder.set_no_embed() + with open(DEFAULT_TEST_FILE, "rb") as source: + with io.BytesIO() as output_buffer: + manifest_data = builder.sign( + signer, "image/jpeg", + source, output_buffer, + ) + output_buffer.seek(0) + read_buffer = io.BytesIO( + output_buffer.getvalue() + ) + reader = Reader( + "image/jpeg", read_buffer, + manifest_data, context=context, + ) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + read_buffer.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( + "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() + unittest.main(warnings='ignore') diff --git a/tests/test_unit_tests_threaded.py b/tests/test_unit_tests_threaded.py index 14ef48fe..8eaacf88 100644 --- a/tests/test_unit_tests_threaded.py +++ b/tests/test_unit_tests_threaded.py @@ -21,7 +21,8 @@ import asyncio import random -from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version +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 PROJECT_PATH = os.getcwd() @@ -40,11 +41,11 @@ class TestReaderWithThreads(unittest.TestCase): def setUp(self): # Use the fixtures_dir fixture to set up paths self.data_dir = FIXTURES_FOLDER - self.testPath = DEFAULT_TEST_FILE + self.test_path = DEFAULT_TEST_FILE def test_stream_read(self): def read_metadata(): - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: reader = Reader("image/jpeg", file) json_data = reader.json() self.assertIn("C.jpg", json_data) @@ -64,7 +65,7 @@ def read_metadata(): def test_stream_read_and_parse(self): def read_and_parse(): - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: reader = Reader("image/jpeg", file) manifest_store = json.loads(reader.json()) title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] @@ -316,6 +317,213 @@ def process_file_with_cache(filename): if errors: self.fail("\n".join(errors)) + +class TestContextualReaderWithThreads(unittest.TestCase): + def setUp(self): + self.data_dir = FIXTURES_FOLDER + self.test_path = DEFAULT_TEST_FILE + + def test_stream_read(self): + def read_metadata(): + ctx = Context() + with open(self.test_path, "rb") as file: + reader = Reader("image/jpeg", file, context=ctx) + json_data = reader.json() + self.assertIn("C.jpg", json_data) + return json_data + + thread1 = threading.Thread(target=read_metadata) + thread2 = threading.Thread(target=read_metadata) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + def test_stream_read_and_parse(self): + def read_and_parse(): + ctx = Context() + with open(self.test_path, "rb") as file: + reader = Reader("image/jpeg", file, context=ctx) + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, "C.jpg") + return manifest_store + + thread1 = threading.Thread(target=read_and_parse) + thread2 = threading.Thread(target=read_and_parse) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + def test_read_all_files(self): + """Test reading C2PA metadata from all files using context APIs.""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + 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_files = {'.DS_Store'} + + def process_file(filename): + if filename in skip_files: + return None + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + ctx = Context() + with open(file_path, "rb") as file: + reader = Reader(mime_type, file, context=ctx) + json_data = reader.json() + manifest = json.loads(json_data) + if "manifests" not in manifest or "active_manifest" not in manifest: + return f"Invalid manifest structure in {filename}" + return None + except Exception as e: + return f"Failed to read metadata from {filename}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + future_to_file = { + executor.submit(process_file, filename): filename + for filename in os.listdir(reading_dir) + } + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename}: {str(e)}") + if errors: + self.fail("\n".join(errors)) + + def test_read_cached_all_files(self): + """Test reading C2PA metadata with cache using context APIs.""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + 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_files = {'.DS_Store'} + + def process_file_with_cache(filename): + if filename in skip_files: + return None + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + ctx = Context() + with open(file_path, "rb") as file: + reader = Reader(mime_type, file, context=ctx) + if reader._manifest_json_str_cache is not None: + return f"JSON cache should be None initially for {filename}" + if reader._manifest_data_cache is not None: + return f"Manifest data cache should be None initially for {filename}" + json_data_1 = reader.json() + if reader._manifest_json_str_cache is None: + return f"JSON cache not set after first json() call for {filename}" + if json_data_1 != reader._manifest_json_str_cache: + return f"JSON cache doesn't match return value for {filename}" + json_data_2 = reader.json() + if json_data_1 != json_data_2: + return f"JSON inconsistency for {filename}" + if not isinstance(json_data_1, str): + return f"JSON data is not a string for {filename}" + try: + active_manifest = reader.get_active_manifest() + if not isinstance(active_manifest, dict): + return f"Active manifest not dict for {filename}" + if reader._manifest_json_str_cache is None: + return f"JSON cache not set after get_active_manifest for {filename}" + if reader._manifest_data_cache is None: + return f"Manifest data cache not set after get_active_manifest for {filename}" + active_manifest_2 = reader.get_active_manifest() + if active_manifest != active_manifest_2: + return f"Active manifest cache inconsistency for {filename}" + validation_state = reader.get_validation_state() + validation_results = reader.get_validation_results() + validation_state_2 = reader.get_validation_state() + if validation_state != validation_state_2: + return f"Validation state cache inconsistency for {filename}" + validation_results_2 = reader.get_validation_results() + if validation_results != validation_results_2: + return f"Validation results cache inconsistency for {filename}" + except KeyError: + pass + manifest = json.loads(json_data_1) + if "manifests" not in manifest: + return f"Missing 'manifests' key in {filename}" + if "active_manifest" not in manifest: + return f"Missing 'active_manifest' key in {filename}" + reader.close() + if reader._manifest_json_str_cache is not None: + return f"JSON cache not cleared for {filename}" + if reader._manifest_data_cache is not None: + return f"Manifest data cache not cleared for {filename}" + return None + except Exception as e: + return f"Failed to read cached metadata from {filename}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + future_to_file = { + executor.submit(process_file_with_cache, filename): filename + for filename in os.listdir(reading_dir) + } + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename}: {str(e)}") + if errors: + self.fail("\n".join(errors)) + + class TestBuilderWithThreads(unittest.TestCase): def setUp(self): # Use the fixtures_dir fixture to set up paths @@ -334,10 +542,10 @@ def setUp(self): ) self.signer = Signer.from_info(self.signer_info) - self.testPath = DEFAULT_TEST_FILE - self.testPath2 = INGREDIENT_TEST_FILE - self.testPath3 = OTHER_ALTERNATIVE_INGREDIENT_TEST_FILE - self.testPath4 = ALTERNATIVE_INGREDIENT_TEST_FILE + self.test_path = DEFAULT_TEST_FILE + self.test_path2 = INGREDIENT_TEST_FILE + self.test_path3 = OTHER_ALTERNATIVE_INGREDIENT_TEST_FILE + self.test_path4 = ALTERNATIVE_INGREDIENT_TEST_FILE # For that test manifest, we use a placeholder assertion with content # varying depending on thread/manifest, to check for data scrambling. @@ -659,7 +867,7 @@ def test_parallel_manifest_writing(self): output2 = io.BytesIO(bytearray()) def write_manifest(manifest_def, output_stream, thread_id): - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: builder = Builder(manifest_def) builder.sign(self.signer, "image/jpeg", file, output_stream) output_stream.seek(0) @@ -907,7 +1115,7 @@ def test_concurrent_read_after_write(self): def write_manifest(): try: - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: builder = Builder(self.manifestDefinition_1) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) @@ -980,7 +1188,7 @@ def test_concurrent_read_write_multiple_readers(self): def write_manifest(): try: - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: builder = Builder(self.manifestDefinition_1) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) # Reset stream position after write @@ -1069,7 +1277,7 @@ def test_resource_contention_read(self): stream_lock = threading.Lock() # Lock for stream access # First write some data to read - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: builder = Builder(self.manifestDefinition_1) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) @@ -1150,7 +1358,7 @@ def test_resource_contention_read_parallel(self): start_times_lock = threading.Lock() # First write some data to read - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: builder = Builder(self.manifestDefinition_1) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) @@ -1237,7 +1445,7 @@ def archive_sign( manifest_def, thread_id): try: - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: # Create and save archive builder = Builder(manifest_def) builder.to_archive(archive_stream) @@ -1339,7 +1547,7 @@ def test_sign_all_files_twice(self): def sign_file(output_stream, manifest_def, thread_id): try: - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: # Sign the file builder = Builder(manifest_def) builder.sign( @@ -1444,7 +1652,7 @@ def test_concurrent_read_after_write_async(self): async def write_manifest(): nonlocal write_success try: - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: builder = Builder(self.manifestDefinition_1) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) @@ -1543,7 +1751,7 @@ def test_resource_contention_read_parallel_async(self): start_barrier = asyncio.Barrier(reader_count) # First write some data to read - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: builder = Builder(self.manifestDefinition_1) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) @@ -1610,257 +1818,60 @@ 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._builder is not None + 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 + TOTAL_THREADS_USED = 12 + + # Define the specific files to use as ingredients + # Those files should be valid to use as ingredient + ingredient_files = [ + os.path.join(self.data_dir, "A_thumbnail.jpg"), + os.path.join(self.data_dir, "C.jpg"), + os.path.join(self.data_dir, "cloud.jpg") + ] # Thread synchronization - add_errors = [] - add_lock = threading.Lock() + thread_results = {} completed_threads = 0 - completion_lock = threading.Lock() + thread_lock = threading.Lock() # Lock for thread-safe access to shared data - def add_ingredient_from_stream(ingredient_json, file_path, thread_id): + def thread_work(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) - ) + # Create a new builder for this thread + builder = Builder.from_json(self.manifestDefinition) - # Start both threads - thread1.start() - thread2.start() + # Add each ingredient + for i, file_path in enumerate(ingredient_files, 1): + ingredient_json = json.dumps({ + "title": f"Thread {thread_id} Ingredient {i} - {os.path.basename(file_path)}" + }) - # Wait for both threads to complete - thread1.join() - thread2.join() + with open(file_path, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) - # 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)) + # Use A.jpg as the file to sign + sign_file_path = os.path.join(self.data_dir, "A.jpg") - # 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") + # Sign the file + with open(sign_file_path, "rb") as file: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", file, output) - # 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) + # Ensure all data is written + output.flush() - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] + # Get the complete data + output_data = output.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] + # Create a new BytesIO with the complete data + input_stream = io.BytesIO(output_data) - # 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._builder 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 - TOTAL_THREADS_USED = 12 - - # Define the specific files to use as ingredients - # THose files should be valid to use as ingredient - ingredient_files = [ - os.path.join(self.data_dir, "A_thumbnail.jpg"), - os.path.join(self.data_dir, "C.jpg"), - os.path.join(self.data_dir, "cloud.jpg") - ] - - # Thread synchronization - thread_results = {} - completed_threads = 0 - thread_lock = threading.Lock() # Lock for thread-safe access to shared data - - def thread_work(thread_id): - nonlocal completed_threads - try: - # Create a new builder for this thread - builder = Builder.from_json(self.manifestDefinition) - - # Add each ingredient - for i, file_path in enumerate(ingredient_files, 1): - ingredient_json = json.dumps({ - "title": f"Thread {thread_id} Ingredient {i} - {os.path.basename(file_path)}" - }) - - with open(file_path, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - # Use A.jpg as the file to sign - sign_file_path = os.path.join(self.data_dir, "A.jpg") - - # Sign the file - with open(sign_file_path, "rb") as file: - output = io.BytesIO() - builder.sign(self.signer, "image/jpeg", file, output) - - # Ensure all data is written - output.flush() - - # Get the complete data - output_data = output.getvalue() - - # Create a new BytesIO with the complete data - input_stream = io.BytesIO(output_data) - - # Now read and verify the signed manifest - reader = Reader("image/jpeg", input_stream) - json_data = reader.json() - manifest_data = json.loads(json_data) + # Now read and verify the signed manifest + reader = Reader("image/jpeg", input_stream) + json_data = reader.json() + manifest_data = json.loads(json_data) # Store results for verification with thread_lock: @@ -1977,5 +1988,768 @@ def thread_work(thread_id): other_manifest["active_manifest"], f"Thread {thread_id} and {other_thread_id} share the same active manifest ID") + +class TestContextualBuilderWithThreads(TestBuilderWithThreads): + """Same as TestBuilderWithThreads but using only the context APIs (Context, Builder/Reader with context=ctx).""" + + def test_sign_all_files(self): + """Test signing all files using a thread pool with Context""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + 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 = {'sample3.invalid.wav'} + + def sign_file(filename, thread_id): + if filename in skip_files: + return None + file_path = os.path.join(signing_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + with open(file_path, "rb") as file: + manifest_def = self.manifestDefinition_2 if thread_id % 2 == 0 else self.manifestDefinition_1 + expected_author = "Tester Two" if thread_id % 2 == 0 else "Tester One" + ctx = Context() + builder = Builder(manifest_def, ctx) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + read_ctx = Context() + reader = Reader(mime_type, output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + 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) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + output.close() + return None + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign {filename} in thread {thread_id}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + future_to_file = { + executor.submit(sign_file, filename, i): (filename, i) + for i, filename in enumerate(all_files) + } + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename, thread_id = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename} in thread {thread_id}: {str(e)}") + if errors: + self.fail("\n".join(errors)) + + def test_sign_all_files_async(self): + """Test signing all files using asyncio with Context""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + 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 = {'sample3.invalid.wav'} + + async def async_sign_file(filename, thread_id): + if filename in skip_files: + return None + file_path = os.path.join(signing_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + with open(file_path, "rb") as file: + manifest_def = self.manifestDefinition_2 if thread_id % 2 == 0 else self.manifestDefinition_1 + expected_author = "Tester Two" if thread_id % 2 == 0 else "Tester One" + ctx = Context() + builder = Builder(manifest_def, ctx) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + read_ctx = Context() + reader = Reader(mime_type, output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + 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) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + output.close() + return None + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign {filename} in thread {thread_id}: {str(e)}" + + async def run_async_tests(): + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + tasks = [asyncio.create_task(async_sign_file(f, i)) for i, f in enumerate(all_files)] + results = await asyncio.gather(*tasks, return_exceptions=True) + errors = [] + for result in results: + if isinstance(result, Exception): + errors.append(str(result)) + elif result: + errors.append(result) + if errors: + self.fail("\n".join(errors)) + asyncio.run(run_async_tests()) + + def test_parallel_manifest_writing(self): + """Test writing different manifests in parallel using context APIs""" + output1 = io.BytesIO(bytearray()) + output2 = io.BytesIO(bytearray()) + + def write_manifest(manifest_def, output_stream, thread_id): + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(manifest_def, ctx) + builder.sign(self.signer, "image/jpeg", file, output_stream) + output_stream.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output_stream, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], f"python_test_{thread_id}/0.0.1") + self.assertEqual(active_manifest["title"], f"Python Test Image {thread_id}") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], f"Tester {'One' if thread_id == 1 else 'Two'}") + break + return active_manifest + + thread1 = threading.Thread(target=write_manifest, args=(self.manifestDefinition_1, output1, 1)) + thread2 = threading.Thread(target=write_manifest, args=(self.manifestDefinition_2, output2, 2)) + thread1.start() + thread2.start() + thread2.join() + thread1.join() + output1.seek(0) + output2.seek(0) + read_ctx1 = Context() + read_ctx2 = Context() + reader1 = Reader("image/jpeg", output1, context=read_ctx1) + reader2 = Reader("image/jpeg", output2, context=read_ctx2) + manifest_store1 = json.loads(reader1.json()) + manifest_store2 = json.loads(reader2.json()) + active_manifest1 = manifest_store1["manifests"][manifest_store1["active_manifest"]] + active_manifest2 = manifest_store2["manifests"][manifest_store2["active_manifest"]] + self.assertNotEqual(active_manifest1["claim_generator"], active_manifest2["claim_generator"]) + self.assertNotEqual(active_manifest1["title"], active_manifest2["title"]) + output1.close() + output2.close() + + def test_parallel_sign_all_files_interleaved(self): + """Test signing all files with context APIs, thread pool cycling through manifest definitions""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + 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 = {'sample3.invalid.wav'} + thread_counter = 0 + thread_counter_lock = threading.Lock() + thread_execution_order = [] + thread_order_lock = threading.Lock() + + def sign_file(filename, thread_id): + nonlocal thread_counter + if filename in skip_files: + return None + file_path = os.path.join(signing_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + with open(file_path, "rb") as file: + if thread_id % 3 == 0: + manifest_def = self.manifestDefinition + expected_author = "Tester" + expected_thread = "" + elif thread_id % 3 == 1: + manifest_def = self.manifestDefinition_1 + expected_author = "Tester One" + expected_thread = "1" + else: + manifest_def = self.manifestDefinition_2 + expected_author = "Tester Two" + expected_thread = "2" + with thread_counter_lock: + current_count = thread_counter + thread_counter += 1 + with thread_order_lock: + thread_execution_order.append((current_count, thread_id)) + time.sleep(0.01) + ctx = Context() + builder = Builder(manifest_def, ctx) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + read_ctx = Context() + reader = Reader(mime_type, output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + expected_claim_generator = "python_test/0.0.1" if thread_id % 3 == 0 else f"python_test_{expected_thread}/0.0.1" + self.assertEqual(active_manifest["claim_generator"], expected_claim_generator) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + output.close() + return None + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign {filename} in thread {thread_id}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + future_to_file = {executor.submit(sign_file, filename, i): (filename, i) for i, filename in enumerate(all_files)} + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename, thread_id = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename} in thread {thread_id}: {str(e)}") + max_same_thread_sequence = 3 + current_sequence = 1 + current_thread = thread_execution_order[0][1] if thread_execution_order else None + for i in range(1, len(thread_execution_order)): + 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") + else: + current_sequence = 1 + current_thread = thread_execution_order[i][1] + if errors: + self.fail("\n".join(errors)) + + def test_concurrent_read_after_write(self): + """Test reading from a file after writing is complete, using context APIs""" + output = io.BytesIO(bytearray()) + write_complete = threading.Event() + write_errors = [] + read_errors = [] + + def write_manifest(): + try: + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + def read_manifest(): + try: + write_complete.wait() + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Read error: {str(e)}") + + read_thread = threading.Thread(target=read_manifest) + write_thread = threading.Thread(target=write_manifest) + read_thread.start() + write_thread.start() + write_thread.join() + read_thread.join() + output.close() + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + + def test_concurrent_read_write_multiple_readers(self): + """Test multiple readers reading after write, using context APIs""" + output = io.BytesIO(bytearray()) + write_complete = threading.Event() + write_errors = [] + read_errors = [] + reader_count = 3 + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() + + def write_manifest(): + try: + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + write_complete.wait() + with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + + write_thread = threading.Thread(target=write_manifest) + write_thread.start() + read_threads = [threading.Thread(target=read_manifest, args=(i,)) for i in range(reader_count)] + for t in read_threads: + t.start() + write_thread.join() + for t in read_threads: + t.join() + output.close() + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + def test_resource_contention_read(self): + """Test multiple threads reading the same file with context APIs""" + output = io.BytesIO(bytearray()) + read_errors = [] + reader_count = 5 + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() + + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + time.sleep(0.01) + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + + read_threads = [threading.Thread(target=read_manifest, args=(i,)) for i in range(reader_count)] + for t in read_threads: + t.start() + for t in read_threads: + t.join() + output.close() + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + def test_resource_contention_read_parallel(self): + """Test multiple threads starting simultaneously to read with context APIs""" + output = io.BytesIO(bytearray()) + read_errors = [] + reader_count = 5 + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() + start_barrier = threading.Barrier(reader_count) + + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + start_barrier.wait() + with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + + read_threads = [threading.Thread(target=read_manifest, args=(i,)) for i in range(reader_count)] + for t in read_threads: + t.start() + for t in read_threads: + t.join() + output.close() + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + def test_sign_all_files_twice(self): + """Test signing the same file twice with different manifests using context APIs""" + output1 = io.BytesIO(bytearray()) + output2 = io.BytesIO(bytearray()) + sign_errors = [] + thread_results = {} + thread_lock = threading.Lock() + + def sign_file(output_stream, manifest_def, thread_id): + try: + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(manifest_def, ctx) + builder.sign(self.signer, "image/jpeg", file, output_stream) + output_stream.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output_stream, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + if thread_id == 1: + expected_claim_generator = "python_test_1/0.0.1" + expected_author = "Tester One" + else: + expected_claim_generator = "python_test_2/0.0.1" + expected_author = "Tester Two" + with thread_lock: + thread_results[thread_id] = {'manifest': active_manifest} + self.assertEqual(active_manifest["claim_generator"], expected_claim_generator) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + return None + except Exception as e: + return f"Thread {thread_id} error: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + future1 = executor.submit(sign_file, output1, self.manifestDefinition_1, 1) + future2 = executor.submit(sign_file, output2, self.manifestDefinition_2, 2) + for future in concurrent.futures.as_completed([future1, future2]): + error = future.result() + if error: + sign_errors.append(error) + if sign_errors: + self.fail("\n".join(sign_errors)) + self.assertEqual(len(thread_results), 2) + output1.seek(0) + output2.seek(0) + read_ctx1 = Context() + read_ctx2 = Context() + reader1 = Reader("image/jpeg", output1, context=read_ctx1) + reader2 = Reader("image/jpeg", output2, context=read_ctx2) + manifest_store1 = json.loads(reader1.json()) + manifest_store2 = json.loads(reader2.json()) + active_manifest1 = manifest_store1["manifests"][manifest_store1["active_manifest"]] + active_manifest2 = manifest_store2["manifests"][manifest_store2["active_manifest"]] + self.assertNotEqual(active_manifest1["claim_generator"], active_manifest2["claim_generator"]) + self.assertNotEqual(active_manifest1["title"], active_manifest2["title"]) + output1.close() + output2.close() + + def test_concurrent_read_after_write_async(self): + """Test read after write using asyncio with context APIs""" + output = io.BytesIO(bytearray()) + write_complete = asyncio.Event() + write_errors = [] + read_errors = [] + write_success = False + + async def write_manifest(): + nonlocal write_success + try: + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + write_success = True + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + async def read_manifest(): + try: + await write_complete.wait() + if not write_success: + raise Exception("Write operation did not complete successfully") + self.assertGreater(len(output.getvalue()), 0) + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + self.assertIn("manifests", manifest_store) + self.assertIn("active_manifest", manifest_store) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + author_found = False + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + author_found = True + break + self.assertTrue(author_found) + except Exception as e: + read_errors.append(f"Read error: {str(e)}") + + async def run_async_tests(): + write_task = asyncio.create_task(write_manifest()) + await write_task + read_task = asyncio.create_task(read_manifest()) + await read_task + asyncio.run(run_async_tests()) + output.close() + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + + def test_resource_contention_read_parallel_async(self): + """Test multiple async tasks reading the same file with context APIs""" + output = io.BytesIO(bytearray()) + read_errors = [] + reader_count = 5 + active_readers = 0 + readers_lock = asyncio.Lock() + stream_lock = asyncio.Lock() + start_barrier = asyncio.Barrier(reader_count) + + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + async def read_manifest(reader_id): + nonlocal active_readers + try: + async with readers_lock: + active_readers += 1 + await start_barrier.wait() + async with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + async with readers_lock: + active_readers -= 1 + + async def run_async_tests(): + tasks = [asyncio.create_task(read_manifest(i)) for i in range(reader_count)] + await asyncio.gather(*tasks) + asyncio.run(run_async_tests()) + output.close() + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + 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 + ingredient_files = [ + os.path.join(self.data_dir, "A_thumbnail.jpg"), + os.path.join(self.data_dir, "C.jpg"), + os.path.join(self.data_dir, "cloud.jpg") + ] + thread_results = {} + completed_threads = 0 + thread_lock = threading.Lock() + + def thread_work(thread_id): + nonlocal completed_threads + try: + ctx = Context() + builder = Builder.from_json(self.manifestDefinition, context=ctx) + for i, file_path in enumerate(ingredient_files, 1): + ingredient_json = json.dumps({"title": f"Thread {thread_id} Ingredient {i} - {os.path.basename(file_path)}"}) + with open(file_path, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + sign_file_path = os.path.join(self.data_dir, "A.jpg") + with open(sign_file_path, "rb") as file: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", file, output) + output.flush() + output_data = output.getvalue() + input_stream = io.BytesIO(output_data) + read_ctx = Context() + reader = Reader("image/jpeg", input_stream, context=read_ctx) + json_data = reader.json() + manifest_data = json.loads(json_data) + with thread_lock: + thread_results[thread_id] = { + 'manifest': manifest_data, + 'ingredient_files': [os.path.basename(f) for f in ingredient_files], + 'sign_file': os.path.basename(sign_file_path), + 'manifest_hash': hash(json.dumps(manifest_data, sort_keys=True)) + } + output.close() + input_stream.close() + builder.close() + except Exception as e: + with thread_lock: + thread_results[thread_id] = {'error': str(e)} + finally: + with thread_lock: + completed_threads += 1 + + threads = [threading.Thread(target=thread_work, args=(i,)) for i in range(1, TOTAL_THREADS_USED + 1)] + for t in threads: + t.start() + for t in threads: + t.join() + self.assertEqual(completed_threads, TOTAL_THREADS_USED) + self.assertEqual(len(thread_results), TOTAL_THREADS_USED) + manifest_hashes = set() + thread_manifest_data = {} + for thread_id in range(1, TOTAL_THREADS_USED + 1): + result = thread_results[thread_id] + if 'error' in result: + self.fail(f"Thread {thread_id} failed with error: {result['error']}") + manifest_data = result['manifest'] + ingredient_files_basename = result['ingredient_files'] + manifest_hash = result['manifest_hash'] + thread_manifest_data[thread_id] = manifest_data + manifest_hashes.add(manifest_hash) + 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"]), 3) + ingredient_titles = [ing["title"] for ing in active_manifest["ingredients"]] + for i, file_name in enumerate(ingredient_files_basename, 1): + self.assertIn(f"Thread {thread_id} Ingredient {i} - {file_name}", ingredient_titles) + for other_thread_id in range(1, TOTAL_THREADS_USED + 1): + if other_thread_id != thread_id: + for title in ingredient_titles: + self.assertNotIn(f"Thread {other_thread_id} Ingredient", title) + self.assertEqual(len(manifest_hashes), TOTAL_THREADS_USED) + for thread_id in range(1, TOTAL_THREADS_USED + 1): + current_manifest = thread_manifest_data[thread_id] + self.assertIn("active_manifest", current_manifest) + self.assertIn("manifests", current_manifest) + for other_thread_id in range(1, TOTAL_THREADS_USED + 1): + if other_thread_id != thread_id: + self.assertNotEqual(current_manifest["active_manifest"], thread_manifest_data[other_thread_id]["active_manifest"]) + + if __name__ == '__main__': unittest.main()