From f2a89511049502a2e51c6b836e829a5e9be79263 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 08:43:14 -0700 Subject: [PATCH 01/46] fix: create signer tests --- tests/test_unit_tests.py | 67 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index a2a07870..826a6837 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -18,9 +18,13 @@ from unittest.mock import mock_open, patch import ctypes import warnings +import pytest +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.backends import default_backend from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version -from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings +from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer # Suppress deprecation warnings warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -1006,5 +1010,66 @@ def test_sign_file(self): os.remove(output_path) +class TestCreateSigner(unittest.TestCase): + """Test cases for the create_signer function.""" + + def setUp(self): + """Set up test fixtures.""" + self.data_dir = FIXTURES_DIR + + # Load test certificates and key + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + self.certs = cert_file.read().decode('utf-8') + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + self.key = key_file.read().decode('utf-8') + + def test_create_signer_with_callback(self): + """Test creating a signer with a callback function.""" + def mock_sign_callback(data: bytes) -> bytes: + """Mock signing callback that returns a fake signature.""" + # Return a fake signature (64 bytes for Ed25519) + return b"fake_signature_" + b"0" * 50 + + # Test with Ed25519 algorithm + signer = create_signer( + callback=mock_sign_callback, + alg=SigningAlg.ED25519, + certs=self.certs + ) + + # Verify the signer was created successfully + self.assertIsInstance(signer, Signer) + self.assertFalse(signer.closed) + + # Test that reserve_size works + reserve_size = signer.reserve_size() + self.assertIsInstance(reserve_size, int) + self.assertGreaterEqual(reserve_size, 0) + + # Clean up + signer.close() + + def test_create_signer_callback_error_handling(self): + """Test that callback errors are properly handled.""" + def error_callback(data: bytes) -> bytes: + """Callback that raises an exception.""" + raise ValueError("Test callback error") + + # The create_signer function doesn't wrap callbacks with error handling + # The error handling happens when the callback is actually called during signing + # So creating the signer should succeed, but using it might fail + signer = create_signer( + callback=error_callback, + alg=SigningAlg.ES256, + certs=self.certs + ) + + # Verify the signer was created successfully + self.assertIsInstance(signer, Signer) + self.assertFalse(signer.closed) + + # Clean up + signer.close() + if __name__ == '__main__': unittest.main() From 32a14b87612fa3509ae62bed131e41f083b371a7 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 08:59:11 -0700 Subject: [PATCH 02/46] fix: Test repro --- tests/test_unit_tests.py | 71 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 826a6837..b5f22e01 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -752,6 +752,71 @@ def test_sign_file(self): # Clean up the temporary directory shutil.rmtree(temp_dir) + def test_sign_file_callback_signer(self): + """Test signing a file using the sign_file method.""" + import tempfile + import shutil + + # Create a temporary directory for the test + temp_dir = tempfile.mkdtemp() + try: + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed_output.jpg") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + + # Create a real ES256 signing callback + def sign_callback(data: bytes) -> bytes: + """Real ES256 signing callback that creates actual signatures.""" + # Load the private key from the test fixtures + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + private_key_data = key_file.read() + + # Load the private key using cryptography + private_key = serialization.load_pem_private_key( + private_key_data, + password=None, + backend=default_backend() + ) + + # Create the signature using ES256 (ECDSA with SHA-256) + signature = private_key.sign( + data, + padding=None, # ECDSA doesn't use padding + algorithm=hashes.SHA256() + ) + + return signature + + # Create signer with callback + signer = create_signer( + callback=sign_callback, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + result = 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)) + + # 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) + class TestStream(unittest.TestCase): def setUp(self): @@ -1027,13 +1092,13 @@ def test_create_signer_with_callback(self): """Test creating a signer with a callback function.""" def mock_sign_callback(data: bytes) -> bytes: """Mock signing callback that returns a fake signature.""" - # Return a fake signature (64 bytes for Ed25519) + # Return a fake signature (64 bytes for ES256) return b"fake_signature_" + b"0" * 50 - # Test with Ed25519 algorithm + # Test with ES256 algorithm signer = create_signer( callback=mock_sign_callback, - alg=SigningAlg.ED25519, + alg=SigningAlg.ES256, certs=self.certs ) From 07729ac17d6aa597245b57a92bbc5945d53d7bb0 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 09:00:36 -0700 Subject: [PATCH 03/46] fix: Clean up --- tests/test_unit_tests.py | 62 ---------------------------------------- 1 file changed, 62 deletions(-) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index b5f22e01..c7da4d13 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -1074,67 +1074,5 @@ def test_sign_file(self): if os.path.exists(output_path): os.remove(output_path) - -class TestCreateSigner(unittest.TestCase): - """Test cases for the create_signer function.""" - - def setUp(self): - """Set up test fixtures.""" - self.data_dir = FIXTURES_DIR - - # Load test certificates and key - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: - self.certs = cert_file.read().decode('utf-8') - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - self.key = key_file.read().decode('utf-8') - - def test_create_signer_with_callback(self): - """Test creating a signer with a callback function.""" - def mock_sign_callback(data: bytes) -> bytes: - """Mock signing callback that returns a fake signature.""" - # Return a fake signature (64 bytes for ES256) - return b"fake_signature_" + b"0" * 50 - - # Test with ES256 algorithm - signer = create_signer( - callback=mock_sign_callback, - alg=SigningAlg.ES256, - certs=self.certs - ) - - # Verify the signer was created successfully - self.assertIsInstance(signer, Signer) - self.assertFalse(signer.closed) - - # Test that reserve_size works - reserve_size = signer.reserve_size() - self.assertIsInstance(reserve_size, int) - self.assertGreaterEqual(reserve_size, 0) - - # Clean up - signer.close() - - def test_create_signer_callback_error_handling(self): - """Test that callback errors are properly handled.""" - def error_callback(data: bytes) -> bytes: - """Callback that raises an exception.""" - raise ValueError("Test callback error") - - # The create_signer function doesn't wrap callbacks with error handling - # The error handling happens when the callback is actually called during signing - # So creating the signer should succeed, but using it might fail - signer = create_signer( - callback=error_callback, - alg=SigningAlg.ES256, - certs=self.certs - ) - - # Verify the signer was created successfully - self.assertIsInstance(signer, Signer) - self.assertFalse(signer.closed) - - # Clean up - signer.close() - if __name__ == '__main__': unittest.main() From a60d37602dd936cc57d3bd15c7a2afbfe9e0693a Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 19:03:02 -0700 Subject: [PATCH 04/46] fix: Plenty of debug logs --- src/c2pa/c2pa.py | 221 +++++++++++++++++++++++++++++++++++++-- tests/test_unit_tests.py | 10 +- 2 files changed, 216 insertions(+), 15 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 40c15316..4dec4b47 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -9,6 +9,33 @@ import time from .lib import dynamically_load_library import mimetypes +import logging + +# Force output to stderr immediately +sys.stderr.write("## c2pa module loading - logging setup starting\n") +sys.stderr.flush() + +# Configure logging for the c2pa module +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + force=True # Force reconfiguration even if already configured +) + +# Get the logger for this module +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +# Ensure the logger has a handler +if not logger.handlers: + handler = logging.StreamHandler(sys.stderr) # Force to stderr + handler.setLevel(logging.DEBUG) + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) + +sys.stderr.write("## c2pa module loading - logging setup complete\n") +sys.stderr.flush() # Define required function names _REQUIRED_FUNCTIONS = [ @@ -1308,6 +1335,13 @@ def from_callback( C2paError: If there was an error creating the signer C2paError.Encoding: If the certificate data or TSA URL contains invalid UTF-8 characters """ + import logging + logger = logging.getLogger(__name__) + + sys.stderr.write("## Signer.from_callback called\n") + sys.stderr.flush() + logger.info("Signer.from_callback called") + # Validate inputs before creating if not certs: raise C2paError( @@ -1317,17 +1351,74 @@ def from_callback( raise C2paError( cls._error_messages['invalid_tsa'].format("Invalid TSA URL format")) + sys.stderr.write("## Creating wrapped_callback\n") + sys.stderr.flush() + logger.info("Creating wrapped_callback") + # Create a wrapper callback that handles errors and memory management - def wrapped_callback(data: bytes) -> bytes: + def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): + logger = logging.getLogger(__name__) + + sys.stderr.write(f"## create_signer wrapped_callback: context: {context}, data_ptr: {data_ptr}, data_len: {data_len}, signed_bytes_ptr: {signed_bytes_ptr}, signed_len: {signed_len}\n") + sys.stderr.flush() + logger.debug(f"create_signer wrapped_callback called: context={context}, data_len={data_len}, signed_len={signed_len}") + try: + if not data_ptr or data_len <= 0: + error_msg = f"Invalid input: data_ptr={data_ptr}, data_len={data_len}" + sys.stderr.write(f"## ERROR: {error_msg}\n") + sys.stderr.flush() + logger.error(error_msg) + return 0 # Error: invalid input + + # Convert C pointer to Python bytes + data = bytes(data_ptr[:data_len]) + sys.stderr.write(f"## Converted data: {len(data)} bytes\n") + sys.stderr.flush() + logger.debug(f"Converted data: {len(data)} bytes") + if not data: - raise ValueError("Empty data provided for signing") - return callback(data) + error_msg = "Empty data after conversion" + sys.stderr.write(f"## ERROR: {error_msg}\n") + sys.stderr.flush() + logger.error(error_msg) + return 0 # Error: empty data + + # Call the user's callback + sys.stderr.write("## Calling user callback...\n") + sys.stderr.flush() + logger.debug("Calling user callback...") + signature = callback(data) + sys.stderr.write(f"## User callback returned: {len(signature) if signature else 0} bytes\n") + sys.stderr.flush() + logger.debug(f"User callback returned: {len(signature) if signature else 0} bytes") + + if not signature: + error_msg = "User callback returned empty signature" + sys.stderr.write(f"## ERROR: {error_msg}\n") + sys.stderr.flush() + logger.error(error_msg) + return 0 # Error: empty signature + + # Copy the signature back to the C buffer + actual_len = min(len(signature), signed_len) + sys.stderr.write(f"## Copying {actual_len} bytes to buffer (signature_len={len(signature)}, signed_len={signed_len})\n") + sys.stderr.flush() + logger.debug(f"Copying {actual_len} bytes to buffer") + + for i in range(actual_len): + signed_bytes_ptr[i] = signature[i] + + sys.stderr.write(f"## Successfully copied signature, returning {actual_len}\n") + sys.stderr.flush() + logger.debug(f"Successfully copied signature, returning {actual_len}") + return actual_len # Return the number of bytes written except Exception as e: - print( - cls._error_messages['callback_error'].format( - str(e)), file=sys.stderr) - raise C2paError.Signature(str(e)) + error_msg = f"Exception in wrapped_callback: {e}" + sys.stderr.write(f"## ERROR: {error_msg}\n") + sys.stderr.flush() + logger.error(error_msg) + return 0 # Return 0 to indicate error # Encode strings with error handling try: @@ -1339,21 +1430,38 @@ def wrapped_callback(data: bytes) -> bytes: str(e))) # Create the signer with the wrapped callback + # Store the callback as an instance attribute to keep it alive + signer_instance = cls.__new__(cls) + signer_instance._callback_cb = SignerCallback(wrapped_callback) + + sys.stderr.write("## About to call c2pa_signer_create from create_signer\n") + sys.stderr.flush() + logger.info("About to call c2pa_signer_create from create_signer") + signer_ptr = _lib.c2pa_signer_create( None, # context - SignerCallback(wrapped_callback), + signer_instance._callback_cb, alg, certs_bytes, tsa_url_bytes ) + sys.stderr.write(f"## c2pa_signer_create returned: {signer_ptr}\n") + sys.stderr.flush() + logger.info(f"c2pa_signer_create returned: {signer_ptr}") + if not signer_ptr: error = _parse_operation_result_for_error(_lib.c2pa_error()) if error: raise C2paError(error) raise C2paError("Failed to create signer") - return cls(signer_ptr) + # Initialize the signer instance + signer_instance._signer = signer_ptr + signer_instance._closed = False + signer_instance._error_messages = cls._error_messages + + return signer_instance def __enter__(self): """Context manager entry.""" @@ -1895,6 +2003,13 @@ def create_signer( C2paError: If there was an error creating the signer C2paError.Encoding: If the certificate data or TSA URL contains invalid UTF-8 characters """ + import logging + logger = logging.getLogger(__name__) + + sys.stderr.write("## create_signer called\n") + sys.stderr.flush() + logger.info("create_signer called") + try: certs_bytes = certs.encode('utf-8') tsa_url_bytes = tsa_url.encode('utf-8') if tsa_url else None @@ -1902,18 +2017,102 @@ def create_signer( raise C2paError.Encoding( f"Invalid UTF-8 characters in certificate data or TSA URL: {str(e)}") + sys.stderr.write("## About to call c2pa_signer_create from create_signer\n") + sys.stderr.flush() + logger.info("About to call c2pa_signer_create from create_signer") + + # Create a wrapper callback that handles errors and memory management + def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): + logger = logging.getLogger(__name__) + + sys.stderr.write(f"## create_signer wrapped_callback: context: {context}, data_ptr: {data_ptr}, data_len: {data_len}, signed_bytes_ptr: {signed_bytes_ptr}, signed_len: {signed_len}\n") + sys.stderr.flush() + logger.debug(f"create_signer wrapped_callback called: context={context}, data_len={data_len}, signed_len={signed_len}") + + # Add immediate return for debugging + sys.stderr.write("## wrapped_callback entered - about to process\n") + sys.stderr.flush() + + try: + if not data_ptr or data_len <= 0: + error_msg = f"Invalid input: data_ptr={data_ptr}, data_len={data_len}" + sys.stderr.write(f"## ERROR: {error_msg}\n") + sys.stderr.flush() + logger.error(error_msg) + return 0 # Error: invalid input + + # Convert C pointer to Python bytes + data = bytes(data_ptr[:data_len]) + sys.stderr.write(f"## Converted data: {len(data)} bytes\n") + sys.stderr.flush() + logger.debug(f"Converted data: {len(data)} bytes") + + if not data: + error_msg = "Empty data after conversion" + sys.stderr.write(f"## ERROR: {error_msg}\n") + sys.stderr.flush() + logger.error(error_msg) + return 0 # Error: empty data + + # Call the user's callback + sys.stderr.write("## Calling user callback...\n") + sys.stderr.flush() + logger.debug("Calling user callback...") + signature = callback(data) + sys.stderr.write(f"## User callback returned: {len(signature) if signature else 0} bytes\n") + sys.stderr.flush() + logger.debug(f"User callback returned: {len(signature) if signature else 0} bytes") + + if not signature: + error_msg = "User callback returned empty signature" + sys.stderr.write(f"## ERROR: {error_msg}\n") + sys.stderr.flush() + logger.error(error_msg) + return 0 # Error: empty signature + + # Copy the signature back to the C buffer + actual_len = min(len(signature), signed_len) + sys.stderr.write(f"## Copying {actual_len} bytes to buffer (signature_len={len(signature)}, signed_len={signed_len})\n") + sys.stderr.flush() + logger.debug(f"Copying {actual_len} bytes to buffer") + + for i in range(actual_len): + signed_bytes_ptr[i] = signature[i] + + sys.stderr.write(f"## Successfully copied signature, returning {actual_len}\n") + sys.stderr.flush() + logger.debug(f"Successfully copied signature, returning {actual_len}") + return actual_len # Return the number of bytes written + except Exception as e: + error_msg = f"Exception in wrapped_callback: {e}" + sys.stderr.write(f"## ERROR: {error_msg}\n") + sys.stderr.flush() + logger.error(error_msg) + return 0 # Return 0 to indicate error + + # Store the callback to keep it alive + if not hasattr(create_signer, '_callbacks'): + create_signer._callbacks = [] + + # Create the C callback and store it + c_callback = SignerCallback(wrapped_callback) + create_signer._callbacks.append(c_callback) # Keep it alive + signer_ptr = _lib.c2pa_signer_create( None, # context - SignerCallback(callback), + c_callback, # Use the stored callback alg, certs_bytes, tsa_url_bytes ) + sys.stderr.write(f"## c2pa_signer_create returned: {signer_ptr}\n") + sys.stderr.flush() + logger.info(f"c2pa_signer_create returned: {signer_ptr}") + 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") diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index c7da4d13..0e4ece5a 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -18,7 +18,6 @@ from unittest.mock import mock_open, patch import ctypes import warnings -import pytest from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.backends import default_backend @@ -38,7 +37,7 @@ class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.55.0", sdk_version()) + self.assertIn("0.57.0", sdk_version()) class TestReader(unittest.TestCase): @@ -781,10 +780,13 @@ def sign_callback(data: bytes) -> bytes: ) # Create the signature using ES256 (ECDSA with SHA-256) + # For ECDSA, we use the signature_algorithm_constructor + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec + signature = private_key.sign( data, - padding=None, # ECDSA doesn't use padding - algorithm=hashes.SHA256() + ec.ECDSA(hashes.SHA256()) ) return signature From 8e791d1f27bbed683d6ddae942dfd18209618d5e Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 19:11:04 -0700 Subject: [PATCH 05/46] fix: Remove debug logs --- src/c2pa/c2pa.py | 148 ++--------------------------------------------- 1 file changed, 6 insertions(+), 142 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 4dec4b47..211c989b 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -9,33 +9,6 @@ import time from .lib import dynamically_load_library import mimetypes -import logging - -# Force output to stderr immediately -sys.stderr.write("## c2pa module loading - logging setup starting\n") -sys.stderr.flush() - -# Configure logging for the c2pa module -logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - force=True # Force reconfiguration even if already configured -) - -# Get the logger for this module -logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) - -# Ensure the logger has a handler -if not logger.handlers: - handler = logging.StreamHandler(sys.stderr) # Force to stderr - handler.setLevel(logging.DEBUG) - formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') - handler.setFormatter(formatter) - logger.addHandler(handler) - -sys.stderr.write("## c2pa module loading - logging setup complete\n") -sys.stderr.flush() # Define required function names _REQUIRED_FUNCTIONS = [ @@ -1335,13 +1308,6 @@ def from_callback( C2paError: If there was an error creating the signer C2paError.Encoding: If the certificate data or TSA URL contains invalid UTF-8 characters """ - import logging - logger = logging.getLogger(__name__) - - sys.stderr.write("## Signer.from_callback called\n") - sys.stderr.flush() - logger.info("Signer.from_callback called") - # Validate inputs before creating if not certs: raise C2paError( @@ -1351,73 +1317,35 @@ def from_callback( raise C2paError( cls._error_messages['invalid_tsa'].format("Invalid TSA URL format")) - sys.stderr.write("## Creating wrapped_callback\n") - sys.stderr.flush() - logger.info("Creating wrapped_callback") - # Create a wrapper callback that handles errors and memory management def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): - logger = logging.getLogger(__name__) - - sys.stderr.write(f"## create_signer wrapped_callback: context: {context}, data_ptr: {data_ptr}, data_len: {data_len}, signed_bytes_ptr: {signed_bytes_ptr}, signed_len: {signed_len}\n") - sys.stderr.flush() - logger.debug(f"create_signer wrapped_callback called: context={context}, data_len={data_len}, signed_len={signed_len}") - try: if not data_ptr or data_len <= 0: - error_msg = f"Invalid input: data_ptr={data_ptr}, data_len={data_len}" - sys.stderr.write(f"## ERROR: {error_msg}\n") - sys.stderr.flush() - logger.error(error_msg) return 0 # Error: invalid input # Convert C pointer to Python bytes data = bytes(data_ptr[:data_len]) - sys.stderr.write(f"## Converted data: {len(data)} bytes\n") - sys.stderr.flush() - logger.debug(f"Converted data: {len(data)} bytes") if not data: - error_msg = "Empty data after conversion" - sys.stderr.write(f"## ERROR: {error_msg}\n") - sys.stderr.flush() - logger.error(error_msg) return 0 # Error: empty data # Call the user's callback - sys.stderr.write("## Calling user callback...\n") - sys.stderr.flush() - logger.debug("Calling user callback...") signature = callback(data) - sys.stderr.write(f"## User callback returned: {len(signature) if signature else 0} bytes\n") - sys.stderr.flush() - logger.debug(f"User callback returned: {len(signature) if signature else 0} bytes") if not signature: - error_msg = "User callback returned empty signature" - sys.stderr.write(f"## ERROR: {error_msg}\n") - sys.stderr.flush() - logger.error(error_msg) return 0 # Error: empty signature # Copy the signature back to the C buffer actual_len = min(len(signature), signed_len) - sys.stderr.write(f"## Copying {actual_len} bytes to buffer (signature_len={len(signature)}, signed_len={signed_len})\n") - sys.stderr.flush() - logger.debug(f"Copying {actual_len} bytes to buffer") - + for i in range(actual_len): signed_bytes_ptr[i] = signature[i] - sys.stderr.write(f"## Successfully copied signature, returning {actual_len}\n") - sys.stderr.flush() - logger.debug(f"Successfully copied signature, returning {actual_len}") return actual_len # Return the number of bytes written except Exception as e: - error_msg = f"Exception in wrapped_callback: {e}" - sys.stderr.write(f"## ERROR: {error_msg}\n") - sys.stderr.flush() - logger.error(error_msg) + print( + cls._error_messages['callback_error'].format( + str(e)), file=sys.stderr) return 0 # Return 0 to indicate error # Encode strings with error handling @@ -1433,11 +1361,7 @@ def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): # Store the callback as an instance attribute to keep it alive signer_instance = cls.__new__(cls) signer_instance._callback_cb = SignerCallback(wrapped_callback) - - sys.stderr.write("## About to call c2pa_signer_create from create_signer\n") - sys.stderr.flush() - logger.info("About to call c2pa_signer_create from create_signer") - + signer_ptr = _lib.c2pa_signer_create( None, # context signer_instance._callback_cb, @@ -1446,10 +1370,6 @@ def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): tsa_url_bytes ) - sys.stderr.write(f"## c2pa_signer_create returned: {signer_ptr}\n") - sys.stderr.flush() - logger.info(f"c2pa_signer_create returned: {signer_ptr}") - if not signer_ptr: error = _parse_operation_result_for_error(_lib.c2pa_error()) if error: @@ -1460,7 +1380,7 @@ def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): signer_instance._signer = signer_ptr signer_instance._closed = False signer_instance._error_messages = cls._error_messages - + return signer_instance def __enter__(self): @@ -2003,13 +1923,6 @@ def create_signer( C2paError: If there was an error creating the signer C2paError.Encoding: If the certificate data or TSA URL contains invalid UTF-8 characters """ - import logging - logger = logging.getLogger(__name__) - - sys.stderr.write("## create_signer called\n") - sys.stderr.flush() - logger.info("create_signer called") - try: certs_bytes = certs.encode('utf-8') tsa_url_bytes = tsa_url.encode('utf-8') if tsa_url else None @@ -2017,77 +1930,32 @@ def create_signer( raise C2paError.Encoding( f"Invalid UTF-8 characters in certificate data or TSA URL: {str(e)}") - sys.stderr.write("## About to call c2pa_signer_create from create_signer\n") - sys.stderr.flush() - logger.info("About to call c2pa_signer_create from create_signer") - # Create a wrapper callback that handles errors and memory management def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): - logger = logging.getLogger(__name__) - - sys.stderr.write(f"## create_signer wrapped_callback: context: {context}, data_ptr: {data_ptr}, data_len: {data_len}, signed_bytes_ptr: {signed_bytes_ptr}, signed_len: {signed_len}\n") - sys.stderr.flush() - logger.debug(f"create_signer wrapped_callback called: context={context}, data_len={data_len}, signed_len={signed_len}") - - # Add immediate return for debugging - sys.stderr.write("## wrapped_callback entered - about to process\n") - sys.stderr.flush() - try: if not data_ptr or data_len <= 0: - error_msg = f"Invalid input: data_ptr={data_ptr}, data_len={data_len}" - sys.stderr.write(f"## ERROR: {error_msg}\n") - sys.stderr.flush() - logger.error(error_msg) return 0 # Error: invalid input # Convert C pointer to Python bytes data = bytes(data_ptr[:data_len]) - sys.stderr.write(f"## Converted data: {len(data)} bytes\n") - sys.stderr.flush() - logger.debug(f"Converted data: {len(data)} bytes") if not data: - error_msg = "Empty data after conversion" - sys.stderr.write(f"## ERROR: {error_msg}\n") - sys.stderr.flush() - logger.error(error_msg) return 0 # Error: empty data # Call the user's callback - sys.stderr.write("## Calling user callback...\n") - sys.stderr.flush() - logger.debug("Calling user callback...") signature = callback(data) - sys.stderr.write(f"## User callback returned: {len(signature) if signature else 0} bytes\n") - sys.stderr.flush() - logger.debug(f"User callback returned: {len(signature) if signature else 0} bytes") if not signature: - error_msg = "User callback returned empty signature" - sys.stderr.write(f"## ERROR: {error_msg}\n") - sys.stderr.flush() - logger.error(error_msg) return 0 # Error: empty signature # Copy the signature back to the C buffer actual_len = min(len(signature), signed_len) - sys.stderr.write(f"## Copying {actual_len} bytes to buffer (signature_len={len(signature)}, signed_len={signed_len})\n") - sys.stderr.flush() - logger.debug(f"Copying {actual_len} bytes to buffer") for i in range(actual_len): signed_bytes_ptr[i] = signature[i] - sys.stderr.write(f"## Successfully copied signature, returning {actual_len}\n") - sys.stderr.flush() - logger.debug(f"Successfully copied signature, returning {actual_len}") return actual_len # Return the number of bytes written except Exception as e: - error_msg = f"Exception in wrapped_callback: {e}" - sys.stderr.write(f"## ERROR: {error_msg}\n") - sys.stderr.flush() - logger.error(error_msg) return 0 # Return 0 to indicate error # Store the callback to keep it alive @@ -2106,10 +1974,6 @@ def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): tsa_url_bytes ) - sys.stderr.write(f"## c2pa_signer_create returned: {signer_ptr}\n") - sys.stderr.flush() - logger.info(f"c2pa_signer_create returned: {signer_ptr}") - if not signer_ptr: error = _parse_operation_result_for_error(_lib.c2pa_error()) if error: From 97374d38a5a0c82d5aa7ef239c9eab8204b93337 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 19:15:20 -0700 Subject: [PATCH 06/46] fix: Clean up --- src/c2pa/c2pa.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 211c989b..c9dd7f7f 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1319,34 +1319,33 @@ def from_callback( # Create a wrapper callback that handles errors and memory management def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): + # Returns 0 on error as this case is handled in the native code gracefully try: if not data_ptr or data_len <= 0: return 0 # Error: invalid input # Convert C pointer to Python bytes data = bytes(data_ptr[:data_len]) - if not data: - return 0 # Error: empty data + return 0 # Error: empty data, native code will handle it! # Call the user's callback signature = callback(data) - if not signature: - return 0 # Error: empty signature + return 0 # Error: empty signature, native code will handle that too! # Copy the signature back to the C buffer actual_len = min(len(signature), signed_len) - for i in range(actual_len): signed_bytes_ptr[i] = signature[i] - return actual_len # Return the number of bytes written + # Native code expects the signed len to be returned, we oblige + return actual_len except Exception as e: print( cls._error_messages['callback_error'].format( str(e)), file=sys.stderr) - return 0 # Return 0 to indicate error + return 0 # Encode strings with error handling try: @@ -1358,10 +1357,11 @@ def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): str(e))) # Create the signer with the wrapped callback - # Store the callback as an instance attribute to keep it alive + # Store the callback as an instance attribute to keep it alive, as this prevents + # garbage colelction and lifetime issues. signer_instance = cls.__new__(cls) signer_instance._callback_cb = SignerCallback(wrapped_callback) - + signer_ptr = _lib.c2pa_signer_create( None, # context signer_instance._callback_cb, @@ -1380,7 +1380,7 @@ def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): signer_instance._signer = signer_ptr signer_instance._closed = False signer_instance._error_messages = cls._error_messages - + return signer_instance def __enter__(self): From 9b41397cf02e28b7bf7da31817062e99c3a65b69 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 19:33:06 -0700 Subject: [PATCH 07/46] fix: FOrmat --- src/c2pa/c2pa.py | 28 +++++++++++++++++----------- tests/test_unit_tests.py | 2 +- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index c9dd7f7f..9148c7aa 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1320,21 +1320,27 @@ def from_callback( # Create a wrapper callback that handles errors and memory management def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): # Returns 0 on error as this case is handled in the native code gracefully + # The reason is that otherwise we ping-pong errors between native code and Python code, + # which can become tedious in handling. So we let the native code deal with it and + # raise the errors accordingly, since it already checks the signature length for correctness. try: if not data_ptr or data_len <= 0: - return 0 # Error: invalid input + # Error: invalid input, native code will handle if seeing signature size being 0 + return 0 # Convert C pointer to Python bytes data = bytes(data_ptr[:data_len]) if not data: - return 0 # Error: empty data, native code will handle it! + # Error: empty data, native code will handle it! + return 0 # Call the user's callback signature = callback(data) if not signature: - return 0 # Error: empty signature, native code will handle that too! + # Error: empty signature, native code will handle that too! + return 0 - # Copy the signature back to the C buffer + # Copy the signature back to the C buffer (since callback is sued in native code) actual_len = min(len(signature), signed_len) for i in range(actual_len): signed_bytes_ptr[i] = signature[i] @@ -1347,7 +1353,7 @@ def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): str(e)), file=sys.stderr) return 0 - # Encode strings with error handling + # Encode strings with error handling in case it's invalid UTF8 try: certs_bytes = certs.encode('utf-8') tsa_url_bytes = tsa_url.encode('utf-8') if tsa_url else None @@ -1358,12 +1364,12 @@ def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): # Create the signer with the wrapped callback # Store the callback as an instance attribute to keep it alive, as this prevents - # garbage colelction and lifetime issues. + # garbage collection and lifetime issues. signer_instance = cls.__new__(cls) signer_instance._callback_cb = SignerCallback(wrapped_callback) signer_ptr = _lib.c2pa_signer_create( - None, # context + None, signer_instance._callback_cb, alg, certs_bytes, @@ -1950,22 +1956,22 @@ def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): # Copy the signature back to the C buffer actual_len = min(len(signature), signed_len) - + for i in range(actual_len): signed_bytes_ptr[i] = signature[i] return actual_len # Return the number of bytes written except Exception as e: return 0 # Return 0 to indicate error - + # Store the callback to keep it alive if not hasattr(create_signer, '_callbacks'): create_signer._callbacks = [] - + # Create the C callback and store it c_callback = SignerCallback(wrapped_callback) create_signer._callbacks.append(c_callback) # Keep it alive - + signer_ptr = _lib.c2pa_signer_create( None, # context c_callback, # Use the stored callback diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 0e4ece5a..51b44258 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -783,7 +783,7 @@ def sign_callback(data: bytes) -> bytes: # For ECDSA, we use the signature_algorithm_constructor from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec - + signature = private_key.sign( data, ec.ECDSA(hashes.SHA256()) From 4e81626986a6b57a31c6eba6c551e448aaf2bc75 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 19:38:24 -0700 Subject: [PATCH 08/46] fix: Clean up --- requirements-dev.txt | 5 ++- src/c2pa/c2pa.py | 26 +++++++++++---- tests/test_unit_tests.py | 72 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 90 insertions(+), 13 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index cd916d6e..9c47e9c4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,4 +12,7 @@ pytest-benchmark>=5.1.0 requests>=2.0.0 # Code formatting -autopep8==2.0.4 # For automatic code formatting \ No newline at end of file +autopep8==2.0.4 # For automatic code formatting + +# Test dependencies (for callback signers) +cryptography==45.0.4 \ No newline at end of file diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 9148c7aa..96dbaf85 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1308,14 +1308,28 @@ def from_callback( C2paError: If there was an error creating the signer C2paError.Encoding: If the certificate data or TSA URL contains invalid UTF-8 characters """ + # Define error messages locally since they're instance attributes + error_messages = { + 'closed_error': "Signer is closed", + 'cleanup_error': "Error during cleanup: {}", + 'signer_cleanup': "Error cleaning up signer: {}", + 'size_error': "Error getting reserve size: {}", + 'callback_error': "Error in signer callback: {}", + 'info_error': "Error creating signer from info: {}", + 'invalid_data': "Invalid data for signing: {}", + 'invalid_certs': "Invalid certificate data: {}", + 'invalid_tsa': "Invalid TSA URL: {}", + 'encoding_error': "Invalid UTF-8 characters in input: {}" + } + # Validate inputs before creating if not certs: raise C2paError( - cls._error_messages['invalid_certs'].format("Missing certificate data")) + error_messages['invalid_certs'].format("Missing certificate data")) if tsa_url and not tsa_url.startswith(('http://', 'https://')): raise C2paError( - cls._error_messages['invalid_tsa'].format("Invalid TSA URL format")) + error_messages['invalid_tsa'].format("Invalid TSA URL format")) # Create a wrapper callback that handles errors and memory management def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): @@ -1349,7 +1363,7 @@ def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): return actual_len except Exception as e: print( - cls._error_messages['callback_error'].format( + error_messages['callback_error'].format( str(e)), file=sys.stderr) return 0 @@ -1359,7 +1373,7 @@ def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): tsa_url_bytes = tsa_url.encode('utf-8') if tsa_url else None except UnicodeError as e: raise C2paError.Encoding( - cls._error_messages['encoding_error'].format( + error_messages['encoding_error'].format( str(e))) # Create the signer with the wrapped callback @@ -1385,7 +1399,7 @@ def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): # Initialize the signer instance signer_instance._signer = signer_ptr signer_instance._closed = False - signer_instance._error_messages = cls._error_messages + signer_instance._error_messages = error_messages return signer_instance @@ -1973,7 +1987,7 @@ def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): create_signer._callbacks.append(c_callback) # Keep it alive signer_ptr = _lib.c2pa_signer_create( - None, # context + None, c_callback, # Use the stored callback alg, certs_bytes, diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 51b44258..3155c987 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -21,6 +21,8 @@ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.backends import default_backend +import tempfile +import shutil from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer @@ -720,9 +722,6 @@ def test_builder_set_remote_url_no_embed(self): def test_sign_file(self): """Test signing a file using the sign_file method.""" - import tempfile - import shutil - # Create a temporary directory for the test temp_dir = tempfile.mkdtemp() try: @@ -753,9 +752,6 @@ def test_sign_file(self): def test_sign_file_callback_signer(self): """Test signing a file using the sign_file method.""" - import tempfile - import shutil - # Create a temporary directory for the test temp_dir = tempfile.mkdtemp() try: @@ -819,6 +815,70 @@ def sign_callback(data: bytes) -> bytes: # Clean up the temporary directory shutil.rmtree(temp_dir) + def test_sign_file_callback_signer_from_callback(self): + """Test signing a file using the sign_file method with Signer.from_callback.""" + # Create a temporary directory for the test + temp_dir = tempfile.mkdtemp() + try: + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed_output_from_callback.jpg") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + + # Create a real ES256 signing callback + def sign_callback(data: bytes) -> bytes: + """Real ES256 signing callback that creates actual signatures.""" + # Load the private key from the test fixtures + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + private_key_data = key_file.read() + + # Load the private key using cryptography + private_key = serialization.load_pem_private_key( + private_key_data, + password=None, + backend=default_backend() + ) + + # Create the signature using ES256 (ECDSA with SHA-256) + # For ECDSA, we use the signature_algorithm_constructor + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec + + signature = private_key.sign( + data, + ec.ECDSA(hashes.SHA256()) + ) + + return signature + + # Create signer with callback using Signer.from_callback + signer = Signer.from_callback( + callback=sign_callback, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + result = 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)) + + # 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) class TestStream(unittest.TestCase): def setUp(self): From 547cd740f7af1b4829fd54aa9f7a693a20417c9f Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 19:59:40 -0700 Subject: [PATCH 09/46] fix: Change return --- src/c2pa/c2pa.py | 109 +++++++++++++++++++++++++++++++++++++++ tests/test_unit_tests.py | 82 +++++++++++++++++++++++++++-- 2 files changed, 187 insertions(+), 4 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 96dbaf85..5ff1e50e 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -679,6 +679,114 @@ def sign_file( signer.close() +def sign_file_with_callback_signer( + source_path: Union[str, Path], + dest_path: Union[str, Path], + manifest: str, + callback: Callable[[bytes], bytes], + alg: C2paSigningAlg, + certs: str, + tsa_url: Optional[str] = None, + data_dir: Optional[Union[str, Path]] = None +) -> bytes: + """Sign a file with a C2PA manifest using a callback signer. + + This function provides a shortcut to sign files using a callback-based signer + and returns the raw manifest bytes. + + Args: + source_path: Path to the source file + dest_path: Path to write the signed file to + manifest: The manifest JSON string + callback: Function that signs data and returns the signature + alg: The signing algorithm to use + certs: Certificate chain in PEM format + tsa_url: Optional RFC 3161 timestamp authority URL + data_dir: Optional directory to write binary resources to + + Returns: + The manifest bytes (binary data) + + Raises: + C2paError: If there was an error signing the file + C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters + C2paError.NotSupported: If the file type cannot be determined + """ + try: + # Create a signer from the callback + signer = Signer.from_callback(callback, alg, certs, tsa_url) + + # Create a builder from the manifest + builder = Builder(manifest) + + # Open source and destination files + with open(source_path, 'rb') as source_file, open(dest_path, 'wb') as dest_file: + # Get the MIME type from the file extension + mime_type = mimetypes.guess_type(str(source_path))[0] + if not mime_type: + raise C2paError.NotSupported(f"Could not determine MIME type for file: {source_path}") + + # Convert Python streams to Stream objects + source_stream = Stream(source_file) + dest_stream = Stream(dest_file) + + # Use the internal signing logic to get manifest bytes + format_str = mime_type.encode('utf-8') + manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() + + # Call the native signing function + result = _lib.c2pa_builder_sign( + builder._builder, + format_str, + source_stream._stream, + dest_stream._stream, + signer._signer, + ctypes.byref(manifest_bytes_ptr) + ) + + if result < 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + + # Capture the manifest bytes if available + manifest_bytes = b"" + if manifest_bytes_ptr: + # Convert the C pointer to Python bytes + manifest_bytes = bytes(manifest_bytes_ptr[:result]) + # Free the C-allocated memory + _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) + + # If we have manifest bytes and a data directory, write them + if manifest_bytes and data_dir: + manifest_path = os.path.join(str(data_dir), 'manifest.json') + with open(manifest_path, 'wb') as f: + f.write(manifest_bytes) + + return manifest_bytes + + except Exception as e: + # Clean up destination file if it exists and there was an error + if os.path.exists(dest_path): + try: + os.remove(dest_path) + except OSError: + pass # Ignore cleanup errors + + # Re-raise the error + raise C2paError(f"Error signing file: {str(e)}") + finally: + # Ensure resources are cleaned up + if 'builder' in locals(): + builder.close() + if 'signer' in locals(): + signer.close() + if 'source_stream' in locals(): + source_stream.close() + if 'dest_stream' in locals(): + dest_stream.close() + + class Stream: # Class-level counter for generating unique stream IDs # (useful for tracing streams usage in debug) @@ -2083,6 +2191,7 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'read_file', 'read_ingredient_file', 'sign_file', + 'sign_file_with_callback_signer', 'format_embeddable', 'ed25519_sign', 'sdk_version' diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 3155c987..eda6741c 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -25,7 +25,7 @@ import shutil from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version -from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer +from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer, sign_file_with_callback_signer # Suppress deprecation warnings warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -541,16 +541,16 @@ def test_builder_sign_with_duplicate_ingredient(self): # Verify the first ingredient's title matches what we set first_ingredient = active_manifest["ingredients"][0] self.assertEqual(first_ingredient["title"], "Test Ingredient") - + # Verify subsequent labels are unique and have a double underscore with a monotonically inc. index second_ingredient = active_manifest["ingredients"][1] self.assertTrue(second_ingredient["label"].endswith("__1")) third_ingredient = active_manifest["ingredients"][2] self.assertTrue(third_ingredient["label"].endswith("__2")) - + builder.close() - + def test_builder_sign_with_ingredient_from_stream(self): """Test Builder class operations with a real file using stream for ingredient.""" # Test creating builder from JSON @@ -880,6 +880,80 @@ def sign_callback(data: bytes) -> bytes: # Clean up the temporary directory shutil.rmtree(temp_dir) + def test_sign_file_with_callback_signer(self): + """Test signing a file using the sign_file_with_callback_signer function.""" + # Create a temporary directory for the test + temp_dir = tempfile.mkdtemp() + try: + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed_output_callback.jpg") + + # Create a real ES256 signing callback + def sign_callback(data: bytes) -> bytes: + """Real ES256 signing callback that creates actual signatures.""" + # Load the private key from the test fixtures + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + private_key_data = key_file.read() + + # Load the private key using cryptography + private_key = serialization.load_pem_private_key( + private_key_data, + password=None, + backend=default_backend() + ) + + # Create the signature using ES256 (ECDSA with SHA-256) + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec + + signature = private_key.sign( + data, + ec.ECDSA(hashes.SHA256()) + ) + + return signature + + # Import the new function + from c2pa.c2pa import sign_file_with_callback_signer + + # Use the sign_file_with_callback_signer function + manifest_bytes = sign_file_with_callback_signer( + source_path=self.testPath, + dest_path=output_path, + manifest=self.manifestDefinition, + callback=sign_callback, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) + + # Verify the manifest bytes are binary data (not JSON text) + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) + + # Try to decode as UTF-8 to see if it's text-based (it shouldn't be) + try: + manifest_bytes.decode('utf-8') + # If we get here, it's text-based, which is unexpected + self.fail("Manifest bytes should be binary data, not UTF-8 text") + except UnicodeDecodeError: + # This is expected - manifest bytes should be binary + pass + + # Read the signed file and verify the manifest contains expected content + with open(output_path, "rb") as file: + reader = Reader("image/jpeg", file) + file_manifest_json = reader.json() + self.assertIn("Python Test", file_manifest_json) + self.assertNotIn("validation_status", file_manifest_json) + + finally: + # Clean up the temporary directory + shutil.rmtree(temp_dir) + class TestStream(unittest.TestCase): def setUp(self): # Create a temporary file for testing From fced710c3d61c51d7acada7820cc6709da0be7cc Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 20:05:34 -0700 Subject: [PATCH 10/46] fix: Change sign_file signature again --- src/c2pa/c2pa.py | 52 ++++++++++++---------------------------- tests/test_unit_tests.py | 21 +++++++++++++--- 2 files changed, 33 insertions(+), 40 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 5ff1e50e..36ac7081 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -730,32 +730,8 @@ def sign_file_with_callback_signer( source_stream = Stream(source_file) dest_stream = Stream(dest_file) - # Use the internal signing logic to get manifest bytes - format_str = mime_type.encode('utf-8') - manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() - - # Call the native signing function - result = _lib.c2pa_builder_sign( - builder._builder, - format_str, - source_stream._stream, - dest_stream._stream, - signer._signer, - ctypes.byref(manifest_bytes_ptr) - ) - - if result < 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - - # Capture the manifest bytes if available - manifest_bytes = b"" - if manifest_bytes_ptr: - # Convert the C pointer to Python bytes - manifest_bytes = bytes(manifest_bytes_ptr[:result]) - # Free the C-allocated memory - _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) + # Use the builder's internal signing logic + result, manifest_bytes = builder._sign_internal(signer, mime_type, source_stream, dest_stream) # If we have manifest bytes and a data directory, write them if manifest_bytes and data_dir: @@ -781,10 +757,6 @@ def sign_file_with_callback_signer( builder.close() if 'signer' in locals(): signer.close() - if 'source_stream' in locals(): - source_stream.close() - if 'dest_stream' in locals(): - dest_stream.close() class Stream: @@ -1886,7 +1858,7 @@ def _sign_internal( signer: Signer, format: str, source_stream: Stream, - dest_stream: Stream) -> int: + dest_stream: Stream) -> tuple[int, bytes]: """Internal signing logic shared between sign() and sign_file() methods, to use same native calls but expose different API surface. @@ -1897,7 +1869,7 @@ def _sign_internal( dest_stream: The destination stream Returns: - Size of C2PA data + A tuple of (size of C2PA data, manifest bytes) Raises: C2paError: If there was an error during signing @@ -1924,11 +1896,15 @@ def _sign_internal( if error: raise C2paError(error) + # Capture the manifest bytes if available + manifest_bytes = b"" if manifest_bytes_ptr: - # Free the manifest bytes pointer if it was allocated + # Convert the C pointer to Python bytes + manifest_bytes = bytes(manifest_bytes_ptr[:result]) + # Free the C-allocated memory _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) - return result + return result, manifest_bytes finally: # Ensure both streams are cleaned up source_stream.close() @@ -1956,6 +1932,7 @@ def sign( dest_stream = Stream(dest) # Use the internal stream-base signing logic + # Ignore the return value since this method returns None self._sign_internal(signer, format, source_stream, dest_stream) def sign_file(self, @@ -1963,7 +1940,7 @@ def sign_file(self, Path], dest_path: Union[str, Path], - signer: Signer) -> int: + signer: Signer) -> tuple[int, bytes]: """Sign a file and write the signed data to an output file. Args: @@ -1972,7 +1949,7 @@ def sign_file(self, signer: The signer to use Returns: - Size of C2PA data + A tuple of (size of C2PA data, manifest bytes) Raises: C2paError: If there was an error during signing @@ -1989,7 +1966,8 @@ def sign_file(self, dest_stream = Stream(dest_file) # Use the internal stream-base signing logic - return self._sign_internal(signer, mime_type, source_stream, dest_stream) + result, manifest_bytes = self._sign_internal(signer, mime_type, source_stream, dest_stream) + return result, manifest_bytes def format_embeddable(format: str, manifest_bytes: bytes) -> tuple[int, bytes]: diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index eda6741c..967000c8 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -730,7 +730,7 @@ def test_sign_file(self): # Use the sign_file method builder = Builder(self.manifestDefinition) - result = builder.sign_file( + result, manifest_bytes = builder.sign_file( source_path=self.testPath, dest_path=output_path, signer=self.signer @@ -739,6 +739,11 @@ def test_sign_file(self): # Verify the output file was created self.assertTrue(os.path.exists(output_path)) + # Verify we got both result and manifest bytes + self.assertIsInstance(result, int) + 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 = Reader("image/jpeg", file) @@ -795,7 +800,7 @@ def sign_callback(data: bytes) -> bytes: tsa_url="http://timestamp.digicert.com" ) - result = builder.sign_file( + result, manifest_bytes = builder.sign_file( source_path=self.testPath, dest_path=output_path, signer=signer @@ -804,6 +809,11 @@ def sign_callback(data: bytes) -> bytes: # Verify the output file was created self.assertTrue(os.path.exists(output_path)) + # Verify we got both result and manifest bytes + self.assertIsInstance(result, int) + 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 = Reader("image/jpeg", file) @@ -860,7 +870,7 @@ def sign_callback(data: bytes) -> bytes: tsa_url="http://timestamp.digicert.com" ) - result = builder.sign_file( + result, manifest_bytes = builder.sign_file( source_path=self.testPath, dest_path=output_path, signer=signer @@ -869,6 +879,11 @@ def sign_callback(data: bytes) -> bytes: # Verify the output file was created self.assertTrue(os.path.exists(output_path)) + # Verify we got both result and manifest bytes + self.assertIsInstance(result, int) + 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 = Reader("image/jpeg", file) From f693f5510d68612bd6b19fd2020f2d78344daac5 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 20:15:26 -0700 Subject: [PATCH 11/46] fix: Improve pointer handling and refactor --- src/c2pa/c2pa.py | 53 ++++++++++++++-------------------------- tests/test_unit_tests.py | 13 +++++++--- 2 files changed, 28 insertions(+), 38 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 36ac7081..c28a8fb5 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -683,26 +683,18 @@ def sign_file_with_callback_signer( source_path: Union[str, Path], dest_path: Union[str, Path], manifest: str, - callback: Callable[[bytes], bytes], - alg: C2paSigningAlg, - certs: str, - tsa_url: Optional[str] = None, - data_dir: Optional[Union[str, Path]] = None + signer: 'Signer' ) -> bytes: - """Sign a file with a C2PA manifest using a callback signer. + """Sign a file with a C2PA manifest using a signer. - This function provides a shortcut to sign files using a callback-based signer + This function provides a shortcut to sign files using a signer and returns the raw manifest bytes. Args: source_path: Path to the source file dest_path: Path to write the signed file to manifest: The manifest JSON string - callback: Function that signs data and returns the signature - alg: The signing algorithm to use - certs: Certificate chain in PEM format - tsa_url: Optional RFC 3161 timestamp authority URL - data_dir: Optional directory to write binary resources to + signer: The signer to use Returns: The manifest bytes (binary data) @@ -713,9 +705,6 @@ def sign_file_with_callback_signer( C2paError.NotSupported: If the file type cannot be determined """ try: - # Create a signer from the callback - signer = Signer.from_callback(callback, alg, certs, tsa_url) - # Create a builder from the manifest builder = Builder(manifest) @@ -733,12 +722,6 @@ def sign_file_with_callback_signer( # Use the builder's internal signing logic result, manifest_bytes = builder._sign_internal(signer, mime_type, source_stream, dest_stream) - # If we have manifest bytes and a data directory, write them - if manifest_bytes and data_dir: - manifest_path = os.path.join(str(data_dir), 'manifest.json') - with open(manifest_path, 'wb') as f: - f.write(manifest_bytes) - return manifest_bytes except Exception as e: @@ -755,8 +738,6 @@ def sign_file_with_callback_signer( # Ensure resources are cleaned up if 'builder' in locals(): builder.close() - if 'signer' in locals(): - signer.close() class Stream: @@ -1349,10 +1330,6 @@ def from_info(cls, signer_info: C2paSignerInfo) -> 'Signer': Raises: C2paError: If there was an error creating the signer """ - # Validate signer info before creating - if not signer_info.sign_cert or not signer_info.private_key: - raise C2paError("Missing certificate or private key") - signer_ptr = _lib.c2pa_signer_from_info(ctypes.byref(signer_info)) if not signer_ptr: @@ -1360,8 +1337,7 @@ def from_info(cls, signer_info: C2paSignerInfo) -> 'Signer': if error: # More detailed error message when possible raise C2paError(error) - raise C2paError( - "Failed to create signer from configured signer info") + raise C2paError("Failed to create signer from info") return cls(signer_ptr) @@ -1898,11 +1874,20 @@ def _sign_internal( # Capture the manifest bytes if available manifest_bytes = b"" - if manifest_bytes_ptr: - # Convert the C pointer to Python bytes - manifest_bytes = bytes(manifest_bytes_ptr[:result]) - # Free the C-allocated memory - _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) + if manifest_bytes_ptr and result > 0: + try: + # Convert the C pointer to Python bytes + manifest_bytes = bytes(manifest_bytes_ptr[:result]) + except Exception: + # If there's any error accessing the memory, just return empty bytes + manifest_bytes = b"" + finally: + # Always free the C-allocated memory, even if we failed to copy it + try: + _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) + except Exception: + # Ignore errors during cleanup + pass return result, manifest_bytes finally: diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 967000c8..a7ebe038 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -928,6 +928,14 @@ def sign_callback(data: bytes) -> bytes: return signature + # Create signer with callback + signer = Signer.from_callback( + callback=sign_callback, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + # Import the new function from c2pa.c2pa import sign_file_with_callback_signer @@ -936,10 +944,7 @@ def sign_callback(data: bytes) -> bytes: source_path=self.testPath, dest_path=output_path, manifest=self.manifestDefinition, - callback=sign_callback, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" + signer=signer ) # Verify the output file was created From 53f057408b99b94678b32b840503177ddf1bd8d2 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 20:22:24 -0700 Subject: [PATCH 12/46] fix: Refactor --- src/c2pa/c2pa.py | 63 +--------------------------------------- tests/test_unit_tests.py | 2 +- 2 files changed, 2 insertions(+), 63 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index c28a8fb5..dd39e83d 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -2014,64 +2014,7 @@ def create_signer( C2paError: If there was an error creating the signer C2paError.Encoding: If the certificate data or TSA URL contains invalid UTF-8 characters """ - try: - certs_bytes = certs.encode('utf-8') - tsa_url_bytes = tsa_url.encode('utf-8') if tsa_url else None - except UnicodeError as e: - raise C2paError.Encoding( - f"Invalid UTF-8 characters in certificate data or TSA URL: {str(e)}") - - # Create a wrapper callback that handles errors and memory management - def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): - try: - if not data_ptr or data_len <= 0: - return 0 # Error: invalid input - - # Convert C pointer to Python bytes - data = bytes(data_ptr[:data_len]) - - if not data: - return 0 # Error: empty data - - # Call the user's callback - signature = callback(data) - - if not signature: - return 0 # Error: empty signature - - # Copy the signature back to the C buffer - actual_len = min(len(signature), signed_len) - - for i in range(actual_len): - signed_bytes_ptr[i] = signature[i] - - return actual_len # Return the number of bytes written - except Exception as e: - return 0 # Return 0 to indicate error - - # Store the callback to keep it alive - if not hasattr(create_signer, '_callbacks'): - create_signer._callbacks = [] - - # Create the C callback and store it - c_callback = SignerCallback(wrapped_callback) - create_signer._callbacks.append(c_callback) # Keep it alive - - signer_ptr = _lib.c2pa_signer_create( - None, - c_callback, # Use the stored callback - alg, - certs_bytes, - 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") - - return Signer(signer_ptr) + return Signer.from_callback(callback, alg, certs, tsa_url) def create_signer_from_info(signer_info: C2paSignerInfo) -> Signer: @@ -2098,10 +2041,6 @@ def create_signer_from_info(signer_info: C2paSignerInfo) -> Signer: return Signer(signer_ptr) -# Rename the old create_signer to _create_signer since it's now internal -_create_signer = create_signer - - def ed25519_sign(data: bytes, private_key: str) -> bytes: """Sign data using the Ed25519 algorithm. diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index a7ebe038..3503f36a 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -953,7 +953,7 @@ def sign_callback(data: bytes) -> bytes: # Verify the manifest bytes are binary data (not JSON text) self.assertIsInstance(manifest_bytes, bytes) self.assertGreater(len(manifest_bytes), 0) - + # Try to decode as UTF-8 to see if it's text-based (it shouldn't be) try: manifest_bytes.decode('utf-8') From 500eb60e553b471e64d0f5785fce5e2908d74e38 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 20:24:02 -0700 Subject: [PATCH 13/46] fix: Refactor --- src/c2pa/c2pa.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index dd39e83d..04cc7c19 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -2029,16 +2029,7 @@ def create_signer_from_info(signer_info: C2paSignerInfo) -> Signer: Raises: C2paError: If there was an error creating the 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 info") - - return Signer(signer_ptr) + return Signer.from_info(signer_info) def ed25519_sign(data: bytes, private_key: str) -> bytes: From 496ee20cfa8624e12083ef238bd46743bc2b8658 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 20:25:38 -0700 Subject: [PATCH 14/46] fix: Refactor --- src/c2pa/c2pa.py | 4 ++-- tests/test_unit_tests.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 04cc7c19..cf127422 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -679,7 +679,7 @@ def sign_file( signer.close() -def sign_file_with_callback_signer( +def sign_file_using_callback_signer( source_path: Union[str, Path], dest_path: Union[str, Path], manifest: str, @@ -2084,7 +2084,7 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'read_file', 'read_ingredient_file', 'sign_file', - 'sign_file_with_callback_signer', + 'sign_file_using_callback_signer', 'format_embeddable', 'ed25519_sign', 'sdk_version' diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 3503f36a..3627cab3 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -25,7 +25,7 @@ import shutil from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version -from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer, sign_file_with_callback_signer +from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer, sign_file_using_callback_signer # Suppress deprecation warnings warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -895,8 +895,8 @@ def sign_callback(data: bytes) -> bytes: # Clean up the temporary directory shutil.rmtree(temp_dir) - def test_sign_file_with_callback_signer(self): - """Test signing a file using the sign_file_with_callback_signer function.""" + def test_sign_file_using_callback_signer(self): + """Test signing a file using the sign_file_using_callback_signer function.""" # Create a temporary directory for the test temp_dir = tempfile.mkdtemp() try: @@ -937,10 +937,10 @@ def sign_callback(data: bytes) -> bytes: ) # Import the new function - from c2pa.c2pa import sign_file_with_callback_signer + from c2pa.c2pa import sign_file_using_callback_signer - # Use the sign_file_with_callback_signer function - manifest_bytes = sign_file_with_callback_signer( + # Use the sign_file_using_callback_signer function + manifest_bytes = sign_file_using_callback_signer( source_path=self.testPath, dest_path=output_path, manifest=self.manifestDefinition, From e262237f74ccd5b6a52fdc07e51d3cd379cdc16d Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 20:27:12 -0700 Subject: [PATCH 15/46] fix: Refactor 3 --- src/c2pa/c2pa.py | 46 ++++++++++++++++++++++++++-------------- tests/test_unit_tests.py | 9 -------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index cf127422..5a0a6ac2 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -581,8 +581,7 @@ def read_file(path: Union[str, Path], "The read_file function is deprecated and will be removed in a future version. " "Please use the Reader class for reading C2PA metadata instead.", DeprecationWarning, - stacklevel=2 - ) + stacklevel=2) container = _StringContainer() @@ -626,8 +625,7 @@ def sign_file( "The sign_file function is deprecated and will be removed in a future version. " "Please use the Builder class for signing and the Reader class for reading signed data instead.", DeprecationWarning, - stacklevel=2 - ) + stacklevel=2) try: # Create a signer from the signer info @@ -641,7 +639,8 @@ def sign_file( # Get the MIME type from the file extension mime_type = mimetypes.guess_type(str(source_path))[0] if not mime_type: - raise C2paError.NotSupported(f"Could not determine MIME type for file: {source_path}") + raise C2paError.NotSupported( + f"Could not determine MIME type for file: {source_path}") # Sign the file using the builder manifest_bytes = builder.sign( @@ -713,14 +712,16 @@ def sign_file_using_callback_signer( # Get the MIME type from the file extension mime_type = mimetypes.guess_type(str(source_path))[0] if not mime_type: - raise C2paError.NotSupported(f"Could not determine MIME type for file: {source_path}") + raise C2paError.NotSupported( + f"Could not determine MIME type for file: {source_path}") # Convert Python streams to Stream objects source_stream = Stream(source_file) dest_stream = Stream(dest_file) # Use the builder's internal signing logic - result, manifest_bytes = builder._sign_internal(signer, mime_type, source_stream, dest_stream) + result, manifest_bytes = builder._sign_internal( + signer, mime_type, source_stream, dest_stream) return manifest_bytes @@ -762,7 +763,8 @@ def __init__(self, file): Raises: TypeError: If the file object doesn't implement all required methods """ - # Initialize _closed first to prevent AttributeError during garbage collection + # Initialize _closed first to prevent AttributeError during garbage + # collection self._closed = False self._initialized = False self._stream = None @@ -1388,14 +1390,21 @@ def from_callback( error_messages['invalid_tsa'].format("Invalid TSA URL format")) # Create a wrapper callback that handles errors and memory management - def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): + def wrapped_callback( + context, + data_ptr, + data_len, + signed_bytes_ptr, + signed_len): # Returns 0 on error as this case is handled in the native code gracefully # The reason is that otherwise we ping-pong errors between native code and Python code, # which can become tedious in handling. So we let the native code deal with it and - # raise the errors accordingly, since it already checks the signature length for correctness. + # raise the errors accordingly, since it already checks the + # signature length for correctness. try: if not data_ptr or data_len <= 0: - # Error: invalid input, native code will handle if seeing signature size being 0 + # Error: invalid input, native code will handle if seeing + # signature size being 0 return 0 # Convert C pointer to Python bytes @@ -1410,7 +1419,8 @@ def wrapped_callback(context, data_ptr, data_len, signed_bytes_ptr, signed_len): # Error: empty signature, native code will handle that too! return 0 - # Copy the signature back to the C buffer (since callback is sued in native code) + # Copy the signature back to the C buffer (since callback is + # sued in native code) actual_len = min(len(signature), signed_len) for i in range(actual_len): signed_bytes_ptr[i] = signature[i] @@ -1879,10 +1889,12 @@ def _sign_internal( # Convert the C pointer to Python bytes manifest_bytes = bytes(manifest_bytes_ptr[:result]) except Exception: - # If there's any error accessing the memory, just return empty bytes + # If there's any error accessing the memory, just return + # empty bytes manifest_bytes = b"" finally: - # Always free the C-allocated memory, even if we failed to copy it + # Always free the C-allocated memory, even if we failed to + # copy it try: _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) except Exception: @@ -1942,7 +1954,8 @@ def sign_file(self, # Get the MIME type from the file extension mime_type = mimetypes.guess_type(str(source_path))[0] if not mime_type: - raise C2paError.NotSupported(f"Could not determine MIME type for file: {source_path}") + raise C2paError.NotSupported( + f"Could not determine MIME type for file: {source_path}") # Open source and destination files with open(source_path, 'rb') as source_file, open(dest_path, 'wb') as dest_file: @@ -1951,7 +1964,8 @@ def sign_file(self, dest_stream = Stream(dest_file) # Use the internal stream-base signing logic - result, manifest_bytes = self._sign_internal(signer, mime_type, source_stream, dest_stream) + result, manifest_bytes = self._sign_internal( + signer, mime_type, source_stream, dest_stream) return result, manifest_bytes diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 3627cab3..7085bcb0 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -954,15 +954,6 @@ def sign_callback(data: bytes) -> bytes: self.assertIsInstance(manifest_bytes, bytes) self.assertGreater(len(manifest_bytes), 0) - # Try to decode as UTF-8 to see if it's text-based (it shouldn't be) - try: - manifest_bytes.decode('utf-8') - # If we get here, it's text-based, which is unexpected - self.fail("Manifest bytes should be binary data, not UTF-8 text") - except UnicodeDecodeError: - # This is expected - manifest bytes should be binary - pass - # Read the signed file and verify the manifest contains expected content with open(output_path, "rb") as file: reader = Reader("image/jpeg", file) From 0db06c8b1f7881d1e46a8203e36c225e41811b79 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 20:34:51 -0700 Subject: [PATCH 16/46] fix: Refactor once more --- src/c2pa/c2pa.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 5a0a6ac2..fa2ae273 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -2100,6 +2100,5 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'sign_file', 'sign_file_using_callback_signer', 'format_embeddable', - 'ed25519_sign', 'sdk_version' ] From 3ca91c6e2045e67ffb34bffadfa8d16227db14d5 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 20:48:10 -0700 Subject: [PATCH 17/46] fix: Refactor once more with overload --- src/c2pa/c2pa.py | 130 +++++++++++++-------------------------- tests/test_unit_tests.py | 96 ++++++++++++++++++++++++----- 2 files changed, 122 insertions(+), 104 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index fa2ae273..2519b824 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -5,7 +5,7 @@ import os import warnings from pathlib import Path -from typing import Optional, Union, Callable, Any +from typing import Optional, Union, Callable, Any, overload import time from .lib import dynamically_load_library import mimetypes @@ -578,7 +578,7 @@ def read_file(path: Union[str, Path], C2paError: If there was an error reading the file """ warnings.warn( - "The read_file function is deprecated and will be removed in a future version. " + "The read_file function is deprecated and will be removed in a future version." "Please use the Reader class for reading C2PA metadata instead.", DeprecationWarning, stacklevel=2) @@ -592,26 +592,50 @@ def read_file(path: Union[str, Path], return _parse_operation_result_for_error(result) +@overload def sign_file( source_path: Union[str, Path], dest_path: Union[str, Path], manifest: str, - signer_info: C2paSignerInfo, - data_dir: Optional[Union[str, Path]] = None + signer_info: C2paSignerInfo ) -> str: - """Sign a file with a C2PA manifest. - For now, this function is left here to provide a backwards-compatible API. + """Sign a file with a C2PA manifest using signer info. + + .. deprecated:: 0.10.0 + This function is deprecated and will be removed in a future version. + Please use the Builder class for signing and the Reader class for reading signed data instead. + """ + ... + +@overload +def sign_file( + source_path: Union[str, Path], + dest_path: Union[str, Path], + manifest: str, + signer: 'Signer' +) -> str: + """Sign a file with a C2PA manifest using a signer. .. deprecated:: 0.10.0 This function is deprecated and will be removed in a future version. Please use the Builder class for signing and the Reader class for reading signed data instead. + """ + ... + +def sign_file( + source_path: Union[str, Path], + dest_path: Union[str, Path], + manifest: str, + signer_or_info: Union[C2paSignerInfo, 'Signer'] +) -> str: + """Sign a file with a C2PA manifest. + For now, this function is left here to provide a backwards-compatible API. Args: source_path: Path to the source file dest_path: Path to write the signed file to manifest: The manifest JSON string - signer_info: Signing configuration - data_dir: Optional directory to write binary resources to + signer_or_info: Either a signer configuration or a signer object Returns: The signed manifest as a JSON string @@ -621,15 +645,15 @@ def sign_file( C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters C2paError.NotSupported: If the file type cannot be determined """ - warnings.warn( - "The sign_file function is deprecated and will be removed in a future version. " - "Please use the Builder class for signing and the Reader class for reading signed data instead.", - DeprecationWarning, - stacklevel=2) try: - # Create a signer from the signer info - signer = Signer.from_info(signer_info) + # Determine if we have a signer or signer info + if isinstance(signer_or_info, C2paSignerInfo): + signer = Signer.from_info(signer_or_info) + own_signer = True + else: + signer = signer_or_info + own_signer = False # Create a builder from the manifest builder = Builder(manifest) @@ -643,19 +667,13 @@ def sign_file( f"Could not determine MIME type for file: {source_path}") # Sign the file using the builder - manifest_bytes = builder.sign( + builder.sign( signer=signer, format=mime_type, source=source_file, dest=dest_file ) - # If we have manifest bytes and a data directory, write them - if manifest_bytes and data_dir: - manifest_path = os.path.join(str(data_dir), 'manifest.json') - with open(manifest_path, 'wb') as f: - f.write(manifest_bytes) - # Read the signed manifest from the destination file with Reader(dest_path) as reader: return reader.json() @@ -674,73 +692,10 @@ def sign_file( # Ensure resources are cleaned up if 'builder' in locals(): builder.close() - if 'signer' in locals(): + if 'signer' in locals() and own_signer: signer.close() -def sign_file_using_callback_signer( - source_path: Union[str, Path], - dest_path: Union[str, Path], - manifest: str, - signer: 'Signer' -) -> bytes: - """Sign a file with a C2PA manifest using a signer. - - This function provides a shortcut to sign files using a signer - and returns the raw manifest bytes. - - Args: - source_path: Path to the source file - dest_path: Path to write the signed file to - manifest: The manifest JSON string - signer: The signer to use - - Returns: - The manifest bytes (binary data) - - Raises: - C2paError: If there was an error signing the file - C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters - C2paError.NotSupported: If the file type cannot be determined - """ - try: - # Create a builder from the manifest - builder = Builder(manifest) - - # Open source and destination files - with open(source_path, 'rb') as source_file, open(dest_path, 'wb') as dest_file: - # Get the MIME type from the file extension - mime_type = mimetypes.guess_type(str(source_path))[0] - if not mime_type: - raise C2paError.NotSupported( - f"Could not determine MIME type for file: {source_path}") - - # Convert Python streams to Stream objects - source_stream = Stream(source_file) - dest_stream = Stream(dest_file) - - # Use the builder's internal signing logic - result, manifest_bytes = builder._sign_internal( - signer, mime_type, source_stream, dest_stream) - - return manifest_bytes - - except Exception as e: - # Clean up destination file if it exists and there was an error - if os.path.exists(dest_path): - try: - os.remove(dest_path) - except OSError: - pass # Ignore cleanup errors - - # Re-raise the error - raise C2paError(f"Error signing file: {str(e)}") - finally: - # Ensure resources are cleaned up - if 'builder' in locals(): - builder.close() - - class Stream: # Class-level counter for generating unique stream IDs # (useful for tracing streams usage in debug) @@ -2093,12 +2048,11 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'Reader', 'Builder', 'Signer', - 'version', 'load_settings', 'read_file', 'read_ingredient_file', 'sign_file', - 'sign_file_using_callback_signer', 'format_embeddable', + 'version', 'sdk_version' ] diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 7085bcb0..200a70a0 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -25,7 +25,7 @@ import shutil from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version -from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer, sign_file_using_callback_signer +from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer # Suppress deprecation warnings warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -896,7 +896,7 @@ def sign_callback(data: bytes) -> bytes: shutil.rmtree(temp_dir) def test_sign_file_using_callback_signer(self): - """Test signing a file using the sign_file_using_callback_signer function.""" + """Test signing a file using the sign_file function with a Signer object.""" # Create a temporary directory for the test temp_dir = tempfile.mkdtemp() try: @@ -936,23 +936,25 @@ def sign_callback(data: bytes) -> bytes: tsa_url="http://timestamp.digicert.com" ) - # Import the new function - from c2pa.c2pa import sign_file_using_callback_signer - - # Use the sign_file_using_callback_signer function - manifest_bytes = sign_file_using_callback_signer( - source_path=self.testPath, - dest_path=output_path, - manifest=self.manifestDefinition, - signer=signer + # Use the overloaded sign_file function with a Signer object + result_json = sign_file( + self.testPath, + output_path, + self.manifestDefinition, + signer ) # Verify the output file was created self.assertTrue(os.path.exists(output_path)) - # Verify the manifest bytes are binary data (not JSON text) - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) + # Verify the result is a JSON string (not binary data) + self.assertIsInstance(result_json, str) + self.assertGreater(len(result_json), 0) + + # Parse the JSON and verify it contains expected content + manifest_data = json.loads(result_json) + self.assertIn("manifests", manifest_data) + self.assertIn("active_manifest", manifest_data) # Read the signed file and verify the manifest contains expected content with open(output_path, "rb") as file: @@ -965,6 +967,69 @@ def sign_callback(data: bytes) -> bytes: # Clean up the temporary directory shutil.rmtree(temp_dir) + def test_sign_file_overloads(self): + """Test that the overloaded sign_file function works with both parameter types.""" + # Create a temporary directory for the test + temp_dir = tempfile.mkdtemp() + try: + # Test with C2paSignerInfo + output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") + + # Load test certificates and key + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + key = key_file.read() + + # Create signer info + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + + # Test with C2paSignerInfo parameter + result_1 = sign_file( + self.testPath, + output_path_1, + self.manifestDefinition, + signer_info + ) + + self.assertIsInstance(result_1, str) + self.assertTrue(os.path.exists(output_path_1)) + + # Test with Signer object + output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") + + # Create a signer from the signer info + signer = Signer.from_info(signer_info) + + # Test with Signer parameter + result_2 = sign_file( + self.testPath, + output_path_2, + self.manifestDefinition, + signer + ) + + self.assertIsInstance(result_2, str) + self.assertTrue(os.path.exists(output_path_2)) + + # Both results should be similar (same manifest structure) + manifest_1 = json.loads(result_1) + manifest_2 = json.loads(result_2) + + self.assertIn("manifests", manifest_1) + self.assertIn("manifests", manifest_2) + self.assertIn("active_manifest", manifest_1) + self.assertIn("active_manifest", manifest_2) + + finally: + # Clean up the temporary directory + shutil.rmtree(temp_dir) + class TestStream(unittest.TestCase): def setUp(self): # Create a temporary file for testing @@ -1212,8 +1277,7 @@ def test_sign_file(self): self.testPath, output_path, manifest_json, - signer_info, - temp_data_dir + signer_info ) finally: From be4978114dc2be2a7efb388fd1aac64d6d3c5422 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 20:55:22 -0700 Subject: [PATCH 18/46] fix: Refactor once more with overload --- src/c2pa/c2pa.py | 57 +++++++++++++++++++---------------- tests/test_unit_tests.py | 64 +++++++++++++++++++++++++++++++++++----- 2 files changed, 89 insertions(+), 32 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 2519b824..9291b748 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -597,13 +597,10 @@ def sign_file( source_path: Union[str, Path], dest_path: Union[str, Path], manifest: str, - signer_info: C2paSignerInfo -) -> str: + signer_info: C2paSignerInfo, + return_manifest_as_bytes: bool = False +) -> Union[str, bytes]: """Sign a file with a C2PA manifest using signer info. - - .. deprecated:: 0.10.0 - This function is deprecated and will be removed in a future version. - Please use the Builder class for signing and the Reader class for reading signed data instead. """ ... @@ -612,13 +609,10 @@ def sign_file( source_path: Union[str, Path], dest_path: Union[str, Path], manifest: str, - signer: 'Signer' -) -> str: + signer: 'Signer', + return_manifest_as_bytes: bool = False +) -> Union[str, bytes]: """Sign a file with a C2PA manifest using a signer. - - .. deprecated:: 0.10.0 - This function is deprecated and will be removed in a future version. - Please use the Builder class for signing and the Reader class for reading signed data instead. """ ... @@ -626,8 +620,9 @@ def sign_file( source_path: Union[str, Path], dest_path: Union[str, Path], manifest: str, - signer_or_info: Union[C2paSignerInfo, 'Signer'] -) -> str: + signer_or_info: Union[C2paSignerInfo, 'Signer'], + return_manifest_as_bytes: bool = False +) -> Union[str, bytes]: """Sign a file with a C2PA manifest. For now, this function is left here to provide a backwards-compatible API. @@ -636,9 +631,10 @@ def sign_file( dest_path: Path to write the signed file to manifest: The manifest JSON string signer_or_info: Either a signer configuration or a signer object + return_manifest_as_bytes: If True, return manifest bytes instead of JSON string Returns: - The signed manifest as a JSON string + The signed manifest as a JSON string or bytes, depending on return_manifest_as_bytes Raises: C2paError: If there was an error signing the file @@ -666,17 +662,28 @@ def sign_file( raise C2paError.NotSupported( f"Could not determine MIME type for file: {source_path}") - # Sign the file using the builder - builder.sign( - signer=signer, - format=mime_type, - source=source_file, - dest=dest_file - ) + if return_manifest_as_bytes: + # Convert Python streams to Stream objects for internal signing + source_stream = Stream(source_file) + dest_stream = Stream(dest_file) + + # Use the builder's internal signing logic to get manifest bytes + result, manifest_bytes = builder._sign_internal( + signer, mime_type, source_stream, dest_stream) + + return manifest_bytes + else: + # Sign the file using the builder + builder.sign( + signer=signer, + format=mime_type, + source=source_file, + dest=dest_file + ) - # Read the signed manifest from the destination file - with Reader(dest_path) as reader: - return reader.json() + # Read the signed manifest from the destination file + with Reader(dest_path) as reader: + return reader.json() except Exception as e: # Clean up destination file if it exists and there was an error diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 200a70a0..5f50e2af 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -936,12 +936,13 @@ def sign_callback(data: bytes) -> bytes: tsa_url="http://timestamp.digicert.com" ) - # Use the overloaded sign_file function with a Signer object + # Test with return_manifest_as_bytes=False (default) - should return JSON string result_json = sign_file( self.testPath, output_path, self.manifestDefinition, - signer + signer, + False ) # Verify the output file was created @@ -956,6 +957,23 @@ def sign_callback(data: bytes) -> bytes: self.assertIn("manifests", manifest_data) self.assertIn("active_manifest", manifest_data) + # Test with return_manifest_as_bytes=True - should return bytes + output_path_bytes = os.path.join(temp_dir, "signed_output_callback_bytes.jpg") + result_bytes = sign_file( + self.testPath, + output_path_bytes, + self.manifestDefinition, + signer, + True + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path_bytes)) + + # Verify the result is bytes (not JSON string) + self.assertIsInstance(result_bytes, bytes) + self.assertGreater(len(result_bytes), 0) + # Read the signed file and verify the manifest contains expected content with open(output_path, "rb") as file: reader = Reader("image/jpeg", file) @@ -989,35 +1007,63 @@ def test_sign_file_overloads(self): ta_url=b"http://timestamp.digicert.com" ) - # Test with C2paSignerInfo parameter + # Test with C2paSignerInfo parameter - JSON return result_1 = sign_file( self.testPath, output_path_1, self.manifestDefinition, - signer_info + signer_info, + False ) self.assertIsInstance(result_1, str) self.assertTrue(os.path.exists(output_path_1)) + # Test with C2paSignerInfo parameter - bytes return + output_path_1_bytes = os.path.join(temp_dir, "signed_output_1_bytes.jpg") + result_1_bytes = sign_file( + self.testPath, + output_path_1_bytes, + self.manifestDefinition, + signer_info, + True + ) + + self.assertIsInstance(result_1_bytes, bytes) + self.assertTrue(os.path.exists(output_path_1_bytes)) + # Test with Signer object output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") # Create a signer from the signer info signer = Signer.from_info(signer_info) - # Test with Signer parameter + # Test with Signer parameter - JSON return result_2 = sign_file( self.testPath, output_path_2, self.manifestDefinition, - signer + signer, + False ) self.assertIsInstance(result_2, str) self.assertTrue(os.path.exists(output_path_2)) + + # Test with Signer parameter - bytes return + output_path_2_bytes = os.path.join(temp_dir, "signed_output_2_bytes.jpg") + result_2_bytes = sign_file( + self.testPath, + output_path_2_bytes, + self.manifestDefinition, + signer, + True + ) - # Both results should be similar (same manifest structure) + self.assertIsInstance(result_2_bytes, bytes) + self.assertTrue(os.path.exists(output_path_2_bytes)) + + # Both JSON results should be similar (same manifest structure) manifest_1 = json.loads(result_1) manifest_2 = json.loads(result_2) @@ -1026,6 +1072,10 @@ def test_sign_file_overloads(self): self.assertIn("active_manifest", manifest_1) self.assertIn("active_manifest", manifest_2) + # Both bytes results should be non-empty + self.assertGreater(len(result_1_bytes), 0) + self.assertGreater(len(result_2_bytes), 0) + finally: # Clean up the temporary directory shutil.rmtree(temp_dir) From 8b5c6d44eac7f94aa690e7cc97f43f6d2bcb2ddf Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 21:01:41 -0700 Subject: [PATCH 19/46] fix: Format --- src/c2pa/c2pa.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 9291b748..80f1a0c5 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -725,8 +725,8 @@ def __init__(self, file): Raises: TypeError: If the file object doesn't implement all required methods """ - # Initialize _closed first to prevent AttributeError during garbage - # collection + # Initialize _closed first to prevent AttributeError + # during garbage collection self._closed = False self._initialized = False self._stream = None @@ -1301,7 +1301,7 @@ def from_info(cls, signer_info: C2paSignerInfo) -> 'Signer': if error: # More detailed error message when possible raise C2paError(error) - raise C2paError("Failed to create signer from info") + raise C2paError("Failed to create signer from configured signer_info") return cls(signer_ptr) @@ -1855,8 +1855,8 @@ def _sign_internal( # empty bytes manifest_bytes = b"" finally: - # Always free the C-allocated memory, even if we failed to - # copy it + # Always free the C-allocated memory, + # even if we failed to copy manifest bytes try: _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) except Exception: From 22860df732848dc6c857e3c7339819beb1ccdfb7 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 22:12:49 -0700 Subject: [PATCH 20/46] fix: Change logic --- src/c2pa/c2pa.py | 8 ++++---- tests/test_unit_tests.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 80f1a0c5..e8e9df24 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1367,19 +1367,19 @@ def wrapped_callback( if not data_ptr or data_len <= 0: # Error: invalid input, native code will handle if seeing # signature size being 0 - return 0 + return -1 # Convert C pointer to Python bytes data = bytes(data_ptr[:data_len]) if not data: # Error: empty data, native code will handle it! - return 0 + return -1 # Call the user's callback signature = callback(data) if not signature: # Error: empty signature, native code will handle that too! - return 0 + return -1 # Copy the signature back to the C buffer (since callback is # sued in native code) @@ -1393,7 +1393,7 @@ def wrapped_callback( print( error_messages['callback_error'].format( str(e)), file=sys.stderr) - return 0 + return -1 # Encode strings with error handling in case it's invalid UTF8 try: diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 5f50e2af..0328d976 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -39,7 +39,7 @@ class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.57.0", sdk_version()) + self.assertIn("0.55.0", sdk_version()) class TestReader(unittest.TestCase): From a97a28f6dfd01170c8c423ce6fa25ba5ac2aee63 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 22:23:07 -0700 Subject: [PATCH 21/46] fix: Deprecation --- src/c2pa/c2pa.py | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index e8e9df24..0f25efaf 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -534,6 +534,15 @@ def read_ingredient_file( path: Union[str, Path], data_dir: Union[str, Path]) -> str: """Read a C2PA ingredient from a file. + .. deprecated:: 0.11.0 + This function is deprecated and will be removed in a future version. + Please use the Reader class for reading C2PA metadata instead. + Example: + ```python + with Reader(path) as reader: + manifest_json = reader.json() + ``` + Args: path: Path to the file to read data_dir: Directory to write binary resources to @@ -544,6 +553,12 @@ def read_ingredient_file( Raises: C2paError: If there was an error reading the file """ + warnings.warn( + "The read_ingredient_file function is deprecated and will be removed in a future version." + "Please use Reader(path).json() for reading C2PA metadata instead.", + DeprecationWarning, + stacklevel=2) + container = _StringContainer() container._path_str = str(path).encode('utf-8') @@ -1382,7 +1397,7 @@ def wrapped_callback( return -1 # Copy the signature back to the C buffer (since callback is - # sued in native code) + # used in native code) actual_len = min(len(signature), signed_len) for i in range(actual_len): signed_bytes_ptr[i] = signature[i] @@ -1977,6 +1992,14 @@ def create_signer( ) -> Signer: """Create a signer from a callback function. + .. deprecated:: 0.11.0 + 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) + ``` + Args: callback: Function that signs data and returns the signature alg: The signing algorithm to use @@ -1990,12 +2013,26 @@ def create_signer( C2paError: If there was an error creating the signer C2paError.Encoding: If the certificate data or TSA URL contains invalid UTF-8 characters """ + warnings.warn( + "The create_signer function is deprecated and will be removed in a future version." + "Please use Signer.from_callback() instead.", + DeprecationWarning, + stacklevel=2) + return Signer.from_callback(callback, alg, certs, tsa_url) def create_signer_from_info(signer_info: C2paSignerInfo) -> Signer: """Create a signer from signer information. + .. deprecated:: 0.11.0 + 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) + ``` + Args: signer_info: The signer configuration @@ -2005,6 +2042,12 @@ def create_signer_from_info(signer_info: C2paSignerInfo) -> Signer: Raises: C2paError: If there was an error creating the signer """ + warnings.warn( + "The create_signer_from_info function is deprecated and will be removed in a future version." + "Please use Signer.from_info() instead.", + DeprecationWarning, + stacklevel=2) + return Signer.from_info(signer_info) From 532825edc267b5742d1d370f22f18a1733f3c12e Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 22:24:10 -0700 Subject: [PATCH 22/46] fix: Logic --- src/c2pa/c2pa.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 0f25efaf..568e40f8 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1399,8 +1399,8 @@ def wrapped_callback( # Copy the signature back to the C buffer (since callback is # used in native code) actual_len = min(len(signature), signed_len) - for i in range(actual_len): - signed_bytes_ptr[i] = signature[i] + # Use memmove for efficient memory copying instead of byte-by-byte loop + ctypes.memmove(signed_bytes_ptr, signature, actual_len) # Native code expects the signed len to be returned, we oblige return actual_len From e9b9f7aeea744901b862f244fc0e686fa354b1b0 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 22:30:49 -0700 Subject: [PATCH 23/46] fix: Test stdout output --- src/c2pa/c2pa.py | 12 ++++++++---- tests/test_unit_tests.py | 4 ++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 568e40f8..843aba2c 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1380,20 +1380,22 @@ def wrapped_callback( # signature length for correctness. try: if not data_ptr or data_len <= 0: - # Error: invalid input, native code will handle if seeing - # signature size being 0 + # Error: invalid input, invalid so return -1, + # native code will handle it! return -1 # Convert C pointer to Python bytes data = bytes(data_ptr[:data_len]) if not data: - # Error: empty data, native code will handle it! + # Error: empty data, invalid so return -1, + # native code will also handle it! return -1 # Call the user's callback signature = callback(data) if not signature: - # Error: empty signature, native code will handle that too! + # Error: empty signature, invalid so return -1, + # native code will handle that too! return -1 # Copy the signature back to the C buffer (since callback is @@ -1408,6 +1410,8 @@ def wrapped_callback( print( error_messages['callback_error'].format( str(e)), file=sys.stderr) + # Error: exception raised, invalid so return -1, + # native code will handle the error when seeing -1 return -1 # Encode strings with error handling in case it's invalid UTF8 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 0328d976..d95f6bb3 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -244,6 +244,9 @@ def test_read_cawg_data_file(self): class TestBuilder(unittest.TestCase): def setUp(self): + # Filter deprecation warnings for create_signer function + warnings.filterwarnings("ignore", message="The create_signer function is deprecated") + # Use the fixtures_dir fixture to set up paths self.data_dir = FIXTURES_DIR self.testPath = DEFAULT_TEST_FILE @@ -1223,6 +1226,7 @@ def setUp(self): # Filter specific deprecation warnings for legacy API tests warnings.filterwarnings("ignore", message="The read_file function is deprecated") warnings.filterwarnings("ignore", message="The sign_file function is deprecated") + warnings.filterwarnings("ignore", message="The read_ingredient_file function is deprecated") self.data_dir = FIXTURES_DIR self.testPath = DEFAULT_TEST_FILE From 97754074dc451cd3d8aaa2bbae6b8d650e47c5ce Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 20 Jun 2025 22:36:57 -0700 Subject: [PATCH 24/46] fix: One last format --- tests/test_unit_tests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index d95f6bb3..39781932 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -246,7 +246,7 @@ class TestBuilder(unittest.TestCase): def setUp(self): # Filter deprecation warnings for create_signer function warnings.filterwarnings("ignore", message="The create_signer function is deprecated") - + # Use the fixtures_dir fixture to set up paths self.data_dir = FIXTURES_DIR self.testPath = DEFAULT_TEST_FILE @@ -1083,6 +1083,7 @@ def test_sign_file_overloads(self): # Clean up the temporary directory shutil.rmtree(temp_dir) + class TestStream(unittest.TestCase): def setUp(self): # Create a temporary file for testing @@ -1339,5 +1340,6 @@ def test_sign_file(self): if os.path.exists(output_path): os.remove(output_path) + if __name__ == '__main__': unittest.main() From 2ca229846af314fbef0dc41461be8c5125bda274 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 24 Jun 2025 15:39:43 -0700 Subject: [PATCH 25/46] fix: Refactor --- tests/test_unit_tests.py | 120 +++++++++------------------------------ 1 file changed, 28 insertions(+), 92 deletions(-) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 39781932..386f8eca 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -19,7 +19,7 @@ import ctypes import warnings from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric import padding, ec from cryptography.hazmat.backends import default_backend import tempfile import shutil @@ -292,6 +292,20 @@ def setUp(self): ] } + # Define an example ES256 callback signer + def callback_signer_es256(data: bytes) -> bytes: + private_key = serialization.load_pem_private_key( + self.key, + password=None, + backend=default_backend() + ) + signature = private_key.sign( + data, + ec.ECDSA(hashes.SHA256()) + ) + return signature + self.callback_signer_es256 = callback_signer_es256 + def test_reserve_size_on_closed_signer(self): self.signer.close() with self.assertRaises(Error): @@ -769,35 +783,9 @@ def test_sign_file_callback_signer(self): # Use the sign_file method builder = Builder(self.manifestDefinition) - # Create a real ES256 signing callback - def sign_callback(data: bytes) -> bytes: - """Real ES256 signing callback that creates actual signatures.""" - # Load the private key from the test fixtures - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - private_key_data = key_file.read() - - # Load the private key using cryptography - private_key = serialization.load_pem_private_key( - private_key_data, - password=None, - backend=default_backend() - ) - - # Create the signature using ES256 (ECDSA with SHA-256) - # For ECDSA, we use the signature_algorithm_constructor - from cryptography.hazmat.primitives import hashes - from cryptography.hazmat.primitives.asymmetric import ec - - signature = private_key.sign( - data, - ec.ECDSA(hashes.SHA256()) - ) - - return signature - # Create signer with callback signer = create_signer( - callback=sign_callback, + callback=self.callback_signer_es256, alg=SigningAlg.ES256, certs=self.certs.decode('utf-8'), tsa_url="http://timestamp.digicert.com" @@ -839,35 +827,9 @@ def test_sign_file_callback_signer_from_callback(self): # Use the sign_file method builder = Builder(self.manifestDefinition) - # Create a real ES256 signing callback - def sign_callback(data: bytes) -> bytes: - """Real ES256 signing callback that creates actual signatures.""" - # Load the private key from the test fixtures - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - private_key_data = key_file.read() - - # Load the private key using cryptography - private_key = serialization.load_pem_private_key( - private_key_data, - password=None, - backend=default_backend() - ) - - # Create the signature using ES256 (ECDSA with SHA-256) - # For ECDSA, we use the signature_algorithm_constructor - from cryptography.hazmat.primitives import hashes - from cryptography.hazmat.primitives.asymmetric import ec - - signature = private_key.sign( - data, - ec.ECDSA(hashes.SHA256()) - ) - - return signature - # Create signer with callback using Signer.from_callback signer = Signer.from_callback( - callback=sign_callback, + callback=self.callback_signer_es256, alg=SigningAlg.ES256, certs=self.certs.decode('utf-8'), tsa_url="http://timestamp.digicert.com" @@ -906,34 +868,9 @@ def test_sign_file_using_callback_signer(self): # Create a temporary output file path output_path = os.path.join(temp_dir, "signed_output_callback.jpg") - # Create a real ES256 signing callback - def sign_callback(data: bytes) -> bytes: - """Real ES256 signing callback that creates actual signatures.""" - # Load the private key from the test fixtures - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - private_key_data = key_file.read() - - # Load the private key using cryptography - private_key = serialization.load_pem_private_key( - private_key_data, - password=None, - backend=default_backend() - ) - - # Create the signature using ES256 (ECDSA with SHA-256) - from cryptography.hazmat.primitives import hashes - from cryptography.hazmat.primitives.asymmetric import ec - - signature = private_key.sign( - data, - ec.ECDSA(hashes.SHA256()) - ) - - return signature - # Create signer with callback signer = Signer.from_callback( - callback=sign_callback, + callback=self.callback_signer_es256, alg=SigningAlg.ES256, certs=self.certs.decode('utf-8'), tsa_url="http://timestamp.digicert.com" @@ -995,7 +932,7 @@ def test_sign_file_overloads(self): try: # Test with C2paSignerInfo output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") - + # Load test certificates and key with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: certs = cert_file.read() @@ -1018,7 +955,7 @@ def test_sign_file_overloads(self): signer_info, False ) - + self.assertIsInstance(result_1, str) self.assertTrue(os.path.exists(output_path_1)) @@ -1031,16 +968,16 @@ def test_sign_file_overloads(self): signer_info, True ) - + self.assertIsInstance(result_1_bytes, bytes) self.assertTrue(os.path.exists(output_path_1_bytes)) # Test with Signer object output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") - + # Create a signer from the signer info signer = Signer.from_info(signer_info) - + # Test with Signer parameter - JSON return result_2 = sign_file( self.testPath, @@ -1049,7 +986,7 @@ def test_sign_file_overloads(self): signer, False ) - + self.assertIsInstance(result_2, str) self.assertTrue(os.path.exists(output_path_2)) @@ -1062,14 +999,14 @@ def test_sign_file_overloads(self): signer, True ) - + self.assertIsInstance(result_2_bytes, bytes) self.assertTrue(os.path.exists(output_path_2_bytes)) - + # Both JSON results should be similar (same manifest structure) manifest_1 = json.loads(result_1) manifest_2 = json.loads(result_2) - + self.assertIn("manifests", manifest_1) self.assertIn("manifests", manifest_2) self.assertIn("active_manifest", manifest_1) @@ -1239,11 +1176,10 @@ def setUp(self): def tearDown(self): """Clean up temporary files after each test.""" if os.path.exists(self.temp_data_dir): - import shutil shutil.rmtree(self.temp_data_dir) def test_invalid_settings_str(self): - """Test loading a malformed settings string.""" + """Test loading a malformed settings string.""" with self.assertRaises(Error): load_settings(r'{"verify": { "remote_manifest_fetch": false }') From 12f04d40872e9c3fb7e801edf0e66eb52a6ddc45 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 24 Jun 2025 15:45:51 -0700 Subject: [PATCH 26/46] fix: Refactor 2 --- tests/test_unit_tests.py | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 386f8eca..282bf4d6 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -774,16 +774,16 @@ def test_sign_file(self): def test_sign_file_callback_signer(self): """Test signing a file using the sign_file method.""" - # Create a temporary directory for the test + temp_dir = tempfile.mkdtemp() + try: - # Create a temporary output file path output_path = os.path.join(temp_dir, "signed_output.jpg") # Use the sign_file method builder = Builder(self.manifestDefinition) - # Create signer with callback + # Create signer with callback using create_signer function signer = create_signer( callback=self.callback_signer_es256, alg=SigningAlg.ES256, @@ -800,7 +800,7 @@ def test_sign_file_callback_signer(self): # Verify the output file was created self.assertTrue(os.path.exists(output_path)) - # Verify we got both result and manifest bytes + # Verify results self.assertIsInstance(result, int) self.assertIsInstance(manifest_bytes, bytes) self.assertGreater(len(manifest_bytes), 0) @@ -813,18 +813,17 @@ def test_sign_file_callback_signer(self): self.assertNotIn("validation_status", json_data) finally: - # Clean up the temporary directory shutil.rmtree(temp_dir) - def test_sign_file_callback_signer_from_callback(self): + def test_builder_sign_file_callback_signer_from_callback(self): """Test signing a file using the sign_file method with Signer.from_callback.""" - # Create a temporary directory for the test + temp_dir = tempfile.mkdtemp() try: - # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed_output_from_callback.jpg") - # Use the sign_file method + # Will use the sign_file method builder = Builder(self.manifestDefinition) # Create signer with callback using Signer.from_callback @@ -844,7 +843,7 @@ def test_sign_file_callback_signer_from_callback(self): # Verify the output file was created self.assertTrue(os.path.exists(output_path)) - # Verify we got both result and manifest bytes + # Verify results self.assertIsInstance(result, int) self.assertIsInstance(manifest_bytes, bytes) self.assertGreater(len(manifest_bytes), 0) @@ -857,13 +856,13 @@ def test_sign_file_callback_signer_from_callback(self): self.assertNotIn("validation_status", json_data) finally: - # Clean up the temporary directory shutil.rmtree(temp_dir) - def test_sign_file_using_callback_signer(self): + def test_sign_file_using_callback_signer_overloads(self): """Test signing a file using the sign_file function with a Signer object.""" # Create a temporary directory for the test temp_dir = tempfile.mkdtemp() + try: # Create a temporary output file path output_path = os.path.join(temp_dir, "signed_output_callback.jpg") @@ -876,7 +875,7 @@ def test_sign_file_using_callback_signer(self): tsa_url="http://timestamp.digicert.com" ) - # Test with return_manifest_as_bytes=False (default) - should return JSON string + # Overload that returns a JSON string result_json = sign_file( self.testPath, output_path, @@ -888,17 +887,16 @@ def test_sign_file_using_callback_signer(self): # Verify the output file was created self.assertTrue(os.path.exists(output_path)) - # Verify the result is a JSON string (not binary data) + # Verify the result is JSON self.assertIsInstance(result_json, str) self.assertGreater(len(result_json), 0) - # Parse the JSON and verify it contains expected content manifest_data = json.loads(result_json) self.assertIn("manifests", manifest_data) self.assertIn("active_manifest", manifest_data) - # Test with return_manifest_as_bytes=True - should return bytes output_path_bytes = os.path.join(temp_dir, "signed_output_callback_bytes.jpg") + # Overload that returns bytes result_bytes = sign_file( self.testPath, output_path_bytes, @@ -910,7 +908,7 @@ def test_sign_file_using_callback_signer(self): # Verify the output file was created self.assertTrue(os.path.exists(output_path_bytes)) - # Verify the result is bytes (not JSON string) + # Verify the result is bytes self.assertIsInstance(result_bytes, bytes) self.assertGreater(len(result_bytes), 0) @@ -922,7 +920,6 @@ def test_sign_file_using_callback_signer(self): self.assertNotIn("validation_status", file_manifest_json) finally: - # Clean up the temporary directory shutil.rmtree(temp_dir) def test_sign_file_overloads(self): From b2a47f4171cc84e73022ac924e4b97dd28b003d4 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 24 Jun 2025 18:47:55 -0700 Subject: [PATCH 27/46] fix: Refactor --- src/c2pa/c2pa.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 843aba2c..e0650c93 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1276,13 +1276,18 @@ def resource_to_stream(self, uri: str, stream: Any) -> int: class Signer: """High-level wrapper for C2PA Signer operations.""" - def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): + def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner), callback_cb: Optional[SignerCallback] = None): """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 C2PA signer + callback_cb: Optional callback function (for callback-based signers) """ self._signer = signer_ptr + self._callback_cb = callback_cb # Keep callback alive to prevent garbage collection self._closed = False self._error_messages = { 'closed_error': "Signer is closed", @@ -1373,11 +1378,10 @@ def wrapped_callback( data_len, signed_bytes_ptr, signed_len): - # Returns 0 on error as this case is handled in the native code gracefully + # Returns -1 on error as it is what the native code expects. # The reason is that otherwise we ping-pong errors between native code and Python code, # which can become tedious in handling. So we let the native code deal with it and - # raise the errors accordingly, since it already checks the - # signature length for correctness. + # raise the errors accordingly, since it already does checks. try: if not data_ptr or data_len <= 0: # Error: invalid input, invalid so return -1, @@ -1398,8 +1402,8 @@ def wrapped_callback( # native code will handle that too! return -1 - # Copy the signature back to the C buffer (since callback is - # used in native code) + # Copy the signature back to the C buffer + # (since callback is used in native code) actual_len = min(len(signature), signed_len) # Use memmove for efficient memory copying instead of byte-by-byte loop ctypes.memmove(signed_bytes_ptr, signature, actual_len) @@ -1423,15 +1427,13 @@ def wrapped_callback( error_messages['encoding_error'].format( str(e))) - # Create the signer with the wrapped callback - # Store the callback as an instance attribute to keep it alive, as this prevents - # garbage collection and lifetime issues. - signer_instance = cls.__new__(cls) - signer_instance._callback_cb = SignerCallback(wrapped_callback) + # Create the callback object using the callback function + callback_cb = SignerCallback(wrapped_callback) + # Create the signer with the wrapped callback signer_ptr = _lib.c2pa_signer_create( None, - signer_instance._callback_cb, + callback_cb, alg, certs_bytes, tsa_url_bytes @@ -1443,12 +1445,8 @@ def wrapped_callback( raise C2paError(error) raise C2paError("Failed to create signer") - # Initialize the signer instance - signer_instance._signer = signer_ptr - signer_instance._closed = False - signer_instance._error_messages = error_messages - - return signer_instance + # Create and return the signer instance with the callback + return cls(signer_ptr, callback_cb) def __enter__(self): """Context manager entry.""" @@ -1888,6 +1886,7 @@ def _sign_internal( source_stream.close() dest_stream.close() + def sign( self, signer: Signer, From 7b075af88df51f5f2566a3eba67edcfbb0443432 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 24 Jun 2025 19:14:26 -0700 Subject: [PATCH 28/46] fix: Better API --- src/c2pa/c2pa.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index e0650c93..8a382c29 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1276,7 +1276,7 @@ def resource_to_stream(self, uri: str, stream: Any) -> int: class Signer: """High-level wrapper for C2PA Signer operations.""" - def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner), callback_cb: Optional[SignerCallback] = None): + def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): """Initialize a new Signer instance. Note: This constructor is not meant to be called directly. @@ -1284,10 +1284,8 @@ def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner), callback_cb: Optional Args: signer_ptr: Pointer to the C2PA signer - callback_cb: Optional callback function (for callback-based signers) """ self._signer = signer_ptr - self._callback_cb = callback_cb # Keep callback alive to prevent garbage collection self._closed = False self._error_messages = { 'closed_error': "Signer is closed", @@ -1446,7 +1444,14 @@ def wrapped_callback( raise C2paError("Failed to create signer") # Create and return the signer instance with the callback - return cls(signer_ptr, callback_cb) + 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 __enter__(self): """Context manager entry.""" From 8df82ba33fd04a8eabe328185bdde5776929df47 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 24 Jun 2025 22:20:10 -0700 Subject: [PATCH 29/46] fix: Verify error gets raised --- tests/test_unit_tests.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 282bf4d6..490e1be9 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -1017,6 +1017,40 @@ def test_sign_file_overloads(self): # Clean up the temporary directory shutil.rmtree(temp_dir) + def test_sign_file_callback_signer_reports_error(self): + """Test signing a file using the sign_file method with a callback that reports an error.""" + + temp_dir = tempfile.mkdtemp() + + try: + output_path = os.path.join(temp_dir, "signed_output.jpg") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + + # Define a callback that always returns -1 to simulate an error + def error_callback_signer(data: bytes) -> bytes: + # Return -1 to indicate an error condition + return -1 + + # Create signer with error callback using create_signer function + signer = create_signer( + callback=error_callback_signer, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + # The signing operation should fail due to the error callback + with self.assertRaises(Error): + result, manifest_bytes = builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + finally: + shutil.rmtree(temp_dir) class TestStream(unittest.TestCase): def setUp(self): From 6c865bd2f649a0213f1e8841503cfeaad6848b1a Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 24 Jun 2025 22:22:08 -0700 Subject: [PATCH 30/46] fix: Verify error gets raised 2 --- tests/test_unit_tests.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 490e1be9..97d8848b 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -1049,6 +1049,11 @@ def error_callback_signer(data: bytes) -> bytes: signer=signer ) + # Verify the output file stays empty, + # as no data should have been written + self.assertTrue(os.path.exists(output_path)) + self.assertEqual(os.path.getsize(output_path), 0) + finally: shutil.rmtree(temp_dir) From cd13130170004e3b207b4c3c96b001c9e2ff042e Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 24 Jun 2025 22:27:49 -0700 Subject: [PATCH 31/46] fix: Add context manager test for callback signer --- tests/test_unit_tests.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 97d8848b..2e69d074 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -815,6 +815,44 @@ def test_sign_file_callback_signer(self): finally: shutil.rmtree(temp_dir) + def test_sign_file_callback_signer_managed(self): + """Test signing a file using the sign_file method with context managers.""" + + temp_dir = tempfile.mkdtemp() + + try: + output_path = os.path.join(temp_dir, "signed_output_managed.jpg") + + # Create builder and signer with context managers + with Builder(self.manifestDefinition) as builder, create_signer( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) as signer: + + # Sign the file + result, manifest_bytes = builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + # Verify results + self.assertTrue(os.path.exists(output_path)) + self.assertIsInstance(result, int) + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) + + # Verify signed data can be read + 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) + + finally: + shutil.rmtree(temp_dir) + def test_builder_sign_file_callback_signer_from_callback(self): """Test signing a file using the sign_file method with Signer.from_callback.""" From 4d50c0579771e3baa7540343859a1afb2ff9e4bc Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 24 Jun 2025 22:37:23 -0700 Subject: [PATCH 32/46] fix: Verify used alg in tests --- tests/test_unit_tests.py | 42 ++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 2e69d074..cc93657d 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -293,6 +293,7 @@ def setUp(self): } # Define an example ES256 callback signer + self.callback_signer_alg = "Es256" def callback_signer_es256(data: bytes) -> bytes: private_key = serialization.load_pem_private_key( self.key, @@ -806,12 +807,19 @@ def test_sign_file_callback_signer(self): self.assertGreater(len(manifest_bytes), 0) # Read the signed file and verify the manifest - with open(output_path, "rb") as file: - reader = Reader("image/jpeg", file) + 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) + # 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) @@ -850,6 +858,16 @@ def test_sign_file_callback_signer_managed(self): self.assertIn("Python Test", json_data) self.assertNotIn("validation_status", json_data) + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify the signature_info contains the correct algorithm + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + finally: shutil.rmtree(temp_dir) @@ -887,12 +905,21 @@ def test_builder_sign_file_callback_signer_from_callback(self): self.assertGreater(len(manifest_bytes), 0) # Read the signed file and verify the manifest - with open(output_path, "rb") as file: - reader = Reader("image/jpeg", file) + 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) + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify the signature_info contains the correct algorithm + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + finally: shutil.rmtree(temp_dir) @@ -1066,10 +1093,9 @@ def test_sign_file_callback_signer_reports_error(self): # Use the sign_file method builder = Builder(self.manifestDefinition) - # Define a callback that always returns -1 to simulate an error + # Define a callback that always returns None to simulate an error def error_callback_signer(data: bytes) -> bytes: - # Return -1 to indicate an error condition - return -1 + return None # Create signer with error callback using create_signer function signer = create_signer( From 40a05f26d5c7517ab1907e2b57ed69f707cde46f Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 24 Jun 2025 22:40:35 -0700 Subject: [PATCH 33/46] fix: More tests --- tests/test_unit_tests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index cc93657d..f1f27d3c 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -1095,6 +1095,8 @@ def test_sign_file_callback_signer_reports_error(self): # Define a callback that always returns None to simulate an error def error_callback_signer(data: bytes) -> bytes: + # Could alternatively also raise an error + # raise RuntimeError("Simulated signing error") return None # Create signer with error callback using create_signer function From 034c779de5b540c2385873079d417b9c2a9880e4 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 24 Jun 2025 22:43:15 -0700 Subject: [PATCH 34/46] fix: Verify signer can be used multiple times --- tests/test_unit_tests.py | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index f1f27d3c..51e2e81e 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -871,6 +871,69 @@ def test_sign_file_callback_signer_managed(self): finally: shutil.rmtree(temp_dir) + def test_sign_file_callback_signer_managed_multiple_uses(self): + """Test that a signer can be used multiple times with context managers.""" + + temp_dir = tempfile.mkdtemp() + + try: + # Create builder and signer with context managers + with Builder(self.manifestDefinition) as builder, create_signer( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) as signer: + + # First signing operation + output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") + result_1, manifest_bytes_1 = builder.sign_file( + source_path=self.testPath, + dest_path=output_path_1, + signer=signer + ) + + # Verify first signing was successful + self.assertTrue(os.path.exists(output_path_1)) + self.assertIsInstance(result_1, int) + self.assertIsInstance(manifest_bytes_1, bytes) + self.assertGreater(len(manifest_bytes_1), 0) + + # Second signing operation with the same signer + # This is to verify we don't free the signer or the callback too early + output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") + result_2, manifest_bytes_2 = builder.sign_file( + source_path=self.testPath, + dest_path=output_path_2, + signer=signer + ) + + # Verify second signing was successful + self.assertTrue(os.path.exists(output_path_2)) + self.assertIsInstance(result_2, int) + self.assertIsInstance(manifest_bytes_2, bytes) + self.assertGreater(len(manifest_bytes_2), 0) + + # Verify both files contain valid C2PA data + for output_path in [output_path_1, output_path_2]: + with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify the signature_info contains the correct algorithm + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + + finally: + shutil.rmtree(temp_dir) + def test_builder_sign_file_callback_signer_from_callback(self): """Test signing a file using the sign_file method with Signer.from_callback.""" From b551e69ba9f1d6ff01546d24a25c8c60e8a7e6e0 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 24 Jun 2025 22:55:23 -0700 Subject: [PATCH 35/46] fix: Be more friendly with input --- src/c2pa/c2pa.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 8a382c29..56c55a10 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1381,11 +1381,15 @@ def wrapped_callback( # which can become tedious in handling. So we let the native code deal with it and # raise the errors accordingly, since it already does checks. try: - if not data_ptr or data_len <= 0: + if not data_ptr or data_len <= 0 or not signed_bytes_ptr or signed_len <= 0: # Error: invalid input, invalid so return -1, # native code will handle it! return -1 + # Validate buffer sizes before memory operations + if data_len > 1024 * 1024: # 1MB limit + return -1 + # Convert C pointer to Python bytes data = bytes(data_ptr[:data_len]) if not data: @@ -1418,8 +1422,9 @@ def wrapped_callback( # Encode strings with error handling in case it's invalid UTF8 try: - certs_bytes = certs.encode('utf-8') - tsa_url_bytes = tsa_url.encode('utf-8') if tsa_url else None + # Only encode if not already bytes, avoid unnecessary encoding + certs_bytes = certs.encode('utf-8') if isinstance(certs, str) else certs + tsa_url_bytes = tsa_url.encode('utf-8') if tsa_url and isinstance(tsa_url, str) else tsa_url except UnicodeError as e: raise C2paError.Encoding( error_messages['encoding_error'].format( From 2835bb32157a04318334d2c7eb726af2706ae473 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 24 Jun 2025 23:01:14 -0700 Subject: [PATCH 36/46] fix: Throw in stream optimization --- src/c2pa/c2pa.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 56c55a10..5042a047 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -746,13 +746,11 @@ def __init__(self, file): self._initialized = False self._stream = None - # Generate unique stream ID with timestamp - timestamp = int(time.time() * 1000) # milliseconds since epoch - + # Generate unique stream ID efficiently using object ID and counter # Safely increment stream ID with overflow protection if Stream._next_stream_id >= Stream._MAX_STREAM_ID: Stream._next_stream_id = 0 # Reset to 0 if we hit the maximum - self._stream_id = f"{timestamp}-{Stream._next_stream_id}" + self._stream_id = f"{id(self)}-{Stream._next_stream_id}" Stream._next_stream_id += 1 # Rest of the existing initialization code... From b20c9dba599f64e87cf3051f66fc915b86dde10e Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 24 Jun 2025 23:08:34 -0700 Subject: [PATCH 37/46] fix: Faster memory tricks --- src/c2pa/c2pa.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 5042a047..60efcbde 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1388,8 +1388,8 @@ def wrapped_callback( if data_len > 1024 * 1024: # 1MB limit return -1 - # Convert C pointer to Python bytes - data = bytes(data_ptr[:data_len]) + # Convert C pointer to Python bytes using direct memory access + data = bytes(ctypes.cast(data_ptr, ctypes.POINTER(ctypes.c_ubyte * data_len)).contents) if not data: # Error: empty data, invalid so return -1, # native code will also handle it! From 7b9698f91e625b40198b6edb1a3cfebc6caa8101 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 25 Jun 2025 08:19:29 -0700 Subject: [PATCH 38/46] fix: Memory handling change --- src/c2pa/c2pa.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 60efcbde..7aa11e86 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1388,8 +1388,11 @@ def wrapped_callback( if data_len > 1024 * 1024: # 1MB limit return -1 - # Convert C pointer to Python bytes using direct memory access - data = bytes(ctypes.cast(data_ptr, ctypes.POINTER(ctypes.c_ubyte * data_len)).contents) + # Recover signed data (copy, to avoid lifetime issues) + temp_buffer = (ctypes.c_ubyte * data_len)() + ctypes.memmove(temp_buffer, data_ptr, data_len) + data = bytes(temp_buffer) + if not data: # Error: empty data, invalid so return -1, # native code will also handle it! @@ -1874,7 +1877,9 @@ def _sign_internal( if manifest_bytes_ptr and result > 0: try: # Convert the C pointer to Python bytes - manifest_bytes = bytes(manifest_bytes_ptr[:result]) + temp_buffer = (ctypes.c_ubyte * result)() + ctypes.memmove(temp_buffer, manifest_bytes_ptr, result) + manifest_bytes = bytes(temp_buffer) except Exception: # If there's any error accessing the memory, just return # empty bytes From aa701a729f2a194137d2254c9a6a0d3ceafe0673 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 25 Jun 2025 08:25:04 -0700 Subject: [PATCH 39/46] fix: Docs --- src/c2pa/c2pa.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 7aa11e86..547629e3 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1279,9 +1279,6 @@ def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): Note: This constructor is not meant to be called directly. Use from_info() or from_callback() instead. - - Args: - signer_ptr: Pointer to the C2PA signer """ self._signer = signer_ptr self._closed = False From 5cb1a71b5ac4e05237ba6e2ef6641569fca8ca3f Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 25 Jun 2025 08:26:13 -0700 Subject: [PATCH 40/46] fix: Docs --- src/c2pa/c2pa.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 547629e3..350e90ae 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -746,10 +746,9 @@ def __init__(self, file): self._initialized = False self._stream = None - # Generate unique stream ID efficiently using object ID and counter - # Safely increment stream ID with overflow protection + # Generate unique stream ID using object ID and counter if Stream._next_stream_id >= Stream._MAX_STREAM_ID: - Stream._next_stream_id = 0 # Reset to 0 if we hit the maximum + Stream._next_stream_id = 0 self._stream_id = f"{id(self)}-{Stream._next_stream_id}" Stream._next_stream_id += 1 From 876d32adbcfd398f131b6710245e31013578ce3d Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 25 Jun 2025 09:01:41 -0700 Subject: [PATCH 41/46] fix: Refactor --- src/c2pa/c2pa.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 350e90ae..f1133f3f 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1918,7 +1918,6 @@ def sign( dest_stream = Stream(dest) # Use the internal stream-base signing logic - # Ignore the return value since this method returns None self._sign_internal(signer, format, source_stream, dest_stream) def sign_file(self, From e9828eab4143f7a18154d76b07dbe3c4f9f6763f Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 25 Jun 2025 13:13:36 -0700 Subject: [PATCH 42/46] fix: Import changes, error handling made consistent, formatting (#126) * fix: No error emssage dict recreation, all strings are static, so... * fix: Imports * fix: autopep8 formatting opinions * fix: Errors become consistent * fix: Stream closing error handling * fix: Stream error handling update * fix: Format * fix: Format 2 --- src/c2pa/c2pa.py | 230 ++++++++++++++++++++++++----------------------- 1 file changed, 120 insertions(+), 110 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index f1133f3f..0a55fc60 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -554,7 +554,7 @@ def read_ingredient_file( C2paError: If there was an error reading the file """ warnings.warn( - "The read_ingredient_file function is deprecated and will be removed in a future version." + "The read_ingredient_file function is deprecated and will be removed in a future version." "Please use Reader(path).json() for reading C2PA metadata instead.", DeprecationWarning, stacklevel=2) @@ -619,6 +619,7 @@ def sign_file( """ ... + @overload def sign_file( source_path: Union[str, Path], @@ -631,6 +632,7 @@ def sign_file( """ ... + def sign_file( source_path: Union[str, Path], dest_path: Union[str, Path], @@ -682,7 +684,8 @@ def sign_file( source_stream = Stream(source_file) dest_stream = Stream(dest_file) - # Use the builder's internal signing logic to get manifest bytes + # Use the builder's internal signing logic to get manifest + # bytes result, manifest_bytes = builder._sign_internal( signer, mime_type, source_stream, dest_stream) @@ -731,6 +734,22 @@ class Stream: # of the stream ID ensures uniqueness even after counter reset _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): """Initialize a new Stream wrapper around a file-like object. @@ -953,7 +972,7 @@ def close(self): _lib.c2pa_release_stream(self._stream) except Exception as e: print( - self._error_messages['stream_error'].format( + Stream._ERROR_MESSAGES['stream_error'].format( str(e)), file=sys.stderr) finally: self._stream = None @@ -965,13 +984,13 @@ def close(self): setattr(self, attr, None) except Exception as e: print( - self._error_messages['callback_error'].format( + Stream._ERROR_MESSAGES['callback_error'].format( attr, str(e)), file=sys.stderr) # Note: We don't close self._file as we don't own it except Exception as e: print( - self._error_messages['cleanup_error'].format( + Stream._ERROR_MESSAGES['cleanup_error'].format( str(e)), file=sys.stderr) finally: self._closed = True @@ -999,6 +1018,19 @@ def initialized(self) -> bool: class Reader: """High-level wrapper for C2PA Reader operations.""" + # Class-level error messages to avoid multiple creation + _ERROR_MESSAGES = { + 'unsupported': "Unsupported format", + 'io_error': "IO error: {}", + 'manifest_error': "Invalid manifest data: must be bytes", + 'reader_error': "Failed to create reader: {}", + 'cleanup_error': "Error during cleanup: {}", + 'stream_error': "Error cleaning up stream: {}", + 'file_error': "Error cleaning up file: {}", + 'reader_cleanup_error': "Error cleaning up reader: {}", + 'encoding_error': "Invalid UTF-8 characters in input: {}" + } + def __init__(self, format_or_path: Union[str, Path], @@ -1018,33 +1050,13 @@ def __init__(self, self._reader = None self._own_stream = None - self._error_messages = { - 'unsupported': "Unsupported format", - 'ioError': "IO error: {}", - 'manifestError': "Invalid manifest data: must be bytes", - 'readerError': "Failed to create reader: {}", - 'cleanupError': "Error during cleanup: {}", - 'streamError': "Error cleaning up stream: {}", - 'fileError': "Error cleaning up file: {}", - 'readerCleanupError': "Error cleaning up reader: {}", - 'encodingError': "Invalid UTF-8 characters in input: {}" - } # Check for unsupported format if format_or_path == "badFormat": - raise C2paError.NotSupported(self._error_messages['unsupported']) + raise C2paError.NotSupported(Reader._ERROR_MESSAGES['unsupported']) if stream is None: # Create a stream from the file path - - # Check if mimetypes is already imported to avoid duplicate imports - # This is important because mimetypes initialization can be expensive - # and we want to reuse the existing module if it's already loaded - if 'mimetypes' not in sys.modules: - import mimetypes - else: - mimetypes = sys.modules['mimetypes'] - path = str(format_or_path) mime_type = mimetypes.guess_type( path)[0] @@ -1054,7 +1066,7 @@ def __init__(self, self._mime_type_str = mime_type.encode('utf-8') except UnicodeError as e: raise C2paError.Encoding( - self._error_messages['encoding_error'].format( + Reader._ERROR_MESSAGES['encoding_error'].format( str(e))) try: @@ -1075,7 +1087,7 @@ def __init__(self, if error: raise C2paError(error) raise C2paError( - self._error_messages['reader_error'].format("Unknown error")) + Reader._ERROR_MESSAGES['reader_error'].format("Unknown error")) # Store the file to close it later self._file = file @@ -1086,7 +1098,7 @@ def __init__(self, if hasattr(self, '_file'): self._file.close() raise C2paError.Io( - self._error_messages['io_error'].format( + Reader._ERROR_MESSAGES['io_error'].format( str(e))) elif isinstance(stream, str): # If stream is a string, treat it as a path and try to open it @@ -1100,7 +1112,8 @@ def __init__(self, self._format_str, self._own_stream._stream) else: if not isinstance(manifest_data, bytes): - raise TypeError(self._error_messages['manifest_error']) + raise TypeError( + Reader._ERROR_MESSAGES['manifest_error']) manifest_array = ( ctypes.c_ubyte * len(manifest_data))( @@ -1121,7 +1134,7 @@ def __init__(self, if error: raise C2paError(error) raise C2paError( - self._error_messages['reader_error'].format("Unknown error")) + Reader._ERROR_MESSAGES['reader_error'].format("Unknown error")) self._file = file except Exception as e: @@ -1130,7 +1143,7 @@ def __init__(self, if hasattr(self, '_file'): self._file.close() raise C2paError.Io( - self._error_messages['io_error'].format( + Reader._ERROR_MESSAGES['io_error'].format( str(e))) else: # Use the provided stream @@ -1143,7 +1156,8 @@ def __init__(self, self._format_str, stream_obj._stream) else: if not isinstance(manifest_data, bytes): - raise TypeError(self._error_messages['manifest_error']) + raise TypeError( + Reader._ERROR_MESSAGES['manifest_error']) manifest_array = ( ctypes.c_ubyte * len(manifest_data))( @@ -1158,7 +1172,7 @@ def __init__(self, if error: raise C2paError(error) raise C2paError( - self._error_messages['reader_error'].format("Unknown error")) + Reader._ERROR_MESSAGES['reader_error'].format("Unknown error")) def __enter__(self): return self @@ -1188,7 +1202,7 @@ def close(self): _lib.c2pa_reader_free(self._reader) except Exception as e: print( - self._error_messages['reader_cleanup'].format( + Reader._ERROR_MESSAGES['reader_cleanup_error'].format( str(e)), file=sys.stderr) finally: self._reader = None @@ -1199,7 +1213,7 @@ def close(self): self._own_stream.close() except Exception as e: print( - self._error_messages['stream_error'].format( + Reader._ERROR_MESSAGES['stream_error'].format( str(e)), file=sys.stderr) finally: self._own_stream = None @@ -1210,7 +1224,7 @@ def close(self): self._file.close() except Exception as e: print( - self._error_messages['file_error'].format( + Reader._ERROR_MESSAGES['file_error'].format( str(e)), file=sys.stderr) finally: self._file = None @@ -1220,7 +1234,7 @@ def close(self): self._strings.clear() except Exception as e: print( - self._error_messages['cleanup_error'].format( + Reader._ERROR_MESSAGES['cleanup_error'].format( str(e)), file=sys.stderr) finally: self._closed = True @@ -1273,6 +1287,20 @@ def resource_to_stream(self, uri: str, stream: Any) -> int: class Signer: """High-level wrapper for C2PA Signer operations.""" + # Class-level error messages to avoid multiple creation + _ERROR_MESSAGES = { + 'closed_error': "Signer is closed", + 'cleanup_error': "Error during cleanup: {}", + 'signer_cleanup': "Error cleaning up signer: {}", + 'size_error': "Error getting reserve size: {}", + 'callback_error': "Error in signer callback: {}", + 'info_error': "Error creating signer from info: {}", + 'invalid_data': "Invalid data for signing: {}", + 'invalid_certs': "Invalid certificate data: {}", + 'invalid_tsa': "Invalid TSA URL: {}", + 'encoding_error': "Invalid UTF-8 characters in input: {}" + } + def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): """Initialize a new Signer instance. @@ -1281,17 +1309,6 @@ def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): """ self._signer = signer_ptr self._closed = False - self._error_messages = { - 'closed_error': "Signer is closed", - 'cleanup_error': "Error during cleanup: {}", - 'signer_cleanup': "Error cleaning up signer: {}", - 'size_error': "Error getting reserve size: {}", - 'callback_error': "Error in signer callback: {}", - 'info_error': "Error creating signer from info: {}", - 'invalid_data': "Invalid data for signing: {}", - 'invalid_certs': "Invalid certificate data: {}", - 'invalid_tsa': "Invalid TSA URL: {}" - } @classmethod def from_info(cls, signer_info: C2paSignerInfo) -> 'Signer': @@ -1313,7 +1330,8 @@ def from_info(cls, signer_info: C2paSignerInfo) -> 'Signer': if error: # More detailed error message when possible raise C2paError(error) - raise C2paError("Failed to create signer from configured signer_info") + raise C2paError( + "Failed to create signer from configured signer_info") return cls(signer_ptr) @@ -1340,28 +1358,14 @@ def from_callback( C2paError: If there was an error creating the signer C2paError.Encoding: If the certificate data or TSA URL contains invalid UTF-8 characters """ - # Define error messages locally since they're instance attributes - error_messages = { - 'closed_error': "Signer is closed", - 'cleanup_error': "Error during cleanup: {}", - 'signer_cleanup': "Error cleaning up signer: {}", - 'size_error': "Error getting reserve size: {}", - 'callback_error': "Error in signer callback: {}", - 'info_error': "Error creating signer from info: {}", - 'invalid_data': "Invalid data for signing: {}", - 'invalid_certs': "Invalid certificate data: {}", - 'invalid_tsa': "Invalid TSA URL: {}", - 'encoding_error': "Invalid UTF-8 characters in input: {}" - } - # Validate inputs before creating if not certs: raise C2paError( - error_messages['invalid_certs'].format("Missing certificate data")) + cls._ERROR_MESSAGES['invalid_certs'].format("Missing certificate data")) if tsa_url and not tsa_url.startswith(('http://', 'https://')): raise C2paError( - error_messages['invalid_tsa'].format("Invalid TSA URL format")) + cls._ERROR_MESSAGES['invalid_tsa'].format("Invalid TSA URL format")) # Create a wrapper callback that handles errors and memory management def wrapped_callback( @@ -1404,14 +1408,15 @@ def wrapped_callback( # Copy the signature back to the C buffer # (since callback is used in native code) actual_len = min(len(signature), signed_len) - # Use memmove for efficient memory copying instead of byte-by-byte loop + # Use memmove for efficient memory copying instead of + # byte-by-byte loop ctypes.memmove(signed_bytes_ptr, signature, actual_len) # Native code expects the signed len to be returned, we oblige return actual_len except Exception as e: print( - error_messages['callback_error'].format( + cls._ERROR_MESSAGES['callback_error'].format( str(e)), file=sys.stderr) # Error: exception raised, invalid so return -1, # native code will handle the error when seeing -1 @@ -1420,11 +1425,13 @@ def wrapped_callback( # Encode strings with error handling in case it's invalid UTF8 try: # Only encode if not already bytes, avoid unnecessary encoding - certs_bytes = certs.encode('utf-8') if isinstance(certs, str) else certs - tsa_url_bytes = tsa_url.encode('utf-8') if tsa_url and isinstance(tsa_url, str) else tsa_url + certs_bytes = certs.encode( + 'utf-8') if isinstance(certs, str) else certs + tsa_url_bytes = tsa_url.encode( + 'utf-8') if tsa_url and isinstance(tsa_url, str) else tsa_url except UnicodeError as e: raise C2paError.Encoding( - error_messages['encoding_error'].format( + cls._ERROR_MESSAGES['encoding_error'].format( str(e))) # Create the callback object using the callback function @@ -1458,7 +1465,7 @@ def wrapped_callback( def __enter__(self): """Context manager entry.""" if self._closed: - raise C2paError(self._error_messages['closed_error']) + raise C2paError(Signer._ERROR_MESSAGES['closed_error']) return self def __exit__(self, exc_type, exc_val, exc_tb): @@ -1481,13 +1488,13 @@ def close(self): _lib.c2pa_signer_free(self._signer) except Exception as e: print( - self._error_messages['signer_cleanup'].format( + Signer._ERROR_MESSAGES['signer_cleanup'].format( str(e)), file=sys.stderr) finally: self._signer = None except Exception as e: print( - self._error_messages['cleanup_error'].format( + Signer._ERROR_MESSAGES['cleanup_error'].format( str(e)), file=sys.stderr) finally: self._closed = True @@ -1502,7 +1509,7 @@ def reserve_size(self) -> int: C2paError: If there was an error getting the size """ if self._closed or not self._signer: - raise C2paError(self._error_messages['closed_error']) + raise C2paError(Signer._ERROR_MESSAGES['closed_error']) try: result = _lib.c2pa_signer_reserve_size(self._signer) @@ -1515,7 +1522,9 @@ def reserve_size(self) -> int: return result except Exception as e: - raise C2paError(self._error_messages['size_error'].format(str(e))) + raise C2paError( + Signer._ERROR_MESSAGES['size_error'].format( + str(e))) @property def closed(self) -> bool: @@ -1530,6 +1539,22 @@ def closed(self) -> bool: class Builder: """High-level wrapper for C2PA Builder operations.""" + # Class-level error messages to avoid multiple creation + _ERROR_MESSAGES = { + 'builder_error': "Failed to create builder: {}", + 'cleanup_error': "Error during cleanup: {}", + 'builder_cleanup': "Error cleaning up builder: {}", + 'closed_error': "Builder is closed", + 'manifest_error': "Invalid manifest data: must be string or dict", + 'url_error': "Error setting remote URL: {}", + 'resource_error': "Error adding resource: {}", + 'ingredient_error': "Error adding ingredient: {}", + 'archive_error': "Error writing archive: {}", + 'sign_error': "Error during signing: {}", + 'encoding_error': "Invalid UTF-8 characters in manifest: {}", + 'json_error': "Failed to serialize manifest JSON: {}" + } + def __init__(self, manifest_json: Any): """Initialize a new Builder instance. @@ -1542,34 +1567,20 @@ def __init__(self, manifest_json: Any): C2paError.Json: If the manifest JSON cannot be serialized """ self._builder = None - self._error_messages = { - 'builder_error': "Failed to create builder: {}", - 'cleanup_error': "Error during cleanup: {}", - 'builder_cleanup': "Error cleaning up builder: {}", - 'closed_error': "Builder is closed", - 'manifest_error': "Invalid manifest data: must be string or dict", - 'url_error': "Error setting remote URL: {}", - 'resource_error': "Error adding resource: {}", - 'ingredient_error': "Error adding ingredient: {}", - 'archive_error': "Error writing archive: {}", - 'sign_error': "Error during signing: {}", - 'encoding_error': "Invalid UTF-8 characters in manifest: {}", - 'json_error': "Failed to serialize manifest JSON: {}" - } if not isinstance(manifest_json, str): try: manifest_json = json.dumps(manifest_json) except (TypeError, ValueError) as e: raise C2paError.Json( - self._error_messages['json_error'].format( + Builder._ERROR_MESSAGES['json_error'].format( str(e))) try: json_str = manifest_json.encode('utf-8') except UnicodeError as e: raise C2paError.Encoding( - self._error_messages['encoding_error'].format( + Builder._ERROR_MESSAGES['encoding_error'].format( str(e))) self._builder = _lib.c2pa_builder_from_json(json_str) @@ -1579,7 +1590,7 @@ def __init__(self, manifest_json: Any): if error: raise C2paError(error) raise C2paError( - self._error_messages['builder_error'].format("Unknown error")) + Builder._ERROR_MESSAGES['builder_error'].format("Unknown error")) @classmethod def from_json(cls, manifest_json: Any) -> 'Builder': @@ -1647,13 +1658,13 @@ def close(self): _lib.c2pa_builder_free(self._builder) except Exception as e: print( - self._error_messages['builder_cleanup'].format( + Builder._ERROR_MESSAGES['builder_cleanup'].format( str(e)), file=sys.stderr) finally: self._builder = None except Exception as e: print( - self._error_messages['cleanup_error'].format( + Builder._ERROR_MESSAGES['cleanup_error'].format( str(e)), file=sys.stderr) finally: self._closed = True @@ -1677,7 +1688,7 @@ def set_no_embed(self): This is useful when creating cloud or sidecar manifests. """ if not self._builder: - raise C2paError(self._error_messages['closed_error']) + raise C2paError(Builder._ERROR_MESSAGES['closed_error']) _lib.c2pa_builder_set_no_embed(self._builder) def set_remote_url(self, remote_url: str): @@ -1693,7 +1704,7 @@ def set_remote_url(self, remote_url: str): C2paError: If there was an error setting the remote URL """ if not self._builder: - raise C2paError(self._error_messages['closed_error']) + raise C2paError(Builder._ERROR_MESSAGES['closed_error']) url_str = remote_url.encode('utf-8') result = _lib.c2pa_builder_set_remote_url(self._builder, url_str) @@ -1703,7 +1714,7 @@ def set_remote_url(self, remote_url: str): if error: raise C2paError(error) raise C2paError( - self._error_messages['url_error'].format("Unknown error")) + Builder._ERROR_MESSAGES['url_error'].format("Unknown error")) def add_resource(self, uri: str, stream: Any): """Add a resource to the builder. @@ -1716,7 +1727,7 @@ def add_resource(self, uri: str, stream: Any): C2paError: If there was an error adding the resource """ if not self._builder: - raise C2paError(self._error_messages['closed_error']) + raise C2paError(Builder._ERROR_MESSAGES['closed_error']) uri_str = uri.encode('utf-8') with Stream(stream) as stream_obj: @@ -1728,7 +1739,7 @@ def add_resource(self, uri: str, stream: Any): if error: raise C2paError(error) raise C2paError( - self._error_messages['resource_error'].format("Unknown error")) + Builder._ERROR_MESSAGES['resource_error'].format("Unknown error")) def add_ingredient(self, ingredient_json: str, format: str, source: Any): """Add an ingredient to the builder. @@ -1743,14 +1754,14 @@ def add_ingredient(self, ingredient_json: str, format: str, source: Any): C2paError.Encoding: If the ingredient JSON contains invalid UTF-8 characters """ if not self._builder: - raise C2paError(self._error_messages['closed_error']) + raise C2paError(Builder._ERROR_MESSAGES['closed_error']) try: ingredient_str = ingredient_json.encode('utf-8') format_str = format.encode('utf-8') except UnicodeError as e: raise C2paError.Encoding( - self._error_messages['encoding_error'].format( + Builder._ERROR_MESSAGES['encoding_error'].format( str(e))) source_stream = Stream(source) @@ -1762,7 +1773,7 @@ def add_ingredient(self, ingredient_json: str, format: str, source: Any): if error: raise C2paError(error) raise C2paError( - self._error_messages['ingredient_error'].format("Unknown error")) + Builder._ERROR_MESSAGES['ingredient_error'].format("Unknown error")) def add_ingredient_from_stream( self, @@ -1781,14 +1792,14 @@ def add_ingredient_from_stream( C2paError.Encoding: If the ingredient JSON or format contains invalid UTF-8 characters """ if not self._builder: - raise C2paError(self._error_messages['closed_error']) + raise C2paError(Builder._ERROR_MESSAGES['closed_error']) try: ingredient_str = ingredient_json.encode('utf-8') format_str = format.encode('utf-8') except UnicodeError as e: raise C2paError.Encoding( - self._error_messages['encoding_error'].format( + Builder._ERROR_MESSAGES['encoding_error'].format( str(e))) with Stream(source) as source_stream: @@ -1800,7 +1811,7 @@ def add_ingredient_from_stream( if error: raise C2paError(error) raise C2paError( - self._error_messages['ingredient_error'].format("Unknown error")) + Builder._ERROR_MESSAGES['ingredient_error'].format("Unknown error")) def to_archive(self, stream: Any): """Write an archive of the builder to a stream. @@ -1812,7 +1823,7 @@ def to_archive(self, stream: Any): C2paError: If there was an error writing the archive """ if not self._builder: - raise C2paError(self._error_messages['closed_error']) + raise C2paError(Builder._ERROR_MESSAGES['closed_error']) with Stream(stream) as stream_obj: result = _lib.c2pa_builder_to_archive( @@ -1823,7 +1834,7 @@ def to_archive(self, stream: Any): if error: raise C2paError(error) raise C2paError( - self._error_messages['archive_error'].format("Unknown error")) + Builder._ERROR_MESSAGES['archive_error'].format("Unknown error")) def _sign_internal( self, @@ -1847,7 +1858,7 @@ def _sign_internal( C2paError: If there was an error during signing """ if not self._builder: - raise C2paError(self._error_messages['closed_error']) + raise C2paError(Builder._ERROR_MESSAGES['closed_error']) try: format_str = format.encode('utf-8') @@ -1895,7 +1906,6 @@ def _sign_internal( source_stream.close() dest_stream.close() - def sign( self, signer: Signer, From c399d796e637706d43e63edac56e1776fbd4e50a Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 25 Jun 2025 13:18:18 -0700 Subject: [PATCH 43/46] fix: Return sign values --- src/c2pa/c2pa.py | 2 +- tests/test_unit_tests.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 0a55fc60..ffdf9870 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1928,7 +1928,7 @@ def sign( dest_stream = Stream(dest) # Use the internal stream-base signing logic - self._sign_internal(signer, format, source_stream, dest_stream) + return self._sign_internal(signer, format, source_stream, dest_stream) def sign_file(self, source_path: Union[str, diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 51e2e81e..f2f85eee 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -354,6 +354,21 @@ def test_remote_sign(self): reader = Reader("image/jpeg", output) output.close() + def test_remote_sign_using_returned_bytes(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + builder.set_no_embed() + with io.BytesIO() as output_buffer: + _, manifest_data = builder.sign( + self.signer, "image/jpeg", file, output_buffer) + output_buffer.seek(0) + read_buffer = io.BytesIO(output_buffer.getvalue()) + + with Reader("image/jpeg", read_buffer, manifest_data) as reader: + manifest_data = reader.json() + self.assertIn("Python Test", manifest_data) + self.assertNotIn("validation_status", manifest_data) + def test_sign_all_files(self): """Test signing all files in both fixtures directories""" signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") From 839a570d1e214fb407955cf72334de203dcdc883 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 25 Jun 2025 13:18:39 -0700 Subject: [PATCH 44/46] fix: Return sign values --- 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 f2f85eee..eb47b81a 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -345,7 +345,7 @@ def test_remote_sign(self): builder = Builder(self.manifestDefinition) builder.set_no_embed() output = io.BytesIO(bytearray()) - result_data = builder.sign(self.signer, "image/jpeg", file, output) + builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) # When set_no_embed() is used, no manifest should be embedded in the file From a8b590448d1e0995162fde4384051f763a43214d Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 26 Jun 2025 13:14:12 -0700 Subject: [PATCH 45/46] fix: Change API build.sign* to return manifest bytes --- src/c2pa/c2pa.py | 7 +++---- tests/test_unit_tests.py | 28 +++++++++------------------- 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index ffdf9870..7bb25a03 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -686,7 +686,7 @@ def sign_file( # Use the builder's internal signing logic to get manifest # bytes - result, manifest_bytes = builder._sign_internal( + manifest_bytes = builder._sign_internal( signer, mime_type, source_stream, dest_stream) return manifest_bytes @@ -1900,7 +1900,7 @@ def _sign_internal( # Ignore errors during cleanup pass - return result, manifest_bytes + return manifest_bytes finally: # Ensure both streams are cleaned up source_stream.close() @@ -1962,9 +1962,8 @@ def sign_file(self, dest_stream = Stream(dest_file) # Use the internal stream-base signing logic - result, manifest_bytes = self._sign_internal( + return self._sign_internal( signer, mime_type, source_stream, dest_stream) - return result, manifest_bytes def format_embeddable(format: str, manifest_bytes: bytes) -> tuple[int, bytes]: diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index eb47b81a..ab07256e 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -359,7 +359,7 @@ def test_remote_sign_using_returned_bytes(self): builder = Builder(self.manifestDefinition) builder.set_no_embed() with io.BytesIO() as output_buffer: - _, manifest_data = builder.sign( + manifest_data = builder.sign( self.signer, "image/jpeg", file, output_buffer) output_buffer.seek(0) read_buffer = io.BytesIO(output_buffer.getvalue()) @@ -732,7 +732,7 @@ def test_builder_set_remote_url(self): builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) d = output.read() - self.assertIn(b'provenance="http://this_does_not_exist/foo.jpg"', d) + self.assertIn(b'provenance="http://this_does_not_exist/foo.jpg"', d) def test_builder_set_remote_url_no_embed(self): """Test setting the remote url of a builder with no embed flag.""" @@ -763,7 +763,7 @@ def test_sign_file(self): # Use the sign_file method builder = Builder(self.manifestDefinition) - result, manifest_bytes = builder.sign_file( + manifest_bytes = builder.sign_file( source_path=self.testPath, dest_path=output_path, signer=self.signer @@ -772,11 +772,6 @@ def test_sign_file(self): # Verify the output file was created self.assertTrue(os.path.exists(output_path)) - # Verify we got both result and manifest bytes - self.assertIsInstance(result, int) - 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 = Reader("image/jpeg", file) @@ -807,7 +802,7 @@ def test_sign_file_callback_signer(self): tsa_url="http://timestamp.digicert.com" ) - result, manifest_bytes = builder.sign_file( + manifest_bytes = builder.sign_file( source_path=self.testPath, dest_path=output_path, signer=signer @@ -817,7 +812,6 @@ def test_sign_file_callback_signer(self): self.assertTrue(os.path.exists(output_path)) # Verify results - self.assertIsInstance(result, int) self.assertIsInstance(manifest_bytes, bytes) self.assertGreater(len(manifest_bytes), 0) @@ -855,7 +849,7 @@ def test_sign_file_callback_signer_managed(self): ) as signer: # Sign the file - result, manifest_bytes = builder.sign_file( + manifest_bytes = builder.sign_file( source_path=self.testPath, dest_path=output_path, signer=signer @@ -863,7 +857,6 @@ def test_sign_file_callback_signer_managed(self): # Verify results self.assertTrue(os.path.exists(output_path)) - self.assertIsInstance(result, int) self.assertIsInstance(manifest_bytes, bytes) self.assertGreater(len(manifest_bytes), 0) @@ -902,7 +895,7 @@ def test_sign_file_callback_signer_managed_multiple_uses(self): # First signing operation output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") - result_1, manifest_bytes_1 = builder.sign_file( + manifest_bytes_1 = builder.sign_file( source_path=self.testPath, dest_path=output_path_1, signer=signer @@ -910,14 +903,13 @@ def test_sign_file_callback_signer_managed_multiple_uses(self): # Verify first signing was successful self.assertTrue(os.path.exists(output_path_1)) - self.assertIsInstance(result_1, int) self.assertIsInstance(manifest_bytes_1, bytes) self.assertGreater(len(manifest_bytes_1), 0) # Second signing operation with the same signer # This is to verify we don't free the signer or the callback too early output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") - result_2, manifest_bytes_2 = builder.sign_file( + manifest_bytes_2 = builder.sign_file( source_path=self.testPath, dest_path=output_path_2, signer=signer @@ -925,7 +917,6 @@ def test_sign_file_callback_signer_managed_multiple_uses(self): # Verify second signing was successful self.assertTrue(os.path.exists(output_path_2)) - self.assertIsInstance(result_2, int) self.assertIsInstance(manifest_bytes_2, bytes) self.assertGreater(len(manifest_bytes_2), 0) @@ -968,7 +959,7 @@ def test_builder_sign_file_callback_signer_from_callback(self): tsa_url="http://timestamp.digicert.com" ) - result, manifest_bytes = builder.sign_file( + manifest_bytes = builder.sign_file( source_path=self.testPath, dest_path=output_path, signer=signer @@ -978,7 +969,6 @@ def test_builder_sign_file_callback_signer_from_callback(self): self.assertTrue(os.path.exists(output_path)) # Verify results - self.assertIsInstance(result, int) self.assertIsInstance(manifest_bytes, bytes) self.assertGreater(len(manifest_bytes), 0) @@ -1187,7 +1177,7 @@ def error_callback_signer(data: bytes) -> bytes: # The signing operation should fail due to the error callback with self.assertRaises(Error): - result, manifest_bytes = builder.sign_file( + builder.sign_file( source_path=self.testPath, dest_path=output_path, signer=signer From ef06eec568e57dc809ec06c1f291d3a82d24cf48 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 26 Jun 2025 16:06:02 -0700 Subject: [PATCH 46/46] fix: Version number bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index afa30d5c..9d12bb95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.11.1" +version = "0.12.0" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" }