Skip to content

Commit 547cd74

Browse files
committed
fix: Change return
1 parent 4e81626 commit 547cd74

File tree

2 files changed

+187
-4
lines changed

2 files changed

+187
-4
lines changed

src/c2pa/c2pa.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,114 @@ def sign_file(
679679
signer.close()
680680

681681

682+
def sign_file_with_callback_signer(
683+
source_path: Union[str, Path],
684+
dest_path: Union[str, Path],
685+
manifest: str,
686+
callback: Callable[[bytes], bytes],
687+
alg: C2paSigningAlg,
688+
certs: str,
689+
tsa_url: Optional[str] = None,
690+
data_dir: Optional[Union[str, Path]] = None
691+
) -> bytes:
692+
"""Sign a file with a C2PA manifest using a callback signer.
693+
694+
This function provides a shortcut to sign files using a callback-based signer
695+
and returns the raw manifest bytes.
696+
697+
Args:
698+
source_path: Path to the source file
699+
dest_path: Path to write the signed file to
700+
manifest: The manifest JSON string
701+
callback: Function that signs data and returns the signature
702+
alg: The signing algorithm to use
703+
certs: Certificate chain in PEM format
704+
tsa_url: Optional RFC 3161 timestamp authority URL
705+
data_dir: Optional directory to write binary resources to
706+
707+
Returns:
708+
The manifest bytes (binary data)
709+
710+
Raises:
711+
C2paError: If there was an error signing the file
712+
C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters
713+
C2paError.NotSupported: If the file type cannot be determined
714+
"""
715+
try:
716+
# Create a signer from the callback
717+
signer = Signer.from_callback(callback, alg, certs, tsa_url)
718+
719+
# Create a builder from the manifest
720+
builder = Builder(manifest)
721+
722+
# Open source and destination files
723+
with open(source_path, 'rb') as source_file, open(dest_path, 'wb') as dest_file:
724+
# Get the MIME type from the file extension
725+
mime_type = mimetypes.guess_type(str(source_path))[0]
726+
if not mime_type:
727+
raise C2paError.NotSupported(f"Could not determine MIME type for file: {source_path}")
728+
729+
# Convert Python streams to Stream objects
730+
source_stream = Stream(source_file)
731+
dest_stream = Stream(dest_file)
732+
733+
# Use the internal signing logic to get manifest bytes
734+
format_str = mime_type.encode('utf-8')
735+
manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)()
736+
737+
# Call the native signing function
738+
result = _lib.c2pa_builder_sign(
739+
builder._builder,
740+
format_str,
741+
source_stream._stream,
742+
dest_stream._stream,
743+
signer._signer,
744+
ctypes.byref(manifest_bytes_ptr)
745+
)
746+
747+
if result < 0:
748+
error = _parse_operation_result_for_error(_lib.c2pa_error())
749+
if error:
750+
raise C2paError(error)
751+
752+
# Capture the manifest bytes if available
753+
manifest_bytes = b""
754+
if manifest_bytes_ptr:
755+
# Convert the C pointer to Python bytes
756+
manifest_bytes = bytes(manifest_bytes_ptr[:result])
757+
# Free the C-allocated memory
758+
_lib.c2pa_manifest_bytes_free(manifest_bytes_ptr)
759+
760+
# If we have manifest bytes and a data directory, write them
761+
if manifest_bytes and data_dir:
762+
manifest_path = os.path.join(str(data_dir), 'manifest.json')
763+
with open(manifest_path, 'wb') as f:
764+
f.write(manifest_bytes)
765+
766+
return manifest_bytes
767+
768+
except Exception as e:
769+
# Clean up destination file if it exists and there was an error
770+
if os.path.exists(dest_path):
771+
try:
772+
os.remove(dest_path)
773+
except OSError:
774+
pass # Ignore cleanup errors
775+
776+
# Re-raise the error
777+
raise C2paError(f"Error signing file: {str(e)}")
778+
finally:
779+
# Ensure resources are cleaned up
780+
if 'builder' in locals():
781+
builder.close()
782+
if 'signer' in locals():
783+
signer.close()
784+
if 'source_stream' in locals():
785+
source_stream.close()
786+
if 'dest_stream' in locals():
787+
dest_stream.close()
788+
789+
682790
class Stream:
683791
# Class-level counter for generating unique stream IDs
684792
# (useful for tracing streams usage in debug)
@@ -2083,6 +2191,7 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes:
20832191
'read_file',
20842192
'read_ingredient_file',
20852193
'sign_file',
2194+
'sign_file_with_callback_signer',
20862195
'format_embeddable',
20872196
'ed25519_sign',
20882197
'sdk_version'

