diff --git a/.github/workflows/build-wheel.yml b/.github/workflows/build-wheel.yml index d7b5fcaf..9b7b35c8 100644 --- a/.github/workflows/build-wheel.yml +++ b/.github/workflows/build-wheel.yml @@ -66,7 +66,8 @@ jobs: C2PA_LIBS_PLATFORM=\"${{ format('{0}', inputs.architecture == 'aarch64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu') }}\" /opt/python/cp310-cp310/bin/python scripts/download_artifacts.py $C2PA_VERSION && for PYBIN in /opt/python/cp3{10,11}-*/bin; do \${PYBIN}/pip install --upgrade pip wheel && - \${PYBIN}/pip install toml && + \${PYBIN}/pip install toml==0.10.2 && + \${PYBIN}/pip install setuptools==68.0.0 && CFLAGS=\"-I/opt/python/cp310-cp310/include/python3.10\" LDFLAGS=\"-L/opt/python/cp310-cp310/lib\" \${PYBIN}/python setup.py bdist_wheel --plat-name $PLATFORM_TAG done && rm -f /io/dist/*-linux_*.whl diff --git a/.gitignore b/.gitignore index 30d941cd..1f67fec6 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,5 @@ target/ *.dll *.so src/c2pa/libs/ +!tests/fixtures/*.pem +!tests/fixtures/*.key diff --git a/Makefile b/Makefile index 9129b17b..4c501458 100644 --- a/Makefile +++ b/Makefile @@ -20,11 +20,10 @@ clean-c2pa-env: clean install-deps: python3 -m pip install -r requirements.txt python3 -m pip install -r requirements-dev.txt - pip install -e . # Installs the package in development mode build-python: - pip install -e . + python3 -m pip install -e . # Performs a complete rebuild of the development environment rebuild: clean-c2pa-env install-deps download-native-artifacts build-python @@ -32,6 +31,7 @@ rebuild: clean-c2pa-env install-deps download-native-artifacts build-python run-examples: python3 ./examples/sign.py + python3 ./examples/sign_info.py python3 ./examples/training.py rm -rf output/ @@ -42,7 +42,7 @@ test: # Runs benchmarks in the venv benchmark: - python -m pytest tests/benchmark.py -v + python3 -m pytest tests/benchmark.py -v # Tests building and installing a local wheel package # Downloads required artifacts, builds the wheel, installs it, and verifies the installation @@ -50,15 +50,15 @@ test-local-wheel-build: # Clean any existing builds rm -rf build/ dist/ # Download artifacts and place them where they should go - python scripts/download_artifacts.py $(C2PA_VERSION) + python3 scripts/download_artifacts.py $(C2PA_VERSION) # Install Python python3 -m pip install -r requirements.txt python3 -m pip install -r requirements-dev.txt - python -m build --wheel + python3 -m build --wheel # Install local build in venv pip install $$(ls dist/*.whl) # Verify installation in local venv - python -c "import c2pa; print('C2PA package installed at:', c2pa.__file__)" + python3 -c "import c2pa; print('C2PA package installed at:', c2pa.__file__)" # Verify wheel structure twine check dist/* @@ -68,23 +68,23 @@ test-local-sdist-build: # Clean any existing builds rm -rf build/ dist/ # Download artifacts and place them where they should go - python scripts/download_artifacts.py $(C2PA_VERSION) + python3 scripts/download_artifacts.py $(C2PA_VERSION) # Install Python python3 -m pip install -r requirements.txt python3 -m pip install -r requirements-dev.txt # Build sdist package - python setup.py sdist + python3 setup.py sdist # Install local build in venv pip install $$(ls dist/*.tar.gz) # Verify installation in local venv - python -c "import c2pa; print('C2PA package installed at:', c2pa.__file__)" + python3 -c "import c2pa; print('C2PA package installed at:', c2pa.__file__)" # Verify sdist structure twine check dist/* # Verifies the wheel build process and checks the built package and its metadata verify-wheel-build: rm -rf build/ dist/ src/*.egg-info/ - python -m build + python3 -m build twine check dist/* # Manually publishes the package to PyPI after creating a release @@ -92,9 +92,13 @@ publish: release python3 -m pip install twine python3 -m twine upload dist/* +# Code analysis +check-format: + flake8 src/c2pa/c2pa.py + # Formats Python source code using autopep8 with aggressive settings format: - autopep8 --aggressive --aggressive --in-place src/c2pa/**/*.py + autopep8 --aggressive --aggressive --in-place src/c2pa/*.py # Downloads the required native artifacts for the specified version download-native-artifacts: diff --git a/README.md b/README.md index 09c9e088..1fd37e03 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # C2PA Python library The [c2pa-python](https://github.com/contentauth/c2pa-python) repository provides a Python library that can: + - Read and validate C2PA manifest data from media files in supported formats. - Create and sign manifest data, and attach it to media files in supported formats. diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 660950f7..fcafb103 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.58.0 +c2pa-v0.59.1 diff --git a/examples/README.md b/examples/README.md index 42a57d9c..ce8003b9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,6 +11,7 @@ The [`examples/sign.py`](https://github.com/contentauth/c2pa-python/blob/main/ex The `examples/sign.py` script shows how to sign an asset with a C2PA manifest and verify it using a callback signer. Callback signers let you define signing logic, for example where to load keys from. The `examples/sign_info.py` script shows how to sign an asset with a C2PA manifest and verify it using a "default" signer created with the needed signer information. + These statements create a `builder` object with the specified manifest JSON (omitted in the snippet below), call `builder.sign()` to sign and attach the manifest to the source file, `tests/fixtures/C.jpg`, and save the signed asset to the output file, `output/C_signed.jpg`: ```py @@ -80,14 +81,6 @@ Run the "do not train" assertion example: python examples/training.py ``` -### Run the signing and verification example - -In this example, `SignerInfo` creates a `Signer` object that signs the manifest. - -```bash -python examples/sign_info.py -``` - ### Run the callback signing and verification example In this example, a callback signer is the signer: @@ -96,4 +89,10 @@ In this example, a callback signer is the signer: python examples/sign.py ``` +### Run the signing and verification example + +In this example, `SignerInfo` creates a `Signer` object that signs the manifest. +```bash +python examples/sign_info.py +``` diff --git a/examples/sign.py b/examples/sign.py index 74fddb0e..070572a1 100644 --- a/examples/sign.py +++ b/examples/sign.py @@ -35,7 +35,7 @@ print(version) -# Load certificates and private key (here from the test fixtures) +# Load certificates and private key (here from the test fixtures). # This is OK for development, but in production you should use a # secure way to load the certificates and private key. certs = open(fixtures_dir + "es256_certs.pem", "rb").read() @@ -63,15 +63,17 @@ def callback_signer_es256(data: bytes) -> bytes: tsa_url="http://timestamp.digicert.com" ) -# Create a manifest definition as a dictionary -# This manifest follows the V2 manifest format +# Create a manifest definition as a dictionary. +# This manifest follows the V2 manifest format. manifest_definition = { "claim_generator": "python_example", "claim_generator_info": [{ "name": "python_example", "version": "0.0.1", }], - "claim_version": 2, + # Claims version 2 is the default, so the version + # number can be omitted. + # "claim_version": 2, "format": "image/jpeg", "title": "Python Example Image", "ingredients": [], @@ -82,10 +84,7 @@ def callback_signer_es256(data: bytes) -> bytes: "actions": [ { "action": "c2pa.created", - "parameters": { - # could hold additional information about this step - # eg. model used, etc. - } + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" } ] } @@ -99,6 +98,7 @@ def callback_signer_es256(data: bytes) -> bytes: # Sign the image with the signer created above, # which will use the callback signer print("\nSigning the image file...") + builder.sign_file( source_path=fixtures_dir + "A.jpg", dest_path=output_dir + "A_signed.jpg", diff --git a/examples/sign_info.py b/examples/sign_info.py index 48fbd37a..51f28b33 100644 --- a/examples/sign_info.py +++ b/examples/sign_info.py @@ -61,12 +61,16 @@ # Create a manifest definition as a dictionary # This examples signs using a V1 manifest +# Note that this is a v1 spec manifest (legacy) manifest_definition = { "claim_generator": "python_example", "claim_generator_info": [{ "name": "python_example", "version": "0.0.1", }], + # This manifest uses v1 claims, + # so the version 1 must be explicitly set. + "claim_version": 1, "format": "image/jpeg", "title": "Python Example Image", "ingredients": [], @@ -77,9 +81,7 @@ "actions": [ { "action": "c2pa.created", - "parameters": { - # could hold additional information about this step - } + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" } ] } @@ -93,7 +95,9 @@ # Sign the image print("\nSigning the image...") with open(fixtures_dir + "C.jpg", "rb") as source: - with open(output_dir + "C_signed.jpg", "wb") as dest: + # File needs to be opened in write+read mode to be signed + # and verified properly. + with open(output_dir + "C_signed.jpg", "w+b") as dest: result = builder.sign(signer, "image/jpeg", source, dest) # Read the signed image to verify diff --git a/examples/training.py b/examples/training.py index fbcf8cb3..48f4b22e 100644 --- a/examples/training.py +++ b/examples/training.py @@ -15,7 +15,6 @@ import json import os -import sys # Example of using python crypto to sign data using openssl with Ps256 from cryptography.hazmat.primitives import hashes, serialization @@ -54,19 +53,32 @@ def getitem(d, key): "format": "image/jpeg", "identifier": "thumbnail" }, - "assertions": [{ - "label": "cawg.training-mining", - "data": { - "entries": { - "cawg.ai_inference": { - "use": "notAllowed" - }, - "cawg.ai_generative_training": { - "use": "notAllowed" + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + }, + { + "label": "cawg.training-mining", + "data": { + "entries": { + "cawg.ai_inference": { + "use": "notAllowed" + }, + "cawg.ai_generative_training": { + "use": "notAllowed" + } } } } - }] + ] } ingredient_json = { @@ -109,37 +121,48 @@ def getitem(d, key): # Sign the file using the stream-based sign method with open(testFile, "rb") as source_file: - with open(testOutputFile, "wb") as dest_file: + with open(testOutputFile, "w+b") as dest_file: result = builder.sign(signer, "image/jpeg", source_file, dest_file) -except Exception as err: - sys.exit(err) + # As an alternative, you can also use file paths directly during signing: + # builder.sign_file(testFile, testOutputFile, signer) -print("V2: successfully added do not train manifest to file " + testOutputFile) + # Clean up native resources (using a with statement works too!) + signer.close() + builder.close() + +except Exception as err: + print("Exception during signing: ", err) +print("\nSuccessfully added do not train manifest to file " + testOutputFile) # now verify the asset and check the manifest for a do not train assertion... allowed = True # opt out model, assume training is ok if the assertion doesn't exist try: - # Create reader using the current API + # Create reader using the Reader API reader = c2pa.Reader(testOutputFile) + + # Retrieve the manifest store manifest_store = json.loads(reader.json()) + # Look at data in the active manifest manifest = manifest_store["manifests"][manifest_store["active_manifest"]] - for assertion in manifest["assertions"]: if assertion["label"] == "cawg.training-mining": if getitem(assertion, ("data","entries","cawg.ai_generative_training","use")) == "notAllowed": allowed = False - # get the ingredient thumbnail and save it to a file using resource_to_stream + # Get the ingredient thumbnail and save it to a file using resource_to_stream uri = getitem(manifest,("ingredients", 0, "thumbnail", "identifier")) with open(output_dir + "thumbnail_v2.jpg", "wb") as thumbnail_output: reader.resource_to_stream(uri, thumbnail_output) + # Clean up native resources (using a with statement works too!) + reader.close() + except Exception as err: - sys.exit(err) + print("Exception during assertions reading: ", err) if allowed: print("Training is allowed") diff --git a/pyproject.toml b/pyproject.toml index ea41c1b3..d0e45cb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.12.1" +version = "0.13.0" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/requirements-dev.txt b/requirements-dev.txt index 9c47e9c4..148567ba 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,6 +13,7 @@ requests>=2.0.0 # Code formatting autopep8==2.0.4 # For automatic code formatting +flake8==7.3.0 # Test dependencies (for callback signers) cryptography==45.0.4 \ No newline at end of file diff --git a/setup.py b/setup.py index 1216abdc..03fccaa2 100644 --- a/setup.py +++ b/setup.py @@ -17,8 +17,7 @@ def get_version(): PLATFORM_EXTENSIONS = { 'win_amd64': 'dll', 'win_arm64': 'dll', - 'macosx_x86_64': 'dylib', - 'apple-darwin': 'dylib', # we need to update the published keys + 'apple-darwin': 'dylib', # universal 'linux_x86_64': 'so', 'linux_aarch64': 'so', } @@ -26,11 +25,9 @@ def get_version(): # Based on what c2pa-rs repo publishes PLATFORM_FOLDERS = { 'universal-apple-darwin': 'dylib', - 'aarch64-apple-darwin': 'dylib', - 'x86_64-apple-darwin': 'dylib', 'x86_64-pc-windows-msvc': 'dll', 'x86_64-unknown-linux-gnu': 'so', - 'aarch64-unknown-linux-gnu': 'so', # Add ARM Linux support + 'aarch64-unknown-linux-gnu': 'so', } # Directory structure @@ -38,7 +35,7 @@ def get_version(): PACKAGE_LIBS_DIR = Path('src/c2pa/libs') # Where libraries will be copied for the wheel -def get_platform_identifier(cpu_arch = None) -> str: +def get_platform_identifier() -> str: """Get a platform identifier (arch-os) for the current system, matching downloaded identifiers used by the Github publisher. @@ -47,9 +44,7 @@ def get_platform_identifier(cpu_arch = None) -> str: cpu_arch: Optional CPU architecture for macOS. If not provided, returns universal build. Returns one of: - - universal-apple-darwin (for Mac, when cpu_arch is None, fallback) - - aarch64-apple-darwin (for Mac ARM64) - - x86_64-apple-darwin (for Mac x86_64) + - universal-apple-darwin (for macOS) - x86_64-pc-windows-msvc (for Windows 64-bit) - x86_64-unknown-linux-gnu (for Linux 64-bit) - aarch64-unknown-linux-gnu (for Linux ARM64) @@ -57,14 +52,7 @@ def get_platform_identifier(cpu_arch = None) -> str: system = platform.system().lower() if system == "darwin": - if cpu_arch is None: - return "universal-apple-darwin" - elif cpu_arch == "arm64": - return "aarch64-apple-darwin" - elif cpu_arch == "x86_64": - return "x86_64-apple-darwin" - else: - raise ValueError(f"Unsupported CPU architecture for macOS: {cpu_arch}") + return "universal-apple-darwin" elif system == "windows": return "x86_64-pc-windows-msvc" elif system == "linux": diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index 297123e2..3fa137f5 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -1,7 +1,7 @@ try: from importlib.metadata import version __version__ = version("c2pa-python") -except ImportError: +except ImportError: # pragma: no cover __version__ = "unknown" from .c2pa import ( diff --git a/src/c2pa/build.py b/src/c2pa/build.py index 032ca776..3ee6107b 100644 --- a/src/c2pa/build.py +++ b/src/c2pa/build.py @@ -1,11 +1,9 @@ import os import sys -import json import requests from pathlib import Path import zipfile import io -from typing import Optional # Constants REPO_OWNER = "contentauth" @@ -23,7 +21,10 @@ def get_latest_release() -> dict: def download_artifact(url: str, platform_name: str) -> None: - """Download and extract an artifact to the appropriate platform directory.""" + """ + Download and extract an artifact + to the appropriate platform directory. + """ print(f"Downloading artifact for {platform_name}...") # Create platform directory @@ -44,7 +45,8 @@ def download_artifact(url: str, platform_name: str) -> None: def download_artifacts() -> None: - """Main function to download artifacts. Can be called as a script or from hatch.""" + """Main function to download artifacts. + Can be called as a script or from hatch.""" try: # Create artifacts directory if it doesn't exist ARTIFACTS_DIR.mkdir(exist_ok=True) @@ -78,7 +80,8 @@ def download_artifacts() -> None: def inject_version(): - """Inject the version from pyproject.toml into src/c2pa/__init__.py as __version__.""" + """Inject the version from pyproject.toml + into src/c2pa/__init__.py as __version__.""" import toml pyproject_path = os.path.abspath( os.path.join( diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 0826793b..0edc61bb 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -6,7 +6,7 @@ import warnings from pathlib import Path from typing import Optional, Union, Callable, Any, overload -import time +import io from .lib import dynamically_load_library import mimetypes @@ -42,8 +42,15 @@ 'c2pa_signer_free', 'c2pa_ed25519_sign', 'c2pa_signature_free', + 'c2pa_free_string_array', + 'c2pa_reader_supported_mime_types', + 'c2pa_builder_supported_mime_types', ] +# TODO Bindings: +# c2pa_reader_is_embedded +# c2pa_reader_remote_url + def _validate_library_exports(lib): """Validate that all required functions are present in the loaded library. @@ -52,7 +59,7 @@ def _validate_library_exports(lib): 1. Security: - Prevents loading of libraries that might be missing critical functions - - Ensures the library has all expected functionality before any code execution + - Ensures the library has expected functionality before code execution - Helps detect tampered or incomplete libraries 2. Reliability: @@ -61,7 +68,8 @@ def _validate_library_exports(lib): - Ensures all required functionality is available before use 3. Version Compatibility: - - Helps detect version mismatches where the library doesn't have all expected functions + - Helps detect version mismatches where the library + doesn't have all expected functions - Prevents partial functionality that could lead to undefined behavior - Ensures the library matches the expected API version @@ -69,33 +77,37 @@ def _validate_library_exports(lib): lib: The loaded library object Raises: - ImportError: If any required function is missing, with a detailed message listing - the missing functions. This helps diagnose issues with the library - installation or version compatibility. + ImportError: If any required function is missing, + with a detailed message listing + the missing functions. This helps diagnose issues + with the library installation or version compatibility. """ missing_functions = [] for func_name in _REQUIRED_FUNCTIONS: - if not hasattr(lib, func_name): + if not hasattr(lib, func_name): # pragma: no cover missing_functions.append(func_name) - if missing_functions: + if missing_functions: # pragma: no cover raise ImportError( - f"Library is missing required functions symbols: {', '.join(missing_functions)}\n" - "This could indicate an incomplete or corrupted library installation or a version mismatch between the library and this Python wrapper" + f"Library is missing required function symbols: " + f"{', '.join(missing_functions)}\n" + "This could indicate an incomplete or corrupted library " + "installation or a version mismatch between the library " + "and this Python wrapper." ) # Determine the library name based on the platform -if sys.platform == "win32": +if sys.platform == "win32": # pragma: no cover _lib_name_default = "c2pa_c.dll" -elif sys.platform == "darwin": +elif sys.platform == "darwin": # pragma: no cover _lib_name_default = "libc2pa_c.dylib" -else: +else: # pragma: no cover _lib_name_default = "libc2pa_c.so" # Check for C2PA_LIBRARY_NAME environment variable env_lib_name = os.environ.get("C2PA_LIBRARY_NAME") -if env_lib_name: +if env_lib_name: # pragma: no cover # Use the environment variable library name _lib = dynamically_load_library(env_lib_name) else: @@ -176,9 +188,10 @@ class C2paSigner(ctypes.Structure): class C2paStream(ctypes.Structure): """A C2paStream is a Rust Read/Write/Seek stream that can be created in C. - This class represents a low-level stream interface that bridges Python and Rust/C code. - It implements the Rust Read/Write/Seek traits in C, allowing for efficient data transfer - between Python and the C2PA library without unnecessary copying. + This class represents a low-level stream interface that bridges Python + and Rust/C code. It implements the Rust Read/Write/Seek traits in C, + allowing for efficient data transfer between Python and the C2PA library + without unnecessary copying. The stream is used for various operations including: - Reading manifest data from files @@ -186,14 +199,14 @@ class C2paStream(ctypes.Structure): - Handling binary resources - Managing ingredient data - The structure contains function pointers that implement the stream operations: + The structure contains function pointers that implement stream operations: - reader: Function to read data from the stream - seeker: Function to change the stream position - writer: Function to write data to the stream - flusher: Function to flush any buffered data - This is a critical component for performance as it allows direct memory access - between Python and the C2PA library without intermediate copies. + This is a critical component for performance as it allows direct memory + access between Python and the C2PA library without intermediate copies. """ _fields_ = [ # Opaque context pointer for the stream @@ -222,13 +235,15 @@ def __init__(self, alg, sign_cert, private_key, ta_url): """Initialize C2paSignerInfo with optional parameters. Args: - alg: The signing algorithm, either as a C2paSigningAlg enum or string or bytes + alg: The signing algorithm, either as a + C2paSigningAlg enum or string or bytes (will be converted accordingly to bytes for native library use) sign_cert: The signing certificate as a string private_key: The private key as a string ta_url: The timestamp authority URL as bytes """ - # Handle alg parameter: can be C2paSigningAlg enum or string (or bytes), convert as needed + # Handle alg parameter: can be C2paSigningAlg enum + # or string (or bytes), convert as needed if isinstance(alg, C2paSigningAlg): # Convert enum to string representation alg_str = _ALG_TO_STRING_BYTES_MAPPING.get(alg) @@ -242,9 +257,13 @@ def __init__(self, alg, sign_cert, private_key, ta_url): # In bytes already pass else: - raise TypeError(f"alg must be C2paSigningAlg enum, string, or bytes, got {type(alg)}") + raise TypeError( + f"alg must be C2paSigningAlg enum, string, or bytes, " + f"got {type(alg)}" + ) - # Handle ta_url parameter: allow string or bytes, convert string to bytes as needed + # Handle ta_url parameter: + # allow string or bytes, convert string to bytes as needed if isinstance(ta_url, str): # String to bytes, as requested by native lib ta_url = ta_url.encode('utf-8') @@ -252,7 +271,9 @@ def __init__(self, alg, sign_cert, private_key, ta_url): # In bytes already pass else: - raise TypeError(f"ta_url must be string or bytes, got {type(ta_url)}") + raise TypeError( + f"ta_url must be string or bytes, got {type(ta_url)}" + ) # Call parent constructor with processed values super().__init__(alg, sign_cert, private_key, ta_url) @@ -275,7 +296,7 @@ def _setup_function(func, argtypes, restype=None): func.restype = restype -# Set up function prototypes +# Set up function prototypes (some may need the types defined above) _setup_function(_lib.c2pa_create_stream, [ctypes.POINTER(StreamContext), ReadCallback, @@ -285,9 +306,13 @@ def _setup_function(func, argtypes, restype=None): ctypes.POINTER(C2paStream)) # Add release_stream prototype -_setup_function(_lib.c2pa_release_stream, [ctypes.POINTER(C2paStream)], None) +_setup_function( + _lib.c2pa_release_stream, + [ctypes.POINTER(C2paStream)], + None +) -# Set up core function prototypes +# Set up function prototypes not attached to an API object _setup_function(_lib.c2pa_version, [], ctypes.c_void_p) _setup_function(_lib.c2pa_error, [], ctypes.c_void_p) _setup_function(_lib.c2pa_string_free, [ctypes.c_void_p], None) @@ -295,13 +320,12 @@ def _setup_function(func, argtypes, restype=None): _lib.c2pa_load_settings, [ ctypes.c_char_p, ctypes.c_char_p], ctypes.c_int) _setup_function( - _lib.c2pa_read_file, [ - ctypes.c_char_p, ctypes.c_char_p], ctypes.c_void_p) -_setup_function( - _lib.c2pa_read_ingredient_file, [ - ctypes.c_char_p, ctypes.c_char_p], ctypes.c_void_p) + _lib.c2pa_free_string_array, + [ctypes.POINTER(ctypes.c_char_p), ctypes.c_size_t], + None +) -# Set up Reader and Builder function prototypes +# Set up Reader function prototypes _setup_function(_lib.c2pa_reader_from_stream, [ctypes.c_char_p, ctypes.POINTER(C2paStream)], ctypes.POINTER(C2paReader)) @@ -315,6 +339,11 @@ def _setup_function(func, argtypes, restype=None): ctypes.POINTER(C2paReader)], ctypes.c_void_p) _setup_function(_lib.c2pa_reader_resource_to_stream, [ctypes.POINTER( C2paReader), ctypes.c_char_p, ctypes.POINTER(C2paStream)], ctypes.c_int64) +_setup_function( + _lib.c2pa_reader_supported_mime_types, + [ctypes.POINTER(ctypes.c_size_t)], + ctypes.POINTER(ctypes.c_char_p) +) # Set up Builder function prototypes _setup_function( @@ -337,8 +366,6 @@ def _setup_function(func, argtypes, restype=None): ctypes.c_char_p, ctypes.POINTER(C2paStream)], ctypes.c_int) - -# Set up additional Builder function prototypes _setup_function(_lib.c2pa_builder_to_archive, [ctypes.POINTER(C2paBuilder), ctypes.POINTER(C2paStream)], ctypes.c_int) @@ -356,11 +383,11 @@ def _setup_function(func, argtypes, restype=None): ctypes.c_ubyte)], None) _setup_function( _lib.c2pa_builder_data_hashed_placeholder, [ - ctypes.POINTER(C2paBuilder), ctypes.c_size_t, ctypes.c_char_p, ctypes.POINTER( - ctypes.POINTER( - ctypes.c_ubyte))], ctypes.c_int64) - -# Set up additional function prototypes + ctypes.POINTER(C2paBuilder), ctypes.c_size_t, ctypes.c_char_p, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)) + ], + ctypes.c_int64, +) _setup_function(_lib.c2pa_builder_sign_data_hashed_embeddable, [ctypes.POINTER(C2paBuilder), ctypes.POINTER(C2paSigner), @@ -385,8 +412,14 @@ def _setup_function(func, argtypes, restype=None): _setup_function(_lib.c2pa_signer_from_info, [ctypes.POINTER(C2paSignerInfo)], ctypes.POINTER(C2paSigner)) +_setup_function( + _lib.c2pa_read_file, [ + ctypes.c_char_p, ctypes.c_char_p], ctypes.c_void_p) +_setup_function( + _lib.c2pa_read_ingredient_file, [ + ctypes.c_char_p, ctypes.c_char_p], ctypes.c_void_p) -# Set up final function prototypes +# Set up Signer function prototypes _setup_function( _lib.c2pa_signer_reserve_size, [ ctypes.POINTER(C2paSigner)], ctypes.c_int64) @@ -400,6 +433,11 @@ def _setup_function(func, argtypes, restype=None): _lib.c2pa_signature_free, [ ctypes.POINTER( ctypes.c_ubyte)], None) +_setup_function( + _lib.c2pa_builder_supported_mime_types, + [ctypes.POINTER(ctypes.c_size_t)], + ctypes.POINTER(ctypes.c_char_p) +) class C2paError(Exception): @@ -471,32 +509,56 @@ class Verify(Exception): class _StringContainer: - """Container class to hold encoded strings and prevent them from being garbage collected. + """Container class to hold encoded strings, + and prevent them from being garbage collected. - This class is used to store encoded strings that need to remain in memory - while being used by C functions. The strings are stored as instance attributes + This class is used to store encoded strings + that need to remain in memory while being used by C functions. + The strings are stored as instance attributes, to prevent them from being garbage collected. - This is an internal implementation detail and should not be used outside this module. + This is an internal implementation detail + and should not be used outside this module. """ def __init__(self): """Initialize an empty string container.""" - pass + self._path_str = "" + self._data_dir_str = "" + + +def _convert_to_py_string(value) -> str: + if value is None: + return "" + + py_string = "" + ptr = ctypes.cast(value, ctypes.c_char_p) + + # Only if we got a valid pointer + if ptr and ptr.value is not None: + try: + py_string = ptr.value.decode('utf-8', errors='replace') + except Exception: + py_string = "" + + # Free the Rust-allocated memory + _lib.c2pa_string_free(value) + + # In case of invalid pointer, no free (avoids double-free) + return py_string def _parse_operation_result_for_error( - result: ctypes.c_void_p, + result: ctypes.c_void_p | None, check_error: bool = True) -> Optional[str]: """Helper function to handle string results from C2PA functions.""" - if not result: + if not result: # pragma: no cover if check_error: error = _lib.c2pa_error() if error: error_str = ctypes.cast( error, ctypes.c_char_p).value.decode('utf-8') _lib.c2pa_string_free(error) - print("## error_str:", error_str) parts = error_str.split(' ', 1) if len(parts) > 1: error_type, message = parts @@ -533,33 +595,27 @@ def _parse_operation_result_for_error( return error_str return None - # Convert to Python string and free the Rust-allocated memory - py_string = ctypes.cast(result, ctypes.c_char_p).value.decode('utf-8') - _lib.c2pa_string_free(result) - - return py_string + # In the case result would be a string already (error message) + return _convert_to_py_string(result) def sdk_version() -> str: """ - Returns the underlying c2pa-rs version string, e.g., "0.49.5". + Returns the underlying c2pa-rs/c2pa-c-ffi version string """ vstr = version() # Example: "c2pa-c/0.49.5 c2pa-rs/0.49.5" for part in vstr.split(): if part.startswith("c2pa-rs/"): return part.split("/", 1)[1] - return vstr # fallback if not found + # Fallback to full string n case format would change, eg. local builds + return vstr # pragma: no cover def version() -> str: """Get the C2PA library version.""" result = _lib.c2pa_version() - # print(f"Type: {type(result)}") - # print(f"Address: {hex(result)}") - py_string = ctypes.cast(result, ctypes.c_char_p).value.decode("utf-8") - _lib.c2pa_string_free(result) # Free the Rust-allocated memory - return py_string + return _convert_to_py_string(result) def load_settings(settings: str, format: str = "json") -> None: @@ -580,11 +636,15 @@ def load_settings(settings: str, format: str = "json") -> None: error = _parse_operation_result_for_error(_lib.c2pa_error()) if error: raise C2paError(error) + raise C2paError("Error loading settings") + + return result def read_ingredient_file( path: Union[str, Path], data_dir: Union[str, Path]) -> str: - """Read a C2PA ingredient from a file. + """Read a file as C2PA ingredient. + This creates the JSON string that would be used as the ingredient JSON. .. deprecated:: 0.11.0 This function is deprecated and will be removed in a future version. @@ -595,6 +655,13 @@ def read_ingredient_file( manifest_json = reader.json() ``` + To add ingredients to a manifest, please use the Builder class. + Example: + ``` + with open(ingredient_file_path, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + ``` + Args: path: Path to the file to read data_dir: Directory to write binary resources to @@ -606,10 +673,14 @@ 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." - "Please use Reader(path).json() for reading C2PA metadata instead.", + "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, or " + "Builder.add_ingredient(json, format, stream) to add ingredients " + "to a manifest.", DeprecationWarning, - stacklevel=2) + stacklevel=2, + ) container = _StringContainer() @@ -618,7 +689,16 @@ def read_ingredient_file( result = _lib.c2pa_read_ingredient_file( container._path_str, container._data_dir_str) - return _parse_operation_result_for_error(result) + + if result is None: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError( + "Error reading ingredient file {}".format(path) + ) + + return _convert_to_py_string(result) def read_file(path: Union[str, Path], @@ -645,10 +725,12 @@ 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." - "Please use the Reader class for reading C2PA metadata instead.", + "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() @@ -656,7 +738,15 @@ def read_file(path: Union[str, Path], container._data_dir_str = str(data_dir).encode('utf-8') result = _lib.c2pa_read_file(container._path_str, container._data_dir_str) - return _parse_operation_result_for_error(result) + if result is None: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error is not None: + raise C2paError(error) + raise C2paError.Other( + "Error during read of manifest from file {}".format(path) + ) + + return _convert_to_py_string(result) @overload @@ -695,22 +785,37 @@ def sign_file( """Sign a file with a C2PA manifest. For now, this function is left here to provide a backwards-compatible API. + .. deprecated:: 0.13.0 + This function is deprecated and will be removed in a future version. + Use :meth:`Builder.sign` instead. + Args: source_path: Path to the source 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 + return_manifest_as_bytes: If True, return manifest bytes instead + of JSON string Returns: - The signed manifest as a JSON string or bytes, depending on return_manifest_as_bytes + 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 - C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters + 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 object and Builder.sign() " + "instead.", + DeprecationWarning, + stacklevel=2, + ) + try: # Determine if we have a signer or signer info if isinstance(signer_or_info, C2paSignerInfo): @@ -723,37 +828,18 @@ def sign_file( # 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}") - - 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 - 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 - ) + manifest_bytes = builder.sign_file( + source_path, + dest_path, + signer + ) - # Read the signed manifest from the destination file - with Reader(dest_path) as reader: - return reader.json() + if return_manifest_as_bytes: + return manifest_bytes + else: + # 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 @@ -764,7 +850,7 @@ def sign_file( pass # Ignore cleanup errors # Re-raise the error - raise C2paError(f"Error signing file: {str(e)}") + raise C2paError(f"Error signing file: {str(e)}") from e finally: # Ensure resources are cleaned up if 'builder' in locals(): @@ -802,14 +888,17 @@ class Stream: 'flush_error': "Error during flush operation: {}" } - def __init__(self, file): - """Initialize a new Stream wrapper around a file-like object. + def __init__(self, file_like_stream): + """Initialize a new Stream wrapper around a file-like object + (or an in-memory stream). Args: - file: A file-like object that implements read, write, seek, tell, and flush methods + file_like_stream: A file-like stream object or an in-memory stream + that implements read, write, seek, tell, and flush methods Raises: - TypeError: If the file object doesn't implement all required methods + TypeError: The file stream object doesn't + implement all required methods """ # Initialize _closed first to prevent AttributeError # during garbage collection @@ -818,7 +907,7 @@ def __init__(self, file): self._stream = None # Generate unique stream ID using object ID and counter - if Stream._next_stream_id >= Stream._MAX_STREAM_ID: + if Stream._next_stream_id >= Stream._MAX_STREAM_ID: # pragma: no cover Stream._next_stream_id = 0 self._stream_id = f"{id(self)}-{Stream._next_stream_id}" Stream._next_stream_id += 1 @@ -827,19 +916,22 @@ def __init__(self, file): required_methods = ['read', 'write', 'seek', 'tell', 'flush'] missing_methods = [ method for method in required_methods if not hasattr( - file, method)] + file_like_stream, method)] if missing_methods: raise TypeError( - "Object must be a stream-like object with methods: {}. Missing: {}".format( - ', '.join(required_methods), - ', '.join(missing_methods))) + "Object must be a stream-like object with methods: {}. " + "Missing: {}".format( + ", ".join(required_methods), + ", ".join(missing_methods), + ) + ) - self._file = file + self._file_like_stream = file_like_stream def read_callback(ctx, data, length): """Callback function for reading data from the Python stream. - This function is called by the C2PA library when it needs to read data. + This function is called by C2PA library when it needs to read data. It handles: - Stream state validation - Memory safety @@ -855,14 +947,12 @@ def read_callback(ctx, data, length): Number of bytes read, or -1 on error """ if not self._initialized or self._closed: - # print(self._error_messages['read'], file=sys.stderr) return -1 try: if not data or length <= 0: - # print(self._error_messages['memory_error'].format("Invalid read parameters"), file=sys.stderr) return -1 - buffer = self._file.read(length) + buffer = self._file_like_stream.read(length) if not buffer: # EOF return 0 @@ -875,8 +965,7 @@ def read_callback(ctx, data, length): # Direct memory copy for better performance ctypes.memmove(data, buffer_view, actual_length) return actual_length - except Exception as e: - # print(self._error_messages['read_error'].format(str(e)), file=sys.stderr) + except Exception: return -1 def seek_callback(ctx, offset, whence): @@ -897,19 +986,17 @@ def seek_callback(ctx, offset, whence): New position in the stream, or -1 on error """ if not self._initialized or self._closed: - # print(self._error_messages['seek'], file=sys.stderr) return -1 try: - self._file.seek(offset, whence) - return self._file.tell() - except Exception as e: - # print(self._error_messages['seek_error'].format(str(e)), file=sys.stderr) + self._file_like_stream.seek(offset, whence) + return self._file_like_stream.tell() + except Exception: return -1 def write_callback(ctx, data, length): """Callback function for writing data to the Python stream. - This function is called by the C2PA library when it needs to write data. + This function is called by C2PA library when needing to write data. It handles: - Stream state validation - Memory safety @@ -925,11 +1012,9 @@ def write_callback(ctx, data, length): Number of bytes written, or -1 on error """ if not self._initialized or self._closed: - # print(self._error_messages['write'], file=sys.stderr) return -1 try: if not data or length <= 0: - # print(self._error_messages['memory_error'].format("Invalid write parameters"), file=sys.stderr) return -1 # Create a temporary buffer to safely handle the data @@ -938,13 +1023,12 @@ def write_callback(ctx, data, length): # Copy data to our temporary buffer ctypes.memmove(temp_buffer, data, length) # Write from our safe buffer - self._file.write(bytes(temp_buffer)) + self._file_like_stream.write(bytes(temp_buffer)) return length finally: # Ensure temporary buffer is cleared ctypes.memset(temp_buffer, 0, length) - except Exception as e: - # print(self._error_messages['write_error'].format(str(e)), file=sys.stderr) + except Exception: return -1 def flush_callback(ctx): @@ -962,13 +1046,11 @@ def flush_callback(ctx): 0 on success, -1 on error """ if not self._initialized or self._closed: - # print(self._error_messages['flush'], file=sys.stderr) return -1 try: - self._file.flush() + self._file_like_stream.flush() return 0 - except Exception as e: - # print(self._error_messages['flush_error'].format(str(e)), file=sys.stderr) + except Exception: return -1 # Create callbacks that will be kept alive by being instance attributes @@ -1009,8 +1091,9 @@ def __del__(self): def close(self): """Release the stream resources. - This method ensures all resources are properly cleaned up, even if errors occur during cleanup. - Errors during cleanup are logged but not raised to ensure cleanup completes. + This method ensures all resources are properly cleaned up, + even if errors occur during cleanup. + Errors during cleanup are logged but not raised to ensure cleanup. Multiple calls to close() are handled gracefully. """ @@ -1019,6 +1102,8 @@ def close(self): try: # Clean up stream first as it depends on callbacks + # Note: We don't close self._file_like_stream as we don't own it, + # the opener owns it. if self._stream: try: _lib.c2pa_release_stream(self._stream) @@ -1039,7 +1124,6 @@ def close(self): 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( Stream._ERROR_MESSAGES['cleanup_error'].format( @@ -1048,6 +1132,10 @@ def close(self): self._closed = True self._initialized = False + def write_to_target(self, dest_stream): + self._file_like_stream.seek(0) + dest_stream.write(self._file_like_stream.getvalue()) + @property def closed(self) -> bool: """Check if the stream is closed. @@ -1070,6 +1158,9 @@ def initialized(self) -> bool: class Reader: """High-level wrapper for C2PA Reader operations.""" + # Supported mimetypes cache + _supported_mime_types_cache = None + # Class-level error messages to avoid multiple creation _ERROR_MESSAGES = { 'unsupported': "Unsupported format", @@ -1083,6 +1174,25 @@ class Reader: 'encoding_error': "Invalid UTF-8 characters in input: {}" } + @classmethod + def get_supported_mime_types(cls) -> list[str]: + if cls._supported_mime_types_cache is not None: + return cls._supported_mime_types_cache + + count = ctypes.c_size_t() + arr = _lib.c2pa_reader_supported_mime_types(ctypes.byref(count)) + + try: + # CDecode values to place them in Python managed memory + result = [arr[i].decode("utf-8") for i in range(count.value)] + finally: + # Release native memory, as per API contract + # c2pa_reader_supported_mime_types must call c2pa_free_string_array + _lib.c2pa_free_string_array(arr, count.value) + + cls._supported_mime_types_cache = result + return cls._supported_mime_types_cache + def __init__(self, format_or_path: Union[str, Path], @@ -1092,28 +1202,33 @@ def __init__(self, Args: format_or_path: The format or path to read from - stream: Optional stream to read from (any Python stream-like object) + stream: Optional stream to read from (Python stream-like object) manifest_data: Optional manifest data in bytes Raises: C2paError: If there was an error creating the reader - C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters + C2paError.Encoding: If any of the string inputs + contain invalid UTF-8 characters """ self._reader = None self._own_stream = None - # Check for unsupported format - if format_or_path == "badFormat": - raise C2paError.NotSupported(Reader._ERROR_MESSAGES['unsupported']) - if stream is None: - # Create a stream from the file path + # If we don't get a stream as param: + # Create a stream from the file path in format_or_path path = str(format_or_path) mime_type = mimetypes.guess_type( path)[0] - # Keep mime_type string alive + if not mime_type: + raise C2paError.NotSupported( + f"Could not determine MIME type for file: {path}") + + if mime_type not in Reader.get_supported_mime_types(): + raise C2paError.NotSupported( + f"Reader does not support {mime_type}") + try: self._mime_type_str = mime_type.encode('utf-8') except UnicodeError as e: @@ -1139,25 +1254,37 @@ def __init__(self, if error: raise C2paError(error) raise C2paError( - Reader._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 + self._file_like_stream = file except Exception as e: if self._own_stream: self._own_stream.close() - if hasattr(self, '_file'): - self._file.close() + if hasattr(self, '_file_like_stream'): + self._file_like_stream.close() raise C2paError.Io( Reader._ERROR_MESSAGES['io_error'].format( str(e))) elif isinstance(stream, str): + # We may have gotten format + a file path # If stream is a string, treat it as a path and try to open it + + # format_or_path is a format + if format_or_path not in Reader.get_supported_mime_types(): + raise C2paError.NotSupported( + f"Reader does not support {format_or_path}") + try: file = open(stream, 'rb') self._own_stream = Stream(file) - self._format_str = format_or_path.encode('utf-8') + + format_str = str(format_or_path) + self._format_str = format_str.encode('utf-8') if manifest_data is None: self._reader = _lib.c2pa_reader_from_stream( @@ -1171,11 +1298,13 @@ def __init__(self, len(manifest_data))( * manifest_data) - self._reader = _lib.c2pa_reader_from_manifest_data_and_stream( - self._format_str, - self._own_stream._stream, - manifest_array, - len(manifest_data) + self._reader = ( + _lib.c2pa_reader_from_manifest_data_and_stream( + self._format_str, + self._own_stream._stream, + manifest_array, + len(manifest_data), + ) ) if not self._reader: @@ -1186,21 +1315,29 @@ def __init__(self, if error: raise C2paError(error) raise C2paError( - Reader._ERROR_MESSAGES['reader_error'].format("Unknown error")) + Reader._ERROR_MESSAGES['reader_error'].format( + "Unknown error" + ) + ) - self._file = file + self._file_like_stream = file except Exception as e: if self._own_stream: self._own_stream.close() - if hasattr(self, '_file'): - self._file.close() + if hasattr(self, '_file_like_stream'): + self._file_like_stream.close() raise C2paError.Io( Reader._ERROR_MESSAGES['io_error'].format( str(e))) else: + # format_or_path is a format string + format_str = str(format_or_path) + if format_str not in Reader.get_supported_mime_types(): + raise C2paError.NotSupported( + f"Reader does not support {format_str}") + # Use the provided stream - # Keep format string alive - self._format_str = format_or_path.encode('utf-8') + self._format_str = format_str.encode('utf-8') with Stream(stream) as stream_obj: if manifest_data is None: @@ -1215,8 +1352,14 @@ def __init__(self, len(manifest_data))( * manifest_data) - self._reader = _lib.c2pa_reader_from_manifest_data_and_stream( - self._format_str, stream_obj._stream, manifest_array, len(manifest_data)) + self._reader = ( + _lib.c2pa_reader_from_manifest_data_and_stream( + self._format_str, + stream_obj._stream, + manifest_array, + len(manifest_data) + ) + ) if not self._reader: error = _parse_operation_result_for_error( @@ -1224,7 +1367,10 @@ def __init__(self, if error: raise C2paError(error) raise C2paError( - Reader._ERROR_MESSAGES['reader_error'].format("Unknown error")) + Reader._ERROR_MESSAGES['reader_error'].format( + "Unknown error" + ) + ) def __enter__(self): return self @@ -1235,8 +1381,9 @@ def __exit__(self, exc_type, exc_val, exc_tb): def close(self): """Release the reader resources. - This method ensures all resources are properly cleaned up, even if errors occur during cleanup. - Errors during cleanup are logged but not raised to ensure cleanup completes. + This method ensures all resources are properly cleaned up, + even if errors occur during cleanup. + Errors during cleanup are logged but not raised to ensure cleanup. Multiple calls to close() are handled gracefully. """ @@ -1271,15 +1418,15 @@ def close(self): self._own_stream = None # Clean up file - if hasattr(self, '_file'): + if hasattr(self, '_file_like_stream'): try: - self._file.close() + self._file_like_stream.close() except Exception as e: print( Reader._ERROR_MESSAGES['file_error'].format( str(e)), file=sys.stderr) finally: - self._file = None + self._file_like_stream = None # Clear any stored strings if hasattr(self, '_strings'): @@ -1304,7 +1451,14 @@ def json(self) -> str: if not self._reader: raise C2paError("Reader is closed") result = _lib.c2pa_reader_json(self._reader) - return _parse_operation_result_for_error(result) + + if result is None: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError("Error during manifest parsing in Reader") + + return _convert_to_py_string(result) def resource_to_stream(self, uri: str, stream: Any) -> int: """Write a resource to a stream. @@ -1317,12 +1471,11 @@ def resource_to_stream(self, uri: str, stream: Any) -> int: The number of bytes written Raises: - C2paError: If there was an error writing the resource + C2paError: If there was an error writing the resource to stream """ if not self._reader: raise C2paError("Reader is closed") - # Keep uri string alive self._uri_str = uri.encode('utf-8') with Stream(stream) as stream_obj: result = _lib.c2pa_reader_resource_to_stream( @@ -1332,6 +1485,9 @@ def resource_to_stream(self, uri: str, stream: Any) -> int: error = _parse_operation_result_for_error(_lib.c2pa_error()) if error: raise C2paError(error) + raise C2paError.Other( + "Error during resource {} to stream conversion".format(uri) + ) return result @@ -1344,7 +1500,6 @@ class Signer: '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: {}", @@ -1362,6 +1517,9 @@ def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): self._signer = signer_ptr self._closed = False + # Set only for signers which are callback signers + self._callback_cb = None + @classmethod def from_info(cls, signer_info: C2paSignerInfo) -> 'Signer': """Create a new Signer from signer information. @@ -1408,16 +1566,23 @@ def from_callback( Raises: C2paError: If there was an error creating the signer - C2paError.Encoding: If the certificate data or TSA URL contains invalid UTF-8 characters + C2paError.Encoding: If the certificate data or TSA URL + contains invalid UTF-8 characters """ # Validate inputs before creating if not certs: raise C2paError( - cls._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://')): + if tsa_url and not tsa_url.startswith(("http://", "https://")): raise C2paError( - cls._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( @@ -1427,11 +1592,18 @@ def wrapped_callback( signed_bytes_ptr, signed_len): # 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 + # 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 does checks. try: - if not data_ptr or data_len <= 0 or not signed_bytes_ptr or signed_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 @@ -1527,8 +1699,9 @@ def __exit__(self, exc_type, exc_val, exc_tb): def close(self): """Release the signer resources. - This method ensures all resources are properly cleaned up, even if errors occur during cleanup. - Errors during cleanup are logged but not raised to ensure cleanup completes. + This method ensures all resources are properly cleaned up, + even if errors occur during cleanup. + Errors during cleanup are logged but not raised to ensure cleanup. Multiple calls to close() are handled gracefully. """ if self._closed: @@ -1563,20 +1736,15 @@ def reserve_size(self) -> int: if self._closed or not self._signer: raise C2paError(Signer._ERROR_MESSAGES['closed_error']) - try: - result = _lib.c2pa_signer_reserve_size(self._signer) + result = _lib.c2pa_signer_reserve_size(self._signer) - if result < 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to get reserve size") + if result < 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError("Failed to get reserve size") - return result - except Exception as e: - raise C2paError( - Signer._ERROR_MESSAGES['size_error'].format( - str(e))) + return result @property def closed(self) -> bool: @@ -1591,6 +1759,9 @@ def closed(self) -> bool: class Builder: """High-level wrapper for C2PA Builder operations.""" + # Supported mimetypes cache + _supported_mime_types_cache = None + # Class-level error messages to avoid multiple creation _ERROR_MESSAGES = { 'builder_error': "Failed to create builder: {}", @@ -1607,6 +1778,26 @@ class Builder: 'json_error': "Failed to serialize manifest JSON: {}" } + @classmethod + def get_supported_mime_types(cls) -> list[str]: + if cls._supported_mime_types_cache is not None: + return cls._supported_mime_types_cache + + count = ctypes.c_size_t() + arr = _lib.c2pa_builder_supported_mime_types(ctypes.byref(count)) + + try: + # CDecode values to place them in Python managed memory + result = [arr[i].decode("utf-8") for i in range(count.value)] + finally: + # Release native memory, as per API contract + # c2pa_builder_supported_mime_types must call + # c2pa_free_string_array + _lib.c2pa_free_string_array(arr, count.value) + + cls._supported_mime_types_cache = result + return cls._supported_mime_types_cache + def __init__(self, manifest_json: Any): """Initialize a new Builder instance. @@ -1615,7 +1806,7 @@ def __init__(self, manifest_json: Any): Raises: C2paError: If there was an error creating the builder - C2paError.Encoding: If the manifest JSON contains invalid UTF-8 characters + C2paError.Encoding: If manifest JSON contains invalid UTF-8 chars C2paError.Json: If the manifest JSON cannot be serialized """ self._builder = None @@ -1642,7 +1833,10 @@ def __init__(self, manifest_json: Any): if error: raise C2paError(error) raise C2paError( - Builder._ERROR_MESSAGES['builder_error'].format("Unknown error")) + Builder._ERROR_MESSAGES['builder_error'].format( + "Unknown error" + ) + ) @classmethod def from_json(cls, manifest_json: Any) -> 'Builder': @@ -1664,13 +1858,14 @@ def from_archive(cls, stream: Any) -> 'Builder': """Create a new Builder from an archive stream. Args: - stream: The stream containing the archive (any Python stream-like object) + stream: The stream containing the archive + (any Python stream-like object) Returns: A new Builder instance Raises: - C2paError: If there was an error creating the builder from the archive + C2paError: If there was an error creating the builder from archive """ builder = cls({}) stream_obj = Stream(stream) @@ -1692,8 +1887,9 @@ def __del__(self): def close(self): """Release the builder resources. - This method ensures all resources are properly cleaned up, even if errors occur during cleanup. - Errors during cleanup are logged but not raised to ensure cleanup completes. + This method ensures all resources are properly cleaned up, + even if errors occur during cleanup. + Errors during cleanup are logged but not raised to ensure cleanup. Multiple calls to close() are handled gracefully. """ # Track if we've already cleaned up @@ -1721,12 +1917,6 @@ def close(self): finally: self._closed = True - def set_manifest(self, manifest): - if not isinstance(manifest, str): - manifest = json.dumps(manifest) - super().with_json(manifest) - return self - def __enter__(self): return self @@ -1736,17 +1926,19 @@ def __exit__(self, exc_type, exc_val, exc_tb): def set_no_embed(self): """Set the no-embed flag. - When set, the builder will not embed a C2PA manifest store into the asset when signing. + When set, the builder will not embed a C2PA manifest store + into the asset when signing. This is useful when creating cloud or sidecar manifests. """ if not self._builder: raise C2paError(Builder._ERROR_MESSAGES['closed_error']) + _lib.c2pa_builder_set_no_embed(self._builder) def set_remote_url(self, remote_url: str): """Set the remote URL. - When set, the builder will embed a remote URL into the asset when signing. + When set, the builder embeds a remote URL into the asset when signing. This is useful when creating cloud based Manifests. Args: @@ -1773,7 +1965,8 @@ def add_resource(self, uri: str, stream: Any): Args: uri: The URI to identify the resource - stream: The stream containing the resource data (any Python stream-like object) + stream: The stream containing the resource data + (any Python stream-like object) Raises: C2paError: If there was an error adding the resource @@ -1791,41 +1984,28 @@ def add_resource(self, uri: str, stream: Any): if error: raise C2paError(error) raise C2paError( - Builder._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. + """Add an ingredient to the builder (facade method). + The added ingredient's source should be a stream-like object + (for instance, a file opened as stream). Args: ingredient_json: The JSON ingredient definition format: The MIME type or extension of the ingredient - source: The stream containing the ingredient data (any Python stream-like object) + source: The stream containing the ingredient data + (any Python stream-like object) Raises: C2paError: If there was an error adding the ingredient - C2paError.Encoding: If the ingredient JSON contains invalid UTF-8 characters + C2paError.Encoding: If the ingredient JSON contains + invalid UTF-8 characters """ - if not self._builder: - 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( - Builder._ERROR_MESSAGES['encoding_error'].format( - str(e))) - - source_stream = Stream(source) - result = _lib.c2pa_builder_add_ingredient_from_stream( - self._builder, ingredient_str, format_str, source_stream._stream) - - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Builder._ERROR_MESSAGES['ingredient_error'].format("Unknown error")) + return self.add_ingredient_from_stream(ingredient_json, format, source) def add_ingredient_from_stream( self, @@ -1833,15 +2013,18 @@ def add_ingredient_from_stream( format: str, source: Any): """Add an ingredient from a stream to the builder. + Explicitly named API requiring a stream as input parameter. Args: ingredient_json: The JSON ingredient definition format: The MIME type or extension of the ingredient - source: The stream containing the ingredient data (any Python stream-like object) + source: The stream containing the ingredient data + (any Python stream-like object) Raises: C2paError: If there was an error adding the ingredient - C2paError.Encoding: If the ingredient JSON or format contains invalid UTF-8 characters + C2paError.Encoding: If the ingredient JSON or format + contains invalid UTF-8 characters """ if not self._builder: raise C2paError(Builder._ERROR_MESSAGES['closed_error']) @@ -1855,21 +2038,76 @@ def add_ingredient_from_stream( str(e))) with Stream(source) as source_stream: - result = _lib.c2pa_builder_add_ingredient_from_stream( - self._builder, ingredient_str, format_str, source_stream._stream) + result = ( + _lib.c2pa_builder_add_ingredient_from_stream( + self._builder, + ingredient_str, + format_str, + source_stream._stream + ) + ) if result != 0: error = _parse_operation_result_for_error(_lib.c2pa_error()) if error: raise C2paError(error) raise C2paError( - Builder._ERROR_MESSAGES['ingredient_error'].format("Unknown error")) + Builder._ERROR_MESSAGES['ingredient_error'].format( + "Unknown error" + ) + ) - def to_archive(self, stream: Any): + def add_ingredient_from_file_path( + self, + ingredient_json: str, + format: str, + filepath: Union[str, Path]): + """Add an ingredient from a file path to the builder. + This is a legacy method. + + .. deprecated:: 0.13.0 + This method is deprecated and will be removed in a future version. + Use :meth:`add_ingredient` with a file stream instead. + + Args: + ingredient_json: The JSON ingredient definition + format: The MIME type or extension of the ingredient + filepath: The path to the file containing the ingredient data + (can be a string or Path object) + + Raises: + C2paError: If there was an error adding the ingredient + C2paError.Encoding: If the ingredient JSON or format + contains invalid UTF-8 characters + FileNotFoundError: If the file at the specified path does not exist + """ + warnings.warn( + "add_ingredient_from_file_path is deprecated and will " + "be removed in a future version. Use add_ingredient " + "with a file stream instead.", + DeprecationWarning, + stacklevel=2, + ) + + try: + # Convert Path object to string if necessary + filepath_str = str(filepath) + + # Does the stream handling to use add_ingredient_from_stream + with open(filepath_str, 'rb') as file_stream: + self.add_ingredient_from_stream( + ingredient_json, format, file_stream) + except FileNotFoundError as e: + raise C2paError.FileNotFound(f"File not found: {filepath}") from e + except Exception as e: + raise C2paError.Other(f"Could not add ingredient: {e}") from e + + def to_archive(self, stream: Any) -> None: """Write an archive of the builder to a stream. Args: - stream: The stream to write the archive to (any Python stream-like object) + stream: The stream to write the archive to + (any Python stream-like object) Raises: C2paError: If there was an error writing the archive @@ -1886,7 +2124,10 @@ def to_archive(self, stream: Any): if error: raise C2paError(error) raise C2paError( - Builder._ERROR_MESSAGES['archive_error'].format("Unknown error")) + Builder._ERROR_MESSAGES["archive_error"].format( + "Unknown error" + ) + ) def _sign_internal( self, @@ -1894,14 +2135,15 @@ def _sign_internal( format: str, source_stream: Stream, dest_stream: Stream) -> bytes: - """Internal signing logic shared between sign() and sign_file() methods, + """Internal signing logic shared between sign() and sign_file() methods to use same native calls but expose different API surface. Args: signer: The signer to use format: The MIME type or extension of the content source_stream: The source stream - dest_stream: The destination stream + dest_stream: The destination stream, + opened in w+b (write+read binary) mode. Returns: Manifest bytes @@ -1912,82 +2154,105 @@ def _sign_internal( if not self._builder: raise C2paError(Builder._ERROR_MESSAGES['closed_error']) - try: - format_str = format.encode('utf-8') - manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() - - # c2pa_builder_sign uses streams - result = _lib.c2pa_builder_sign( - self._builder, - format_str, - source_stream._stream, - dest_stream._stream, - signer._signer, - ctypes.byref(manifest_bytes_ptr) - ) + if format not in Builder.get_supported_mime_types(): + raise C2paError.NotSupported( + f"Builder does not support {format}") + + format_str = format.encode('utf-8') + manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() + + # c2pa_builder_sign uses streams + result = _lib.c2pa_builder_sign( + self._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) + if result < 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError("Error during signing") - # Capture the manifest bytes if available - manifest_bytes = b"" - if manifest_bytes_ptr and result > 0: + # Capture the manifest bytes if available + manifest_bytes = b"" + if manifest_bytes_ptr and result > 0: + try: + # Convert the C pointer to Python bytes + 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 + manifest_bytes = b"" + finally: + # Always free the C-allocated memory, + # even if we failed to copy manifest bytes try: - # Convert the C pointer to Python bytes - temp_buffer = (ctypes.c_ubyte * result)() - ctypes.memmove(temp_buffer, manifest_bytes_ptr, result) - manifest_bytes = bytes(temp_buffer) + _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) 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 manifest bytes - try: - _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) - except Exception: - # Ignore errors during cleanup - pass + # Ignore errors during cleanup + pass - return manifest_bytes - finally: - # Ensure both streams are cleaned up - source_stream.close() - dest_stream.close() + return manifest_bytes def sign( self, signer: Signer, format: str, source: Any, - dest: Any = None) -> None: + dest: Any = None) -> bytes: """Sign the builder's content and write to a destination stream. Args: format: The MIME type or extension of the content source: The source stream (any Python stream-like object) - dest: The destination stream (any Python stream-like object) + dest: The destination stream (any Python stream-like object), + opened in w+b (write+read binary) mode. signer: The signer to use + Returns: + Manifest bytes + Raises: C2paError: If there was an error during signing """ # Convert Python streams to Stream objects source_stream = Stream(source) - dest_stream = Stream(dest) + + if dest: + # dest is optional, only if we write back somewhere + dest_stream = Stream(dest) + else: + # no destination? + # we keep things in-memory for validation and processing + mem_buffer = io.BytesIO() + dest_stream = Stream(mem_buffer) # Use the internal stream-base signing logic - return self._sign_internal(signer, format, source_stream, dest_stream) + manifest_bytes = self._sign_internal( + signer, + format, + source_stream, + dest_stream + ) + + if not dest: + # Close temporary in-memory stream since we own it + dest_stream.close() + + return manifest_bytes def sign_file(self, source_path: Union[str, Path], dest_path: Union[str, Path], - signer: Signer) -> tuple[int, bytes]: + signer: Signer) -> bytes: """Sign a file and write the signed data to an output file. Args: @@ -1996,7 +2261,7 @@ def sign_file(self, signer: The signer to use Returns: - A tuple of (size of C2PA data, manifest bytes) + Manifest bytes Raises: C2paError: If there was an error during signing @@ -2007,15 +2272,13 @@ def sign_file(self, 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: - # Convert Python streams to Stream objects - source_stream = Stream(source_file) - dest_stream = Stream(dest_file) - - # Use the internal stream-base signing logic - return self._sign_internal( - signer, mime_type, source_stream, dest_stream) + try: + # Open source file and destination file, then use the sign method + with open(source_path, 'rb') as source_file, \ + open(dest_path, 'w+b') as dest_file: + return self.sign(signer, mime_type, source_file, dest_file) + except Exception as e: + raise C2paError(f"Error signing file: {str(e)}") from e def format_embeddable(format: str, manifest_bytes: bytes) -> tuple[int, bytes]: @@ -2083,13 +2346,15 @@ def create_signer( Raises: C2paError: If there was an error creating the signer - C2paError.Encoding: If the certificate data or TSA URL contains invalid UTF-8 characters + 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.", + "The create_signer function is deprecated and will be removed in a " + "future version. Please use Signer.from_callback() instead.", DeprecationWarning, - stacklevel=2) + stacklevel=2, + ) return Signer.from_callback(callback, alg, certs, tsa_url) @@ -2115,10 +2380,11 @@ def create_signer_from_info(signer_info: C2paSignerInfo) -> Signer: 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.", + "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) + stacklevel=2, + ) return Signer.from_info(signer_info) @@ -2135,7 +2401,7 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: Raises: C2paError: If there was an error signing the data - C2paError.Encoding: If the private key contains invalid UTF-8 characters + C2paError.Encoding: If the private key contains invalid UTF-8 chars """ data_array = (ctypes.c_ubyte * len(data))(*data) try: diff --git a/src/c2pa/lib.py b/src/c2pa/lib.py index 186c8d3a..af324b93 100644 --- a/src/c2pa/lib.py +++ b/src/c2pa/lib.py @@ -10,7 +10,7 @@ import logging import platform from pathlib import Path -from typing import Optional, Tuple +from typing import Optional from enum import Enum # Debug flag for library loading @@ -25,40 +25,27 @@ class CPUArchitecture(Enum): """CPU architecture enum for platform-specific identifiers.""" AARCH64 = "aarch64" X86_64 = "x86_64" + ARM64 = "arm64" -def get_platform_identifier(cpu_arch: Optional[CPUArchitecture] = None) -> str: - """Get the full platform identifier (arch-os) for the current system, +def get_platform_identifier() -> str: + """Get the platform identifier (arch-os) for the current system, matching the downloaded identifiers used by the Github publisher. - Args: - cpu_arch: Optional CPU architecture for macOS. - If not provided, returns universal build. - Only used on macOS systems. - Returns one of: - - universal-apple-darwin (for Mac, when cpu_arch is None) - - aarch64-apple-darwin (for Mac ARM64) - - x86_64-apple-darwin (for Mac x86_64) + - universal-apple-darwin (for Mac, ARM or Intel) - x86_64-pc-windows-msvc (for Windows 64-bit) - x86_64-unknown-linux-gnu (for Linux 64-bit) + - aarch64-unknown-linux-gnu (for Linux ARM) """ system = platform.system().lower() if system == "darwin": - if cpu_arch is None: - return "universal-apple-darwin" - elif cpu_arch == CPUArchitecture.AARCH64: - return "aarch64-apple-darwin" - elif cpu_arch == CPUArchitecture.X86_64: - return "x86_64-apple-darwin" - else: - raise ValueError( - f"Unsupported CPU architecture for macOS: {cpu_arch}") + return "universal-apple-darwin" elif system == "windows": return "x86_64-pc-windows-msvc" elif system == "linux": - if _get_architecture() in ['arm64', 'aarch64']: + if _get_architecture() in [CPUArchitecture.ARM64.value, CPUArchitecture.AARCH64.value]: return "aarch64-unknown-linux-gnu" return "x86_64-unknown-linux-gnu" else: @@ -75,13 +62,14 @@ def _get_architecture() -> str: if sys.platform == "darwin": # On macOS, we need to check if we're running under Rosetta if platform.processor() == 'arm': - return 'arm64' + return CPUArchitecture.ARM64.value else: - return 'x86_64' + return CPUArchitecture.X86_64.value elif sys.platform == "linux": return platform.machine() elif sys.platform == "win32": - # win32 will cover all Windows versions (the 32 is a historical quirk) + # win32 will cover all Windows versions + # (the 32 is a historical quirk) return platform.machine() else: raise RuntimeError(f"Unsupported platform: {sys.platform}") @@ -99,7 +87,8 @@ def _get_platform_dir() -> str: elif sys.platform == "linux": return "unknown-linux-gnu" elif sys.platform == "win32": - # win32 will cover all Windows versions (the 32 is a historical quirk) + # win32 will cover all Windows versions + # (the 32 is a historical quirk) return "pc-windows-msvc" else: raise RuntimeError(f"Unsupported platform: {sys.platform}") @@ -117,28 +106,31 @@ def _load_single_library(lib_name: str, Returns: The loaded library or None if loading failed """ - if DEBUG_LIBRARY_LOADING: - logger.info(f"Searching for library '{lib_name}' in paths: {[str(p) for p in search_paths]}") + if DEBUG_LIBRARY_LOADING: # pragma: no cover + logger.info( + f"Searching for library '{lib_name}' in paths: {[str(p) for p in search_paths]}") current_arch = _get_architecture() - if DEBUG_LIBRARY_LOADING: + if DEBUG_LIBRARY_LOADING: # pragma: no cover logger.info(f"Current architecture: {current_arch}") for path in search_paths: lib_path = path / lib_name - if DEBUG_LIBRARY_LOADING: + if DEBUG_LIBRARY_LOADING: # pragma: no cover logger.info(f"Checking path: {lib_path}") if lib_path.exists(): - if DEBUG_LIBRARY_LOADING: + if DEBUG_LIBRARY_LOADING: # pragma: no cover logger.info(f"Found library at: {lib_path}") try: return ctypes.CDLL(str(lib_path)) except Exception as e: error_msg = str(e) if "incompatible architecture" in error_msg: - logger.error(f"Architecture mismatch: Library at {lib_path} is not compatible with current architecture {current_arch}") + logger.error( + f"Architecture mismatch: Library at {lib_path} is not compatible with current architecture {current_arch}") logger.error(f"Error details: {error_msg}") else: - logger.error(f"Failed to load library from {lib_path}: {e}") + logger.error( + f"Failed to load library from {lib_path}: {e}") else: logger.debug(f"Library not found at: {lib_path}") return None @@ -155,7 +147,7 @@ def _get_possible_search_paths() -> list[Path]: platform_dir = _get_platform_dir() platform_id = get_platform_identifier() - if DEBUG_LIBRARY_LOADING: + if DEBUG_LIBRARY_LOADING: # pragma: no cover logger.info(f"Using platform directory: {platform_dir}") logger.info(f"Using platform identifier: {platform_id}") @@ -196,9 +188,12 @@ def dynamically_load_library( Load the dynamic library containing the C-API based on the platform. Args: - lib_name: Optional specific library name to load. If provided, only this library will be loaded. - This enables to potentially load wrapper libraries of the C-API that may have an other name - (the presence of required symbols will nevertheless be verified once the library is loaded). + lib_name: Optional specific library name to load. + If provided, only this library will be loaded. + This enables to potentially load wrapper libraries + of the C-API that may have an other name + (the presence of required symbols will nevertheless + be verified once the library is loaded). Returns: The loaded library or None if loading failed @@ -212,7 +207,7 @@ def dynamically_load_library( else: raise RuntimeError(f"Unsupported platform: {sys.platform}") - if DEBUG_LIBRARY_LOADING: + if DEBUG_LIBRARY_LOADING: # pragma: no cover logger.info(f"Current working directory: {Path.cwd()}") logger.info(f"Package directory: {Path(__file__).parent}") logger.info(f"System architecture: {_get_architecture()}") @@ -220,7 +215,7 @@ def dynamically_load_library( # Check for C2PA_LIBRARY_NAME environment variable env_lib_name = os.environ.get("C2PA_LIBRARY_NAME") if env_lib_name: - if DEBUG_LIBRARY_LOADING: + if DEBUG_LIBRARY_LOADING: # pragma: no cover logger.info( f"Using library name from env var C2PA_LIBRARY_NAME: {env_lib_name}") try: @@ -229,13 +224,14 @@ def dynamically_load_library( if lib: return lib else: - logger.error(f"Could not find library {env_lib_name} in any of the search paths") + logger.error( + f"Could not find library {env_lib_name} in any of the search paths") # Continue with normal loading if environment variable library # name fails except Exception as e: logger.error(f"Failed to load library from C2PA_LIBRARY_NAME: {e}") - # Continue with normal loading if environment variable library name - # fails + # Continue with normal loading if + # environment variable library name fails possible_paths = _get_possible_search_paths() @@ -245,9 +241,12 @@ def dynamically_load_library( if not lib: platform_id = get_platform_identifier() current_arch = _get_architecture() - logger.error(f"Could not find {lib_name} in any of the search paths: {[str(p) for p in possible_paths]}") - logger.error(f"Platform: {platform_id}, Architecture: {current_arch}") - raise RuntimeError(f"Could not find {lib_name} in any of the search paths (Platform: {platform_id}, Architecture: {current_arch})") + logger.error( + f"Could not find {lib_name} in any of the search paths: {[str(p) for p in possible_paths]}") + logger.error( + f"Platform: {platform_id}, Architecture: {current_arch}") + raise RuntimeError( + f"Could not find {lib_name} in any of the search paths (Platform: {platform_id}, Architecture: {current_arch})") return lib # Default path (no library name provided in the environment) diff --git a/tests/benchmark.py b/tests/benchmark.py index e2930c74..afb9a191 100644 --- a/tests/benchmark.py +++ b/tests/benchmark.py @@ -37,18 +37,17 @@ "title": "Python Test Image", "ingredients": [], "assertions": [ - {'label': 'stds.schema-org.CreativeWork', - 'data': { - '@context': 'http://schema.org/', - '@type': 'CreativeWork', - 'author': [ - {'@type': 'Person', - 'name': 'Gavin Peacock' - } + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } ] - }, - 'kind': 'Json' - } + } + } ] } @@ -77,6 +76,7 @@ def test_files_read(): with open(test_path, "rb") as f: reader = Reader("image/jpeg", f) result = reader.json() + reader.close() assert result is not None # Parse the JSON string into a dictionary result_dict = json.loads(result) @@ -93,6 +93,7 @@ def test_streams_read(): source = file.read() reader = Reader("image/jpeg", io.BytesIO(source)) result = reader.json() + reader.close() assert result is not None # Parse the JSON string into a dictionary result_dict = json.loads(result) diff --git a/tests/fixtures/C-recorded-as-mov.mov b/tests/fixtures/C-recorded-as-mov.mov new file mode 100644 index 00000000..9d2627d8 Binary files /dev/null and b/tests/fixtures/C-recorded-as-mov.mov differ diff --git a/tests/fixtures/files-for-reading-tests/C_with_CAWG_data.jpg b/tests/fixtures/C_with_CAWG_data.jpg similarity index 100% rename from tests/fixtures/files-for-reading-tests/C_with_CAWG_data.jpg rename to tests/fixtures/C_with_CAWG_data.jpg diff --git a/tests/fixtures/ed25519.pem b/tests/fixtures/ed25519.pem new file mode 100644 index 00000000..fda14a84 --- /dev/null +++ b/tests/fixtures/ed25519.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIL2+9INLPNSLH3STzKQJ3Wen9R6uPbIYOIKA2574YQ4O +-----END PRIVATE KEY----- diff --git a/tests/fixtures/ed25519.pub b/tests/fixtures/ed25519.pub new file mode 100644 index 00000000..030a6894 --- /dev/null +++ b/tests/fixtures/ed25519.pub @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIICSDCCAfqgAwIBAgIUb+aBTX1CsjJ1iuMJ9kRudz/7qEcwBQYDK2VwMIGMMQsw +CQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExEjAQBgNVBAcMCVNvbWV3aGVyZTEnMCUG +A1UECgweQzJQQSBUZXN0IEludGVybWVkaWF0ZSBSb290IENBMRkwFwYDVQQLDBBG +T1IgVEVTVElOR19PTkxZMRgwFgYDVQQDDA9JbnRlcm1lZGlhdGUgQ0EwHhcNMjIw +NjEwMTg0NjQxWhcNMzAwODI2MTg0NjQxWjCBgDELMAkGA1UEBhMCVVMxCzAJBgNV +BAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUxHzAdBgNVBAoMFkMyUEEgVGVzdCBT +aWduaW5nIENlcnQxGTAXBgNVBAsMEEZPUiBURVNUSU5HX09OTFkxFDASBgNVBAMM +C0MyUEEgU2lnbmVyMCowBQYDK2VwAyEAMp5+0e83nNgQhdhBW8Rshkjy90sa1A9J +IzkItcDqCuKjeDB2MAwGA1UdEwEB/wQCMAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUH +AwQwDgYDVR0PAQH/BAQDAgbAMB0GA1UdDgQWBBTuLrYRqW4wu6yjIK1/iW8ud7dm +kTAfBgNVHSMEGDAWgBRXTAfC/JxQvRlk/bCbdPMDbsSfqTAFBgMrZXADQQB2R6vb +I+X8CTRC54j3NTvsUj454G1/bdzbiHVgl3n+ShOAJ85FJigE7Eoav7SeXeVnNjc8 +QZ1UrJGwgBBEP84G +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICKTCCAdugAwIBAgIUWqg4SaUDuPVy671PLGgfzcIsHMwwBQYDK2VwMHcxCzAJ +BgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdoZXJlMRowGAYD +VQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRFU1RJTkdfT05M +WTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2NDFaFw0zMDA4MjcxODQ2 +NDFaMIGMMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExEjAQBgNVBAcMCVNvbWV3 +aGVyZTEnMCUGA1UECgweQzJQQSBUZXN0IEludGVybWVkaWF0ZSBSb290IENBMRkw +FwYDVQQLDBBGT1IgVEVTVElOR19PTkxZMRgwFgYDVQQDDA9JbnRlcm1lZGlhdGUg +Q0EwKjAFBgMrZXADIQAkzdYBZtpdWfp03GLOb1lmIr/0COsfUa3b8ebt90DorqNj +MGEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFFdM +B8L8nFC9GWT9sJt08wNuxJ+pMB8GA1UdIwQYMBaAFF7mVgKz9Y4kTH4Mnumakchr +qU6MMAUGAytlcANBABXakz5vcdftU8Pbe8JDEcFSc+rTnopT8DXYwhtOd3Hvo7Zv +v0fTuwOYImxLdmu0J1u+ULxNqK+jRO7/jSncvwA= +-----END CERTIFICATE----- + diff --git a/tests/fixtures/ed25519_root.pub_key b/tests/fixtures/ed25519_root.pub_key new file mode 100644 index 00000000..0be7b20e --- /dev/null +++ b/tests/fixtures/ed25519_root.pub_key @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAY9SAr2rUfcN4owaosaNNclKmysul7SSnGRoUx7spQC4= +-----END PUBLIC KEY----- diff --git a/tests/fixtures/file_with_wrong_extension.xyz.jpg b/tests/fixtures/file_with_wrong_extension.xyz.jpg new file mode 100644 index 00000000..afd07a28 Binary files /dev/null and b/tests/fixtures/file_with_wrong_extension.xyz.jpg differ diff --git a/tests/fixtures/files-for-reading-tests/pdf-file.pdf b/tests/fixtures/files-for-reading-tests/pdf-file.pdf new file mode 100644 index 00000000..c9f33135 Binary files /dev/null and b/tests/fixtures/files-for-reading-tests/pdf-file.pdf differ diff --git a/tests/fixtures/files-for-signing-tests/pdf-file.pdf b/tests/fixtures/files-for-signing-tests/pdf-file.pdf new file mode 100644 index 00000000..c9f33135 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/pdf-file.pdf differ diff --git a/tests/fixtures/files-for-signing-tests/video1.mp4 b/tests/fixtures/files-for-signing-tests/video1.mp4 new file mode 100644 index 00000000..5802d5d2 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/video1.mp4 differ diff --git a/tests/fixtures/video1.mp4 b/tests/fixtures/video1.mp4 new file mode 100644 index 00000000..5802d5d2 Binary files /dev/null and b/tests/fixtures/video1.mp4 differ diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index ed6d5c4a..5a42e178 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -15,45 +15,60 @@ import io import json import unittest -from unittest.mock import mock_open, patch import ctypes import warnings from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import padding, ec +from cryptography.hazmat.primitives.asymmetric import ec 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 +import ctypes # Suppress deprecation warnings warnings.filterwarnings("ignore", category=DeprecationWarning) +from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version +from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable + PROJECT_PATH = os.getcwd() FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") DEFAULT_TEST_FILE_NAME = "C.jpg" +INGREDIENT_TEST_FILE_NAME = "A.jpg" DEFAULT_TEST_FILE = os.path.join(FIXTURES_DIR, DEFAULT_TEST_FILE_NAME) -INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, "A.jpg") +INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, INGREDIENT_TEST_FILE_NAME) ALTERNATIVE_INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, "cloud.jpg") class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.58.0", sdk_version()) + self.assertIn("0.59.1", sdk_version()) class TestReader(unittest.TestCase): def setUp(self): - # Use the fixtures_dir fixture to set up paths self.data_dir = FIXTURES_DIR self.testPath = DEFAULT_TEST_FILE + def test_can_retrieve_reader_supported_mimetypes(self): + result1 = Reader.get_supported_mime_types() + self.assertTrue(len(result1) > 0) + + # Cache hit + result2 = Reader.get_supported_mime_types() + self.assertTrue(len(result2) > 0) + + self.assertEqual(result1, result2) + def test_stream_read(self): with open(self.testPath, "rb") as file: reader = Reader("image/jpeg", file) json_data = reader.json() self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + def test_reader_detects_unsupported_mimetype_on_stream(self): + with open(self.testPath, "rb") as file: + with self.assertRaises(Error.NotSupported): + Reader("mimetype/does-not-exist", file) + def test_stream_read_and_parse(self): with open(self.testPath, "rb") as file: reader = Reader("image/jpeg", file) @@ -62,30 +77,36 @@ def test_stream_read_and_parse(self): self.assertEqual(title, DEFAULT_TEST_FILE_NAME) def test_stream_read_string_stream(self): - with Reader("image/jpeg", self.testPath) as reader: + with Reader(self.testPath) as reader: json_data = reader.json() self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) - def test_stream_read_string_stream_and_parse(self): - with Reader("image/jpeg", self.testPath) as reader: - manifest_store = json.loads(reader.json()) - title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] - self.assertEqual(title, DEFAULT_TEST_FILE_NAME) + def test_stream_read_string_stream_mimetype_not_supported(self): + with self.assertRaises(Error.NotSupported): + # xyz is actually an extension that is recognized + # as mimetype chemical/x-xyz + Reader(os.path.join(FIXTURES_DIR, "C.xyz")) - def test_reader_bad_format(self): + def test_stream_read_string_stream_mimetype_not_recognized(self): with self.assertRaises(Error.NotSupported): - with open(self.testPath, "rb") as file: - reader = Reader("badFormat", file) + Reader(os.path.join(FIXTURES_DIR, "C.test")) - def test_settings_trust(self): - # load_settings_file("tests/fixtures/settings.toml") - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) + def test_stream_read_string_stream(self): + with Reader("image/jpeg", self.testPath) as reader: json_data = reader.json() self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + def test_reader_detects_unsupported_mimetype_on_file(self): + with self.assertRaises(Error.NotSupported): + Reader("mimetype/does-not-exist", self.testPath) + + def test_stream_read_filepath_as_stream_and_parse(self): + with Reader("image/jpeg", self.testPath) as reader: + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, DEFAULT_TEST_FILE_NAME) + def test_reader_double_close(self): - """Test that multiple close calls are handled gracefully.""" with open(self.testPath, "rb") as file: reader = Reader("image/jpeg", file) reader.close() @@ -103,7 +124,6 @@ def test_reader_streams_with_nested(self): self.assertEqual(title, DEFAULT_TEST_FILE_NAME) def test_reader_close_cleanup(self): - """Test that close properly cleans up all resources.""" with open(self.testPath, "rb") as file: reader = Reader("image/jpeg", file) # Store references to internal objects @@ -144,7 +164,8 @@ def test_read_all_files(self): '.avi': 'video/x-msvideo', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', - '.wav': 'audio/wav' + '.wav': 'audio/wav', + '.pdf': 'application/pdf', } # Skip system files @@ -172,6 +193,7 @@ def test_read_all_files(self): with open(file_path, "rb") as file: reader = Reader(mime_type, file) json_data = reader.json() + reader.close() self.assertIsInstance(json_data, str) # Verify the manifest contains expected fields manifest = json.loads(json_data) @@ -181,7 +203,7 @@ def test_read_all_files(self): self.fail(f"Failed to read metadata from {filename}: {str(e)}") def test_read_all_files_using_extension(self): - """Test reading C2PA metadata from all files in the fixtures/files-for-reading-tests directory""" + """Test reading C2PA metadata from files in the fixtures/files-for-reading-tests directory""" reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") # Map of file extensions to MIME types @@ -216,6 +238,7 @@ def test_read_all_files_using_extension(self): parsed_extension = ext[1:] reader = Reader(parsed_extension, file) json_data = reader.json() + reader.close() self.assertIsInstance(json_data, str) # Verify the manifest contains expected fields manifest = json.loads(json_data) @@ -224,36 +247,34 @@ def test_read_all_files_using_extension(self): except Exception as e: self.fail(f"Failed to read metadata from {filename}: {str(e)}") - def test_read_cawg_data_file(self): - """Test reading C2PA metadata from C_with_CAWG_data.jpg file.""" - file_path = os.path.join(self.data_dir, "files-for-reading-tests", "C_with_CAWG_data.jpg") + # TODO: Unskip once fixed configuration to read data is clarified + # def test_read_cawg_data_file(self): + # """Test reading C2PA metadata from C_with_CAWG_data.jpg file.""" + # file_path = os.path.join(self.data_dir, "C_with_CAWG_data.jpg") - with open(file_path, "rb") as file: - reader = Reader("image/jpeg", file) - json_data = reader.json() - self.assertIsInstance(json_data, str) + # with open(file_path, "rb") as file: + # reader = Reader("image/jpeg", file) + # json_data = reader.json() + # self.assertIsInstance(json_data, str) - # Parse the JSON and verify specific fields - manifest_data = json.loads(json_data) + # # Parse the JSON and verify specific fields + # manifest_data = json.loads(json_data) - # Verify basic manifest structure - self.assertIn("manifests", manifest_data) - self.assertIn("active_manifest", manifest_data) + # # Verify basic manifest structure + # self.assertIn("manifests", manifest_data) + # self.assertIn("active_manifest", manifest_data) - # Get the active manifest - active_manifest_id = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_id] + # # Get the active manifest + # active_manifest_id = manifest_data["active_manifest"] + # active_manifest = manifest_data["manifests"][active_manifest_id] - # Verify manifest is not null or empty - assert active_manifest is not None, "Active manifest should not be null" - assert len(active_manifest) > 0, "Active manifest should not be empty" + # # Verify manifest is not null or empty + # assert active_manifest is not None, "Active manifest should not be null" + # assert len(active_manifest) > 0, "Active manifest should not be empty" -class TestBuilder(unittest.TestCase): +class TestBuilderWithSigner(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 @@ -263,7 +284,7 @@ def setUp(self): with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: self.key = key_file.read() - # Create a local Ps256 signer with certs and a timestamp server + # Create a local Es256 signer with certs and a timestamp server self.signer_info = C2paSignerInfo( alg=b"es256", sign_cert=self.certs, @@ -282,6 +303,7 @@ def setUp(self): "name": "python_test", "version": "0.0.1", }], + "claim_version": 1, "format": "image/jpeg", "title": "Python Test Image", "ingredients": [], @@ -306,7 +328,8 @@ def setUp(self): "name": "python_test", "version": "0.0.1", }], - "claim_version": 2, + # claim version 2 is the default + # "claim_version": 2, "format": "image/jpeg", "title": "Python Test Image V2", "ingredients": [], @@ -317,8 +340,7 @@ def setUp(self): "actions": [ { "action": "c2pa.created", - "parameters": { - } + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" } ] } @@ -341,12 +363,131 @@ def callback_signer_es256(data: bytes) -> bytes: return signature self.callback_signer_es256 = callback_signer_es256 + def test_can_retrieve_builder_supported_mimetypes(self): + result1 = Builder.get_supported_mime_types() + self.assertTrue(len(result1) > 0) + + # Cache-hit + result2 = Builder.get_supported_mime_types() + self.assertTrue(len(result2) > 0) + + self.assertEqual(result1, result2) + + def test_reserve_size(self): + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + signer.reserve_size() + + def test_signer_creation_error_alg(self): + signer_info = C2paSignerInfo( + alg=b"not-an-alg", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + with self.assertRaises(Error): + Signer.from_info(signer_info) + + def test_signer_from_callback_error_no_cert(self): + with self.assertRaises(Error): + Signer.from_callback( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=None, + tsa_url="http://timestamp.digicert.com" + ) + + def test_signer_from_callback_error_wrong_url(self): + with self.assertRaises(Error): + Signer.from_callback( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=None, + tsa_url="ftp://timestamp.digicert.com" + ) + def test_reserve_size_on_closed_signer(self): - self.signer.close() + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + signer.close() + self.assertTrue(signer.closed) + with self.assertRaises(Error): + signer.reserve_size() + + def test_signer_double_close(self): + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + signer.close() + self.assertTrue(signer.closed) + signer.close() + + def test_builder_detects_malformed_json(self): + with self.assertRaises(Error): + Builder("{this is not json}") + + def test_builder_does_not_allow_sign_after_close(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + builder.close() + with self.assertRaises(Error): + builder.sign(self.signer, "image/jpeg", file, output) + + def test_builder_does_not_allow_archiving_after_close(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + placeholder_stream = io.BytesIO(bytearray()) + builder.close() + with self.assertRaises(Error): + builder.to_archive(placeholder_stream) + + def test_builder_does_not_allow_changing_remote_url_after_close(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + builder.close() + with self.assertRaises(Error): + builder.set_remote_url("a-remote-url-that-is-not-important-in-this-tests") + + def test_builder_does_not_allow_adding_resource_after_close(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + placeholder_stream = io.BytesIO(bytearray()) + builder.close() + with self.assertRaises(Error): + builder.add_resource("a-remote-url-that-is-not-important-in-this-tests", placeholder_stream) + + def test_builder_double_close(self): + builder = Builder(self.manifestDefinition) + # First close + builder.close() + # Second close should not raise an exception + builder.close() + # Verify builder is closed with self.assertRaises(Error): - self.signer.reserve_size() + builder.set_no_embed() + + def test_streams_sign_recover_bytes_only(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + manifest_bytes = builder.sign(self.signer, "image/jpeg", file) + self.assertIsNotNone(manifest_bytes) - def test_streams_sign(self): + def test_streams_sign_with_es256_alg_v1_manifest(self): with open(self.testPath, "rb") as file: builder = Builder(self.manifestDefinition) output = io.BytesIO(bytearray()) @@ -356,6 +497,191 @@ def test_streams_sign(self): json_data = reader.json() self.assertIn("Python Test", json_data) self.assertNotIn("validation_status", json_data) + + # Write buffer to file + # output.seek(0) + # with open('/target_path', 'wb') as f: + # f.write(output.getbuffer()) + + output.close() + + def test_streams_sign_with_es256_alg_v1_manifest_to_existing_empty_file(self): + test_file_name = os.path.join(self.data_dir, "temp_data", "temp_signing.jpg") + # Ensure tmp directory exists + os.makedirs(os.path.dirname(test_file_name), exist_ok=True) + + # Ensure the target file exists before opening it in rb+ mode + with open(test_file_name, "wb") as f: + pass # Create empty file + + try: + with open(self.testPath, "rb") as source, open(test_file_name, "w+b") as target: + builder = Builder(self.manifestDefinition) + builder.sign(self.signer, "image/jpeg", source, target) + reader = Reader("image/jpeg", target) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + + finally: + # Clean up... + + if os.path.exists(test_file_name): + os.remove(test_file_name) + + # Also clean up the temp directory if it's empty + temp_dir = os.path.dirname(test_file_name) + if os.path.exists(temp_dir) and not os.listdir(temp_dir): + os.rmdir(temp_dir) + + def test_streams_sign_with_es256_alg_v1_manifest_to_new_dest_file(self): + test_file_name = os.path.join(self.data_dir, "temp_data", "temp_signing.jpg") + # Ensure tmp directory exists + os.makedirs(os.path.dirname(test_file_name), exist_ok=True) + + # A new target/destination file should be created during the test run + try: + with open(self.testPath, "rb") as source, open(test_file_name, "w+b") as target: + builder = Builder(self.manifestDefinition) + builder.sign(self.signer, "image/jpeg", source, target) + reader = Reader("image/jpeg", target) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + + finally: + # Clean up... + + if os.path.exists(test_file_name): + os.remove(test_file_name) + + # Also clean up the temp directory if it's empty + temp_dir = os.path.dirname(test_file_name) + if os.path.exists(temp_dir) and not os.listdir(temp_dir): + os.rmdir(temp_dir) + + def test_streams_sign_with_es256_alg(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + output.close() + + def test_streams_sign_with_es256_alg_2(self): + with open(self.testPath2, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + output.close() + + def test_sign_with_ed25519_alg(self): + with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ed25519.pem"), "rb") as key_file: + key = key_file.read() + + signer_info = C2paSignerInfo( + alg=b"ed25519", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + output.close() + + def test_sign_with_ed25519_alg_2(self): + with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ed25519.pem"), "rb") as key_file: + key = key_file.read() + + signer_info = C2paSignerInfo( + alg=b"ed25519", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + with open(self.testPath2, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + output.close() + + def test_sign_with_ps256_alg(self): + with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ps256.pem"), "rb") as key_file: + key = key_file.read() + + signer_info = C2paSignerInfo( + alg=b"ps256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + output.close() + + def test_sign_with_ps256_alg_2(self): + with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ps256.pem"), "rb") as key_file: + key = key_file.read() + + signer_info = C2paSignerInfo( + alg=b"ps256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + with open(self.testPath2, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) output.close() def test_archive_sign(self): @@ -374,6 +700,25 @@ def test_archive_sign(self): archive.close() output.close() + def test_archive_sign_with_added_ingredient(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder.from_archive(archive) + output = io.BytesIO(bytearray()) + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + archive.close() + output.close() + def test_remote_sign(self): with open(self.testPath, "rb") as file: builder = Builder(self.manifestDefinition) @@ -385,7 +730,7 @@ def test_remote_sign(self): # When set_no_embed() is used, no manifest should be embedded in the file # So reading from the file should fail with self.assertRaises(Error): - reader = Reader("image/jpeg", output) + Reader("image/jpeg", output) output.close() def test_remote_sign_using_returned_bytes(self): @@ -403,10 +748,25 @@ def test_remote_sign_using_returned_bytes(self): 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") - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + def test_remote_sign_using_returned_bytes_V2(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + builder.set_no_embed() + with io.BytesIO() as output_buffer: + manifest_data = builder.sign( + self.signer, "image/jpeg", file, output_buffer) + output_buffer.seek(0) + read_buffer = io.BytesIO(output_buffer.getvalue()) + + with Reader("image/jpeg", read_buffer, manifest_data) as reader: + manifest_data = reader.json() + self.assertIn("Python Test", manifest_data) + 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") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") # Map of file extensions to MIME types mime_types = { @@ -455,30 +815,20 @@ def test_sign_all_files(self): builder = Builder(self.manifestDefinition) output = io.BytesIO(bytearray()) builder.sign(self.signer, mime_type, file, output) + builder.close() output.seek(0) reader = Reader(mime_type, output) json_data = reader.json() self.assertIn("Python Test", json_data) self.assertNotIn("validation_status", json_data) + reader.close() output.close() except Error.NotSupported: continue except Exception as e: self.fail(f"Failed to sign {filename}: {str(e)}") - def test_builder_double_close(self): - """Test that multiple close calls are handled gracefully.""" - builder = Builder(self.manifestDefinition) - # First close - builder.close() - # Second close should not raise an exception - builder.close() - # Verify builder is closed - with self.assertRaises(Error): - builder.set_no_embed() - - def test_builder_add_ingredient_on_closed_builder(self): - """Test that exception is raised when trying to add ingredient after close.""" + def test_builder_no_added_ingredient_on_closed_builder(self): builder = Builder(self.manifestDefinition) builder.close() @@ -489,9 +839,6 @@ def test_builder_add_ingredient_on_closed_builder(self): builder.add_ingredient(ingredient_json, "image/jpeg", f) def test_builder_add_ingredient(self): - """Test Builder class operations with a real file.""" - # Test creating builder from JSON - builder = Builder.from_json(self.manifestDefinition) assert builder._builder is not None @@ -503,9 +850,6 @@ def test_builder_add_ingredient(self): builder.close() def test_builder_add_multiple_ingredients(self): - """Test Builder class operations with a real file.""" - # Test creating builder from JSON - builder = Builder.from_json(self.manifestDefinition) assert builder._builder is not None @@ -530,14 +874,57 @@ def test_builder_add_multiple_ingredients(self): builder.close() def test_builder_sign_with_ingredient(self): - """Test Builder class operations with a real file.""" - # Test creating builder from JSON + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Test adding ingredient + ingredient_json = '{ "title": "Test Ingredient" }' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify thumbnail for manifest is here + self.assertIn("thumbnail", active_manifest) + thumbnail_data = active_manifest["thumbnail"] + self.assertIn("format", thumbnail_data) + self.assertIn("identifier", thumbnail_data) + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient") + + builder.close() + def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): builder = Builder.from_json(self.manifestDefinition) assert builder._builder is not None + # The following removes the manifest's thumbnail + load_settings('{"builder": { "thumbnail": {"enabled": false}}}') + # Test adding ingredient - ingredient_json = '{"title": "Test Ingredient"}' + ingredient_json = '{ "title": "Test Ingredient" }' with open(self.testPath3, 'rb') as f: builder.add_ingredient(ingredient_json, "image/jpeg", f) @@ -558,6 +945,9 @@ def test_builder_sign_with_ingredient(self): self.assertIn(active_manifest_id, manifest_data["manifests"]) active_manifest = manifest_data["manifests"][active_manifest_id] + # There should be no thumbnail anymore here + self.assertNotIn("thumbnail", active_manifest) + # Verify ingredients array exists in active manifest self.assertIn("ingredients", active_manifest) self.assertIsInstance(active_manifest["ingredients"], list) @@ -569,10 +959,10 @@ def test_builder_sign_with_ingredient(self): builder.close() - def test_builder_sign_with_duplicate_ingredient(self): - """Test Builder class operations with a real file.""" - # Test creating builder from JSON + # Settings are global, so we reset to the default "true" here + load_settings('{"builder": { "thumbnail": {"enabled": true}}}') + def test_builder_sign_with_duplicate_ingredient(self): builder = Builder.from_json(self.manifestDefinition) assert builder._builder is not None @@ -619,8 +1009,6 @@ def test_builder_sign_with_duplicate_ingredient(self): 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 builder = Builder.from_json(self.manifestDefinition) assert builder._builder is not None @@ -661,8 +1049,6 @@ def test_builder_sign_with_ingredient_from_stream(self): builder.close() def test_builder_sign_with_multiple_ingredient(self): - """Test Builder class operations with multiple ingredients.""" - # Test creating builder from JSON builder = Builder.from_json(self.manifestDefinition) assert builder._builder is not None @@ -708,8 +1094,6 @@ def test_builder_sign_with_multiple_ingredient(self): builder.close() def test_builder_sign_with_multiple_ingredients_from_stream(self): - """Test Builder class operations with multiple ingredients using streams.""" - # Test creating builder from JSON builder = Builder.from_json(self.manifestDefinition) assert builder._builder is not None @@ -787,9 +1171,53 @@ def test_builder_set_remote_url_no_embed(self): # Return back to default settings load_settings(r'{"verify": { "remote_manifest_fetch": true} }') - def test_sign_file(self): + def test_sign_single(self): """Test signing a file using the sign_file method.""" - # Create a temporary directory for the test + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + + with open(self.testPath, "rb") as file: + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + # Read the signed file and verify the manifest + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + output.close() + + def test_sign_mp4_video_file_single(self): + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + + with open(os.path.join(FIXTURES_DIR, "video1.mp4"), "rb") as file: + builder.sign(self.signer, "video/mp4", file, output) + output.seek(0) + + # Read the signed file and verify the manifest + reader = Reader("video/mp4", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + output.close() + + def test_sign_mov_video_file_single(self): + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + + with open(os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), "rb") as file: + builder.sign(self.signer, "mov", file, output) + output.seek(0) + + # Read the signed file and verify the manifest + reader = Reader("mov", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + output.close() + + def test_sign_file_tmn_wip(self): temp_dir = tempfile.mkdtemp() try: # Create a temporary output file path @@ -797,10 +1225,10 @@ def test_sign_file(self): # Use the sign_file method builder = Builder(self.manifestDefinition) - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=self.signer + builder.sign_file( + self.testPath, + output_path, + self.signer ) # Verify the output file was created @@ -817,162 +1245,43 @@ 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.""" - + def test_sign_file_video(self): temp_dir = tempfile.mkdtemp() - try: - output_path = os.path.join(temp_dir, "signed_output.jpg") + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed_output.mp4") # Use the sign_file method builder = Builder(self.manifestDefinition) - - # Create signer with callback using create_signer function - signer = create_signer( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) - - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer + builder.sign_file( + os.path.join(FIXTURES_DIR, "video1.mp4"), + output_path, + self.signer ) # Verify the output file was created self.assertTrue(os.path.exists(output_path)) - # Verify results - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) - # Read the signed file and verify the manifest - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: - json_data = reader.json() - self.assertNotIn("validation_status", json_data) - - # Parse the JSON and verify the signature algorithm - manifest_data = json.loads(json_data) - active_manifest_id = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_id] - - self.assertIn("signature_info", active_manifest) - signature_info = active_manifest["signature_info"] - self.assertEqual(signature_info["alg"], self.callback_signer_alg) - - finally: - shutil.rmtree(temp_dir) - - def test_sign_file_callback_signer_managed(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 - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) - - # Verify results - self.assertTrue(os.path.exists(output_path)) - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) - - # Verify signed data can be read - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: + with open(output_path, "rb") as file: + reader = Reader("video/mp4", file) 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: + # Clean up the temporary directory shutil.rmtree(temp_dir) - def test_sign_file_callback_signer_managed_multiple_uses(self): - """Test that a signer can be used multiple times with context managers.""" - - temp_dir = tempfile.mkdtemp() - - try: - # Create builder and signer with context managers - with Builder(self.manifestDefinition) as builder, create_signer( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) as signer: - - # First signing operation - output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") - manifest_bytes_1 = builder.sign_file( - source_path=self.testPath, - dest_path=output_path_1, - signer=signer - ) - - # Verify first signing was successful - self.assertTrue(os.path.exists(output_path_1)) - self.assertIsInstance(manifest_bytes_1, bytes) - self.assertGreater(len(manifest_bytes_1), 0) - - # Second signing operation with the same signer - # This is to verify we don't free the signer or the callback too early - output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") - manifest_bytes_2 = builder.sign_file( - source_path=self.testPath, - dest_path=output_path_2, - signer=signer - ) - - # Verify second signing was successful - self.assertTrue(os.path.exists(output_path_2)) - self.assertIsInstance(manifest_bytes_2, bytes) - self.assertGreater(len(manifest_bytes_2), 0) - - # Verify both files contain valid C2PA data - for output_path in [output_path_1, output_path_2]: - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: - json_data = reader.json() - self.assertIn("Python Test", json_data) - 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) + def test_sign_file_format_manifest_bytes_embeddable(self): + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) - finally: - shutil.rmtree(temp_dir) + with open(self.testPath, "rb") as file: + manifest_bytes = builder.sign(self.signer, "image/jpeg", file, output) + res = format_embeddable("image/jpeg", manifest_bytes) + output.seek(0) + output.close() def test_builder_sign_file_callback_signer_from_callback(self): """Test signing a file using the sign_file method with Signer.from_callback.""" @@ -1076,233 +1385,178 @@ def test_builder_sign_file_callback_signer_from_callback_V2(self): finally: shutil.rmtree(temp_dir) - 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() + def test_builder_sign_with_native_ed25519_callback(self): + # Load Ed25519 private key (PEM) + ed25519_pem = os.path.join(FIXTURES_DIR, "ed25519.pem") + with open(ed25519_pem, "r") as f: + private_key_pem = f.read() + + # Callback here uses native function + def ed25519_callback(data: bytes) -> bytes: + return ed25519_sign(data, private_key_pem) + + # Load the certificate (PUB) + ed25519_pub = os.path.join(FIXTURES_DIR, "ed25519.pub") + with open(ed25519_pub, "r") as f: + certs_pem = f.read() + + # Create a Signer + # signer = create_signer( + # callback=ed25519_callback, + # alg=SigningAlg.ED25519, + # certs=certs_pem, + # tsa_url=None + # ) + signer = Signer.from_callback( + callback=ed25519_callback, + alg=SigningAlg.ED25519, + certs=certs_pem, + tsa_url=None + ) - try: - # Create a temporary output file path - output_path = os.path.join(temp_dir, "signed_output_callback.jpg") + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + builder.close() + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + reader.close() + output.close() - # Create signer with callback - signer = Signer.from_callback( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) + def test_signing_manifest_v2(self): + """Test signing and reading a V2 manifest. + V2 manifests have a slightly different structure. + """ + with open(self.testPath, "rb") as file: + # Create a builder with the V2 manifest definition using context manager + with Builder(self.manifestDefinitionV2) as builder: + output = io.BytesIO(bytearray()) - # Overload that returns a JSON string - result_json = sign_file( - self.testPath, - output_path, - self.manifestDefinition, - signer, - False - ) + # Sign as usual... + builder.sign(self.signer, "image/jpeg", file, output) - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) + output.seek(0) - # Verify the result is JSON - self.assertIsInstance(result_json, str) - self.assertGreater(len(result_json), 0) + # Read the signed file and verify the manifest using context manager + with Reader("image/jpeg", output) as reader: + json_data = reader.json() - manifest_data = json.loads(result_json) - self.assertIn("manifests", manifest_data) - self.assertIn("active_manifest", manifest_data) + # Basic verification of the manifest + self.assertIn("Python Test Image V2", json_data) + self.assertNotIn("validation_status", json_data) - output_path_bytes = os.path.join(temp_dir, "signed_output_callback_bytes.jpg") - # Overload that returns bytes - result_bytes = sign_file( - self.testPath, - output_path_bytes, - self.manifestDefinition, - signer, - True + output.close() + + def test_builder_does_not_sign_unsupported_format(self): + with open(self.testPath, "rb") as file: + with Builder(self.manifestDefinitionV2) as builder: + output = io.BytesIO(bytearray()) + with self.assertRaises(Error.NotSupported): + builder.sign(self.signer, "mimetype/not-supported", file, output) + + def test_sign_file_mp4_video(self): + temp_dir = tempfile.mkdtemp() + try: + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed_output.mp4") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + builder.sign_file( + os.path.join(FIXTURES_DIR, "video1.mp4"), + output_path, + self.signer ) # Verify the output file was created - self.assertTrue(os.path.exists(output_path_bytes)) - - # Verify the result is bytes - self.assertIsInstance(result_bytes, bytes) - self.assertGreater(len(result_bytes), 0) + self.assertTrue(os.path.exists(output_path)) - # Read the signed file and verify the manifest contains expected content + # Read the signed file and verify the manifest 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) + reader = Reader("video/mp4", file) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) finally: + # Clean up the temporary directory shutil.rmtree(temp_dir) - def test_sign_file_overloads(self): - """Test that the overloaded sign_file function works with both parameter types.""" - # Create a temporary directory for the test + def test_sign_file_mov_video(self): temp_dir = tempfile.mkdtemp() try: - # Test with C2paSignerInfo - output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") - - # Load test certificates and key - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - key = key_file.read() - - # Create signer info - signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - - # Test with C2paSignerInfo parameter - JSON return - result_1 = sign_file( - self.testPath, - output_path_1, - self.manifestDefinition, - signer_info, - False - ) - - self.assertIsInstance(result_1, str) - self.assertTrue(os.path.exists(output_path_1)) - - # Test with C2paSignerInfo parameter - bytes return - output_path_1_bytes = os.path.join(temp_dir, "signed_output_1_bytes.jpg") - result_1_bytes = sign_file( - self.testPath, - output_path_1_bytes, - self.manifestDefinition, - signer_info, - True - ) - - self.assertIsInstance(result_1_bytes, bytes) - self.assertTrue(os.path.exists(output_path_1_bytes)) - - # Test with Signer object - output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") - - # Create a signer from the signer info - signer = Signer.from_info(signer_info) - - # Test with Signer parameter - JSON return - result_2 = sign_file( - self.testPath, - output_path_2, - self.manifestDefinition, - signer, - False - ) - - self.assertIsInstance(result_2, str) - self.assertTrue(os.path.exists(output_path_2)) + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed-C-recorded-as-mov.mov") - # 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 + # Use the sign_file method + builder = Builder(self.manifestDefinition) + manifest_bytes = builder.sign_file( + os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), + output_path, + self.signer ) - self.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) + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) - self.assertIn("manifests", manifest_1) - self.assertIn("manifests", manifest_2) - self.assertIn("active_manifest", manifest_1) - self.assertIn("active_manifest", manifest_2) + # Read the signed file and verify the manifest + with open(output_path, "rb") as file: + reader = Reader("mov", file) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) - # Both bytes results should be non-empty - self.assertGreater(len(result_1_bytes), 0) - self.assertGreater(len(result_2_bytes), 0) + # Verify also signed file using manifest bytes + with Reader("mov", output_path, manifest_bytes) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) finally: # Clean up the temporary directory shutil.rmtree(temp_dir) - def test_sign_file_callback_signer_reports_error(self): - """Test signing a file using the sign_file method with a callback that reports an error.""" - + def test_sign_file_mov_video_V2(self): temp_dir = tempfile.mkdtemp() - try: - output_path = os.path.join(temp_dir, "signed_output.jpg") + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed-C-recorded-as-mov.mov") # Use the sign_file method - builder = Builder(self.manifestDefinition) - - # Define a callback that always returns None to simulate an error - def error_callback_signer(data: bytes) -> bytes: - # Could alternatively also raise an error - # raise RuntimeError("Simulated signing error") - return None - - # Create signer with error callback using create_signer function - signer = create_signer( - callback=error_callback_signer, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" + builder = Builder(self.manifestDefinitionV2) + manifest_bytes = builder.sign_file( + os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), + output_path, + self.signer ) - # The signing operation should fail due to the error callback - with self.assertRaises(Error): - builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) - - # Verify the output file stays empty, - # as no data should have been written + # Verify the output file was created self.assertTrue(os.path.exists(output_path)) - self.assertEqual(os.path.getsize(output_path), 0) - - finally: - shutil.rmtree(temp_dir) - - def test_signing_manifest_v2(self): - """Test signing and reading a V2 manifest. - V2 manifests have a slightly different structure. - """ - with open(self.testPath, "rb") as file: - # Create a builder with the V2 manifest definition using context manager - with Builder(self.manifestDefinitionV2) as builder: - output = io.BytesIO(bytearray()) - - # Sign as usual... - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) + # Read the signed file and verify the manifest + with open(output_path, "rb") as file: + reader = Reader("mov", file) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) - # Read the signed file and verify the manifest using context manager - with Reader("image/jpeg", output) as reader: - json_data = reader.json() + # Verify also signed file using manifest bytes + with Reader("mov", output_path, manifest_bytes) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) - # Basic verification of the manifest - self.assertIn("Python Test Image V2", json_data) - self.assertNotIn("validation_status", json_data) + finally: + # Clean up the temporary directory + shutil.rmtree(temp_dir) - output.close() class TestStream(unittest.TestCase): def setUp(self): - # Create a temporary file for testing self.temp_file = io.BytesIO() self.test_data = b"Hello, World!" self.temp_file.write(self.test_data) @@ -1312,19 +1566,16 @@ def tearDown(self): self.temp_file.close() def test_stream_initialization(self): - """Test proper initialization of Stream class.""" stream = Stream(self.temp_file) self.assertTrue(stream.initialized) self.assertFalse(stream.closed) stream.close() def test_stream_initialization_with_invalid_object(self): - """Test initialization with an invalid object.""" with self.assertRaises(TypeError): Stream("not a file-like object") def test_stream_read(self): - """Test reading from a stream.""" stream = Stream(self.temp_file) try: # Create a buffer to read into @@ -1338,7 +1589,6 @@ def test_stream_read(self): stream.close() def test_stream_write(self): - """Test writing to a stream.""" output = io.BytesIO() stream = Stream(output) try: @@ -1355,7 +1605,6 @@ def test_stream_write(self): stream.close() def test_stream_seek(self): - """Test seeking in a stream.""" stream = Stream(self.temp_file) try: # Seek to position 7 (after "Hello, ") @@ -1369,7 +1618,6 @@ def test_stream_seek(self): stream.close() def test_stream_flush(self): - """Test flushing a stream.""" output = io.BytesIO() stream = Stream(output) try: @@ -1384,14 +1632,12 @@ def test_stream_flush(self): stream.close() def test_stream_context_manager(self): - """Test stream as a context manager.""" with Stream(self.temp_file) as stream: self.assertTrue(stream.initialized) self.assertFalse(stream.closed) self.assertTrue(stream.closed) def test_stream_double_close(self): - """Test that multiple close calls are handled gracefully.""" stream = Stream(self.temp_file) stream.close() # Second close should not raise an exception @@ -1399,7 +1645,6 @@ def test_stream_double_close(self): self.assertTrue(stream.closed) def test_stream_read_after_close(self): - """Test reading from a closed stream.""" stream = Stream(self.temp_file) # Store callbacks before closing read_cb = stream._read_cb @@ -1409,7 +1654,6 @@ def test_stream_read_after_close(self): self.assertEqual(read_cb(None, buffer, 13), -1) def test_stream_write_after_close(self): - """Test writing to a closed stream.""" stream = Stream(self.temp_file) # Store callbacks before closing write_cb = stream._write_cb @@ -1420,7 +1664,6 @@ def test_stream_write_after_close(self): self.assertEqual(write_cb(None, buffer, len(test_data)), -1) def test_stream_seek_after_close(self): - """Test seeking in a closed stream.""" stream = Stream(self.temp_file) # Store callbacks before closing seek_cb = stream._seek_cb @@ -1429,7 +1672,6 @@ def test_stream_seek_after_close(self): self.assertEqual(seek_cb(None, 5, 0), -1) def test_stream_flush_after_close(self): - """Test flushing a closed stream.""" stream = Stream(self.temp_file) # Store callbacks before closing flush_cb = stream._flush_cb @@ -1444,23 +1686,82 @@ def setUp(self): warnings.filterwarnings("ignore", message="The read_file function is deprecated") warnings.filterwarnings("ignore", message="The sign_file function is deprecated") warnings.filterwarnings("ignore", message="The read_ingredient_file function is deprecated") + warnings.filterwarnings("ignore", message="The create_signer function is deprecated") + warnings.filterwarnings("ignore", message="The create_signer_from_info function is deprecated") self.data_dir = FIXTURES_DIR self.testPath = DEFAULT_TEST_FILE + self.testPath2 = INGREDIENT_TEST_FILE + self.testPath3 = os.path.join(self.data_dir, "A_thumbnail.jpg") - # Create temp directory for tests - self.temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(self.temp_data_dir, exist_ok=True) - - def tearDown(self): - """Clean up temporary files after each test.""" - if os.path.exists(self.temp_data_dir): - shutil.rmtree(self.temp_data_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() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + self.key = key_file.read() - def test_invalid_settings_str(self): - """Test loading a malformed settings string.""" - with self.assertRaises(Error): - load_settings(r'{"verify": { "remote_manifest_fetch": false }') + # Create a local ES256 signer with certs and a timestamp server + self.signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + self.signer = Signer.from_info(self.signer_info) + + # Define a manifest as a dictionary + self.manifestDefinition = { + "claim_generator": "python_internals_test", + "claim_generator_info": [{ + "name": "python_internals_test", + "version": "0.0.1", + }], + "claim_version": 1, + "format": "image/jpeg", + "title": "Python Test Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.opened" + } + ] + } + } + ] + } + + # Create temp directory for tests + self.temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(self.temp_data_dir, exist_ok=True) + + # Define an example ES256 callback signer + self.callback_signer_alg = "Es256" + def callback_signer_es256(data: bytes) -> bytes: + private_key = serialization.load_pem_private_key( + self.key, + password=None, + backend=default_backend() + ) + signature = private_key.sign( + data, + ec.ECDSA(hashes.SHA256()) + ) + return signature + self.callback_signer_es256 = callback_signer_es256 + + def tearDown(self): + """Clean up temporary files after each test.""" + if os.path.exists(self.temp_data_dir): + shutil.rmtree(self.temp_data_dir) + + def test_invalid_settings_str(self): + """Test loading a malformed settings string.""" + with self.assertRaises(Error): + load_settings(r'{"verify": { "remote_manifest_fetch": false }') def test_read_ingredient_file(self): """Test reading a C2PA ingredient from a file.""" @@ -1476,6 +1777,59 @@ def test_read_ingredient_file(self): self.assertEqual(ingredient_data["format"], "image/jpeg") self.assertIn("thumbnail", ingredient_data) + def test_read_ingredient_file_who_has_no_manifest(self): + """Test reading a C2PA ingredient from a file.""" + # Test reading ingredient from file with data_dir + temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(temp_data_dir, exist_ok=True) + + load_settings('{"builder": { "thumbnail": {"enabled": false}}}') + + ingredient_json_with_dir = read_ingredient_file(self.testPath2, temp_data_dir) + + # Verify some fields + ingredient_data = json.loads(ingredient_json_with_dir) + self.assertEqual(ingredient_data["title"], INGREDIENT_TEST_FILE_NAME) + self.assertEqual(ingredient_data["format"], "image/jpeg") + self.assertIn("thumbnail", ingredient_data) + + def test_compare_read_ingredient_file_with_builder_added_ingredient(self): + """Test reading a C2PA ingredient from a file.""" + # Test reading ingredient from file with data_dir + temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(temp_data_dir, exist_ok=True) + + ingredient_json_with_dir = read_ingredient_file(self.testPath2, temp_data_dir) + + # Ingredient fields from read_ingredient_file + ingredient_data = json.loads(ingredient_json_with_dir) + + # Compare with ingredient added by Builder + builder = Builder.from_json(self.manifestDefinition) + # Only the title is needed (filename), since title not extracted or guessed from filename + ingredient_json = '{ "title" : "A.jpg" }' + with open(self.testPath2, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + # Get ingredient fields from signed manifest + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + only_ingredient = active_manifest["ingredients"][0] + + self.assertEqual(ingredient_data["title"], only_ingredient["title"]) + self.assertEqual(ingredient_data["format"], only_ingredient["format"]) + self.assertEqual(ingredient_data["document_id"], only_ingredient["document_id"]) + self.assertEqual(ingredient_data["instance_id"], only_ingredient["instance_id"]) + self.assertEqual(ingredient_data["relationship"], only_ingredient["relationship"]) + def test_read_file(self): """Test reading a C2PA ingredient from a file.""" temp_data_dir = os.path.join(self.data_dir, "temp_data") @@ -1493,7 +1847,7 @@ def test_read_file(self): self.assertIn("manifests", file_data) self.assertIn(expected_manifest_id, file_data["manifests"]) - def test_sign_file_alg_as_enum(self): + def test_sign_file(self): """Test signing a file with C2PA manifest.""" # Set up test paths temp_data_dir = os.path.join(self.data_dir, "temp_data") @@ -1508,7 +1862,7 @@ def test_sign_file_alg_as_enum(self): # Create signer info signer_info = C2paSignerInfo( - alg=SigningAlg.ES256, + alg=b"es256", sign_cert=certs, private_key=key, ta_url=b"http://timestamp.digicert.com" @@ -1521,6 +1875,8 @@ def test_sign_file_alg_as_enum(self): "name": "python_internals_test", "version": "0.0.1", }], + # Claim version has become mandatory for signing v1 claims + "claim_version": 1, "format": "image/jpeg", "title": "Python Test Signed Image", "ingredients": [], @@ -1542,8 +1898,7 @@ def test_sign_file_alg_as_enum(self): manifest_json = json.dumps(manifest) try: - # Sign the file - result_json = sign_file( + sign_file( self.testPath, output_path, manifest_json, @@ -1555,7 +1910,7 @@ def test_sign_file_alg_as_enum(self): if os.path.exists(output_path): os.remove(output_path) - def test_sign_file_alg_as_bytes(self): + def test_sign_file_does_not_exist_errors(self): """Test signing a file with C2PA manifest.""" # Set up test paths temp_data_dir = os.path.join(self.data_dir, "temp_data") @@ -1583,6 +1938,8 @@ def test_sign_file_alg_as_bytes(self): "name": "python_internals_test", "version": "0.0.1", }], + # Claim version has become mandatory for signing v1 claims + "claim_version": 1, "format": "image/jpeg", "title": "Python Test Signed Image", "ingredients": [], @@ -1604,18 +1961,494 @@ def test_sign_file_alg_as_bytes(self): manifest_json = json.dumps(manifest) try: - # Sign the file + with self.assertRaises(Error): + sign_file( + "this-file-does-not-exist", + output_path, + manifest_json, + signer_info + ) + + finally: + # Clean up + if os.path.exists(output_path): + os.remove(output_path) + + def test_builder_sign_with_ingredient_from_file(self): + """Test Builder class operations with an ingredient added from file path.""" + + builder = Builder.from_json(self.manifestDefinition) + + # Test adding ingredient from file path + ingredient_json = '{"title": "Test Ingredient From File"}' + # Suppress the specific deprecation warning for this test, as this is a legacy method + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", self.testPath3) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient From File") + + builder.close() + + def test_builder_add_ingredient_from_file_path(self): + """Test Builder class add_ingredient_from_file_path method.""" + + # Suppress the specific deprecation warning for this test, as this is a legacy method + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + builder = Builder.from_json(self.manifestDefinition) + + # Test adding ingredient from file path + ingredient_json = '{"test": "ingredient_from_file_path"}' + builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", self.testPath) + + builder.close() + + def test_builder_add_ingredient_from_file_path(self): + """Test Builder class add_ingredient_from_file_path method.""" + + # Suppress the specific deprecation warning for this test, as this is a legacy method + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + builder = Builder.from_json(self.manifestDefinition) + + # Test adding ingredient from file path + ingredient_json = '{"test": "ingredient_from_file_path"}' + + with self.assertRaises(Error.FileNotFound): + builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", "this-file-path-does-not-exist") + + def test_sign_file_using_callback_signer_overloads(self): + """Test signing a file using the sign_file function with a Signer object.""" + # Create a temporary directory for the test + temp_dir = tempfile.mkdtemp() + + try: + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed_output_callback.jpg") + + # Create signer with callback + signer = Signer.from_callback( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + # Overload that returns a JSON string result_json = sign_file( self.testPath, output_path, - manifest_json, - signer_info + self.manifestDefinition, + signer, + False + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) + + # Verify the result is JSON + self.assertIsInstance(result_json, str) + self.assertGreater(len(result_json), 0) + + manifest_data = json.loads(result_json) + self.assertIn("manifests", manifest_data) + self.assertIn("active_manifest", manifest_data) + + output_path_bytes = os.path.join(temp_dir, "signed_output_callback_bytes.jpg") + # Overload that returns bytes + result_bytes = sign_file( + self.testPath, + output_path_bytes, + self.manifestDefinition, + signer, + True ) + # Verify the output file was created + self.assertTrue(os.path.exists(output_path_bytes)) + + # Verify the result is bytes + self.assertIsInstance(result_bytes, bytes) + self.assertGreater(len(result_bytes), 0) + + # Read the signed file and verify the manifest contains expected content + with open(output_path, "rb") as file: + reader = Reader("image/jpeg", file) + file_manifest_json = reader.json() + self.assertIn("Python Test", file_manifest_json) + self.assertNotIn("validation_status", file_manifest_json) + finally: - # Clean up - if os.path.exists(output_path): - os.remove(output_path) + shutil.rmtree(temp_dir) + + def test_sign_file_overloads(self): + """Test that the overloaded sign_file function works with both parameter types.""" + # Create a temporary directory for the test + temp_dir = tempfile.mkdtemp() + try: + # Test with C2paSignerInfo + output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") + + # Load test certificates and key + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + key = key_file.read() + + # Create signer info + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + + # Test with C2paSignerInfo parameter - JSON return + result_1 = sign_file( + self.testPath, + output_path_1, + self.manifestDefinition, + signer_info, + False + ) + + self.assertIsInstance(result_1, str) + self.assertTrue(os.path.exists(output_path_1)) + + # Test with C2paSignerInfo parameter - bytes return + output_path_1_bytes = os.path.join(temp_dir, "signed_output_1_bytes.jpg") + result_1_bytes = sign_file( + self.testPath, + output_path_1_bytes, + self.manifestDefinition, + signer_info, + True + ) + + self.assertIsInstance(result_1_bytes, bytes) + self.assertTrue(os.path.exists(output_path_1_bytes)) + + # Test with Signer object + output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") + + # Create a signer from the signer info + signer = Signer.from_info(signer_info) + + # Test with Signer parameter - JSON return + result_2 = sign_file( + self.testPath, + output_path_2, + self.manifestDefinition, + signer, + False + ) + + self.assertIsInstance(result_2, str) + self.assertTrue(os.path.exists(output_path_2)) + + # Test with Signer parameter - bytes return + output_path_2_bytes = os.path.join(temp_dir, "signed_output_2_bytes.jpg") + result_2_bytes = sign_file( + self.testPath, + output_path_2_bytes, + self.manifestDefinition, + signer, + True + ) + + self.assertIsInstance(result_2_bytes, bytes) + self.assertTrue(os.path.exists(output_path_2_bytes)) + + # Both JSON results should be similar (same manifest structure) + manifest_1 = json.loads(result_1) + manifest_2 = json.loads(result_2) + + self.assertIn("manifests", manifest_1) + self.assertIn("manifests", manifest_2) + self.assertIn("active_manifest", manifest_1) + self.assertIn("active_manifest", manifest_2) + + # Both bytes results should be non-empty + self.assertGreater(len(result_1_bytes), 0) + self.assertGreater(len(result_2_bytes), 0) + + finally: + # Clean up the temporary directory + shutil.rmtree(temp_dir) + + def test_sign_file_callback_signer_reports_error(self): + """Test signing a file using the sign_file method with a callback that reports an error.""" + + temp_dir = tempfile.mkdtemp() + + try: + output_path = os.path.join(temp_dir, "signed_output.jpg") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + + # Define a callback that always returns None to simulate an error + def error_callback_signer(data: bytes) -> bytes: + # Could alternatively also raise an error + # raise RuntimeError("Simulated signing error") + return None + + # Create signer with error callback using create_signer function + signer = create_signer( + callback=error_callback_signer, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + # The signing operation should fail due to the error callback + with self.assertRaises(Error): + builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + finally: + shutil.rmtree(temp_dir) + + def test_sign_file_callback_signer(self): + """Test signing a file using the sign_file method.""" + + temp_dir = tempfile.mkdtemp() + + try: + output_path = os.path.join(temp_dir, "signed_output.jpg") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + + # Create signer with callback using create_signer function + signer = create_signer( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + manifest_bytes = builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) + + # Verify results + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) + + # Read the signed file and verify the manifest + with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: + json_data = reader.json() + self.assertNotIn("validation_status", json_data) + + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + + finally: + shutil.rmtree(temp_dir) + + def test_sign_file_callback_signer(self): + """Test signing a file using the sign_file method.""" + + temp_dir = tempfile.mkdtemp() + + try: + output_path = os.path.join(temp_dir, "signed_output.jpg") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + + # Create signer with callback using create_signer function + signer = create_signer( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + manifest_bytes = builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) + + # Verify results + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) + + # Read the signed file and verify the manifest + with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: + json_data = reader.json() + self.assertNotIn("validation_status", json_data) + + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + + finally: + shutil.rmtree(temp_dir) + + def test_sign_file_callback_signer_managed_single(self): + """Test signing a file using the sign_file method with context managers.""" + + temp_dir = tempfile.mkdtemp() + + try: + output_path = os.path.join(temp_dir, "signed_output_managed.jpg") + + # Create builder and signer with context managers + with Builder(self.manifestDefinition) as builder, create_signer( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) as signer: + + manifest_bytes = builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + # Verify results + self.assertTrue(os.path.exists(output_path)) + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) + + # Verify signed data can be read + with open(output_path, "rb") as file: + with Reader("image/jpeg", file) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify the signature_info contains the correct algorithm + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + + finally: + shutil.rmtree(temp_dir) + + def test_sign_file_callback_signer_managed_multiple_uses(self): + """Test that a signer can be used multiple times with context managers.""" + + temp_dir = tempfile.mkdtemp() + + try: + # Create builder and signer with context managers + with Builder(self.manifestDefinition) as builder, create_signer( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) as signer: + + # First signing operation + output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") + manifest_bytes_1 = builder.sign_file( + source_path=self.testPath, + dest_path=output_path_1, + signer=signer + ) + + # Verify first signing was successful + self.assertTrue(os.path.exists(output_path_1)) + self.assertIsInstance(manifest_bytes_1, bytes) + self.assertGreater(len(manifest_bytes_1), 0) + + # Second signing operation with the same signer + # This is to verify we don't free the signer or the callback too early + output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") + manifest_bytes_2 = builder.sign_file( + source_path=self.testPath, + dest_path=output_path_2, + signer=signer + ) + + # Verify second signing was successful + self.assertTrue(os.path.exists(output_path_2)) + self.assertIsInstance(manifest_bytes_2, bytes) + self.assertGreater(len(manifest_bytes_2), 0) + + # Verify both files contain valid C2PA data + for output_path in [output_path_1, output_path_2]: + with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify the signature_info contains the correct algorithm + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + + finally: + shutil.rmtree(temp_dir) + + def test_create_signer_from_info(self): + """Create a Signer using the create_signer_from_info function""" + signer = create_signer_from_info(self.signer_info) + self.assertIsNotNone(signer) if __name__ == '__main__': diff --git a/tests/test_unit_tests_threaded.py b/tests/test_unit_tests_threaded.py index f1b61331..c5127404 100644 --- a/tests/test_unit_tests_threaded.py +++ b/tests/test_unit_tests_threaded.py @@ -103,7 +103,8 @@ def test_read_all_files(self): '.avi': 'video/x-msvideo', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', - '.wav': 'audio/wav' + '.wav': 'audio/wav', + '.pdf': 'application/pdf', } # Skip system files @@ -174,7 +175,7 @@ def setUp(self): with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: self.key = key_file.read() - # Create a local Ps256 signer with certs and a timestamp server + # Create a local Es256 signer with certs and a timestamp server self.signer_info = C2paSignerInfo( alg=b"es256", sign_cert=self.certs, @@ -197,6 +198,7 @@ def setUp(self): "name": "python_test", "version": "0.0.1", }], + "claim_version": 1, "format": "image/jpeg", "title": "Python Test Image", "ingredients": [], @@ -224,6 +226,7 @@ def setUp(self): "name": "python_test_1", "version": "0.0.1", }], + "claim_version": 1, "format": "image/jpeg", "title": "Python Test Image 1", "ingredients": [], @@ -251,6 +254,7 @@ def setUp(self): "name": "python_test_2", "version": "0.0.1", }], + "claim_version": 1, "format": "image/jpeg", "title": "Python Test Image 2", "ingredients": [], @@ -1466,93 +1470,6 @@ async def run_async_tests(): # Verify all readers completed self.assertEqual(active_readers, 0, "Not all readers completed") - def test_builder_sign_with_multiple_ingredient(self): - """Test Builder class operations with multiple ingredients added in parallel threads.""" - # Test creating builder from JSON - builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None - - # Define paths for test files - cloud_path = os.path.join(self.data_dir, "cloud.jpg") - - # Thread synchronization - ingredient_added = threading.Event() - add_errors = [] - add_lock = threading.Lock() - - def add_ingredient(ingredient_json, file_path, thread_id): - try: - with open(file_path, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - with add_lock: - add_errors.append(None) # Success case - except Exception as e: - with add_lock: - add_errors.append(f"Thread {thread_id} error: {str(e)}") - finally: - ingredient_added.set() - - # Create and start two threads for parallel ingredient addition - thread1 = threading.Thread( - target=add_ingredient, - args=('{"title": "Test Ingredient 1"}', self.testPath3, 1) - ) - thread2 = threading.Thread( - target=add_ingredient, - args=('{"title": "Test Ingredient 2"}', cloud_path, 2) - ) - - # Start both threads - thread1.start() - thread2.start() - - # Wait for both threads to complete - thread1.join() - thread2.join() - - # Check for errors during ingredient addition - if any(error for error in add_errors if error is not None): - self.fail( - "\n".join( - error for error in add_errors if error is not None)) - - # Verify both ingredients were added successfully - self.assertEqual( - len(add_errors), - 2, - "Both threads should have completed") - - # Now sign the manifest with the added ingredients - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertEqual(len(active_manifest["ingredients"]), 2) - - # Verify both ingredients exist in the array (order doesn't matter) - ingredient_titles = [ing["title"] - for ing in active_manifest["ingredients"]] - self.assertIn("Test Ingredient 1", ingredient_titles) - self.assertIn("Test Ingredient 2", ingredient_titles) - - builder.close() - def test_builder_sign_with_multiple_ingredients_from_stream(self): """Test Builder class operations with multiple ingredients using streams.""" # Test creating builder from JSON @@ -1645,248 +1562,6 @@ def add_ingredient_from_stream(ingredient_json, file_path, thread_id): builder.close() - def test_builder_sign_with_multiple_ingredient_random(self): - """Test Builder class operations with 5 random ingredients added in parallel threads.""" - # Test creating builder from JSON - builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None - - # Get list of files from files-for-reading-tests directory - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") - all_files = [ - f for f in os.listdir(reading_dir) if os.path.isfile( - os.path.join( - reading_dir, f))] - - # Select 5 random files - random.seed(42) # For reproducible testing - selected_files = random.sample(all_files, 5) - - # Thread synchronization - add_errors = [] - add_lock = threading.Lock() - completed_threads = 0 - completion_lock = threading.Lock() - - def add_ingredient(file_name, thread_id): - nonlocal completed_threads - try: - file_path = os.path.join(reading_dir, file_name) - ingredient_json = json.dumps({ - "title": f"Test Ingredient Thread {thread_id} - {file_name}" - }) - - with open(file_path, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - with add_lock: - add_errors.append(None) # Success case - except Exception as e: - with add_lock: - add_errors.append( - f"Thread {thread_id} error with file {file_name}: { - str(e)}") - finally: - with completion_lock: - completed_threads += 1 - - # Create and start 5 threads for parallel ingredient addition - threads = [] - for i, file_name in enumerate(selected_files, 1): - thread = threading.Thread( - target=add_ingredient, - args=(file_name, i) - ) - threads.append(thread) - thread.start() - - # Wait for all threads to complete - for thread in threads: - thread.join() - - # Check for errors during ingredient addition - if any(error for error in add_errors if error is not None): - self.fail( - "\n".join( - error for error in add_errors if error is not None)) - - # Verify all ingredients were added successfully - self.assertEqual( - completed_threads, - 5, - "All 5 threads should have completed") - self.assertEqual( - len(add_errors), - 5, - "All 5 threads should have completed without errors") - - # Now sign the manifest with the added ingredients - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertEqual(len(active_manifest["ingredients"]), 5) - - # Verify all ingredients exist in the array with correct thread IDs - ingredient_titles = [ing["title"] - for ing in active_manifest["ingredients"]] - for i in range(1, 6): - # Find an ingredient with this thread ID - thread_ingredients = [ - title for title in ingredient_titles if f"Thread {i}" in title] - self.assertEqual( - len(thread_ingredients), - 1, - f"Should find exactly one ingredient for thread {i}") - - # Verify the ingredient title contains the file name - thread_ingredient = thread_ingredients[0] - file_name = selected_files[i - 1] - self.assertIn( - file_name, - thread_ingredient, - f"Ingredient for thread {i} should contain its file name") - - builder.close() - - def test_builder_sign_with_multiple_ingredient_async_random(self): - """Test Builder class operations with 5 random ingredients added in parallel using asyncio.""" - # Test creating builder from JSON - builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None - - # Get list of files from files-for-reading-tests directory - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") - all_files = [ - f for f in os.listdir(reading_dir) if os.path.isfile( - os.path.join( - reading_dir, f))] - - # Select 5 random files - random.seed(42) # For reproducible testing - selected_files = random.sample(all_files, 5) - - # Async synchronization - add_errors = [] - add_lock = asyncio.Lock() - completed_tasks = 0 - completion_lock = asyncio.Lock() - # Barrier to synchronize task starts - start_barrier = asyncio.Barrier(5) - - async def add_ingredient(file_name, task_id): - nonlocal completed_tasks - try: - # Wait for all tasks to be ready - await start_barrier.wait() - - file_path = os.path.join(reading_dir, file_name) - ingredient_json = json.dumps({ - "title": f"Test Ingredient Task {task_id} - {file_name}" - }) - - with open(file_path, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - async with add_lock: - add_errors.append(None) # Success case - except Exception as e: - async with add_lock: - add_errors.append( - f"Task {task_id} error with file {file_name}: { - str(e)}") - finally: - async with completion_lock: - completed_tasks += 1 - - async def run_async_tests(): - # Create all tasks first - tasks = [] - for i, file_name in enumerate(selected_files, 1): - task = asyncio.create_task(add_ingredient(file_name, i)) - tasks.append(task) - - # Wait for all tasks to complete - await asyncio.gather(*tasks) - - # Check for errors during ingredient addition - if any(error for error in add_errors if error is not None): - raise Exception( - "\n".join( - error for error in add_errors if error is not None)) - - # Verify all ingredients were added successfully - self.assertEqual( - completed_tasks, - 5, - "All 5 tasks should have completed") - self.assertEqual( - len(add_errors), - 5, - "All 5 tasks should have completed without errors") - - # Now sign the manifest with the added ingredients - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertEqual(len(active_manifest["ingredients"]), 5) - - # Verify all ingredients exist in the array with correct task - # IDs - ingredient_titles = [ing["title"] - for ing in active_manifest["ingredients"]] - for i in range(1, 6): - # Find an ingredient with this task ID - task_ingredients = [ - title for title in ingredient_titles if f"Task {i}" in title] - self.assertEqual( - len(task_ingredients), - 1, - f"Should find exactly one ingredient for task {i}") - - # Verify the ingredient title contains the file name - task_ingredient = task_ingredients[0] - file_name = selected_files[i - 1] - self.assertIn(file_name, task_ingredient, f"Ingredient for task { - i} should contain its file name") - - # Run the async tests - asyncio.run(run_async_tests()) - builder.close() - def test_builder_sign_with_same_ingredient_multiple_times(self): """Test Builder class operations with the same ingredient added multiple times from different threads.""" # Test creating builder from JSON