tests/test_unit_tests.py

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import shutil
2626

2727
from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version
28-
from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer
28+
from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer, sign_file_with_callback_signer
2929

3030
# Suppress deprecation warnings
3131
warnings.filterwarnings("ignore", category=DeprecationWarning)
@@ -541,16 +541,16 @@ def test_builder_sign_with_duplicate_ingredient(self):
541541
# Verify the first ingredient's title matches what we set
542542
first_ingredient = active_manifest["ingredients"][0]
543543
self.assertEqual(first_ingredient["title"], "Test Ingredient")
544-
544+
545545
# Verify subsequent labels are unique and have a double underscore with a monotonically inc. index
546546
second_ingredient = active_manifest["ingredients"][1]
547547
self.assertTrue(second_ingredient["label"].endswith("__1"))
548548

549549
third_ingredient = active_manifest["ingredients"][2]
550550
self.assertTrue(third_ingredient["label"].endswith("__2"))
551-
551+
552552
builder.close()
553-
553+
554554
def test_builder_sign_with_ingredient_from_stream(self):
555555
"""Test Builder class operations with a real file using stream for ingredient."""
556556
# Test creating builder from JSON
@@ -880,6 +880,80 @@ def sign_callback(data: bytes) -> bytes:
880880
# Clean up the temporary directory
881881
shutil.rmtree(temp_dir)
882882

883+
def test_sign_file_with_callback_signer(self):
884+
"""Test signing a file using the sign_file_with_callback_signer function."""
885+
# Create a temporary directory for the test
886+
temp_dir = tempfile.mkdtemp()
887+
try:
888+
# Create a temporary output file path
889+
output_path = os.path.join(temp_dir, "signed_output_callback.jpg")
890+
891+
# Create a real ES256 signing callback
892+
def sign_callback(data: bytes) -> bytes:
893+
"""Real ES256 signing callback that creates actual signatures."""
894+
# Load the private key from the test fixtures
895+
with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file:
896+
private_key_data = key_file.read()
897+
898+
# Load the private key using cryptography
899+
private_key = serialization.load_pem_private_key(
900+
private_key_data,
901+
password=None,
902+
backend=default_backend()
903+
)
904+
905+
# Create the signature using ES256 (ECDSA with SHA-256)
906+
from cryptography.hazmat.primitives import hashes
907+
from cryptography.hazmat.primitives.asymmetric import ec
908+
909+
signature = private_key.sign(
910+
data,
911+
ec.ECDSA(hashes.SHA256())
912+
)
913+
914+
return signature
915+
916+
# Import the new function
917+
from c2pa.c2pa import sign_file_with_callback_signer
918+
919+
# Use the sign_file_with_callback_signer function
920+
manifest_bytes = sign_file_with_callback_signer(
921+
source_path=self.testPath,
922+
dest_path=output_path,
923+
manifest=self.manifestDefinition,
924+
callback=sign_callback,
925+
alg=SigningAlg.ES256,
926+
certs=self.certs.decode('utf-8'),
927+
tsa_url="http://timestamp.digicert.com"
928+
)
929+
930+
# Verify the output file was created
931+
self.assertTrue(os.path.exists(output_path))
932+
933+
# Verify the manifest bytes are binary data (not JSON text)
934+
self.assertIsInstance(manifest_bytes, bytes)
935+
self.assertGreater(len(manifest_bytes), 0)
936+
937+
# Try to decode as UTF-8 to see if it's text-based (it shouldn't be)
938+
try:
939+
manifest_bytes.decode('utf-8')
940+
# If we get here, it's text-based, which is unexpected
941+
self.fail("Manifest bytes should be binary data, not UTF-8 text")
942+
except UnicodeDecodeError:
943+
# This is expected - manifest bytes should be binary
944+
pass
945+
946+
# Read the signed file and verify the manifest contains expected content
947+
with open(output_path, "rb") as file:
948+
reader = Reader("image/jpeg", file)
949+
file_manifest_json = reader.json()
950+
self.assertIn("Python Test", file_manifest_json)
951+
self.assertNotIn("validation_status", file_manifest_json)
952+
953+
finally:
954+
# Clean up the temporary directory
955+
shutil.rmtree(temp_dir)
956+
883957
class TestStream(unittest.TestCase):
884958
def setUp(self):
885959
# Create a temporary file for testing

0 commit comments

Comments
 (0)