diff --git a/Makefile b/Makefile index eca23ade..ba70dfb3 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ rebuild: clean-c2pa-env install-deps download-native-artifacts build-python run-examples: python3 ./examples/sign.py python3 ./examples/sign_info.py + python3 ./examples/no_thumbnails.py python3 ./examples/training.py rm -rf output/ diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 27f9930a..5bbbbe60 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.75.21 +c2pa-v0.77.1 diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md new file mode 100644 index 00000000..ba7496a2 --- /dev/null +++ b/docs/native-resources-management.md @@ -0,0 +1,334 @@ +# Native resource management (ManagedResource class) + +`ManagedResource` is the internal base class used by the C2PA Python SDK to wrap native (Rust/FFI) pointers. When adding new wrappers around native resources `ManagedResource` should be subclassed and follow the documented lifecycle rules. + +## Why `ManagedResource`? + +`ManagedResource` is the internal base class responsible for managing native pointers owned by the C2PA Python SDK. It guarantees: + +- Native memory is freed exactly once. +- Resources are cleaned up deterministically via context managers or explicit `close()`. +- Ownership transfers** (e.g. signer to context) are handled safely so the same pointer is never freed twice. +- Cleanup never raises or masks real exceptions. + +Developers wrapping new native resources must inherit from `ManagedResource` and follow the documented lifecycle rules. + +## Why is native resources management needed? + +### Native pointers in a Python wrapper + +The C2PA Python SDK is a wrapper around a native Rust library that exposes a C FFI. When the SDK creates a `Reader`, `Builder`, `Signer`, `Context`, or `Settings` object, that object holds a **pointer** to memory allocated on the native side. + +### How Python's garbage collector works + +Python manages its own objects' memory automatically through garbage collection. In CPython (the standard interpreter), this works primarily through reference counting: each object has a counter tracking how many references point to it, and when that counter reaches zero the object is deallocated. A secondary cycle-detecting collector handles the case where objects reference each other in a loop and their counts never reach zero on their own. + +### Why garbage collection is not enough for native memory + +This system works well for pure Python objects, but native memory sits outside of it entirely. The garbage collector sees the Python wrapper object (e.g. a `Reader` instance) and tracks references to it, but it has no visibility into the native memory that the wrapper's `_handle` attribute points to. It does not know the size of that native allocation, cannot tell when it is no longer needed, and will not call the native library's `c2pa_free` function to release it. If the Python wrapper is collected without first calling `c2pa_free`, the native memory leaks. If `c2pa_free` is called twice on the same pointer, the process crashes. + +### Why `__del__` is not reliable enough + +Python does offer `__del__` as a hook that runs when an object is collected, and `ManagedResource` uses it as a fallback. But `__del__` cannot be relied on as the primary cleanup mechanism: its timing is unpredictable, it may not run at all during interpreter shutdown, and other Python implementations (PyPy, GraalPy) that do not use reference counting make its behavior even less deterministic. + +In CPython, `__del__` runs synchronously when the last reference to an object disappears, which in simple cases happens at a predictable point (e.g. when a local variable goes out of scope). But if the object is part of a reference cycle, its reference count never reaches zero on its own. The cycle collector must discover and break the cycle first, and it runs periodically rather than immediately. An object caught in a cycle might sit in memory for an arbitrary amount of time before `__del__` fires. CPython's cycle collector does not guarantee an order when finalizing groups of objects in a cycle, so `__del__` methods that depend on other objects in the same cycle may find those objects already partially torn down. During interpreter shutdown, the situation is even less reliable: CPython clears module globals and may collect objects in an arbitrary order, and `__del__` methods that reference global state (like the `_lib` handle to the native library) can fail silently because those globals have already been set to `None`. PyPy and GraalPy use tracing garbage collectors (which periodically walk the object graph to find unreachable objects, rather than tracking individual reference counts) instead of reference counting, so `__del__` does not run when the last reference disappears. It runs at some later point when the GC happens to trace that region of the heap, which could be seconds or minutes later, or not at all if the process exits first. + +`ManagedResource` is the internal base class that handles this. Every class that holds a native pointer inherits from it. + +## Class hierarchy + +```mermaid +classDiagram + class ManagedResource { + <> + } + + class ContextProvider { + <> + } + + ManagedResource <|-- Settings + ManagedResource <|-- Context + ManagedResource <|-- Reader + ManagedResource <|-- Builder + ManagedResource <|-- Signer + + ContextProvider <|-- Context + ContextProvider <|-- Settings +``` + +`Context` and `Settings` inherit from both `ManagedResource` and `ContextProvider` (Python supports multiple inheritance). `ContextProvider` is an ABC that requires two properties: `is_valid` and `execution_context`. The `is_valid` implementation lives on `ManagedResource`, so `Context` and `Settings` satisfy the `ContextProvider` contract without duplicating the property. + +## Guarantees provided by ManagedResource + +`ManagedResource` provides the following guarantees. Subclasses and callers can rely on them. These guarantees invariants must be maintained when subclassing the `ManagedResource` class in new implementation/new native resources handlers. + +| Guarantee | Description | +| --------- | ----------- | +| **Pointer freed exactly once** | Each native pointer is passed to `c2pa_free` at most once. No leak (zero frees) and no double-free. | +| **Cleanup is idempotent** | Calling `close()` (or exiting a `with` block) multiple times is safe; after the first successful cleanup, further calls do nothing. | +| **Cleanup never raises** | The cleanup path (including `_release()` and `c2pa_free`) is wrapped so that exceptions are caught and logged, never re-raised. The original exception from the `with` block (if any) is never masked. | +| **State transitions are one-way** | Lifecycle moves only from UNINITIALIZED → ACTIVE → CLOSED. A closed resource cannot be reactivated. | +| **Ownership transfer is safe** | When a pointer is transferred elsewhere (e.g. via `_mark_consumed()`), the object stops managing it and does not call `c2pa_free` on it. | +| **Public methods validate lifecycle state** | Every public API calls `_ensure_valid_state()` before use; closed or invalid state yields `C2paError` instead of undefined behavior or crashes. | + +## Preventing garbage collection of live references + +When a Python object passes a callback or pointer to the native library, that reference must stay alive for as long as the native side might use it. Python's garbage collector has no way to know that native code is still holding a reference to a Python callback. + +The SDK solves this by storing these references as instance attributes on the owning object. For example, `Stream` stores its four callback objects (`_read_cb`, `_seek_cb`, `_write_cb`, `_flush_cb`) as instance attributes. As long as the `Stream` object is alive, its callbacks have a nonzero reference count and will not be collected. Similarly, when a `Signer` is consumed by a `Context`, the Context copies the signer's `_callback_cb` to its own `_signer_callback_cb` attribute so the callback survives even though the Signer object is now closed. + +During cleanup, `_release()` sets these attributes to `None`, which drops the reference count on the callback objects and allows them to be collected. In the cleanup sequence, `_release()` runs first, then `c2pa_free` frees the native pointer. `_release()` goes first so that subclass-specific resources (open file handles, stream wrappers) are torn down before the native pointer they depend on is freed. + +## How native memory is freed + +The native Rust library exposes a single C FFI function, `c2pa_free`, that deallocates memory it previously allocated. `ManagedResource` wraps this in a static method: + +```python +@staticmethod +def _free_native_ptr(ptr): + _lib.c2pa_free(ctypes.cast(ptr, ctypes.c_void_p)) +``` + +All native pointers are freed through this single path, regardless of which constructor created them (`c2pa_reader_from_stream`, `c2pa_builder_from_json`, `c2pa_signer_from_info`, etc.). The `ctypes.cast` to `c_void_p` is needed because the C function accepts a generic void pointer regardless of the original type. + +`ManagedResource` guarantees that `c2pa_free` is called exactly once per pointer: not zero times (leak), not twice (double-free). + +## Lifecycle states + +Each `ManagedResource` tracks its state with a `LifecycleState` enum: + +```mermaid +stateDiagram-v2 + direction LR + [*] --> UNINITIALIZED : __init__() + UNINITIALIZED --> ACTIVE : native pointer created + ACTIVE --> CLOSED : close() / __exit__ / __del__ / _mark_consumed() +``` + +- `UNINITIALIZED`: The Python object exists but the native pointer has not been set yet. This is a transient state during construction. +- `ACTIVE`: The native pointer is valid. The object can be used. +- `CLOSED`: The native pointer has been freed (or ownership was transferred). Any further use raises `C2paError`. + +The transition from ACTIVE to CLOSED is one-way. Once closed, an object cannot be reactivated. + +Every public method calls `_ensure_valid_state()` before doing any work. Besides checking the lifecycle state, this method also calls `_clear_error_state()`, which resets any stale error left over from a previous native library call. Without this, an error from one operation could leak into the next one and produce a misleading error message. + +## Ways to clean up + +### Context manager (`with` statement) + +```python +with Reader("image.jpg") as reader: + print(reader.json()) +# reader is automatically closed here, even if an exception occurs +``` + +When the `with` block exits, `__exit__` calls `close()`, which frees the native pointer. This is the safest approach because cleanup happens even if the code inside the block raises an exception. + +### Explicit `.close()` + +```python +reader = Reader("image.jpg") +try: + print(reader.json()) +finally: + reader.close() +``` + +Calling `.close()` directly is equivalent to exiting a `with` block. It is idempotent: calling it multiple times is safe and does nothing after the first call. + +### Destructor fallback (`__del__`) + +If neither of the above is used, `__del__` attempts to free the native pointer when Python garbage-collects the object. As described above, `__del__` timing is unpredictable and it may not run at all, so it is a safety net rather than a primary cleanup mechanism. + +## Error handling during cleanup + +Cleanup must never raise an exception. A failure during cleanup (for example, the native library crashing on free) should not mask the original exception that caused the `with` block to exit. `ManagedResource` enforces this: + +- `close()` delegates to `_cleanup_resources()`, which wraps the entire cleanup sequence in a try/except that catches and silences all exceptions. +- If freeing the native pointer fails, the error is logged via Python's `logging` module but not re-raised. +- The state is set to `CLOSED` as the very first step, before attempting to free anything. If cleanup fails halfway, the object is still marked closed, preventing a second attempt from doing further damage. +- Cleanup is idempotent. Calling `close()` on an already-closed object returns immediately. + +## Nesting resources + +When multiple native resources are in play at once, they can share a single `with` statement or use nested blocks. Either way, Python cleans them up in reverse order (right to left, or inner to outer). + +```python +with open("photo.jpg", "rb") as file, Reader("image/jpeg", file) as reader: + manifest = reader.json() +# reader is closed first, then file +``` + +The same can be written with nested blocks if readability is better: + +```python +with open("photo.jpg", "rb") as file: + with Reader("image/jpeg", file) as reader: + manifest = reader.json() +``` + +The order matters because resources often depend on each other. In the example above, the `Reader` holds a native pointer that references the file's data through a `Stream` wrapper. If the file handle were closed first, the native library would still hold a pointer into the stream's read callbacks, and any subsequent access (including cleanup) could read freed memory or trigger a segfault. By closing the Reader first, the native pointer is freed while the underlying file is still open and valid. Python's `with` statement guarantees this ordering: resources listed later (or nested deeper) are torn down first. + +## Reader lifecycle + +A `Reader` wraps a stream (or opens a file), passes it to the native library, and holds the returned pointer. While active, callers can use `.json()`, `.detailed_json()`, `.resource_to_stream()`, and other methods. Each of these checks state via `_ensure_valid_state()` before making the FFI call. + +```mermaid +stateDiagram-v2 + direction LR + [*] --> UNINITIALIZED : __init__() + UNINITIALIZED --> ACTIVE : Reader("image.jpg") + ACTIVE --> CLOSED : close() / exit with block + CLOSED --> [*] +``` + +While `ACTIVE`, callers can use `.json()`, `.detailed_json()`, etc. repeatedly without changing state. Calling `.close()` on an already-closed Reader is a no-op. Any other method call on a closed Reader raises `C2paError`. + +When the Reader is closed, it first releases its own resources (open file handles, stream wrappers) via `_release()`, then frees the native pointer via `c2pa_free`. + +## Builder lifecycle + +A `Builder` follows the same pattern as Reader, with one difference: **signing consumes the builder**. The native library takes ownership of the builder's pointer during the sign operation. After signing, the builder is closed and cannot be reused. + +```mermaid +stateDiagram-v2 + direction LR + [*] --> UNINITIALIZED : __init__() + UNINITIALIZED --> ACTIVE : Builder.from_json(manifest) + ACTIVE --> CLOSED : .sign() or close() + CLOSED --> [*] + + note left of CLOSED + .sign() consumes the pointer + close() frees it + end note +``` + +While `ACTIVE`, callers can use `.add_ingredient()`, `.add_action()`, etc. repeatedly. `.sign()` consumes the native pointer (ownership transfers to the native library), so the Builder cannot be reused afterward. Closing without signing frees the pointer normally. + +After `.sign()`, the builder calls `_mark_consumed()`, which sets the handle to `None` and the state to `CLOSED`. Because the native library now owns the pointer, `ManagedResource` does not call `c2pa_free`. That would double-free memory the native library already manages. + +## Ownership transfer + +Some operations transfer a native pointer from one object to another. When this happens, the original object must stop managing the pointer (e.g. so it is not freed twice). + +`_mark_consumed()` handles this. It sets `_handle = None` and `_lifecycle_state = CLOSED` in one step. + +There are two cases where this is relevant: + +- When a `Signer` is passed to a `Context`, the Context takes ownership of the Signer's native pointer. The Signer is marked consumed and must not be used again. + +- When `Builder.sign()` is called, the native library consumes the Builder's pointer. The Builder marks itself consumed regardless of whether the sign operation succeeds or fails, because in both cases the native library has taken the pointer. + +## Consume-and-return + +`_mark_consumed()` closes an object permanently. A different pattern is needed when the native library must replace an object's internal state without discarding the Python-side object. This happens with fragmented media: `Reader.with_fragment()` feeds a new BMFF fragment (used in DASH/HLS streaming) into an existing Reader, and the native library must rebuild its internal representation to account for the new data. The native API does this by consuming the old pointer and returning a new one. Creating a fresh `Reader` from scratch would not work because the native library needs the accumulated state from prior fragments. + +`Builder.with_archive()` follows the same pattern: it loads an archive into an existing Builder, replacing the manifest definition while preserving the Builder's context and settings. + +In both cases the FFI call consumes the current pointer and returns a replacement: + +```mermaid +stateDiagram-v2 + state "ACTIVE (ptr A)" as A + state "ACTIVE (ptr B)" as B + + A --> B : C FFI call consumes ptr A, returns ptr B + note right of B + Same Python object, + new native pointer + end note +``` + +```python +# Reader.with_fragment() internally does: +new_ptr = _lib.c2pa_reader_with_fragment(self._handle, ...) +# self._handle (old pointer) is now invalid +self._handle = new_ptr +``` + +The object stays `ACTIVE` throughout because the Python-side object is still valid: it has a live native pointer, its public methods still work, and callers may continue using it (e.g. reading the updated manifest or feeding in another fragment). The lifecycle state does not change because from `ManagedResource`'s perspective nothing has closed. Only the underlying native pointer has been swapped. This is different from `_mark_consumed()`, where the object transitions to `CLOSED` and becomes unusable. The old pointer must not be freed by `ManagedResource` because the native library already consumed it as part of the FFI call. + +## Subclass-specific cleanup with `_release()` + +Each subclass can override `_release()` to clean up its own resources before the native pointer is freed. The base implementation does nothing. + +Examples from the codebase: + +| Class | What `_release()` cleans up | +| --- | --- | +| Reader | Closes owned file handles and stream wrappers | +| Context | Drops the reference to the signer callback | +| Signer | Drops the reference to the signing callback | +| Settings | (no override, nothing extra to clean up) | +| Builder | (no override, nothing extra to clean up) | + +The cleanup order matters: `_release()` runs first (closing streams, dropping callbacks), then `c2pa_free` frees the native pointer. This order prevents the native library from accessing Python objects that no longer exist. + +## Why is `Stream` not a `ManagedResource`? + +`Stream` wraps a Python stream-like object (file stream or memory stream) so the native library can read from and write to it via callbacks. It does not inherit from `ManagedResource`, and it uses `c2pa_release_stream()` instead of `c2pa_free()` for cleanup. + +The reason is that ownership runs in the opposite direction. A `Reader` or `Builder` holds a native resource that Python code calls methods on. A `Stream` holds a native handle that the native library calls *back into* (read, seek, write, flush). The native library needs a different release function to tear down the callback machinery. + +`Stream` tracks its own state with `_closed` and `_initialized` flags rather than `LifecycleState`, but it supports the same three cleanup paths: context manager, explicit `.close()`, and `__del__` fallback. + +## Implementing a subclass of `ManagedResource` + +To wrap a new native resource, inherit from `ManagedResource` and follow these rules: + +```python +class MyResource(ManagedResource): + def __init__(self, arg): + super().__init__() + + # 1. Initialize ALL instance attributes before any code + # that can raise. If __init__ fails partway through, + # __del__ will call _release(), which accesses these + # attributes. If they don't exist, _release() raises AttributeError. + self._my_stream = None + self._my_cache = None + + # 2. Create the native pointer. + ptr = _lib.c2pa_my_resource_new(arg) + _check_ffi_operation_result(ptr, "Failed to create MyResource") + + # 3. Only set _handle and activate AFTER the FFI call + # succeeded. If it raised, _lifecycle_state stays + # UNINITIALIZED and cleanup won't try to free a + # pointer that doesn't exist. + self._handle = ptr + self._lifecycle_state = LifecycleState.ACTIVE + + def _release(self): + # 4. Clean up class-specific resources. + # Never let this method raise. Use try/except with + # logging if needed. + if self._my_stream: + try: + self._my_stream.close() + except Exception: + logger.error("Failed to close MyResource stream") + finally: + self._my_stream = None + + def do_something(self): + # 5. Check state at the start of every public method. + # This raises C2paError if the resource is closed. + self._ensure_valid_state() + return _lib.c2pa_my_resource_do_something(self._handle) +``` + +### Troubleshooting + +- If `self._my_callback = None` is set after the FFI call that can raise, and the call fails, `_release()` will try to access `self._my_callback` and crash with `AttributeError`. Always initialize attributes right after `super().__init__()`. + +- If `_lifecycle_state = ACTIVE` is set before the FFI call and the call fails, cleanup will try to free a null or invalid pointer. Activation should happen only after a valid handle exists. + +- If `_release()` raises, the exception is silently swallowed by `_cleanup_resources()`. It will not be visible unless logs are checked. Wrap risky operations in try/except. + +- `_release()` can be called more than once (via `close()` then `__del__`, or multiple `close()` calls). Make sure it handles being called on an already-cleaned-up object. Setting attributes to `None` after closing them is the standard pattern. + +- Calling `c2pa_free` directly is not recommended. `ManagedResource` handles this. If the pointer is freed manually and `ManagedResource` frees it again, the process crashes (double-free). diff --git a/docs/usage.md b/docs/usage.md index aeec23a4..63a7d87b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -12,8 +12,13 @@ Import the objects needed from the API: from c2pa import Builder, Reader, Signer, C2paSigningAlg, C2paSignerInfo ``` -You can use both `Builder`, `Reader` and `Signer` classes with context managers by using a `with` statement. -Doing this is recommended to ensure proper resource and memory cleanup. +If you want to use per-instance configuration with `Context` and `Settings`: + +```py +from c2pa import Settings, Context, ContextBuilder, ContextProvider +``` + +All of `Builder`, `Reader`, `Signer`, `Context`, and `Settings` support context managers (the `with` statement) for automatic resource cleanup. ## Define manifest JSON @@ -53,45 +58,68 @@ An asset file may contain many manifests in a manifest store. The most recent ma NOTE: For a comprehensive reference to the JSON manifest structure, see the [Manifest store reference](https://opensource.contentauthenticity.org/docs/manifest/manifest-ref). +#### Reading without Context + ```py try: - # Create a reader from a file path + # Create a Reader from a file path. with Reader("path/to/media_file.jpg") as reader: - # Print manifest store as JSON + # Print manifest store as JSON. print("Manifest store:", reader.json()) # Get the active manifest. manifest = json.loads(reader.json()) active_manifest = manifest["manifests"][manifest["active_manifest"]] if active_manifest: - # Get the uri to the manifest's thumbnail and write it to a file + # Get the uri to the manifest's thumbnail and write it to a file. uri = active_manifest["thumbnail"]["identifier"] - with open("thumbnail_v2.jpg", "wb") as f: + with open("thumbnail.jpg", "wb") as f: reader.resource_to_stream(uri, f) except Exception as err: print(err) ``` +#### Reading with Context + +Pass a `Context` to apply custom settings to the Reader, such as trust anchors or verification flags. + +```py +try: + settings = Settings.from_dict({ + "verify": {"verify_cert_anchors": True}, + "trust": {"trust_anchors": anchors_pem} + }) + + with Context(settings) as ctx: + with Reader("path/to/media_file.jpg", context=ctx) as reader: + print("Manifest store:", reader.json()) + +except Exception as err: + print(err) +``` + ### Add a signed manifest **WARNING**: This example accesses the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as show in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). -Use a `Builder` to add a manifest to an asset: +#### Signing without Context + +Use a `Builder` and `Signer` to add a manifest to an asset: ```py try: - # Create a signer from certificate and key files + # Load certificate and key files with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: cert_data = cert_file.read() key_data = key_file.read() - # Create signer info using cert and key info + # Create signer info with the correct field names signer_info = C2paSignerInfo( alg=C2paSigningAlg.PS256, - cert=cert_data, - key=key_data, - timestamp_url="http://timestamp.digicert.com" + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" ) # Create signer using the defined SignerInfo @@ -99,14 +127,13 @@ try: # Create builder with manifest and add ingredients with Builder(manifest_json) as builder: - # Add any ingredients if needed with open("path/to/ingredient.jpg", "rb") as ingredient_file: ingredient_json = json.dumps({"title": "Ingredient Image"}) builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) - # Sign the file - with open("path/to/source.jpg", "rb") as source_file, open("path/to/output.jpg", "wb") as dest_file: - manifest_bytes = builder.sign(signer, "image/jpeg", source_file, dest_file) + # Sign the file (dest must be opened in w+b mode) + with open("path/to/source.jpg", "rb") as source, open("path/to/output.jpg", "w+b") as dest: + builder.sign(signer, "image/jpeg", source, dest) # Verify the signed file by reading data from the signed output file with Reader("path/to/output.jpg") as reader: @@ -118,74 +145,316 @@ except Exception as e: print("Failed to sign manifest store: " + str(e)) ``` -## Stream-based operation +#### Signing with Context + +Pass a `Context` to the Builder to apply custom settings during signing. The signer is still passed explicitly to `builder.sign()`. + +```py +try: + with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: + cert_data = cert_file.read() + key_data = key_file.read() + + signer_info = C2paSignerInfo( + alg=C2paSigningAlg.PS256, + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" + ) + + with Context() as ctx: + with Signer.from_info(signer_info) as signer: + with Builder(manifest_json, ctx) as builder: + with open("path/to/ingredient.jpg", "rb") as ingredient_file: + ingredient_json = json.dumps({"title": "Ingredient Image"}) + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) + + # Sign using file paths + builder.sign_file("path/to/source.jpg", "path/to/output.jpg", signer) + + # Verify the signed file with the same context + with Reader("path/to/output.jpg", context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + print("Signed manifest:", active_manifest) + +except Exception as e: + print("Failed to sign manifest store: " + str(e)) +``` + +## Settings, Context, and ContextProvider + +The `Settings` and `Context` classes provide per-instance configuration for Reader and Builder operations. This replaces the global `load_settings()` function, which is now deprecated. + +```mermaid +classDiagram + class ContextProvider { + <> + +is_valid bool + +execution_context + } + + class Settings { + +set(path, value) Settings + +update(data) Settings + +from_json(json_str)$ Settings + +from_dict(config)$ Settings + +close() + } + + class Context { + +has_signer bool + +builder()$ ContextBuilder + +from_json(json_str, signer)$ Context + +from_dict(config, signer)$ Context + +close() + } + + class ContextBuilder { + +with_settings(settings) ContextBuilder + +with_signer(signer) ContextBuilder + +build() Context + } + + class Signer { + +from_info(signer_info)$ Signer + +from_callback(callback, alg, certs, tsa_url)$ Signer + +close() + } + + class Reader { + +json() str + +resource_to_stream(uri, stream) + +close() + } + + class Builder { + +add_ingredient(json, format, stream) + +sign(signer, format, source, dest) bytes + +close() + } + + ContextProvider <|-- Context + ContextBuilder --> Context : builds + Context o-- Settings : optional + Context o-- Signer : optional, consumed + Reader ..> ContextProvider : uses + Builder ..> ContextProvider : uses +``` + +### Settings + +`Settings` controls behavior such as thumbnail generation, trust lists, and verification flags. + +```py +from c2pa import Settings + +# Create with defaults +settings = Settings() + +# Set individual values by dot-notation path +settings.set("builder.thumbnail.enabled", "false") + +# Method chaining +settings.set("builder.thumbnail.enabled", "false").set("verify.remote_manifest_fetch", "true") + +# Dict-like access +settings["builder.thumbnail.enabled"] = "false" + +# Create from JSON string +settings = Settings.from_json('{"builder": {"thumbnail": {"enabled": false}}}') + +# Create from a dictionary +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) + +# Merge additional configuration +settings.update({"verify": {"remote_manifest_fetch": True}}) +``` + +### Context + +A `Context` can carry `Settings` and a `Signer`, and is passed to `Reader` or `Builder` to control their behavior through settings propagation. + +```py +from c2pa import Context, Settings, Reader, Builder, Signer + +# Default context (no custom settings) +ctx = Context() + +# Context with settings +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +ctx = Context(settings=settings) + +# Create from JSON or dict directly +ctx = Context.from_json('{"builder": {"thumbnail": {"enabled": false}}}') +ctx = Context.from_dict({"builder": {"thumbnail": {"enabled": False}}}) + +# Use with Reader (keyword argument) +reader = Reader("path/to/media_file.jpg", context=ctx) + +# Use with Builder (positional or keyword argument) +builder = Builder(manifest_json, ctx) +``` + +### ContextBuilder (fluent API) + +`ContextBuilder` provides a fluent interface for constructing a `Context`, matching the c2pa-rs `ContextBuilder` pattern. Use `Context.builder()` to get started. + +```py +from c2pa import Context, ContextBuilder, Settings, Signer + +# Fluent construction with settings and signer +ctx = ( + Context.builder() + .with_settings(settings) + .with_signer(signer) + .build() +) + +# Settings only +ctx = Context.builder().with_settings(settings).build() + +# Default context (equivalent to Context()) +ctx = Context.builder().build() +``` + +### Context with a Signer + +When a `Signer` is passed to `Context`, the `Signer` object is consumed and must not be reused directly. The `Context` takes ownership of the underlying native signer. This allows signing without passing an explicit signer to `Builder.sign()`. + +```py +from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg + +# Create a signer +signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" +) +signer = Signer.from_info(signer_info) + +# Create context with signer (signer is consumed) +ctx = Context(settings=settings, signer=signer) +# The signer object is now invalid and must not be used directly again + +# Build and sign without passing a signer, since the signer is in the context +builder = Builder(manifest_json, ctx) +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + manifest_bytes = builder.sign(format="image/jpeg", source=src, dest=dst) +``` + +If both an explicit signer and a context signer are available, the explicit signer takes precedence: + +```py +# Explicit signer wins over context signer +manifest_bytes = builder.sign(explicit_signer, "image/jpeg", source, dest) +``` + +### ContextProvider (abstract base class) + +`ContextProvider` is an abstract base class (ABC) that allows third-party implementations of custom context providers. Any class that implements the `is_valid` and `execution_context` properties satisfies the interface and can be passed to `Reader` or `Builder` as `context`. + +```py +from c2pa import ContextProvider, Context + +# The built-in Context satisfies ContextProvider +ctx = Context() +assert isinstance(ctx, ContextProvider) +``` + +### Migrating from load_settings + +The `load_settings()` function that set settings in a thread-local fashion is deprecated. +Replace it with `Settings` and `Context` usage to propagate configurations (do not mix legacy and new APIs): + +```py +# Before: +from c2pa import load_settings +load_settings({"builder": {"thumbnail": {"enabled": False}}}) +reader = Reader("file.jpg") + +# After: +from c2pa import Settings, Context, Reader + +# Settings are on the context, and move with the context +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +ctx = Context(settings=settings) +reader = Reader("file.jpg", context=ctx) +``` + +## Stream-based operations Instead of working with files, you can read, validate, and add a signed manifest to streamed data. This example is similar to what the file-based example does. ### Read and validate C2PA data using streams +#### Stream reading without Context + ```py try: - # Create a reader from a format and stream with open("path/to/media_file.jpg", "rb") as stream: - # First parameter should be the type of the file (here, we use the mimetype) - # But in any case we need something to identify the file type with Reader("image/jpeg", stream) as reader: - # Print manifest store as JSON, as extracted by the Reader - print("manifest store:", reader.json()) + print("Manifest store:", reader.json()) - # Get the active manifest manifest = json.loads(reader.json()) active_manifest = manifest["manifests"][manifest["active_manifest"]] if active_manifest: - # get the uri to the manifest's thumbnail and write it to a file uri = active_manifest["thumbnail"]["identifier"] - with open("thumbnail_v2.jpg", "wb") as f: + with open("thumbnail.jpg", "wb") as f: reader.resource_to_stream(uri, f) except Exception as err: print(err) ``` +#### Stream reading with Context + +```py +try: + settings = Settings.from_dict({"verify": {"verify_cert_anchors": True}}) + + with Context(settings) as ctx: + with open("path/to/media_file.jpg", "rb") as stream: + with Reader("image/jpeg", stream, context=ctx) as reader: + print("Manifest store:", reader.json()) + +except Exception as err: + print(err) +``` + ### Add a signed manifest to a stream -**WARNING**: This example accesses the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as show in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). +**WARNING**: These examples access the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as shown in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). -Use a `Builder` to add a manifest to an asset: +#### Stream signing without Context ```py try: - # Create a signer from certificate and key files with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: cert_data = cert_file.read() key_data = key_file.read() - # Create signer info using the read certificate and key data signer_info = C2paSignerInfo( alg=C2paSigningAlg.PS256, - cert=cert_data, - key=key_data, - timestamp_url="http://timestamp.digicert.com" + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" ) - # Create a Signer using the SignerInfo defined previously signer = Signer.from_info(signer_info) - # Create a Builder with manifest and add ingredients with Builder(manifest_json) as builder: - # Add any ingredients as needed with open("path/to/ingredient.jpg", "rb") as ingredient_file: ingredient_json = json.dumps({"title": "Ingredient Image"}) - # Here the ingredient is added using streams builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) - # Sign using streams - with open("path/to/source.jpg", "rb") as source_file, open("path/to/output.jpg", "wb") as dest_file: - manifest_bytes = builder.sign(signer, "image/jpeg", source_file, dest_file) + # Sign using streams (dest must be opened in w+b mode) + with open("path/to/source.jpg", "rb") as source, open("path/to/output.jpg", "w+b") as dest: + builder.sign(signer, "image/jpeg", source, dest) # Verify the signed file with open("path/to/output.jpg", "rb") as stream: - # Create a Reader to read data with Reader("image/jpeg", stream) as reader: manifest_store = json.loads(reader.json()) active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] @@ -194,3 +463,39 @@ try: except Exception as e: print("Failed to sign manifest store: " + str(e)) ``` + +#### Stream signing with Context + +```py +try: + with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: + cert_data = cert_file.read() + key_data = key_file.read() + + signer_info = C2paSignerInfo( + alg=C2paSigningAlg.PS256, + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" + ) + + with Context() as ctx: + with Signer.from_info(signer_info) as signer: + with Builder(manifest_json, ctx) as builder: + with open("path/to/ingredient.jpg", "rb") as ingredient_file: + ingredient_json = json.dumps({"title": "Ingredient Image"}) + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) + + with open("path/to/source.jpg", "rb") as source, open("path/to/output.jpg", "w+b") as dest: + builder.sign(signer, "image/jpeg", source, dest) + + # Verify the signed file with the same context + with open("path/to/output.jpg", "rb") as stream: + with Reader("image/jpeg", stream, context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + print("Signed manifest:", active_manifest) + +except Exception as e: + print("Failed to sign manifest store: " + str(e)) +``` diff --git a/examples/README.md b/examples/README.md index da7733b7..191e88f4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,9 +7,6 @@ The examples use asset files from the `tests/fixtures` directory, save the resul The [`examples/sign.py`](https://github.com/contentauth/c2pa-python/blob/main/examples/sign.py) script shows how to sign an asset with a C2PA manifest and verify the asset. - -The `examples/sign.py` script shows how to sign an asset with a C2PA manifest and verify it using a callback signer. Callback signers let you define signing logic, for example where to load keys from. - The `examples/sign_info.py` script shows how to sign an asset with a C2PA manifest and verify it using a "default" signer created with the needed signer information. These statements create a `builder` object with the specified manifest JSON (omitted in the snippet below), call `builder.sign()` to sign and attach the manifest to the source file, `tests/fixtures/C.jpg`, and save the signed asset to the output file, `output/C_signed.jpg`: diff --git a/examples/no_thumbnails.py b/examples/no_thumbnails.py new file mode 100644 index 00000000..2886bc43 --- /dev/null +++ b/examples/no_thumbnails.py @@ -0,0 +1,110 @@ +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +# Shows how to use Context+Settings API to control +# thumbnails added to the manifest. +# +# This example uses Settings to explicitly turn off +# thumbnail addition when signing. + +import json +import os +import c2pa +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.backends import default_backend + +fixtures_dir = os.path.join(os.path.dirname(__file__), "../tests/fixtures/") +output_dir = os.path.join(os.path.dirname(__file__), "../output/") + +# Ensure the output directory exists. +if not os.path.exists(output_dir): + os.makedirs(output_dir) + +# Load certificates and private key (here from the unit test fixtures). +with open(fixtures_dir + "es256_certs.pem", "rb") as cert_file: + certs = cert_file.read() +with open(fixtures_dir + "es256_private.key", "rb") as key_file: + key = key_file.read() + +# Define a callback signer function. +def callback_signer_es256(data: bytes) -> bytes: + """Callback function that signs data using ES256 algorithm.""" + private_key = serialization.load_pem_private_key( + key, + password=None, + backend=default_backend() + ) + signature = private_key.sign( + data, + ec.ECDSA(hashes.SHA256()) + ) + return signature + +# Create a manifest definition. +manifest_definition = { + "claim_generator_info": [{ + "name": "python_no_thumbnail_example", + "version": "0.1.0", + }], + "format": "image/jpeg", + "title": "No Thumbnail Example", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] +} + +# Use Settings to disable thumbnail generation, +# Settings are JSON matching the C2PA SDK settings schema +settings = c2pa.Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } +}) + +print("Signing image with thumbnails disabled through settings...") +with c2pa.Context(settings) as context: + with c2pa.Signer.from_callback( + callback_signer_es256, + c2pa.C2paSigningAlg.ES256, + certs.decode('utf-8'), + "http://timestamp.digicert.com" + ) as signer: + with c2pa.Builder(manifest_definition, context) as builder: + builder.sign_file( + fixtures_dir + "A.jpg", + output_dir + "A_no_thumbnail.jpg", + signer + ) + + # Read the signed image and verify no thumbnail is present. + with c2pa.Reader(output_dir + "A_no_thumbnail.jpg", context=context) as reader: + manifest_store = json.loads(reader.json()) + manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + if manifest.get("thumbnail") is None: + print("No thumbnail in the manifest as per settings.") + else: + print("Thumbnail found in the manifest.") + +print("\nExample completed successfully!") diff --git a/examples/read.py b/examples/read.py index ea30126b..e4b718a9 100644 --- a/examples/read.py +++ b/examples/read.py @@ -11,27 +11,32 @@ def load_trust_anchors(): + """Load trust anchors and return a Settings object holding trust configuration.""" try: with urllib.request.urlopen(TRUST_ANCHORS_URL) as response: anchors = response.read().decode('utf-8') - settings = { + return c2pa.Settings.from_dict({ "verify": { "verify_cert_anchors": True }, "trust": { "trust_anchors": anchors } - } - c2pa.load_settings(settings) + }) except Exception as e: print(f"Warning: Could not load trust anchors from {TRUST_ANCHORS_URL}: {e}") + return None def read_c2pa_data(media_path: str): print(f"Reading {media_path}") try: - with c2pa.Reader(media_path) as reader: - print(reader.detailed_json()) + settings = load_trust_anchors() + # Settings are put into the context, to make sure they propagate. + # All objects using this context will have trust configured. + with c2pa.Context(settings) as context: + with c2pa.Reader(media_path, context=context) as reader: + print(reader.detailed_json()) except Exception as e: print(f"Error reading C2PA data from {media_path}: {e}") sys.exit(1) @@ -43,5 +48,4 @@ def read_c2pa_data(media_path: str): else: media_path = sys.argv[1] - load_trust_anchors() read_c2pa_data(media_path) diff --git a/examples/sign.py b/examples/sign.py index 3df9fd5b..e6c14859 100644 --- a/examples/sign.py +++ b/examples/sign.py @@ -19,9 +19,8 @@ from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.backends import default_backend -# Note: Builder, Reader, and Signer support being used as context managers -# (with 'with' statements), but this example shows manual usage which requires -# explicitly calling the close() function to clean up resources. +# Note: Builder, Reader, Signer, and Context support being used as context managers +# (with 'with' statements) to automatically clean up resources. fixtures_dir = os.path.join(os.path.dirname(__file__), "../tests/fixtures/") output_dir = os.path.join(os.path.dirname(__file__), "../output/") @@ -89,27 +88,28 @@ def callback_signer_es256(data: bytes) -> bytes: # which will use the callback signer. print("\nSigning the image file...") -with c2pa.Signer.from_callback( - callback=callback_signer_es256, - alg=c2pa.C2paSigningAlg.ES256, - certs=certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" -) as signer: - with c2pa.Builder(manifest_definition) as builder: - builder.sign_file( - source_path=fixtures_dir + "A.jpg", - dest_path=output_dir + "A_signed.jpg", - signer=signer - ) +# Use default Context and Settings. +with c2pa.Context() as context: + with c2pa.Signer.from_callback( + callback_signer_es256, + c2pa.C2paSigningAlg.ES256, + certs.decode('utf-8'), + "http://timestamp.digicert.com" + ) as signer: + with c2pa.Builder(manifest_definition, context) as builder: + builder.sign_file( + fixtures_dir + "A.jpg", + output_dir + "A_signed.jpg", + signer + ) -# Re-Read the signed image to verify -print("\nReading signed image metadata:") -with open(output_dir + "A_signed.jpg", "rb") as file: - with c2pa.Reader("image/jpeg", file) as reader: - # The validation state will depend on loaded trust settings. - # Without loaded trust settings, - # the manifest validation_state will be "Invalid". - print(reader.json()) + # Re-Read the signed image to verify + print("\nReading signed image metadata:") + with open(output_dir + "A_signed.jpg", "rb") as file: + with c2pa.Reader("image/jpeg", file, context=context) as reader: + # The validation state will depend on loaded trust settings. + # Without loaded trust settings, + # the manifest validation_state will be "Invalid". + print(reader.json()) print("\nExample completed successfully!") - diff --git a/examples/training.py b/examples/training.py index b07d47ab..2bb446ce 100644 --- a/examples/training.py +++ b/examples/training.py @@ -90,7 +90,7 @@ def getitem(d, key): } } -# V2 signing API example +# Signing API example (v2 claims) try: # Read the private key and certificate files with open(keyFile, "rb") as key_file: @@ -106,26 +106,29 @@ def getitem(d, key): ta_url=b"http://timestamp.digicert.com" ) - with c2pa.Signer.from_info(signer_info) as signer: - with c2pa.Builder(manifest_json) as builder: - # Add the thumbnail resource using a stream - with open(fixtures_dir + "A_thumbnail.jpg", "rb") as thumbnail_file: - builder.add_resource("thumbnail", thumbnail_file) + with ( + c2pa.Context() as context, + c2pa.Signer.from_info(signer_info) as signer, + c2pa.Builder(manifest_json, context) as builder, + ): + # Add the thumbnail resource using a stream + with open(fixtures_dir + "A_thumbnail.jpg", "rb") as thumbnail_file: + builder.add_resource("thumbnail", thumbnail_file) - # Add the ingredient using the correct method - with open(fixtures_dir + "A_thumbnail.jpg", "rb") as ingredient_file: - builder.add_ingredient(json.dumps(ingredient_json), "image/jpeg", ingredient_file) + # Add the ingredient to the working store (Builder) + with open(fixtures_dir + "A_thumbnail.jpg", "rb") as ingredient_file: + builder.add_ingredient(json.dumps(ingredient_json), "image/jpeg", ingredient_file) - if os.path.exists(testOutputFile): - os.remove(testOutputFile) + if os.path.exists(testOutputFile): + os.remove(testOutputFile) - # Sign the file using the stream-based sign method - with open(testFile, "rb") as source_file: - with open(testOutputFile, "w+b") as dest_file: - result = builder.sign(signer, "image/jpeg", source_file, dest_file) + # Sign the file using the stream-based sign method + with open(testFile, "rb") as source_file: + with open(testOutputFile, "w+b") as dest_file: + result = builder.sign(signer, "image/jpeg", source_file, dest_file) - # As an alternative, you can also use file paths directly during signing: - # builder.sign_file(testFile, testOutputFile, signer) + # As an alternative, you can also use file paths directly during signing: + # builder.sign_file(testFile, testOutputFile, signer) except Exception as err: print(f"Exception during signing: {err}") @@ -136,8 +139,11 @@ def getitem(d, key): allowed = True # opt out model, assume training is ok if the assertion doesn't exist try: - # Create reader using the Reader API - with c2pa.Reader(testOutputFile) as reader: + # Create reader using the Reader API with default Context + with ( + c2pa.Context() as context, + c2pa.Reader(testOutputFile, context=context) as reader, + ): # Retrieve the manifest store manifest_store = json.loads(reader.json()) diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index 8f0c8fe1..5a5bfe78 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -27,6 +27,10 @@ C2paSignerInfo, Signer, Stream, + Settings, + Context, + ContextBuilder, + ContextProvider, sdk_version, read_ingredient_file, load_settings @@ -43,6 +47,10 @@ 'C2paSignerInfo', 'Signer', 'Stream', + 'Settings', + 'Context', + 'ContextBuilder', + 'ContextProvider', 'sdk_version', 'read_ingredient_file', 'load_settings' diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index d4ff669c..c7d4e4b0 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -18,6 +18,7 @@ import sys import os import warnings +from abc import ABC, abstractmethod from pathlib import Path from typing import Optional, Union, Callable, Any, overload import io @@ -31,21 +32,30 @@ # Define required function names _REQUIRED_FUNCTIONS = [ + # Version 'c2pa_version', + # Error retriever and parser 'c2pa_error', - 'c2pa_string_free', + # Legacy APIs, deprecated 'c2pa_load_settings', 'c2pa_read_file', 'c2pa_read_ingredient_file', + # Reader bindings 'c2pa_reader_from_stream', 'c2pa_reader_from_manifest_data_and_stream', - 'c2pa_reader_free', 'c2pa_reader_json', 'c2pa_reader_detailed_json', 'c2pa_reader_resource_to_stream', + 'c2pa_reader_from_context', + 'c2pa_reader_with_stream', + 'c2pa_reader_with_fragment', + 'c2pa_reader_with_manifest_data_and_stream', + 'c2pa_reader_is_embedded', + 'c2pa_reader_remote_url', + 'c2pa_reader_supported_mime_types', + # Builder bindings 'c2pa_builder_from_json', 'c2pa_builder_from_archive', - 'c2pa_builder_free', 'c2pa_builder_set_no_embed', 'c2pa_builder_set_remote_url', 'c2pa_builder_set_intent', @@ -54,21 +64,33 @@ 'c2pa_builder_add_action', 'c2pa_builder_to_archive', 'c2pa_builder_sign', - 'c2pa_manifest_bytes_free', - 'c2pa_builder_data_hashed_placeholder', - 'c2pa_builder_sign_data_hashed_embeddable', + 'c2pa_builder_sign_context', + 'c2pa_builder_from_context', + 'c2pa_builder_with_definition', + 'c2pa_builder_with_archive', + 'c2pa_builder_supported_mime_types', 'c2pa_format_embeddable', + # Signer bindings 'c2pa_signer_create', 'c2pa_signer_from_info', 'c2pa_signer_reserve_size', - 'c2pa_signer_free', 'c2pa_ed25519_sign', 'c2pa_signature_free', + # Settings bindings + 'c2pa_settings_new', + 'c2pa_settings_set_value', + 'c2pa_settings_update_from_string', + # Context bindings + 'c2pa_context_builder_new', + 'c2pa_context_builder_set_settings', + 'c2pa_context_builder_build', + 'c2pa_context_builder_set_signer', + 'c2pa_context_new', + # Free bindings + 'c2pa_string_free', 'c2pa_free_string_array', - 'c2pa_reader_supported_mime_types', - 'c2pa_builder_supported_mime_types', - 'c2pa_reader_is_embedded', - 'c2pa_reader_remote_url', + 'c2pa_manifest_bytes_free', + 'c2pa_free', ] @@ -186,6 +208,115 @@ class C2paBuilderIntent(enum.IntEnum): UPDATE = 2 # Restricted version of Edit for non-editorial changes +class LifecycleState(enum.IntEnum): + """Internal state for lifecycle management. + Object transitions: UNINITIALIZED -> ACTIVE -> CLOSED + """ + UNINITIALIZED = 0 + ACTIVE = 1 + CLOSED = 2 + + +class ManagedResource: + """Base class for objects that hold a native (C FFI) resource. + This is an internal base class that provides lifecycle management + for native resources (e.g. pointers). + + Subclasses must: + - Set `self._handle` to the native pointer after creation. + - Set `self._lifecycle_state = LifecycleState.ACTIVE` once initialized. + - Override `_release()` to free class-specific resources + (streams, caches, callbacks, etc.), called before the + native pointer is freed. + + The native pointer is freed automatically via `_free_native_ptr`. + """ + + def __init__(self): + self._lifecycle_state = LifecycleState.UNINITIALIZED + self._handle = None + _clear_error_state() + + @staticmethod + def _free_native_ptr(ptr): + """Free a native pointer by casting it to c_void_p and calling c2pa_free.""" + _lib.c2pa_free(ctypes.cast(ptr, ctypes.c_void_p)) + + def _ensure_valid_state(self): + """Raise if the resource is closed or uninitialized.""" + name = type(self).__name__ + if self._lifecycle_state == LifecycleState.CLOSED: + raise C2paError(f"{name} is closed") + if self._lifecycle_state != LifecycleState.ACTIVE: + raise C2paError(f"{name} is not properly initialized") + if not self._handle: + raise C2paError(f"{name} has an invalid internal state (active but no handle)") + _clear_error_state() + + def _release(self): + """Override to free class-specific resources (streams, caches, etc.). + + Called during cleanup before the native handle is freed. + The default implementation does nothing. + """ + + def _mark_consumed(self): + """Mark as consumed by an FFI call that took ownership + of native resources e.g. pointers. This means we should not + call clean-up here anymore, and leave it to the new owner. + """ + + self._handle = None + self._lifecycle_state = LifecycleState.CLOSED + + def _cleanup_resources(self): + """Release native resources idempotently.""" + try: + if ( + hasattr(self, '_lifecycle_state') + and self._lifecycle_state != LifecycleState.CLOSED + ): + self._lifecycle_state = LifecycleState.CLOSED + self._release() + if hasattr(self, '_handle') and self._handle: + try: + ManagedResource._free_native_ptr(self._handle) + except Exception: + logger.error( + "Failed to free native %s resources", + type(self).__name__, + ) + finally: + self._handle = None + except Exception: + pass + + @property + def is_valid(self) -> bool: + """Check if the resource is in a valid (active) state.""" + return ( + self._lifecycle_state == LifecycleState.ACTIVE + and self._handle is not None + ) + + def close(self) -> None: + """Release the resource (idempotent, never raises).""" + self._cleanup_resources() + + def __enter__(self): + """For classes with context manager (with) pattern""" + self._ensure_valid_state() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """For classes with context manager (with) pattern""" + self.close() + + def __del__(self): + """Free native resources if close() was not called.""" + self._cleanup_resources() + + # Mapping from C2paSigningAlg enum to string representation, # as the enum value currently maps by default to an integer value. _ALG_TO_STRING_BYTES_MAPPING = { @@ -360,6 +491,21 @@ class C2paBuilder(ctypes.Structure): """Opaque structure for builder context.""" _fields_ = [] # Empty as it's opaque in the C API + +class C2paSettings(ctypes.Structure): + """Opaque structure for settings context.""" + _fields_ = [] # Empty as it's opaque in the C API + + +class C2paContextBuilder(ctypes.Structure): + """Opaque structure for context builder.""" + _fields_ = [] # Empty as it's opaque in the C API + + +class C2paContext(ctypes.Structure): + """Opaque structure for context.""" + _fields_ = [] # Empty as it's opaque in the C API + # Helper function to set function prototypes @@ -405,7 +551,6 @@ def _setup_function(func, argtypes, restype=None): _lib.c2pa_reader_from_manifest_data_and_stream, [ ctypes.c_char_p, ctypes.POINTER(C2paStream), ctypes.POINTER( ctypes.c_ubyte), ctypes.c_size_t], ctypes.POINTER(C2paReader)) -_setup_function(_lib.c2pa_reader_free, [ctypes.POINTER(C2paReader)], None) _setup_function( _lib.c2pa_reader_json, [ ctypes.POINTER(C2paReader)], ctypes.c_void_p) @@ -437,7 +582,10 @@ def _setup_function(func, argtypes, restype=None): _setup_function(_lib.c2pa_builder_from_archive, [ctypes.POINTER(C2paStream)], ctypes.POINTER(C2paBuilder)) -_setup_function(_lib.c2pa_builder_free, [ctypes.POINTER(C2paBuilder)], None) +_setup_function( + _lib.c2pa_builder_with_archive, + [ctypes.POINTER(C2paBuilder), ctypes.POINTER(C2paStream)], + ctypes.POINTER(C2paBuilder)) _setup_function(_lib.c2pa_builder_set_no_embed, [ ctypes.POINTER(C2paBuilder)], None) _setup_function( @@ -473,21 +621,6 @@ def _setup_function(func, argtypes, restype=None): _lib.c2pa_manifest_bytes_free, [ ctypes.POINTER( ctypes.c_ubyte)], None) -_setup_function( - _lib.c2pa_builder_data_hashed_placeholder, [ - ctypes.POINTER(C2paBuilder), ctypes.c_size_t, ctypes.c_char_p, - ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)) - ], - ctypes.c_int64, -) -_setup_function(_lib.c2pa_builder_sign_data_hashed_embeddable, - [ctypes.POINTER(C2paBuilder), - ctypes.POINTER(C2paSigner), - ctypes.c_char_p, - ctypes.c_char_p, - ctypes.POINTER(C2paStream), - ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], - ctypes.c_int64) _setup_function( _lib.c2pa_format_embeddable, [ ctypes.c_char_p, ctypes.POINTER( @@ -515,7 +648,6 @@ def _setup_function(func, argtypes, restype=None): _setup_function( _lib.c2pa_signer_reserve_size, [ ctypes.POINTER(C2paSigner)], ctypes.c_int64) -_setup_function(_lib.c2pa_signer_free, [ctypes.POINTER(C2paSigner)], None) _setup_function( _lib.c2pa_ed25519_sign, [ ctypes.POINTER( @@ -531,6 +663,89 @@ def _setup_function(func, argtypes, restype=None): ctypes.POINTER(ctypes.c_char_p) ) +# Set up Settings function prototypes +_setup_function(_lib.c2pa_settings_new, [], ctypes.POINTER(C2paSettings)) +_setup_function( + _lib.c2pa_settings_set_value, + [ctypes.POINTER(C2paSettings), ctypes.c_char_p, ctypes.c_char_p], + ctypes.c_int +) +_setup_function( + _lib.c2pa_settings_update_from_string, + [ctypes.POINTER(C2paSettings), ctypes.c_char_p, ctypes.c_char_p], + ctypes.c_int +) + +# Set up ContextBuilder function prototypes +_setup_function( + _lib.c2pa_context_builder_new, + [], + ctypes.POINTER(C2paContextBuilder) +) +_setup_function( + _lib.c2pa_context_builder_set_settings, + [ctypes.POINTER(C2paContextBuilder), ctypes.POINTER(C2paSettings)], + ctypes.c_int +) +_setup_function( + _lib.c2pa_context_builder_build, + [ctypes.POINTER(C2paContextBuilder)], + ctypes.POINTER(C2paContext) +) + +# Set up Context function prototypes +_setup_function(_lib.c2pa_context_new, [], ctypes.POINTER(C2paContext)) +_setup_function( + _lib.c2pa_reader_from_context, + [ctypes.POINTER(C2paContext)], + ctypes.POINTER(C2paReader) +) +_setup_function( + _lib.c2pa_reader_with_stream, + [ctypes.POINTER(C2paReader), ctypes.c_char_p, + ctypes.POINTER(C2paStream)], + ctypes.POINTER(C2paReader) +) +_setup_function( + _lib.c2pa_reader_with_manifest_data_and_stream, + [ctypes.POINTER(C2paReader), ctypes.c_char_p, + ctypes.POINTER(C2paStream), + ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t], + ctypes.POINTER(C2paReader), +) +_setup_function( + _lib.c2pa_reader_with_fragment, + [ctypes.POINTER(C2paReader), ctypes.c_char_p, + ctypes.POINTER(C2paStream), ctypes.POINTER(C2paStream)], + ctypes.POINTER(C2paReader) +) +_setup_function( + _lib.c2pa_builder_from_context, + [ctypes.POINTER(C2paContext)], + ctypes.POINTER(C2paBuilder) +) +_setup_function( + _lib.c2pa_builder_with_definition, + [ctypes.POINTER(C2paBuilder), ctypes.c_char_p], + ctypes.POINTER(C2paBuilder) +) +_setup_function(_lib.c2pa_free, [ctypes.c_void_p], ctypes.c_int) + +_setup_function( + _lib.c2pa_context_builder_set_signer, + [ctypes.POINTER(C2paContextBuilder), ctypes.POINTER(C2paSigner)], + ctypes.c_int +) +_setup_function( + _lib.c2pa_builder_sign_context, + [ctypes.POINTER(C2paBuilder), + ctypes.c_char_p, + ctypes.POINTER(C2paStream), + ctypes.POINTER(C2paStream), + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64 +) + class C2paError(Exception): """Exception raised for C2PA errors. @@ -793,6 +1008,58 @@ def _parse_operation_result_for_error( return None +def _check_ffi_operation_result(result, fallback_msg, *, check=lambda r: not r): + """Check an FFI native call result and raise C2paError if it indicates failure. + + Args: + result: The return value from the FFI call + fallback_msg: Error message if the native library has no error details + check: Predicate that returns True when the result indicates failure. + Defaults to `not r` (for pointer-returning calls). + Use `lambda r: r != 0` for status-code-returning calls. + Use `lambda r: r < 0` for signed-result calls. + + Returns: + The result unchanged, if the check passed. + + Raises: + C2paError: If the check indicates failure + """ + if check(result): + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError(fallback_msg) + return result + + +def _to_utf8_bytes(data, error_context="input") -> bytes: + """Convert a string or dict to UTF-8 bytes. + + If data is a dict, it is serialized to JSON first. + + Args: + data: String or dict to encode + error_context: Description for error messages + + Returns: + UTF-8 encoded bytes + + Raises: + C2paError.Json: If dict serialization fails + C2paError.Encoding: If UTF-8 encoding fails + """ + if isinstance(data, dict): + try: + data = json.dumps(data) + except (TypeError, ValueError) as e: + raise C2paError.Json(f"Failed to serialize {error_context}: {e}") + try: + return data.encode('utf-8') + except (UnicodeError, AttributeError) as e: + raise C2paError.Encoding(f"Invalid UTF-8 in {error_context}: {e}") + + def sdk_version() -> str: """ Returns the underlying c2pa-rs/c2pa-c-ffi version string @@ -830,7 +1097,14 @@ def load_settings(settings: dict) -> None: def load_settings(settings: Union[str, dict], format: str = "json") -> None: - """Load C2PA settings from a string or dict. + """Load C2PA settings into thread-local storage from a string or dict. + + .. deprecated:: + Use :class:`Settings` and :class:`Context` for + per-instance configuration instead. Settings and + Context will propagate configurations through object instances, + no thread-local configurations. Avoid mixing Context APIs + and legacy load_settings usage. Args: settings: The settings string or dict to load @@ -840,6 +1114,12 @@ def load_settings(settings: Union[str, dict], format: str = "json") -> None: Raises: C2paError: If there was an error loading the settings """ + warnings.warn( + "load_settings() is deprecated. Use Settings" + " and Context for per-instance configuration.", + DeprecationWarning, + stacklevel=2, + ) _clear_error_state() # Convert to JSON string as necessary @@ -859,11 +1139,7 @@ def load_settings(settings: Union[str, dict], format: str = "json") -> None: raise C2paError(f"Failed to encode settings to UTF-8: {e}") result = _lib.c2pa_load_settings(settings_bytes, format_bytes) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error loading settings") + _check_ffi_operation_result(result, "Error loading settings", check=lambda r: r != 0) return result @@ -936,13 +1212,7 @@ def read_ingredient_file( result = _lib.c2pa_read_ingredient_file( container._path_str, container._data_dir_str) - if result is None: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - "Error reading ingredient file {}".format(path) - ) + _check_ffi_operation_result(result, "Error reading ingredient file {}".format(path)) return _convert_to_py_string(result) @@ -981,13 +1251,7 @@ def read_file(path: Union[str, Path], container._data_dir_str = str(data_dir).encode('utf-8') result = _lib.c2pa_read_file(container._path_str, container._data_dir_str) - if result is None: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error is not None: - raise C2paError(error) - raise C2paError.Other( - "Error during read of manifest from file {}".format(path) - ) + _check_ffi_operation_result(result, "Error during read of manifest from file {}".format(path)) return _convert_to_py_string(result) @@ -1107,119 +1371,423 @@ def sign_file( signer.close() -class Stream: - # Class-level somewhat atomic counter for generating - # unique stream IDs (useful for tracing streams usage in debug) - _stream_id_counter = count(start=0, step=1) +class ContextProvider(ABC): + """Abstract base class for types that provide a C2PA context. - # Maximum value for a 32-bit signed integer (2^31 - 1) - _MAX_STREAM_ID = 2**31 - 1 + Subclass to implement a custom context provider. + The built-in Context class is the standard implementation. + """ - # Class-level error messages to avoid multiple creation - _ERROR_MESSAGES = { - 'stream_error': "Error cleaning up stream: {}", - 'callback_error': "Error cleaning up callback {}: {}", - 'cleanup_error': "Error during cleanup: {}", - 'read': "Stream is closed or not initialized during read operation", - 'memory_error': "Memory error during stream operation: {}", - 'read_error': "Error during read operation: {}", - 'seek': "Stream is closed or not initialized during seek operation", - 'seek_error': "Error during seek operation: {}", - 'write': "Stream is closed or not initialized during write operation", - 'write_error': "Error during write operation: {}", - 'flush': "Stream is closed or not initialized during flush operation", - 'flush_error': "Error during flush operation: {}" - } + @property + @abstractmethod + def is_valid(self) -> bool: ... - def __init__(self, file_like_stream): - """Initialize a new Stream wrapper around a file-like object - (or an in-memory stream). + @property + @abstractmethod + def execution_context(self): ... - Args: - file_like_stream: A file-like stream object or an in-memory stream - that implements read, write, seek, tell, and flush methods - Raises: - TypeError: The file stream object doesn't - implement all required methods - """ - # Initialize _closed first to prevent AttributeError - # during garbage collection - self._closed = False - self._initialized = False - self._stream = None +class Settings(ManagedResource): + """Configuration for C2PA operations. - # Generate unique stream ID using object ID and counter - stream_counter = next(Stream._stream_id_counter) + Settings configure SDK behavior. Use with Context class to + apply settings to Reader/Builder operations. + """ - # Handle counter overflow by resetting the counter - if stream_counter >= Stream._MAX_STREAM_ID: # pragma: no cover - # Reset the counter to 0 and get the next value - Stream._stream_id_counter = count(start=0, step=1) - stream_counter = next(Stream._stream_id_counter) - self._stream_id = f"{id(self)}-{stream_counter}" + def __init__(self): + """Create new Settings with default values.""" + super().__init__() - # Rest of the existing initialization code... - required_methods = ['read', 'write', 'seek', 'tell', 'flush'] - missing_methods = [ - method for method in required_methods if not hasattr( - file_like_stream, method)] - if missing_methods: - raise TypeError( - "Object must be a stream-like object with methods: {}. " - "Missing: {}".format( - ", ".join(required_methods), - ", ".join(missing_methods), - ) - ) + ptr = _lib.c2pa_settings_new() + _check_ffi_operation_result(ptr, "Failed to create Settings") - self._file_like_stream = file_like_stream + self._handle = ptr + self._lifecycle_state = LifecycleState.ACTIVE - def read_callback(ctx, data, length): - """Callback function for reading data from the Python stream. + @classmethod + def from_json(cls, json_str: str) -> 'Settings': + """Create Settings from a serialized JSON configuration string. - This function is called by C2PA library when it needs to read data. - It handles: - - Stream state validation - - Memory safety - - Error handling - - Buffer management + Args: + json_str: JSON string with settings configuration. - Args: - ctx: The stream context (unused) - data: Pointer to the buffer to read into - length: Maximum number of bytes to read + Returns: + A new Settings instance with the given configuration. + """ + settings = cls() + settings.update(json_str) + return settings - Returns: - Number of bytes read, or -1 on error - """ - if not self._initialized or self._closed: - return -1 - try: - if not data or length <= 0: - return -1 + @classmethod + def from_dict(cls, config: dict) -> 'Settings': + """Create Settings from a (JSON-based)dictionary. - buffer = self._file_like_stream.read(length) - if not buffer: # EOF - return 0 + Args: + config: Dictionary with settings configuration. - # Ensure we don't write beyond the allocated memory - actual_length = min(len(buffer), length) - # Direct memory copy - ctypes.memmove(data, buffer, actual_length) + Returns: + A new Settings instance with the given configuration. + """ + return cls.from_json(json.dumps(config)) - return actual_length - except Exception: - return -1 + def set(self, path: str, value: str) -> 'Settings': + """Set a configuration value by dot-notation path. - def seek_callback(ctx, offset, whence): - """Callback function for seeking in the Python stream. + Args: + path: Dot-notation path (e.g. "builder.thumbnail.enabled"). + value: The value to set. - This function is called by the C2PA library when it needs to change - the stream position. It handles: - - Stream state validation - - Position validation + Returns: + self, for method chaining. + """ + self._ensure_valid_state() + + path_bytes = _to_utf8_bytes(path, "settings path") + value_bytes = _to_utf8_bytes(value, "settings value") + + result = _lib.c2pa_settings_set_value( + self._handle, path_bytes, value_bytes + ) + if result != 0: + _parse_operation_result_for_error(None) + + return self + + def update( + self, data: Union[str, dict], + ) -> 'Settings': + """Update current configuration from a JSON string or dict. + + Args: + data: A JSON string or dict with configuration to merge. + + Returns: + self, for method chaining. + """ + self._ensure_valid_state() + + data_bytes = _to_utf8_bytes(data, "settings data") + + result = _lib.c2pa_settings_update_from_string( + self._handle, data_bytes, b"json" + ) + if result != 0: + _parse_operation_result_for_error(None) + + return self + + @property + def _c_settings(self): + """Expose the raw pointer for Context to consume.""" + self._ensure_valid_state() + return self._handle + + + +class ContextBuilder: + """Fluent builder for Context. + + Use Context.builder() to create an instance. + """ + + def __init__(self): + self._settings = None + self._signer = None + + def with_settings( + self, settings: 'Settings', + ) -> 'ContextBuilder': + """Attach Settings to the Context being built.""" + self._settings = settings + return self + + def with_signer( + self, signer: 'Signer', + ) -> 'ContextBuilder': + """Attach a Signer (will be consumed on build).""" + self._signer = signer + return self + + def build(self) -> 'Context': + """Build and return a configured Context.""" + return Context( + settings=self._settings, + signer=self._signer, + ) + + +class Context(ManagedResource, ContextProvider): + """Per-instance context for C2PA operations. + + A Context may carry Settings and a Signer, + and is passed to Reader or Builder to control their behavior, + thus propagating settings and configurations by passing + the Context object (+settings on it) as parameter. + + When a Signer is provided, the Signer object is consumed, + as it becomes included into the Context, and must not be + used directly again after that. + """ + + + def __init__( + self, + settings: Optional['Settings'] = None, + signer: Optional['Signer'] = None, + ): + """Create a Context. + + Args: + settings: Optional Settings for configuration. + If None, default SDK settings are used. + signer: Optional Signer. If provided it is consumed + and must not be used directly again after that call. + + Raises: + C2paError: If creation fails + """ + super().__init__() + self._has_signer = False + self._signer_callback_cb = None + + if settings is None and signer is None: + # Simple default context + ptr = _lib.c2pa_context_new() + _check_ffi_operation_result( + ptr, "Failed to create Context" + ) + self._handle = ptr + else: + # Use ContextBuilder for settings/signer + builder_ptr = _lib.c2pa_context_builder_new() + _check_ffi_operation_result( + builder_ptr, "Failed to create ContextBuilder" + ) + + try: + if settings is not None: + result = ( + _lib.c2pa_context_builder_set_settings( + builder_ptr, settings._c_settings, + ) + ) + if result != 0: + _parse_operation_result_for_error(None) + + if signer is not None: + signer._ensure_valid_state() + result = ( + _lib.c2pa_context_builder_set_signer( + builder_ptr, signer._handle, + ) + ) + if result != 0: + _parse_operation_result_for_error(None) + + # Build consumes builder_ptr + ptr = ( + _lib.c2pa_context_builder_build(builder_ptr) + ) + builder_ptr = None + + _check_ffi_operation_result( + ptr, "Failed to build Context" + ) + self._handle = ptr + + # Build succeeded, consume the Signer. + # Keep its callback ref alive on this Context, + # then mark it so it won't double-free the + # pointer the Context now owns. + if signer is not None: + self._signer_callback_cb = signer._callback_cb + signer._mark_consumed() + self._has_signer = True + except Exception: + # Free builder if build was not reached + if builder_ptr is not None: + try: + ManagedResource._free_native_ptr(builder_ptr) + except Exception: + pass + raise + + self._lifecycle_state = LifecycleState.ACTIVE + + def _release(self): + """Release Context-specific resources.""" + self._signer_callback_cb = None + + @classmethod + def builder(cls) -> 'ContextBuilder': + """Return a fluent ContextBuilder.""" + return ContextBuilder() + + @classmethod + def from_json( + cls, + json_str: str, + signer: Optional['Signer'] = None, + ) -> 'Context': + """Create Context from a JSON configuration string. + + Args: + json_str: JSON string with settings config. + signer: Optional Signer (consumed if provided). + + Returns: + A new Context instance. + """ + settings = Settings.from_json(json_str) + try: + return cls(settings=settings, signer=signer) + finally: + settings.close() + + @classmethod + def from_dict( + cls, + config: dict, + signer: Optional['Signer'] = None, + ) -> 'Context': + """Create Context from a dictionary. + + Args: + config: Dictionary with settings configuration. + signer: Optional Signer (consumed if provided). + + Returns: + A new Context instance. + """ + return cls.from_json(json.dumps(config), signer=signer) + + @property + def has_signer(self) -> bool: + """Whether this context was created with a signer.""" + return self._has_signer + + @property + def execution_context(self): + """Return the raw C2paContext pointer.""" + self._ensure_valid_state() + return self._handle + + + +class Stream: + # Class-level somewhat atomic counter for generating + # unique stream IDs (useful for tracing streams usage in debug) + _stream_id_counter = count(start=0, step=1) + + # Maximum value for a 32-bit signed integer (2^31 - 1) + _MAX_STREAM_ID = 2**31 - 1 + + # Class-level error messages to avoid multiple creation + _ERROR_MESSAGES = { + 'stream_error': "Error cleaning up stream: {}", + 'callback_error': "Error cleaning up callback {}: {}", + 'cleanup_error': "Error during cleanup: {}", + 'read': "Stream is closed or not initialized during read operation", + 'memory_error': "Memory error during stream operation: {}", + 'read_error': "Error during read operation: {}", + 'seek': "Stream is closed or not initialized during seek operation", + 'seek_error': "Error during seek operation: {}", + 'write': "Stream is closed or not initialized during write operation", + 'write_error': "Error during write operation: {}", + 'flush': "Stream is closed or not initialized during flush operation", + 'flush_error': "Error during flush operation: {}" + } + + def __init__(self, file_like_stream): + """Initialize a new Stream wrapper around a file-like object + (or an in-memory stream). + + Args: + file_like_stream: A file-like stream object or an in-memory stream + that implements read, write, seek, tell, and flush methods + + Raises: + TypeError: The file stream object doesn't + implement all required methods + """ + # Initialize _closed first to prevent AttributeError + # during garbage collection + self._closed = False + self._initialized = False + self._stream = None + + # Generate unique stream ID using object ID and counter + stream_counter = next(Stream._stream_id_counter) + + # Handle counter overflow by resetting the counter + if stream_counter >= Stream._MAX_STREAM_ID: # pragma: no cover + # Reset the counter to 0 and get the next value + Stream._stream_id_counter = count(start=0, step=1) + stream_counter = next(Stream._stream_id_counter) + + self._stream_id = f"{id(self)}-{stream_counter}" + + # Rest of the existing initialization code... + required_methods = ['read', 'write', 'seek', 'tell', 'flush'] + missing_methods = [ + method for method in required_methods if not hasattr( + file_like_stream, method)] + if missing_methods: + raise TypeError( + "Object must be a stream-like object with methods: {}. " + "Missing: {}".format( + ", ".join(required_methods), + ", ".join(missing_methods), + ) + ) + + self._file_like_stream = file_like_stream + + def read_callback(ctx, data, length): + """Callback function for reading data from the Python stream. + + This function is called by C2PA library when it needs to read data. + It handles: + - Stream state validation + - Memory safety + - Error handling + - Buffer management + + Args: + ctx: The stream context (unused) + data: Pointer to the buffer to read into + length: Maximum number of bytes to read + + Returns: + Number of bytes read, or -1 on error + """ + if not self._initialized or self._closed: + return -1 + try: + if not data or length <= 0: + return -1 + + buffer = self._file_like_stream.read(length) + if not buffer: # EOF + return 0 + + # Ensure we don't write beyond the allocated memory + actual_length = min(len(buffer), length) + # Direct memory copy + ctypes.memmove(data, buffer, actual_length) + + return actual_length + except Exception: + return -1 + + def seek_callback(ctx, offset, whence): + """Callback function for seeking in the Python stream. + + This function is called by the C2PA library when it needs to change + the stream position. It handles: + - Stream state validation + - Position validation - Error handling Args: @@ -1306,6 +1874,7 @@ def flush_callback(ctx): self._flush_cb = FlushCallback(flush_callback) # Create the stream + _clear_error_state() self._stream = _lib.c2pa_create_stream( None, self._read_cb, @@ -1423,7 +1992,89 @@ def initialized(self) -> bool: return self._initialized -class Reader: +def _get_supported_mime_types(ffi_func, cache): + """Shared helper to retrieve supported MIME types from the native library. + + Args: + ffi_func: The FFI function to call (e.g. _lib.c2pa_reader_supported_mime_types) + cache: The current cache value (frozenset or None) + + Returns: + A tuple of (list of MIME type strings, updated cache value) + """ + if cache is not None: + return list(cache), cache + + _clear_error_state() + count = ctypes.c_size_t() + arr = ffi_func(ctypes.byref(count)) + + if not arr: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(f"Failed to get supported MIME types: {error}") + return [], cache + + if count.value <= 0: + try: + _lib.c2pa_free_string_array(arr, count.value) + except Exception: + pass + return [], cache + + try: + result = [] + for i in range(count.value): + try: + if arr[i] is None: + continue + mime_type = arr[i].decode("utf-8", errors='replace') + if mime_type: + result.append(mime_type) + except Exception: + continue + finally: + try: + _lib.c2pa_free_string_array(arr, count.value) + except Exception: + pass + + if result: + cache = frozenset(result) + + if cache: + return list(cache), cache + return [], cache + + +def _validate_and_encode_format( + format_str: str, supported_types: list[str], class_name: str +) -> bytes: + """Validate a MIME type / format string and encode it to UTF-8 bytes. + + Args: + format_str: The MIME type or format string to validate + supported_types: List of supported MIME types + class_name: Name of the calling class (for error messages) + + Returns: + UTF-8 encoded format bytes + + Raises: + C2paError.NotSupported: If the format is not supported + C2paError.Encoding: If the string contains invalid UTF-8 characters + """ + if format_str.lower() not in supported_types: + raise C2paError.NotSupported( + f"{class_name} does not support {format_str}") + try: + return format_str.encode('utf-8') + except UnicodeError as e: + raise C2paError.Encoding( + f"Invalid UTF-8 characters in input: {e}") + + +class Reader(ManagedResource): """High-level wrapper for C2PA Reader operations. Example: @@ -1434,6 +2085,7 @@ class Reader: Where `output` is either an in-memory stream or an opened file. """ + # Supported mimetypes cache _supported_mime_types_cache = None @@ -1448,7 +2100,8 @@ class Reader: 'file_error': "Error cleaning up file: {}", 'reader_cleanup_error': "Error cleaning up reader: {}", 'encoding_error': "Invalid UTF-8 characters in input: {}", - 'closed_error': "Reader is closed" + 'closed_error': "Reader is closed", + 'fragment_error': "Failed to process fragment: {}" } @classmethod @@ -1462,63 +2115,52 @@ def get_supported_mime_types(cls) -> list[str]: Raises: C2paError: If there was an error retrieving the MIME types """ - if cls._supported_mime_types_cache is not None: - return cls._supported_mime_types_cache - - count = ctypes.c_size_t() - arr = _lib.c2pa_reader_supported_mime_types(ctypes.byref(count)) - - # Validate the returned array pointer - if not arr: - # If no array returned, check for errors - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(f"Failed to get supported MIME types: {error}") - # Return empty list if no error but no array - return [] - - # Validate count value - if count.value <= 0: - # Free the array even if count is invalid - try: - _lib.c2pa_free_string_array(arr, count.value) - except Exception: - pass - return [] + result, cls._supported_mime_types_cache = _get_supported_mime_types( + _lib.c2pa_reader_supported_mime_types, cls._supported_mime_types_cache + ) + return result - try: - result = [] - for i in range(count.value): - try: - # Validate each array element before accessing - if arr[i] is None: - continue + @classmethod + def _is_mime_type_supported(cls, mime_type: str) -> bool: + """Check if a MIME type is supported. - mime_type = arr[i].decode("utf-8", errors='replace') - if mime_type: - result.append(mime_type) - except Exception: - # Ignore cleanup errors - continue - finally: - # Always free the native memory, even if string extraction fails - try: - _lib.c2pa_free_string_array(arr, count.value) - except Exception: - # Ignore cleanup errors - pass + Args: + mime_type: The MIME type to check + + Returns: + True if the MIME type is supported + """ + if cls._supported_mime_types_cache is None: + cls.get_supported_mime_types() + return mime_type in cls._supported_mime_types_cache - # Cache the result - if result: - cls._supported_mime_types_cache = result + @classmethod + @overload + def try_create( + cls, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None, + ) -> Optional["Reader"]: ... - return cls._supported_mime_types_cache + @classmethod + @overload + def try_create( + cls, + format_or_path: Union[str, Path], + stream: Optional[Any], + manifest_data: Optional[Any], + context: 'ContextProvider', + ) -> Optional["Reader"]: ... @classmethod - def try_create(cls, - format_or_path: Union[str, Path], - stream: Optional[Any] = None, - manifest_data: Optional[Any] = None) -> Optional["Reader"]: + def try_create( + cls, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None, + context: Optional['ContextProvider'] = None, + ) -> Optional["Reader"]: """This is a factory method to create a new Reader, returning None if no manifest/c2pa data/JUMBF data could be read (instead of raising a ManifestNotFound: no JUMBF data found exception). @@ -1532,6 +2174,7 @@ def try_create(cls, format_or_path: The format or path to read from stream: Optional stream to read from (Python stream-like object) manifest_data: Optional manifest data in bytes + context: Optional ContextProvider for settings Returns: Reader instance if the asset contains C2PA data, @@ -1541,49 +2184,78 @@ def try_create(cls, C2paError: If there was an error other than ManifestNotFound """ try: - # Reader creations checks deferred to the constructor __init__ method - return cls(format_or_path, stream, manifest_data) + return cls( + format_or_path, stream, manifest_data, + context=context, + ) except C2paError.ManifestNotFound: - # Nothing to read, so no Reader returned return None - def __init__(self, - format_or_path: Union[str, - Path], - stream: Optional[Any] = None, - manifest_data: Optional[Any] = None): + @overload + def __init__( + self, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None, + ) -> None: ... + + @overload + def __init__( + self, + format_or_path: Union[str, Path], + stream: Optional[Any], + manifest_data: Optional[Any], + context: 'ContextProvider', + ) -> None: ... + + def __init__( + self, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None, + context: Optional['ContextProvider'] = None, + ): """Create a new Reader. Args: format_or_path: The format or path to read from stream: Optional stream to read from (Python stream-like object) manifest_data: Optional manifest data in bytes + context: Optional context implementing ContextProvider with settings Raises: C2paError: If there was an error creating the reader C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters """ - # Native libs plumbing: - # Clear any stale error state from previous operations - _clear_error_state() - - self._closed = False - self._initialized = False + super().__init__() - self._reader = None self._own_stream = None # This is used to keep track of a file # we may have opened ourselves, and that we need to close later self._backing_file = None - # Caches for manifest JSON string and parsed data + # Caches for manifest JSON string and parsed data. + # These are invalidated when with_fragment() is called, because each + # new BMFF fragment can refine or update the manifest content as the + # reader progressively builds its understanding of the fragmented stream. + # They are also cleared on close() to release memory. self._manifest_json_str_cache = None self._manifest_data_cache = None + self._context = context + + if context is not None: + self._init_from_context( + context, format_or_path, stream, + manifest_data, + ) + return + + supported = Reader.get_supported_mime_types() + if stream is None: - # If we don't get a stream as param: # Create a stream from the file path in format_or_path path = str(format_or_path) mime_type = _get_mime_type_from_path(path) @@ -1592,237 +2264,185 @@ def __init__(self, raise C2paError.NotSupported( f"Could not determine MIME type for file: {path}") - if mime_type not in Reader.get_supported_mime_types(): - raise C2paError.NotSupported( - f"Reader does not support {mime_type}") - - try: - mime_type_str = mime_type.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Reader._ERROR_MESSAGES['encoding_error'].format( - str(e))) - - try: - with open(path, 'rb') as file: - self._own_stream = Stream(file) - - self._reader = _lib.c2pa_reader_from_stream( - mime_type_str, - self._own_stream._stream - ) - - if not self._reader: - self._own_stream.close() - error = _parse_operation_result_for_error( - _lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Reader._ERROR_MESSAGES['reader_error'].format( - "Unknown error" - ) - ) - - # Store the file to close it later - self._backing_file = file - self._initialized = True + format_bytes = _validate_and_encode_format( + mime_type, supported, "Reader") + self._init_from_file(path, format_bytes) - except Exception as e: - # File automatically closed by context manager - if self._own_stream: - self._own_stream.close() - if hasattr(self, '_backing_file') and self._backing_file: - self._backing_file.close() - raise C2paError.Io( - Reader._ERROR_MESSAGES['io_error'].format( - str(e))) elif isinstance(stream, str): - # We may have gotten format + a file path - # If stream is a string, treat it as a path and try to open it + # stream is a file path, format_or_path is the format + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") + self._init_from_file( + stream, format_bytes, manifest_data) - # format_or_path is a format - format_lower = format_or_path.lower() - if format_lower not in Reader.get_supported_mime_types(): - raise C2paError.NotSupported( - f"Reader does not support {format_or_path}") - - try: - with open(stream, 'rb') as file: - self._own_stream = Stream(file) - - format_str = str(format_or_path) - format_bytes = format_str.encode('utf-8') - - if manifest_data is None: - self._reader = _lib.c2pa_reader_from_stream( - format_bytes, self._own_stream._stream) - else: - if not isinstance(manifest_data, bytes): - raise TypeError( - Reader._ERROR_MESSAGES['manifest_error']) - manifest_array = ( - ctypes.c_ubyte * - len(manifest_data))( - * - manifest_data) - self._reader = ( - _lib.c2pa_reader_from_manifest_data_and_stream( - format_bytes, - self._own_stream._stream, - manifest_array, - len(manifest_data), - ) - ) - - if not self._reader: - self._own_stream.close() - error = _parse_operation_result_for_error( - _lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Reader._ERROR_MESSAGES['reader_error'].format( - "Unknown error" - ) - ) - - self._backing_file = file - self._initialized = True - except Exception as e: - # File closed by context manager - if self._own_stream: - self._own_stream.close() - if hasattr(self, '_backing_file') and self._backing_file: - self._backing_file.close() - raise C2paError.Io( - Reader._ERROR_MESSAGES['io_error'].format( - str(e))) else: - # format_or_path is a format string - format_str = str(format_or_path) - if format_str.lower() not in Reader.get_supported_mime_types(): - raise C2paError.NotSupported( - f"Reader does not support {format_str}") - - # Use the provided stream - self._format_str = format_str.encode('utf-8') + # format_or_path is a format string, stream is a stream object + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") with Stream(stream) as stream_obj: - if manifest_data is None: - self._reader = _lib.c2pa_reader_from_stream( - self._format_str, stream_obj._stream) - else: - if not isinstance(manifest_data, bytes): - raise TypeError( - Reader._ERROR_MESSAGES['manifest_error']) - manifest_array = ( - ctypes.c_ubyte * - len(manifest_data))( - * - manifest_data) - self._reader = ( - _lib.c2pa_reader_from_manifest_data_and_stream( - self._format_str, - stream_obj._stream, - manifest_array, - len(manifest_data) - ) - ) - - if not self._reader: - error = _parse_operation_result_for_error( - _lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Reader._ERROR_MESSAGES['reader_error'].format( - "Unknown error" - ) - ) + self._create_reader( + format_bytes, stream_obj, manifest_data) + self._lifecycle_state = LifecycleState.ACTIVE - self._initialized = True + def _create_reader(self, format_bytes, stream_obj, + manifest_data=None): + """Create a Reader from a Stream. - def __enter__(self): - self._ensure_valid_state() - return self + Args: + format_bytes: UTF-8 encoded format/MIME type + stream_obj: A Stream instance + manifest_data: Optional manifest bytes + """ + if manifest_data is None: + self._handle = _lib.c2pa_reader_from_stream( + format_bytes, stream_obj._stream) + else: + if not isinstance(manifest_data, bytes): + raise TypeError(Reader._ERROR_MESSAGES['manifest_error']) + manifest_array = ( + ctypes.c_ubyte * + len(manifest_data))( + *manifest_data) + self._handle = ( + _lib.c2pa_reader_from_manifest_data_and_stream( + format_bytes, + stream_obj._stream, + manifest_array, + len(manifest_data), + ) + ) - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() + _check_ffi_operation_result(self._handle, + Reader._ERROR_MESSAGES['reader_error'].format("Unknown error") + ) - def __del__(self): - """Ensure resources are cleaned up if close() wasn't called. + def _init_from_file(self, path, format_bytes, + manifest_data=None): + """Open a file and create a reader from it. - This destructor handles cleanup without causing double frees. - It only cleans up if the object hasn't been explicitly closed. + Args: + path: File path to open + format_bytes: UTF-8 encoded format/MIME type + manifest_data: Optional manifest bytes """ - self._cleanup_resources() - - def _ensure_valid_state(self): - """Ensure the reader is in a valid state for operations. - - Raises: - C2paError: If the reader is closed, not initialized, or invalid + try: + self._backing_file = open(path, 'rb') + self._own_stream = Stream(self._backing_file) + self._create_reader(format_bytes, self._own_stream, manifest_data) + self._lifecycle_state = LifecycleState.ACTIVE + except C2paError: + self._close_streams() + raise + except Exception as e: + self._close_streams() + raise C2paError.Io( + Reader._ERROR_MESSAGES['io_error'].format(str(e))) + + def _init_from_context(self, context, format_or_path, + stream, manifest_data=None): + """Initialize Reader from a Context object implementing + the ContextProvider interface/abstract base class. """ - if self._closed: - raise C2paError(Reader._ERROR_MESSAGES['closed_error']) - if not self._initialized: - raise C2paError("Reader is not properly initialized") - if not self._reader: - raise C2paError(Reader._ERROR_MESSAGES['closed_error']) + if not context.is_valid: + raise C2paError("Context is not valid") - def _cleanup_resources(self): - """Internal cleanup method that releases native resources. + # Determine format and open stream + supported = Reader.get_supported_mime_types() + + if stream is None: + path = str(format_or_path) + mime_type = _get_mime_type_from_path(path) + if not mime_type: + raise C2paError.NotSupported( + f"Could not determine MIME type for file: {path}") + format_bytes = _validate_and_encode_format( + mime_type, supported, "Reader") + self._backing_file = open(path, 'rb') + self._own_stream = Stream(self._backing_file) + elif isinstance(stream, str): + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") + self._backing_file = open(stream, 'rb') + self._own_stream = Stream(self._backing_file) + else: + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") + self._own_stream = Stream(stream) - This method handles the actual cleanup logic and can be called - from both close() and __del__ without causing double frees. - """ try: - # Only cleanup if not already closed and we have a valid reader - if hasattr(self, '_closed') and not self._closed: - self._closed = True + # Create reader from context + reader_ptr = _lib.c2pa_reader_from_context( + context.execution_context, + ) + _check_ffi_operation_result(reader_ptr, + Reader._ERROR_MESSAGES[ + 'reader_error' + ].format("Unknown error") + ) - # Clean up reader - if hasattr(self, '_reader') and self._reader: - try: - _lib.c2pa_reader_free(self._reader) - except Exception: - # Cleanup failure doesn't raise exceptions - logger.error( - "Failed to free native Reader resources" - ) - pass - finally: - self._reader = None + if manifest_data is not None: + if not isinstance(manifest_data, bytes): + raise TypeError( + Reader._ERROR_MESSAGES[ + 'manifest_error']) + manifest_array = ( + ctypes.c_ubyte * + len(manifest_data))( + *manifest_data) + # Consume current reader, + # with manifest data and stream (C FFI pattern), + # to create a new one (switch out) + new_ptr = ( + _lib.c2pa_reader_with_manifest_data_and_stream( + reader_ptr, + format_bytes, + self._own_stream._stream, + manifest_array, + len(manifest_data), + ) + ) + # reader_ptr has been invalidated(consumed) + else: + # Consume reader with stream + new_ptr = _lib.c2pa_reader_with_stream( + reader_ptr, format_bytes, + self._own_stream._stream, + ) + # reader_ptr has been invalidated(consumed) - # Clean up stream - if hasattr(self, '_own_stream') and self._own_stream: - try: - self._own_stream.close() - except Exception: - # Cleanup failure doesn't raise exceptions - logger.error("Failed to close Reader stream") - pass - finally: - self._own_stream = None + _check_ffi_operation_result(new_ptr, + Reader._ERROR_MESSAGES[ + 'reader_error' + ].format("Unknown error") + ) - # Clean up backing file (if needed) - if self._backing_file: - try: - self._backing_file.close() - except Exception: - # Cleanup failure doesn't raise exceptions - logger.warning("Failed to close Reader backing file") - pass - finally: - self._backing_file = None + self._handle = new_ptr + self._lifecycle_state = LifecycleState.ACTIVE + except Exception: + self._close_streams() + raise - # Reset initialized state after cleanup - self._initialized = False + def _close_streams(self): + """Close owned stream and backing file if present.""" + if getattr(self, '_own_stream', None): + try: + self._own_stream.close() + except Exception: + logger.error("Failed to close Reader stream") + finally: + self._own_stream = None + if getattr(self, '_backing_file', None): + try: + self._backing_file.close() + except Exception: + logger.warning("Failed to close Reader backing file") + finally: + self._backing_file = None - except Exception: - # Ensure we don't raise exceptions during cleanup - pass + def _release(self): + """Release Reader-specific resources (stream, backing file).""" + self._close_streams() def _get_cached_manifest_data(self) -> Optional[dict]: """Get the cached manifest data, fetching and parsing if not cached. @@ -1851,30 +2471,61 @@ def _get_cached_manifest_data(self) -> Optional[dict]: return self._manifest_data_cache - def close(self): - """Release the reader resources. + def with_fragment(self, format: str, stream, + fragment_stream) -> "Reader": + """Process a BMFF fragment stream with this reader. - This method ensures all resources are properly cleaned up, - even if errors occur during cleanup. - Errors during cleanup are logged but not raised to ensure cleanup. - Multiple calls to close() are handled gracefully. + Used for fragmented BMFF media (DASH/HLS streaming) where + content is split into init segments and fragment files. + + Args: + format: MIME type of the media (e.g., "video/mp4") + stream: Stream-like object with the main/init segment data + fragment_stream: Stream-like object with the fragment data + + Returns: + This reader instance, for method chaining. + + Raises: + C2paError: If there was an error processing the fragment """ - if self._closed: - return + self._ensure_valid_state() - try: - # Use the internal cleanup method - self._cleanup_resources() - except Exception as e: - # Log any unexpected errors during close - logger.error( - Reader._ERROR_MESSAGES['cleanup_error'].format( - str(e))) - finally: - # Clear the cache when closing - self._manifest_json_str_cache = None - self._manifest_data_cache = None - self._closed = True + supported = Reader.get_supported_mime_types() + format_bytes = _validate_and_encode_format( + format, supported, "Reader" + ) + + with Stream(stream) as main_obj, Stream(fragment_stream) as frag_obj: + new_ptr = _lib.c2pa_reader_with_fragment( + self._handle, + format_bytes, + main_obj._stream, + frag_obj._stream, + ) + + if not new_ptr: + self._handle = None + _check_ffi_operation_result(new_ptr, + Reader._ERROR_MESSAGES[ + 'fragment_error' + ].format("Unknown error")) + self._handle = new_ptr + + # Invalidate caches: processing a new BMFF fragment updates the native + # reader's state, which can change the manifest data it returns. + # The cached JSON string and parsed dict may now be stale, so clear + # them to force a fresh read from the native layer on next access. + self._manifest_json_str_cache = None + self._manifest_data_cache = None + + return self + + def close(self): + """Release the reader resources.""" + self._manifest_json_str_cache = None + self._manifest_data_cache = None + super().close() def json(self) -> str: """Get the manifest store as a JSON string. @@ -1892,13 +2543,9 @@ def json(self) -> str: if self._manifest_json_str_cache is not None: return self._manifest_json_str_cache - result = _lib.c2pa_reader_json(self._reader) - - if result is None: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error during manifest parsing in Reader") + result = _lib.c2pa_reader_json(self._handle) + _check_ffi_operation_result(result, + "Error during manifest parsing in Reader") # Cache the result and return it self._manifest_json_str_cache = _convert_to_py_string(result) @@ -1922,16 +2569,30 @@ def detailed_json(self) -> str: self._ensure_valid_state() - result = _lib.c2pa_reader_detailed_json(self._reader) - - if result is None: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error during detailed manifest parsing in Reader") + result = _lib.c2pa_reader_detailed_json(self._handle) + _check_ffi_operation_result(result, + "Error during detailed manifest parsing in Reader") return _convert_to_py_string(result) + def _get_manifest_field(self, extractor): + """Extract a field from (cached) manifest data, or None if unavailable. + + Args: + extractor: A callable that takes the parsed manifest dict + and returns the desired field value. + + Returns: + Extracted field value, or None if not available. + """ + try: + data = self._get_cached_manifest_data() + if data is None: + return None + return extractor(data) + except C2paError.ManifestNotFound: + return None + def get_active_manifest(self) -> Optional[dict]: """Get the active manifest from the manifest store. @@ -1946,30 +2607,18 @@ def get_active_manifest(self) -> Optional[dict]: Raises: KeyError: If the active_manifest key is missing from the JSON """ - try: - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() - if manifest_data is None: - # raise C2paError("Failed to parse manifest JSON") - return None - - # Get the active manfiest id/label - if "active_manifest" not in manifest_data: + def _extract(data): + if "active_manifest" not in data: raise KeyError("No 'active_manifest' key found") - - active_manifest_id = manifest_data["active_manifest"] - - # Retrieve the active manifest data using manifest id/label - if "manifests" not in manifest_data: + active_manifest_id = data["active_manifest"] + if "manifests" not in data: raise KeyError("No 'manifests' key found in manifest data") - - manifests = manifest_data["manifests"] + manifests = data["manifests"] if active_manifest_id not in manifests: raise KeyError("Active manifest not found in manifest store") - return manifests[active_manifest_id] - except C2paError.ManifestNotFound: - return None + + return self._get_manifest_field(_extract) def get_manifest(self, label: str) -> Optional[dict]: """Get a specific manifest from the manifest store by its label. @@ -1988,23 +2637,15 @@ def get_manifest(self, label: str) -> Optional[dict]: Raises: KeyError: If the manifests key is missing from the JSON """ - try: - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() - if manifest_data is None: - # raise C2paError("Failed to parse manifest JSON") - return None - - if "manifests" not in manifest_data: + def _extract(data): + if "manifests" not in data: raise KeyError("No 'manifests' key found in manifest data") - - manifests = manifest_data["manifests"] + manifests = data["manifests"] if label not in manifests: raise KeyError(f"Manifest {label} not found in manifest store") - return manifests[label] - except C2paError.ManifestNotFound: - return None + + return self._get_manifest_field(_extract) def get_validation_state(self) -> Optional[str]: """Get the validation state of the manifest store. @@ -2018,15 +2659,7 @@ def get_validation_state(self) -> Optional[str]: or None if the validation_state field is not present or if no manifest is found or if there was an error parsing the JSON. """ - try: - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() - if manifest_data is None: - return None - - return manifest_data.get("validation_state") - except C2paError.ManifestNotFound: - return None + return self._get_manifest_field(lambda d: d.get("validation_state")) def get_validation_results(self) -> Optional[dict]: """Get the validation results of the manifest store. @@ -2041,15 +2674,7 @@ def get_validation_results(self) -> Optional[dict]: field is not present or if no manifest is found or if there was an error parsing the JSON. """ - try: - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() - if manifest_data is None: - return None - - return manifest_data.get("validation_results") - except C2paError.ManifestNotFound: - return None + return self._get_manifest_field(lambda d: d.get("validation_results")) def resource_to_stream(self, uri: str, stream: Any) -> int: """Write a resource to a stream. @@ -2069,15 +2694,11 @@ def resource_to_stream(self, uri: str, stream: Any) -> int: uri_str = uri.encode('utf-8') with Stream(stream) as stream_obj: result = _lib.c2pa_reader_resource_to_stream( - self._reader, uri_str, stream_obj._stream) - - if result < 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError.Other( - "Error during resource {} to stream conversion".format(uri) - ) + self._handle, uri_str, stream_obj._stream) + + _check_ffi_operation_result(result, + "Error during resource {} to stream conversion".format(uri), + check=lambda r: r < 0) return result @@ -2093,7 +2714,7 @@ def is_embedded(self) -> bool: """ self._ensure_valid_state() - result = _lib.c2pa_reader_is_embedded(self._reader) + result = _lib.c2pa_reader_is_embedded(self._handle) return bool(result) @@ -2110,7 +2731,7 @@ def get_remote_url(self) -> Optional[str]: """ self._ensure_valid_state() - result = _lib.c2pa_reader_remote_url(self._reader) + result = _lib.c2pa_reader_remote_url(self._handle) if result is None: # No remote URL set (manifest is embedded) @@ -2121,9 +2742,10 @@ def get_remote_url(self) -> Optional[str]: return url_str -class Signer: +class Signer(ManagedResource): """High-level wrapper for C2PA Signer operations.""" + # Class-level error messages to avoid multiple creation _ERROR_MESSAGES = { 'closed_error': "Signer is closed", @@ -2156,13 +2778,8 @@ def from_info(cls, signer_info: C2paSignerInfo) -> 'Signer': signer_ptr = _lib.c2pa_signer_from_info(ctypes.byref(signer_info)) - if not signer_ptr: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - # More detailed error message when possible - raise C2paError(error) - raise C2paError( - "Failed to create signer from configured signer_info") + _check_ffi_operation_result(signer_ptr, + "Failed to create signer from configured signer_info") return cls(signer_ptr) @@ -2295,130 +2912,43 @@ def wrapped_callback( tsa_url_bytes ) - if not signer_ptr: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to create signer") - - # Create and return the signer instance with the callback - signer_instance = cls(signer_ptr) - - # Keep callback alive on the object to prevent gc of the callback - # So the callback will live as long as the signer leaves, - # and there is a 1:1 relationship between signer and callback. - signer_instance._callback_cb = callback_cb - - return signer_instance - - def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): - """Initialize a new Signer instance. - - Note: This constructor is not meant to be called directly. - Use from_info() or from_callback() instead. - - Args: - signer_ptr: Pointer to the native C2PA signer - - Raises: - C2paError: If the signer pointer is invalid - """ - # Native libs plumbing: - # Clear any stale error state from previous operations - _clear_error_state() - - # Validate pointer before assignment - if not signer_ptr: - raise C2paError("Invalid signer pointer: pointer is null") - - self._signer = signer_ptr - self._closed = False - - # Set only for signers which are callback signers - self._callback_cb = None + _check_ffi_operation_result(signer_ptr, + "Failed to create signer") - def __enter__(self): - """Context manager entry.""" - self._ensure_valid_state() - - if not self._signer: - raise C2paError("Invalid signer pointer: pointer is null") - - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit.""" - self.close() - - def _cleanup_resources(self): - """Internal cleanup method that releases native resources. - - This method handles the actual cleanup logic and can be called - from both close() and __del__ without causing double frees. - """ - try: - if not self._closed and self._signer: - self._closed = True + # Create and return the signer instance with the callback + signer_instance = cls(signer_ptr) - try: - _lib.c2pa_signer_free(self._signer) - except Exception: - # Log cleanup errors but don't raise exceptions - logger.error("Failed to free native Signer resources") - finally: - self._signer = None + # Keep callback alive on the object to prevent gc of the callback + # So the callback will live as long as the signer leaves, + # and there is a 1:1 relationship between signer and callback. + signer_instance._callback_cb = callback_cb - # Clean up callback reference - if self._callback_cb: - self._callback_cb = None + return signer_instance - except Exception: - # Ensure we don't raise exceptions during cleanup - pass + def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): + """Initialize a new Signer instance. This constructor is not meant + to be called directly. Use from_info() or from_callback() instead. - def _ensure_valid_state(self): - """Ensure the signer is in a valid state for operations. + Args: + signer_ptr: Pointer to the native C2PA signer Raises: - C2paError: If the signer is closed or invalid + C2paError: If the signer pointer is invalid """ - if self._closed: - raise C2paError(Signer._ERROR_MESSAGES['closed_error']) - if not self._signer: - raise C2paError(Signer._ERROR_MESSAGES['closed_error']) - - def close(self): - """Release the signer resources. + super().__init__() - This method ensures all resources are properly cleaned up, - even if errors occur during cleanup. + self._callback_cb = None - Note: - Multiple calls to close() are handled gracefully. - Errors during cleanup are logged but not raised - to ensure cleanup. - """ - if self._closed: - return + if not signer_ptr: + raise C2paError("Invalid signer pointer: pointer is null") - try: - # Validate pointer before cleanup if it exists - if self._signer and self._signer != 0: - # Use the internal cleanup method - self._cleanup_resources() - else: - # Make sure to release the callback - if self._callback_cb: - self._callback_cb = None + self._handle = signer_ptr + self._lifecycle_state = LifecycleState.ACTIVE - except Exception as e: - # Log any unexpected errors during close - logger.error( - Signer._ERROR_MESSAGES['cleanup_error'].format( - str(e))) - finally: - # Always mark as closed, regardless of cleanup success - self._closed = True + def _release(self): + """Release Signer-specific resources (callback reference).""" + if self._callback_cb: + self._callback_cb = None def reserve_size(self) -> int: """Get the size to reserve for signatures from this signer. @@ -2431,20 +2961,18 @@ def reserve_size(self) -> int: """ self._ensure_valid_state() - result = _lib.c2pa_signer_reserve_size(self._signer) + result = _lib.c2pa_signer_reserve_size(self._handle) - if result < 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to get reserve size") + _check_ffi_operation_result(result, + "Failed to get reserve size", check=lambda r: r < 0) return result -class Builder: +class Builder(ManagedResource): """High-level wrapper for C2PA Builder operations.""" + # Supported mimetypes cache _supported_mime_types_cache = None @@ -2476,64 +3004,37 @@ def get_supported_mime_types(cls) -> list[str]: Raises: C2paError: If there was an error retrieving the MIME types """ - if cls._supported_mime_types_cache is not None: - return cls._supported_mime_types_cache - - count = ctypes.c_size_t() - arr = _lib.c2pa_builder_supported_mime_types(ctypes.byref(count)) - - # Validate the returned array pointer - if not arr: - # If no array returned, check for errors - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(f"Failed to get supported MIME types: {error}") - # Return empty list if no error but no array - return [] - - # Validate count value - if count.value <= 0: - # Free the array even if count is invalid - try: - _lib.c2pa_free_string_array(arr, count.value) - except Exception: - pass - return [] - - try: - result = [] - for i in range(count.value): - try: - # Validate each array element before accessing - if arr[i] is None: - continue - - mime_type = arr[i].decode("utf-8", errors='replace') - if mime_type: - result.append(mime_type) - except Exception: - # Ignore decoding failures - continue - finally: - # Always free the native memory, even if string extraction fails - try: - _lib.c2pa_free_string_array(arr, count.value) - except Exception: - # Ignore cleanup errors - pass + result, cls._supported_mime_types_cache = _get_supported_mime_types( + _lib.c2pa_builder_supported_mime_types, cls._supported_mime_types_cache + ) + return result - # Cache the result - if result: - cls._supported_mime_types_cache = result + @classmethod + @overload + def from_json( + cls, + manifest_json: Any, + ) -> 'Builder': ... - return cls._supported_mime_types_cache + @classmethod + @overload + def from_json( + cls, + manifest_json: Any, + context: 'ContextProvider', + ) -> 'Builder': ... @classmethod - def from_json(cls, manifest_json: Any) -> 'Builder': + def from_json( + cls, + manifest_json: Any, + context: Optional['ContextProvider'] = None, + ) -> 'Builder': """Create a new Builder from a JSON manifest. Args: manifest_json: The JSON manifest definition + context: Optional ContextProvider for settings Returns: A new Builder instance @@ -2541,163 +3042,127 @@ def from_json(cls, manifest_json: Any) -> 'Builder': Raises: C2paError: If there was an error creating the builder """ - return cls(manifest_json) + return cls(manifest_json, context=context) @classmethod - def from_archive(cls, stream: Any) -> 'Builder': + def from_archive( + cls, + stream: Any, + ) -> 'Builder': """Create a new Builder from an archive stream. + This creates builder without a context. To use a context, + create a Builder with a context first, then call with_archive() on it. + Args: stream: The stream containing the archive (any Python stream-like object) Returns: - A new Builder instance + A new Builder instance (without any context) Raises: - C2paError: If there was an error creating the builder from archive + C2paError: If there was an error creating the builder + from archive """ builder = cls({}) stream_obj = Stream(stream) - builder._builder = _lib.c2pa_builder_from_archive(stream_obj._stream) + try: + builder._handle = ( + _lib.c2pa_builder_from_archive(stream_obj._stream) + ) + + _check_ffi_operation_result(builder._handle, + "Failed to create builder from archive") - if not builder._builder: - # Clean up the stream object if builder creation fails + builder._state = LifecycleState.ACTIVE + return builder + finally: stream_obj.close() - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to create builder from archive") + @overload + def __init__( + self, + manifest_json: Any, + ) -> None: ... - builder._initialized = True - return builder + @overload + def __init__( + self, + manifest_json: Any, + context: 'ContextProvider', + ) -> None: ... - def __init__(self, manifest_json: Any): + def __init__( + self, + manifest_json: Any, + context: Optional['ContextProvider'] = None, + ): """Initialize a new Builder instance. Args: manifest_json: The manifest JSON definition (string or dict) + context: Optional Context (ContextProvider) for settings Raises: C2paError: If there was an error creating the builder C2paError.Encoding: If manifest JSON contains invalid UTF-8 chars C2paError.Json: If the manifest JSON cannot be serialized """ - # Native libs plumbing: - # Clear any stale error state from previous operations - _clear_error_state() - - self._closed = False - self._initialized = False - self._builder = None + super().__init__() - if not isinstance(manifest_json, str): - try: - manifest_json = json.dumps(manifest_json) - except (TypeError, ValueError) as e: - raise C2paError.Json( - Builder._ERROR_MESSAGES['json_error'].format( - str(e))) + self._context = context + self._has_context_signer = ( + context is not None + and hasattr(context, 'has_signer') + and context.has_signer + ) - try: - json_str = manifest_json.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Builder._ERROR_MESSAGES['encoding_error'].format( - str(e))) + json_str = _to_utf8_bytes(manifest_json, "manifest JSON") - self._builder = _lib.c2pa_builder_from_json(json_str) + if context is not None: + self._init_from_context(context, json_str) + else: + self._handle = _lib.c2pa_builder_from_json(json_str) - if not self._builder: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( + _check_ffi_operation_result(self._handle, Builder._ERROR_MESSAGES['builder_error'].format( "Unknown error" ) ) - self._initialized = True - - def __del__(self): - """Ensure resources are cleaned up if close() wasn't called.""" - self._cleanup_resources() - - def __enter__(self): - self._ensure_valid_state() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() - - def _ensure_valid_state(self): - """Ensure the builder is in a valid state for operations. - - Raises: - C2paError: If the builder is closed, not initialized, or invalid - """ - if self._closed: - raise C2paError(Builder._ERROR_MESSAGES['closed_error']) - if not self._initialized: - raise C2paError("Builder is not properly initialized") - if not self._builder: - raise C2paError(Builder._ERROR_MESSAGES['closed_error']) + self._lifecycle_state = LifecycleState.ACTIVE - def _cleanup_resources(self): - """Internal cleanup method that releases native resources. + def _init_from_context(self, context, json_str): + """Initialize Builder from a ContextProvider. - This method handles the actual cleanup logic and can be called - from both close() and __del__ without causing double frees. + Uses c2pa_builder_from_context + + c2pa_builder_with_definition (consume-and-return). """ - try: - # Only cleanup if not already closed and we have a valid builder - if hasattr(self, '_closed') and not self._closed: - self._closed = True - - if hasattr( - self, - '_builder') and self._builder and self._builder != 0: - try: - _lib.c2pa_builder_free(self._builder) - except Exception: - # Log cleanup errors but don't raise exceptions - logger.error( - "Failed to release native Builder resources" - ) - pass - finally: - self._builder = None + if not context.is_valid: + raise C2paError("Context is not valid") - # Reset initialized state after cleanup - self._initialized = False - except Exception: - # Ensure we don't raise exceptions during cleanup - pass + builder_ptr = _lib.c2pa_builder_from_context( + context.execution_context, + ) + _check_ffi_operation_result(builder_ptr, + Builder._ERROR_MESSAGES[ + 'builder_error' + ].format("Unknown error") + ) - def close(self): - """Release the builder resources. + # Consume-and-return: builder_ptr is consumed, + # new_ptr is the valid pointer going forward + new_ptr = _lib.c2pa_builder_with_definition(builder_ptr, json_str) - This method ensures all resources are properly cleaned up, - even if errors occur during cleanup. - Errors during cleanup are logged but not raised to ensure cleanup. - Multiple calls to close() are handled gracefully. - """ - if self._closed: - return + _check_ffi_operation_result(new_ptr, + Builder._ERROR_MESSAGES[ + 'builder_error' + ].format("Unknown error") + ) - try: - # Use the internal cleanup method - self._cleanup_resources() - except Exception as e: - # Log any unexpected errors during close - logger.error( - Builder._ERROR_MESSAGES['cleanup_error'].format( - str(e))) - finally: - self._closed = True + self._handle = new_ptr def set_no_embed(self): """Set the no-embed flag. @@ -2707,7 +3172,7 @@ def set_no_embed(self): This is useful when creating cloud or sidecar manifests. """ self._ensure_valid_state() - _lib.c2pa_builder_set_no_embed(self._builder) + _lib.c2pa_builder_set_no_embed(self._handle) def set_remote_url(self, remote_url: str): """Set the remote URL. @@ -2723,15 +3188,12 @@ def set_remote_url(self, remote_url: str): """ self._ensure_valid_state() - url_str = remote_url.encode('utf-8') - result = _lib.c2pa_builder_set_remote_url(self._builder, url_str) + url_bytes = _to_utf8_bytes(remote_url, "remote URL") + result = _lib.c2pa_builder_set_remote_url(self._handle, url_bytes) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Builder._ERROR_MESSAGES['url_error'].format("Unknown error")) + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES['url_error'].format("Unknown error"), + check=lambda r: r != 0) def set_intent( self, @@ -2762,16 +3224,14 @@ def set_intent( self._ensure_valid_state() result = _lib.c2pa_builder_set_intent( - self._builder, + self._handle, ctypes.c_uint(intent), ctypes.c_uint(digital_source_type), ) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error setting intent for Builder: Unknown error") + _check_ffi_operation_result(result, + "Error setting intent for Builder: Unknown error", + check=lambda r: r != 0) def add_resource(self, uri: str, stream: Any): """Add a resource to the builder. @@ -2786,20 +3246,16 @@ def add_resource(self, uri: str, stream: Any): """ self._ensure_valid_state() - uri_str = uri.encode('utf-8') + uri_bytes = _to_utf8_bytes(uri, "resource URI") with Stream(stream) as stream_obj: result = _lib.c2pa_builder_add_resource( - self._builder, uri_str, stream_obj._stream) + self._handle, uri_bytes, stream_obj._stream) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Builder._ERROR_MESSAGES['resource_error'].format( - "Unknown error" - ) - ) + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES['resource_error'].format( + "Unknown error" + ), + check=lambda r: r != 0) def add_ingredient( self, ingredient_json: Union[str, dict], format: str, source: Any @@ -2850,36 +3306,24 @@ def add_ingredient_from_stream( """ self._ensure_valid_state() - if isinstance(ingredient_json, dict): - ingredient_json = json.dumps(ingredient_json) - - try: - ingredient_str = ingredient_json.encode('utf-8') - format_str = format.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Builder._ERROR_MESSAGES['encoding_error'].format( - str(e))) + ingredient_str = _to_utf8_bytes(ingredient_json, "ingredient JSON") + format_str = _to_utf8_bytes(format, "ingredient format") with Stream(source) as source_stream: result = ( _lib.c2pa_builder_add_ingredient_from_stream( - self._builder, + self._handle, ingredient_str, format_str, source_stream._stream ) ) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Builder._ERROR_MESSAGES['ingredient_error'].format( - "Unknown error" - ) - ) + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES['ingredient_error'].format( + "Unknown error" + ), + check=lambda r: r != 0) def add_ingredient_from_file_path( self, @@ -2941,27 +3385,14 @@ def add_action(self, action_json: Union[str, dict]) -> None: """ self._ensure_valid_state() - if isinstance(action_json, dict): - action_json = json.dumps(action_json) + action_str = _to_utf8_bytes(action_json, "action JSON") + result = _lib.c2pa_builder_add_action(self._handle, action_str) - try: - action_str = action_json.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Builder._ERROR_MESSAGES['encoding_error'].format(str(e)) - ) - - result = _lib.c2pa_builder_add_action(self._builder, action_str) - - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Builder._ERROR_MESSAGES['action_error'].format( - "Unknown error" - ) - ) + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES['action_error'].format( + "Unknown error" + ), + check=lambda r: r != 0) def to_archive(self, stream: Any) -> None: """Write an archive of the builder to a stream. @@ -2977,33 +3408,64 @@ def to_archive(self, stream: Any) -> None: with Stream(stream) as stream_obj: result = _lib.c2pa_builder_to_archive( - self._builder, stream_obj._stream) + self._handle, stream_obj._stream) + + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES["archive_error"].format( + "Unknown error" + ), + check=lambda r: r != 0) + + def with_archive(self, stream: Any) -> 'Builder': + """Load an archive into this Builder instance, replacing its + manifest definition. The archive carries only the + definition, not settings. Settings come from the context that + was configured to be used with this Builder instance. - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) + Args: + stream: The stream containing the archive + + Returns: + This builder instance, for method chaining. + + Raises: + C2paError: If there was an error loading the archive + """ + self._ensure_valid_state() + + with Stream(stream) as stream_obj: + try: + new_ptr = _lib.c2pa_builder_with_archive(self._handle, stream_obj._stream) + except Exception as e: + self._mark_consumed() raise C2paError( - Builder._ERROR_MESSAGES["archive_error"].format( - "Unknown error" - ) + f"Error loading archive: {e}" ) + # Old handle consumed by FFI + self._handle = None + _check_ffi_operation_result(new_ptr, "Failed to load archive into builder") + self._handle = new_ptr + + return self def _sign_internal( self, - signer: Signer, format: str, source_stream: Stream, - dest_stream: Stream) -> bytes: - """Internal signing logic shared between sign() and sign_file() methods - to use same native calls but expose different API surface. + dest_stream: Stream, + signer: Optional[Signer] = None) -> bytes: + """Internal signing implementation. + When `signer` is provided, calls `c2pa_builder_sign` (explicit + signer). When `signer` is `None`, calls + `c2pa_builder_sign_context` (context-based signer). Args: - signer: The signer to use format: The MIME type or extension of the content source_stream: The source stream dest_stream: The destination stream, - opened in w+b (write+read binary) mode. + opened in w+b (write+read binary) mode. + signer: Signer to use. When None the context + signer is used instead. Returns: Manifest bytes @@ -3013,37 +3475,40 @@ def _sign_internal( """ self._ensure_valid_state() - # Validate signer pointer before use - if not signer or not hasattr(signer, '_signer') or not signer._signer: - raise C2paError("Invalid or closed signer") - - format_lower = format.lower() - if format_lower not in Builder.get_supported_mime_types(): - raise C2paError.NotSupported( - f"Builder does not support {format}") + if signer is not None: + if not hasattr(signer, '_handle') or not signer._handle: + raise C2paError("Invalid or closed signer") - format_str = format.encode('utf-8') + format_bytes = _validate_and_encode_format( + format, Builder.get_supported_mime_types(), "Builder") manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() - # c2pa_builder_sign uses streams try: - result = _lib.c2pa_builder_sign( - self._builder, - format_str, - source_stream._stream, - dest_stream._stream, - signer._signer, - ctypes.byref(manifest_bytes_ptr) - ) + if signer is not None: + result = _lib.c2pa_builder_sign( + self._handle, + format_bytes, + source_stream._stream, + dest_stream._stream, + signer._handle, + ctypes.byref(manifest_bytes_ptr) + ) + else: + result = _lib.c2pa_builder_sign_context( + self._handle, + format_bytes, + source_stream._stream, + dest_stream._stream, + ctypes.byref(manifest_bytes_ptr), + ) + # Builder pointer consumed by Rust FFI at this point + self._mark_consumed() except Exception as e: - # Handle errors during the C function call - raise C2paError(f"Error calling c2pa_builder_sign: {str(e)}") + self._mark_consumed() + raise C2paError(f"Error during signing: {e}") - if result < 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error during signing") + _check_ffi_operation_result(result, + "Error during signing", check=lambda r: r < 0) # Capture the manifest bytes if available manifest_bytes = b"" @@ -3064,24 +3529,94 @@ def _sign_internal( logger.error( "Failed to release native manifest bytes memory" ) - pass return manifest_bytes + def _sign_common( + self, + signer: Optional[Signer], + format: str, + source: Any, + dest: Any = None, + ) -> bytes: + """Shared signing logic for sign(). + + Args: + signer: The signer to use, or None for context signer. + format: The MIME type of the content. + source: The source stream. + dest: The destination stream (optional). + + Returns: + Manifest bytes + """ + source_stream = Stream(source) + try: + if dest: + dest_stream = Stream(dest) + else: + mem_buffer = io.BytesIO() + dest_stream = Stream(mem_buffer) + + try: + if signer is not None: + manifest_bytes = self._sign_internal( + format, source_stream, dest_stream, + signer=signer, + ) + elif self._has_context_signer: + manifest_bytes = self._sign_internal(format, source_stream, dest_stream) + else: + raise C2paError( + "No signer provided. Either pass a" + " signer parameter or create the" + " Builder with a Context that has" + " a signer." + ) + finally: + dest_stream.close() + finally: + source_stream.close() + + return manifest_bytes + + @overload def sign( - self, - signer: Signer, - format: str, - source: Any, - dest: Any = None) -> bytes: - """Sign the builder's content and write to a destination stream. + self, + signer: Signer, + format: str, + source: Any, + dest: Any = None, + ) -> bytes: ... + + @overload + def sign( + self, + format: str, + source: Any, + dest: Any = None, + ) -> bytes: ... + + def sign( + self, + signer_or_format: Union[Signer, str], + format_or_source: Any = None, + source_or_dest: Any = None, + dest: Any = None, + ) -> bytes: + """Sign the builder's content. + + Can be called with or without an explicit signer. + If no signer is provided, the context's signer is + used (builder must have been created with a Context + that has a signer). Args: - format: The MIME type or extension of the content - source: The source stream (any Python stream-like object) - dest: The destination stream (any Python stream-like object), - opened in w+b (write+read binary) mode. - signer: The signer to use + signer: The signer to use. If not provided, the + context's signer is used. + format: The MIME type of the content. + source: The source stream. + dest: The destination stream (optional). Returns: Manifest bytes @@ -3089,46 +3624,58 @@ def sign( Raises: C2paError: If there was an error during signing """ - # Convert Python streams to Stream objects - source_stream = Stream(source) - - if dest: - # dest is optional, only if we write back somewhere - dest_stream = Stream(dest) + if isinstance(signer_or_format, Signer): + return self._sign_common( + signer_or_format, + format_or_source, + source_or_dest, + dest, + ) + elif isinstance(signer_or_format, str): + return self._sign_common( + None, + signer_or_format, + format_or_source, + source_or_dest, + ) else: - # no destination? - # we keep things in-memory for validation and processing - mem_buffer = io.BytesIO() - dest_stream = Stream(mem_buffer) - - # Use the internal stream-base signing logic - manifest_bytes = self._sign_internal( - signer, - format, - source_stream, - dest_stream - ) + raise C2paError( + "First argument must be a Signer or a format string (MIME type)." + ) - if not dest: - # Close temporary in-memory stream since we own it - dest_stream.close() + @overload + def sign_file( + self, + source_path: Union[str, Path], + dest_path: Union[str, Path], + signer: Signer, + ) -> bytes: ... - return manifest_bytes + @overload + def sign_file( + self, + source_path: Union[str, Path], + dest_path: Union[str, Path], + ) -> bytes: ... + + def sign_file( + self, + source_path: Union[str, Path], + dest_path: Union[str, Path], + signer: Optional[Signer] = None, + ) -> bytes: + """Sign a file and write signed data to output. - def sign_file(self, - source_path: Union[str, - Path], - dest_path: Union[str, - Path], - signer: Signer) -> bytes: - """Sign a file and write the signed data to an output file. + Can be called with or without an explicit signer. + If no signer is provided, the context's signer is + used (builder must have been created with a Context + that has a signer). Args: - source_path: Path to the source file. We will attempt - to guess the mimetype of the source file based on - the extension. - dest_path: Path to write the signed file to - signer: The signer to use + source_path: Path to the source file. + dest_path: Path to write the signed file to. + signer: The signer to use. If None, the + context's signer is used. Returns: Manifest bytes @@ -3136,14 +3683,17 @@ def sign_file(self, Raises: C2paError: If there was an error during signing """ - # Get the MIME type from the file extension mime_type = _get_mime_type_from_path(source_path) try: - # Open source file and destination file, then use the sign method - with open(source_path, 'rb') as source_file, \ - open(dest_path, 'w+b') as dest_file: - return self.sign(signer, mime_type, source_file, dest_file) + with ( + open(source_path, 'rb') as source_file, + open(dest_path, 'w+b') as dest_file, + ): + if signer is not None: + return self.sign(signer, mime_type, source_file, dest_file) + # else: + return self.sign(mime_type, source_file, dest_file) except Exception as e: raise C2paError(f"Error signing file: {str(e)}") from e @@ -3174,16 +3724,19 @@ def format_embeddable(format: str, manifest_bytes: bytes) -> tuple[int, bytes]: ctypes.byref(result_bytes_ptr) ) - if result < 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to format embeddable manifest") + _check_ffi_operation_result(result, + "Failed to format embeddable manifest", check=lambda r: r < 0) # Convert the result bytes to a Python bytes object size = result - result_bytes = bytes(result_bytes_ptr[:size]) - _lib.c2pa_manifest_bytes_free(result_bytes_ptr) + try: + result_bytes = bytes(result_bytes_ptr[:size]) + except Exception as e: + raise C2paError( + f"Failed to convert embeddable manifest bytes: {e}" + ) from e + finally: + _lib.c2pa_manifest_bytes_free(result_bytes_ptr) return size, result_bytes @@ -3200,7 +3753,7 @@ def create_signer( This function is deprecated and will be removed in a future version. Please use the Signer class method instead. Example: - ```python + ``` signer = Signer.from_callback(callback, alg, certs, tsa_url) ``` @@ -3235,7 +3788,7 @@ def create_signer_from_info(signer_info: C2paSignerInfo) -> Signer: This function is deprecated and will be removed in a future version. Please use the Signer class method instead. Example: - ```python + ``` signer = Signer.from_info(signer_info) ``` @@ -3303,11 +3856,8 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: key_bytes ) - if not signature_ptr: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to sign data with Ed25519") + _check_ffi_operation_result(signature_ptr, + "Failed to sign data with Ed25519") try: # Ed25519 signatures are always 64 bytes @@ -3330,6 +3880,10 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'C2paDigitalSourceType', 'C2paSignerInfo', 'C2paBuilderIntent', + 'ContextBuilder', + 'ContextProvider', + 'Settings', + 'Context', 'Stream', 'Reader', 'Builder', diff --git a/src/c2pa/lib.py b/src/c2pa/lib.py index 82b6148b..e14f3d6b 100644 --- a/src/c2pa/lib.py +++ b/src/c2pa/lib.py @@ -239,7 +239,7 @@ def dynamically_load_library( logger.info(f"Using library name from env var C2PA_LIBRARY_NAME: {env_lib_name}") try: possible_paths = _get_possible_search_paths() - lib = _load_single_library(env_lib_name, possible_paths) + lib, load_errors = _load_single_library(env_lib_name, possible_paths) if lib: return lib else: diff --git a/tests/fixtures/dash1.m4s b/tests/fixtures/dash1.m4s new file mode 100644 index 00000000..1a9a9964 Binary files /dev/null and b/tests/fixtures/dash1.m4s differ diff --git a/tests/fixtures/dashinit.mp4 b/tests/fixtures/dashinit.mp4 new file mode 100644 index 00000000..b1f703a8 Binary files /dev/null and b/tests/fixtures/dashinit.mp4 differ diff --git a/tests/fixtures/settings.toml b/tests/fixtures/settings.toml deleted file mode 100644 index 31eccf31..00000000 --- a/tests/fixtures/settings.toml +++ /dev/null @@ -1,230 +0,0 @@ -# This sample c2pa settings file enables a trust list. -# We use the toml format here because it does a good job of containing the PEM formatted certificates. -# In practice you should update the trust anchors from a remote source as needed. -# Many other settings are available, see the c2pa documentation for more information. -[verify] -trusted = true - -[trust] -trust_config = """ -//id-kp-emailProtection -1.3.6.1.5.5.7.3.4 -//id-kp-documentSigning -1.3.6.1.5.5.7.3.36 -//id-kp-timeStamping -1.3.6.1.5.5.7.3.8 -//id-kp-OCSPSigning -1.3.6.1.5.5.7.3.9 -// MS C2PA Signing -1.3.6.1.4.1.311.76.59.1.9 -""" -trust_anchors = """ ------BEGIN CERTIFICATE----- -MIICEzCCAcWgAwIBAgIUW4fUnS38162x10PCnB8qFsrQuZgwBQYDK2VwMHcxCzAJ -BgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdoZXJlMRowGAYD -VQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRFU1RJTkdfT05M -WTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2NDFaFw0zMjA2MDcxODQ2 -NDFaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdo -ZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRF -U1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAqMAUGAytlcAMhAGPUgK9q1H3D -eKMGqLGjTXJSpsrLpe0kpxkaFMe7KUAuo2MwYTAdBgNVHQ4EFgQUXuZWArP1jiRM -fgye6ZqRyGupTowwHwYDVR0jBBgwFoAUXuZWArP1jiRMfgye6ZqRyGupTowwDwYD -VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwBQYDK2VwA0EA8E79g54u2fUy -dfVLPyqKmtjenOUMvVQD7waNbetLY7kvUJZCd5eaDghk30/Q1RaNjiP/2RfA/it8 -zGxQnM2hCA== ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIIC2jCCAjygAwIBAgIUYm+LFaltpWbS9kED6RRAamOdUHowCgYIKoZIzj0EAwQw -dzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx -GjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO -R19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw -NzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT -b21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG -T1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIGbMBAGByqGSM49AgEG -BSuBBAAjA4GGAAQBaifSYJBkf5fgH3FWPxRdV84qwIsLd7RcIDcRJrRkan0xUYP5 -zco7R4fFGaQ9YJB8dauyqiNg00LVuPajvKmhgEMAT4eSfEhYC25F2ggXQlBIK3Q7 -mkXwJTIJSObnbw4S9Jy3W6OVKq351VpgWUcmhvGRRejW7S/D8L2tzqRW7JPI2uSj -YzBhMB0GA1UdDgQWBBS6OykommTmfYoLJuPN4OU83wjPqjAfBgNVHSMEGDAWgBS6 -OykommTmfYoLJuPN4OU83wjPqjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQE -AwIBhjAKBggqhkjOPQQDBAOBiwAwgYcCQV4B6uKKoCWecEDlzj2xQLFPmnBQIOzD -nyiSEcYyrCKwMV+HYS39oM+T53NvukLKUTznHwdWc9++HNaqc+IjsDl6AkIB2lXd -5+s3xf0ioU91GJ4E13o5rpAULDxVSrN34A7BlsaXYQLnSkLMqva6E7nq2JBYjkqf -iwNQm1DDcQPtPTnddOs= ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIICkTCCAhagAwIBAgIUIngKvNC/BMF3TRIafgweprIbGgAwCgYIKoZIzj0EAwMw -dzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx -GjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO -R19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw -NzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT -b21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG -T1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMHYwEAYHKoZIzj0CAQYF -K4EEACIDYgAEX3FzSTnCcEAP3wteNaiy4GZzZ+ABd2Y7gJpfyZf3kkCuX/I3psFq -QBRvb3/FEBaDT4VbDNlZ0WLwtw5d3PI42Zufgpxemgfjf31d8H51eU3/IfAz5AFX -y/OarhObHgVvo2MwYTAdBgNVHQ4EFgQUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wHwYD -VR0jBBgwFoAUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wDwYDVR0TAQH/BAUwAwEB/zAO -BgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAPOgmJbVdhDh9KlgQXqE -FzHiCt347JG4strk22MXzOgxQ0LnXStIh+viC3S1INzuBgIxAI1jiUBX/V7Gg0y6 -Y/p6a63Xp2w+ia7vlUaUBWsR3ex9NNSTPLNoDkoTCSDOE2O20w== ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIICUzCCAfmgAwIBAgIUdmkq4byvgk2FSnddHqB2yjoD68gwCgYIKoZIzj0EAwIw -dzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx -GjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO -R19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw -NzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT -b21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG -T1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMFkwEwYHKoZIzj0CAQYI -KoZIzj0DAQcDQgAEre/KpcWwGEHt+mD4xso3xotRnRx2IEsMoYwVIKI7iEJrDEye -PcvJuBywA0qiMw2yvAvGOzW/fqUTu1jABrFIk6NjMGEwHQYDVR0OBBYEFF6ZuIbh -eBvZVxVadQBStikOy6iMMB8GA1UdIwQYMBaAFF6ZuIbheBvZVxVadQBStikOy6iM -MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0gA -MEUCIHBC1xLwkCWSGhVXFlSnQBx9cGZivXzCbt8BuwRqPSUoAiEAteZQDk685yh9 -jgOTkp4H8oAmM1As+qlkRK2b+CHAQ3k= ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIIGezCCBC+gAwIBAgIUIYAhaM4iRhACFliU3bfLnLDvj3wwQQYJKoZIhvcNAQEK -MDSgDzANBglghkgBZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgMF -AKIDAgFAMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t -ZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S -IFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2MzVa -Fw0zMjA2MDcxODQ2MzVaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG -A1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG -A1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ -KoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglg -hkgBZQMEAgMFAKIDAgFAA4ICDwAwggIKAoICAQCrjxW/KXQdtwOPKxjDFDxJaLvF -Jz8EIG6EZZ1JG+SVo8FJlYjazbJWmyCEtmoKCb4pgeeLSltty+pgKHFqZug19eKk -jb/fobN32iF3F3mKJ4/r9+VR5DSiXVMUGSI8i9s72OJu9iCGRsHftufDDVe+jGix -BmacQMqYtmysRqo7tcAUPY8W4hrw5UhykjvJRNi9//nAMMm2BQdWyQj7JN4qnuhL -1qtBZHJbNpo9U7DGHiZ5vE6rsJv68f1gM3RiVJsc71vm6gEDN5Rz3kXd1oMzsXwH -8915SSx1hdmIwcikG5pZU4l9vBB+jTuev5Nm9u+WsMVYk6SE6fsTV3zKKQS67WKZ -XvRkJmbkJf2xZgvUfPHuShQn0k810EFwimoA7kJtrzVE40PECHQwoq2kAs5M+6VY -W2J1s1FQ49GaRH78WARSkV7SSpK+H1/L1oMbavtAoei81oLVrjPdCV4SoixSBzoR -+64aQuSsBJD5vVjL1o37oizsc00mas+mR98TswAHtU4nVSxgZAPp9UuO64YdJ8e8 -bftwsoBKI+DTS+4xjQJhvYxI0Jya42PmP7mlwf7g8zTde1unI6TkaUnlvXdb3+2v -EhhIQCKSN6HdXHQba9Q6/D1PhIaXBmp8ejziSXOoLfSKJ6cMsDOjIxyuM98admN6 -xjZJljVHAqZQynA2KQIDAQABo2MwYTAdBgNVHQ4EFgQUoa/88nSjWTf9DrvK0Imo -kARXMYwwHwYDVR0jBBgwFoAUoa/88nSjWTf9DrvK0ImokARXMYwwDwYDVR0TAQH/ -BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB -ZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgMFAKIDAgFAA4ICAQAH -SCSccH59/JvIMh92cvudtZ4tFzk0+xHWtDqsWxAyYWV009Eg3T6ps/bVbWkiLxCW -cuExWjQ6yLKwJxegSvTRzwJ4H5xkP837UYIWNRoR3rgPrysm1im3Hjo/3WRCfOJp -PtgkiPbDn2TzsJQcBpfc7RIdx2bqX41Uz9/nfeQn60MUVJUbvCtCBIV30UfR+z3k -+w4G5doB4nq6jvQHI364L0gSQcdVdvqgjGyarNTdMHpWFYoN9gPBMoVqSNs2U75d -LrEQkOhjkE/Akw6q+biFmRWymCHjAU9l7qGEvVxLjFGc+DumCJ6gTunMz8GiXgbd -9oiqTyanY8VPzr98MZpo+Ga4OiwiIAXAJExN2vCZVco2Tg5AYESpWOqoHlZANdlQ -4bI25LcZUKuXe+NGRgFY0/8iSvy9Cs44uprUcjAMITODqYj8fCjF2P6qqKY2keGW -mYBtNJqyYGBg6h+90o88XkgemeGX5vhpRLWyBaYpxanFDkXjmGN1QqjAE/x95Q/u -y9McE9m1mxUQPJ3vnZRB6cCQBI95ZkTiJPEO8/eSD+0VWVJwLS2UrtWzCbJ+JPKF -Yxtj/MRT8epTRPMpNZwUEih7MEby+05kziKmYF13OOu+K3jjM0rb7sVoFBSzpISC -r9Fa3LCdekoRZAnjQHXUWko7zo6BLLnCgld97Yem1A== ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIIGezCCBC+gAwIBAgIUA9/dd4gqhU9+6ncE2uFrS3s5xg8wQQYJKoZIhvcNAQEK -MDSgDzANBglghkgBZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgIF -AKIDAgEwMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t -ZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S -IFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2Mjla -Fw0zMjA2MDcxODQ2MjlaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG -A1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG -A1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ -KoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglg -hkgBZQMEAgIFAKIDAgEwA4ICDwAwggIKAoICAQCpWg62bB2Dn3W9PtLtkJivh8ng -31ekgz0FYzelDag4gQkmJFkiWBiIbVTj3aJUt+1n5PrxkamzANq+xKxhP49/IbHF -VptmHuGORtvGi5qa51i3ZRYeUPekqKIGY0z6t3CGmJxYt1mMsvY6L67/3AATGrsK -Ubf+FFls+3FqbaWXL/oRuuBk6S2qH8NCfSMpaoQN9v0wipL2cl9XZrL1W/DzwQXT -KIin/DdWhCFDRWwI6We3Pu52k/AH5VFHrJMLmm5dVnMvQQDxf/08ULQAbISPkOMm -Ik3Wtn8xRAbnsw4BQw3RcaxYZHSikm5JA4AJcPMb8J/cfn5plXLoH0nJUAJfV+y5 -zVm6kshhDhfkOkJ0822B54yFfI1lkyFw9mmHt0cNkSHODbMmPbq78DZILA9RWubO -3m7j8T3OmrilcH6S6BId1G/9mAzjhVSP9P/d/QJhADgWKjcQZQPHadaMbTFHpCFb -klIOwqraYhxQt3E8yWjkgEjhfkAGwvp/bO8XMcu4XL6Z0uHtKiBFncASrgsR7/yN -TpO0A6Grr9DTGFcwvvgvRmMPVntiCP+dyVv1EzlsYG/rkI79UJOg/UqyB2voshsI -mFBuvvWcJYws87qZ6ZhEKuS9yjyTObOcXi0oYvAxDfv10mSjat3Uohm7Bt9VI1Xr -nUBx0EhMKkhtUDaDzQIDAQABo2MwYTAdBgNVHQ4EFgQU1onD7yR1uK85o0RFeVCE -QM11S58wHwYDVR0jBBgwFoAU1onD7yR1uK85o0RFeVCEQM11S58wDwYDVR0TAQH/ -BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB -ZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgIFAKIDAgEwA4ICAQBd -N+WgIQV4l+U/qLoWZYoTXmxg6rzTl2zr4s2goc6CVYXXKoDkap8y4zZ9AdH8pbZn -pMZrJSmNdfuNUFjnJAyKyOJWyx1oX2NCg8voIAdJxhPJNn4bRhDQ8gFv7OEhshEm -V0O0xXc08473fzLJEq8hYPtWuPEtS65umJh4A0dENYsm50rnIut9bacmBXJjGgwe -3sz5oCr9YVCNDG7JDfaMuwWWZKhKZBbY0DsacxSV7AYz/DoYdZ9qLCNNuMmLuV6E -lrHo5imbQdcsBt11Fxq1AFz3Bfs9r6xBsnn7vGT6xqpBJIivo3BahsOI8Bunbze8 -N4rJyxbsJE3MImyBaYiwkh+oV5SwMzXQe2DUj4FWR7DfZNuwS9qXpaVQHRR74qfr -w2RSj6nbxlIt/X193d8rqJDpsa/eaHiv2ihhvwnhI/c4TjUvDIefMmcNhqiH7A2G -FwlsaCV6ngT1IyY8PT+Fb97f5Bzvwwfr4LfWsLOiY8znFcJ28YsrouJdca4Zaa7Q -XwepSPbZ7rDvlVETM7Ut5tymDR3+7of47qIPLuCGxo21FELseJ+hYhSRXSgvMzDG -sUxc9Tb1++E/Qf3bFfG5S2NSKkUuWtAveblQPfqDcyBhXDaC8qwuknb5gs1jNOku -4NWbaM874WvCgmv8TLcqpR0n76bTkfppMRcD5MEFug== ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIIGezCCBC+gAwIBAgIUDAG5+sfGspprX+hlkn1SuB2f5VQwQQYJKoZIhvcNAQEK -MDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEF -AKIDAgEgMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t -ZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S -IFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2MjVa -Fw0zMjA2MDcxODQ2MjVaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG -A1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG -A1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ -KoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglg -hkgBZQMEAgEFAKIDAgEgA4ICDwAwggIKAoICAQC4q3t327HRHDs7Y9NR+ZqernwU -bZ1EiEBR8vKTZ9StXmSfkzgSnvVfsFanvrKuZvFIWq909t/gH2z0klI2ZtChwLi6 -TFYXQjzQt+x5CpRcdWnB9zfUhOpdUHAhRd03Q14H2MyAiI98mqcVreQOiLDydlhP -Dla7Ign4PqedXBH+NwUCEcbQIEr2LvkZ5fzX1GzBtqymClT/Gqz75VO7zM1oV4gq -ElFHLsTLgzv5PR7pydcHauoTvFWhZNgz5s3olXJDKG/n3h0M3vIsjn11OXkcwq99 -Ne5Nm9At2tC1w0Huu4iVdyTLNLIAfM368ookf7CJeNrVJuYdERwLwICpetYvOnid -VTLSDt/YK131pR32XCkzGnrIuuYBm/k6IYgNoWqUhojGJai6o5hI1odAzFIWr9T0 -sa9f66P6RKl4SUqa/9A/uSS8Bx1gSbTPBruOVm6IKMbRZkSNN/O8dgDa1OftYCHD -blCCQh9DtOSh6jlp9I6iOUruLls7d4wPDrstPefi0PuwsfWAg4NzBtQ3uGdzl/lm -yusq6g94FVVq4RXHN/4QJcitE9VPpzVuP41aKWVRM3X/q11IH80rtaEQt54QMJwi -sIv4eEYW3TYY9iQtq7Q7H9mcz60ClJGYQJvd1DR7lA9LtUrnQJIjNY9v6OuHVXEX -EFoDH0viraraHozMdwIDAQABo2MwYTAdBgNVHQ4EFgQURW8b4nQuZgIteSw5+foy -TZQrGVAwHwYDVR0jBBgwFoAURW8b4nQuZgIteSw5+foyTZQrGVAwDwYDVR0TAQH/ -BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB -ZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEFAKIDAgEgA4ICAQBB -WnUOG/EeQoisgC964H5+ns4SDIYFOsNeksJM3WAd0yG2L3CEjUksUYugQzB5hgh4 -BpsxOajrkKIRxXN97hgvoWwbA7aySGHLgfqH1vsGibOlA5tvRQX0WoQ+GMnuliVM -pLjpHdYE2148DfgaDyIlGnHpc4gcXl7YHDYcvTN9NV5Y4P4x/2W/Lh11NC/VOSM9 -aT+jnFE7s7VoiRVfMN2iWssh2aihecdE9rs2w+Wt/E/sCrVClCQ1xaAO1+i4+mBS -a7hW+9lrQKSx2bN9c8K/CyXgAcUtutcIh5rgLm2UWOaB9It3iw0NVaxwyAgWXC9F -qYJsnia4D3AP0TJL4PbpNUaA4f2H76NODtynMfEoXSoG3TYYpOYKZ65lZy3mb26w -fvBfrlASJMClqdiEFHfGhP/dTAZ9eC2cf40iY3ta84qSJybSYnqst8Vb/Gn+dYI9 -qQm0yVHtJtvkbZtgBK5Vg6f5q7I7DhVINQJUVlWzRo6/Vx+/VBz5tC5aVDdqtBAs -q6ZcYS50ECvK/oGnVxjpeOafGvaV2UroZoGy7p7bEoJhqOPrW2yZ4JVNp9K6CCRg -zR6jFN/gUe42P1lIOfcjLZAM1GHixtjP5gLAp6sJS8X05O8xQRBtnOsEwNLj5w0y -MAdtwAzT/Vfv7b08qfx4FfQPFmtjvdu4s82gNatxSA== ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIIF3zCCA8egAwIBAgIUfPyUDhze4auMF066jChlB9aD2yIwDQYJKoZIhvcNAQEL -BQAwdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hl -cmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVT -VElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTI0MDczMTE5MDUwMVoXDTM0 -MDcyOTE5MDUwMVowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQH -DAlTb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQL -DBBGT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIICIjANBgkqhkiG -9w0BAQEFAAOCAg8AMIICCgKCAgEAkBSlOCwlWBgbqLxFu99ERwU23D/V7qBs7GsA -ZPaAvwCKf7FgVTpkzz6xsgArQU6MVo8n1tXUWWThB81xTXwqbWINP0pl5RnZKFxH -TmloE2VEMrEK3q4W6gqMjyiG+hPkwUK450WdJGkUkYi2rp6YF9YWJHv7YqYodz+u -mkIRcsczwRPDaJ7QA6pu3V4YlwrFXZu7jMHHMju02emNoiI8n7QZBJXpRr4C87jT -Ad+aNJQZ1DJ/S/QfiYpaXQ2xNH/Wq7zNXXIMs/LU0kUCggFIj+k6tmaYIAYKJR6o -dmV3anBTF8iSuAqcUXvM4IYMXSqMgzot3MYPYPdC+rj+trQ9bCPOkMAp5ySx8pYr -Upo79FOJvG8P9JzuFRsHBobYjtQqJnn6OczM69HVXCQn4H4tBpotASjT2gc6sHYv -a7YreKCbtFLpJhslNysIzVOxlnDbsugbq1gK8mAwG48ttX15ZUdX10MDTpna1FWu -Jnqa6K9NUfrvoW97ff9itca5NDRmm/K5AVA801NHFX1ApVty9lilt+DFDtaJd7zy -9w0+8U1sZ4+sc8moFRPqvEZZ3gdFtDtVjShcwdbqHZdSNU2lNbVCiycjLs/5EMRO -WfAxNZaKUreKGfOZkvQNqBhuebF3AfgmP6iP1qtO8aSilC1/43DjVRx3SZ1eecO6 -n0VGjgcCAwEAAaNjMGEwHQYDVR0OBBYEFBTOcmBU5xp7Jfn4Nzyw+kIc73yHMB8G -A1UdIwQYMBaAFBTOcmBU5xp7Jfn4Nzyw+kIc73yHMA8GA1UdEwEB/wQFMAMBAf8w -DgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQCLexj0luEpQh/LEB14 -ARG/yQ8iqW2FMonQsobrDQSI4BhrQ4ak5I892MQX9xIoUpRAVp8GkJ/eXM6ChmXa -wMJSkfrPGIvES4TY2CtmXDNo0UmHD1GDfHKQ06FJtRJWpn9upT/9qTclTNtvwxQ8 -bKl/y7lrFsn+fQsKL2i5uoQ9nGpXG7WPirJEt9jcld2yylWSStTS4MXJIZSlALIA -mBTkbzEpzBOLHRRezdfoV4hyL/tWyiXa799436kO48KtwEzvYzC5cZ4bqvM5BXQf -6aiIYZT7VypFwJQtpTgnfrsjr2Y8q/+N7FoMpLfFO4eeqtwWPiP/47/lb9np/WQq -iO/yyIwYVwiqVG0AyzA5Z4pdke1t93y3UuhXgxevJ7GqGXuLCM0iMqFrAkPlLJzI -84THLJzFy+wEKH+/L1Zi94cHNj3WvablAMG5v/Kfr6k+KueNQzrY4jZrQPUEdxjv -xk/1hyZg+khAPVKRxhWeIr6/KIuQYu6kJeTqmXKafx5oHAS6OqcK7G1KbEa1bWMV -K0+GGwenJOzSTKWKtLO/6goBItGnhyQJCjwiBKOvcW5yfEVjLT+fJ7dkvlSzFMaM -OZIbev39n3rQTWb4ORq1HIX2JwNsEQX+gBv6aGjMT2a88QFS0TsAA5LtFl8xeVgt -xPd7wFhjRZHfuWb2cs63xjAGjQ== ------END CERTIFICATE----- -""" \ No newline at end of file diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 18f6b817..7d6744c4 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -27,10 +27,12 @@ import threading # Suppress deprecation warnings -warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.simplefilter("ignore", category=DeprecationWarning) from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType -from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable +from c2pa import Settings, Context, ContextBuilder, ContextProvider +from c2pa.c2pa import Stream, LifecycleState, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable + PROJECT_PATH = os.getcwd() FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") @@ -68,11 +70,12 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): # This test verifies the native libraries used match the expected version. - self.assertIn("0.75.21", sdk_version()) + self.assertIn("0.77.1", sdk_version()) class TestReader(unittest.TestCase): def setUp(self): + warnings.filterwarnings("ignore", message="load_settings\\(\\) is deprecated") self.data_dir = FIXTURES_DIR self.testPath = DEFAULT_TEST_FILE @@ -193,7 +196,7 @@ def test_stream_read_get_validation_state_with_trust_config(self): def read_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -253,7 +256,7 @@ def test_stream_read_detailed_and_parse(self): title = manifest_store["manifests"][manifest_store["active_manifest"]]["claim"]["dc:title"] self.assertEqual(title, DEFAULT_TEST_FILE_NAME) - def test_stream_read_string_stream(self): + def test_stream_read_string_stream_path_only(self): with Reader(self.testPath) as reader: json_data = reader.json() self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) @@ -325,10 +328,10 @@ def test_reader_close_cleanup(self): # Close the reader reader.close() # Verify all resources are cleaned up - self.assertIsNone(reader._reader) + self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) # Verify reader is marked as closed - self.assertTrue(reader._closed) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) def test_resource_to_stream_on_closed_reader(self): """Test that resource_to_stream correctly raises error on closed.""" @@ -690,9 +693,8 @@ def test_reader_context_manager_with_exception(self): try: with Reader(self.testPath) as reader: # Inside context - should be valid - self.assertFalse(reader._closed) - self.assertTrue(reader._initialized) - self.assertIsNotNone(reader._reader) + self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(reader._handle) self.assertIsNotNone(reader._own_stream) self.assertIsNotNone(reader._backing_file) raise ValueError("Test exception") @@ -700,19 +702,17 @@ def test_reader_context_manager_with_exception(self): pass # After exception - should still be closed - self.assertTrue(reader._closed) - self.assertFalse(reader._initialized) - self.assertIsNone(reader._reader) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) def test_reader_partial_initialization_states(self): """Test Reader behavior with partial initialization failures.""" - # Test with _reader = None but _initialized = True + # Test with _reader = None but _state = ACTIVE reader = Reader.__new__(Reader) - reader._closed = False - reader._initialized = True - reader._reader = None + reader._lifecycle_state = LifecycleState.ACTIVE + reader._handle = None reader._own_stream = None reader._backing_file = None @@ -724,9 +724,8 @@ def test_reader_cleanup_state_transitions(self): reader = Reader(self.testPath) reader._cleanup_resources() - self.assertTrue(reader._closed) - self.assertFalse(reader._initialized) - self.assertIsNone(reader._reader) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) @@ -736,13 +735,12 @@ def test_reader_cleanup_idempotency(self): # First cleanup reader._cleanup_resources() - self.assertTrue(reader._closed) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) # Second cleanup should not change state reader._cleanup_resources() - self.assertTrue(reader._closed) - self.assertFalse(reader._initialized) - self.assertIsNone(reader._reader) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) @@ -751,7 +749,7 @@ def test_reader_state_with_invalid_native_pointer(self): reader = Reader(self.testPath) # Simulate invalid native pointer - reader._reader = 0 + reader._handle = 0 # Operations should fail gracefully with self.assertRaises(Error): @@ -1479,7 +1477,7 @@ def test_streams_sign_with_es256_alg_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1553,7 +1551,7 @@ def test_sign_with_ed25519_alg_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1693,7 +1691,7 @@ def test_sign_with_ps256_alg_2_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1771,7 +1769,7 @@ def test_archive_sign_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1843,7 +1841,7 @@ def test_archive_sign_with_added_ingredient_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1939,7 +1937,7 @@ def test_remote_sign_using_returned_bytes_V2_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -2128,7 +2126,7 @@ def test_builder_no_added_ingredient_on_closed_builder(self): def test_builder_add_ingredient(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{"test": "ingredient"}' @@ -2139,7 +2137,7 @@ def test_builder_add_ingredient(self): def test_builder_add_ingredient_dict(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient with a dictionary instead of JSON string ingredient_dict = {"test": "ingredient"} @@ -2150,7 +2148,7 @@ def test_builder_add_ingredient_dict(self): def test_builder_add_multiple_ingredients(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test builder operations builder.set_no_embed() @@ -2170,7 +2168,7 @@ def test_builder_add_multiple_ingredients(self): def test_builder_add_multiple_ingredients_2(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test builder operations builder.set_no_embed() @@ -2190,7 +2188,7 @@ def test_builder_add_multiple_ingredients_2(self): def test_builder_add_multiple_ingredients_and_resources(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test builder operations builder.set_no_embed() @@ -2219,7 +2217,7 @@ def test_builder_add_multiple_ingredients_and_resources(self): def test_builder_add_multiple_ingredients_and_resources_interleaved(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None with open(self.testPath, 'rb') as f: builder.add_resource("test_uri_1", f) @@ -2242,7 +2240,7 @@ def test_builder_add_multiple_ingredients_and_resources_interleaved(self): def test_builder_sign_with_ingredient(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{ "title": "Test Ingredient" }' @@ -2286,7 +2284,7 @@ def test_builder_sign_with_ingredient(self): def test_builder_sign_with_ingredients_edit_intent(self): """Test signing with EDIT intent and ingredient.""" builder = Builder.from_json({}) - assert builder._builder is not None + assert builder._handle is not None # Set the intent for editing existing content builder.set_intent(C2paBuilderIntent.EDIT) @@ -2390,7 +2388,7 @@ def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): load_settings('{"builder": { "thumbnail": {"enabled": false}}}') builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{ "title": "Test Ingredient" }' @@ -2437,7 +2435,7 @@ def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): load_settings({"builder": {"thumbnail": {"enabled": False}}}) builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{ "title": "Test Ingredient" }' @@ -2481,7 +2479,7 @@ def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): def test_builder_sign_with_duplicate_ingredient(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{"title": "Test Ingredient"}' @@ -2527,7 +2525,7 @@ def test_builder_sign_with_duplicate_ingredient(self): def test_builder_sign_with_ingredient_from_stream(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient using stream ingredient_json = '{"title": "Test Ingredient Stream"}' @@ -2567,7 +2565,7 @@ def test_builder_sign_with_ingredient_from_stream(self): def test_builder_sign_with_ingredient_dict_from_stream(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient using stream with a dictionary ingredient_dict = {"title": "Test Ingredient Stream"} @@ -2607,7 +2605,7 @@ def test_builder_sign_with_ingredient_dict_from_stream(self): def test_builder_sign_with_multiple_ingredient(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Add first ingredient ingredient_json1 = '{"title": "Test Ingredient 1"}' @@ -2652,7 +2650,7 @@ def test_builder_sign_with_multiple_ingredient(self): def test_builder_sign_with_multiple_ingredients_from_stream(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Add first ingredient using stream ingredient_json1 = '{"title": "Test Ingredient Stream 1"}' @@ -3139,7 +3137,7 @@ def test_builder_with_invalid_signer_object(self): # Use a mock object that looks like a signer but isn't class MockSigner: def __init__(self): - self._signer = None + self._handle = None mock_signer = MockSigner() @@ -3259,56 +3257,49 @@ def test_builder_state_transitions(self): builder = Builder(self.manifestDefinition) # Initial state - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) # After close builder.close() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_context_manager_states(self): """Test Builder state management in context manager.""" with Builder(self.manifestDefinition) as builder: # Inside context - should be valid - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) # Placeholder operation builder.set_no_embed() # After context exit - should be closed - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_context_manager_with_exception(self): """Test Builder state after exception in context manager.""" try: with Builder(self.manifestDefinition) as builder: # Inside context - should be valid - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) raise ValueError("Test exception") except ValueError: pass # After exception - should still be closed - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_partial_initialization_states(self): """Test Builder behavior with partial initialization failures.""" - # Test with _builder = None but _initialized = True + # Test with _builder = None but _state = ACTIVE builder = Builder.__new__(Builder) - builder._closed = False - builder._initialized = True - builder._builder = None + builder._lifecycle_state = LifecycleState.ACTIVE + builder._handle = None with self.assertRaises(Error): builder._ensure_valid_state() @@ -3319,9 +3310,8 @@ def test_builder_cleanup_state_transitions(self): # Test _cleanup_resources method builder._cleanup_resources() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_cleanup_idempotency(self): """Test that cleanup operations are idempotent.""" @@ -3329,13 +3319,12 @@ def test_builder_cleanup_idempotency(self): # First cleanup builder._cleanup_resources() - self.assertTrue(builder._closed) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) # Second cleanup should not change state builder._cleanup_resources() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_state_after_sign_operations(self): """Test Builder state after signing operations.""" @@ -3344,14 +3333,9 @@ def test_builder_state_after_sign_operations(self): with open(self.testPath, "rb") as file: manifest_bytes = builder.sign(self.signer, "image/jpeg", file) - # State should still be valid after signing - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) - - # Should be able to sign again - with open(self.testPath, "rb") as file: - manifest_bytes2 = builder.sign(self.signer, "image/jpeg", file) + # Builder is consumed by sign — pointer ownership transferred to Rust + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_state_after_archive_operations(self): """Test Builder state after archive operations.""" @@ -3362,9 +3346,8 @@ def test_builder_state_after_archive_operations(self): builder.to_archive(archive_stream) # State should still be valid - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) def test_builder_state_after_double_close(self): """Test Builder state after double close operations.""" @@ -3372,22 +3355,20 @@ def test_builder_state_after_double_close(self): # First close builder.close() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) # Second close should not change state builder.close() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_state_with_invalid_native_pointer(self): """Test Builder state handling with invalid native pointer.""" builder = Builder(self.manifestDefinition) # Simulate invalid native pointer - builder._builder = 0 + builder._handle = 0 # Operations should fail gracefully with self.assertRaises(Error): @@ -4304,6 +4285,7 @@ def setUp(self): warnings.filterwarnings("ignore", message="The read_ingredient_file function is deprecated") warnings.filterwarnings("ignore", message="The create_signer function is deprecated") warnings.filterwarnings("ignore", message="The create_signer_from_info function is deprecated") + warnings.filterwarnings("ignore", message="load_settings\\(\\) is deprecated") self.data_dir = FIXTURES_DIR self.testPath = DEFAULT_TEST_FILE @@ -4684,7 +4666,7 @@ def test_builder_add_ingredient_from_file_path(self): builder.close() - def test_builder_add_ingredient_from_file_path(self): + def test_builder_add_ingredient_from_file_path_not_found(self): """Test Builder class add_ingredient_from_file_path method.""" # Suppress the specific deprecation warning for this test, as this is a legacy method @@ -4943,56 +4925,6 @@ def test_sign_file_callback_signer(self): finally: shutil.rmtree(temp_dir) - def test_sign_file_callback_signer(self): - """Test signing a file using the sign_file method.""" - - temp_dir = tempfile.mkdtemp() - - try: - output_path = os.path.join(temp_dir, "signed_output.jpg") - - # Use the sign_file method - builder = Builder(self.manifestDefinition) - - # Create signer with callback using create_signer function - signer = create_signer( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) - - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) - - # Verify results - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) - - # Read the signed file and verify the manifest - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: - json_data = reader.json() - # Needs trust configuration to be set up to validate as Trusted - # self.assertNotIn("validation_status", json_data) - - # Parse the JSON and verify the signature algorithm - manifest_data = json.loads(json_data) - active_manifest_id = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_id] - - self.assertIn("signature_info", active_manifest) - signature_info = active_manifest["signature_info"] - self.assertEqual(signature_info["alg"], self.callback_signer_alg) - - finally: - shutil.rmtree(temp_dir) - def test_sign_file_callback_signer_managed_single(self): """Test signing a file using the sign_file method with context managers.""" @@ -5068,10 +5000,12 @@ def test_sign_file_callback_signer_managed_multiple_uses(self): self.assertIsInstance(manifest_bytes_1, bytes) self.assertGreater(len(manifest_bytes_1), 0) - # Second signing operation with the same signer - # This is to verify we don't free the signer or the callback too early + # Second signing operation with a new builder but same signer + # Builder is consumed by sign, so we need a fresh one. + # This verifies we don't free the signer or the callback too early. + builder2 = Builder(self.manifestDefinition) output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") - manifest_bytes_2 = builder.sign_file( + manifest_bytes_2 = builder2.sign_file( source_path=self.testPath, dest_path=output_path_2, signer=signer @@ -5109,5 +5043,1146 @@ def test_create_signer_from_info(self): self.assertIsNotNone(signer) +class TestContextAPIs(unittest.TestCase): + """Base for context-related tests; provides test_manifest and signer helpers.""" + + test_manifest = { + "claim_generator": "c2pa_python_sdk_test/context", + "claim_generator_info": [{ + "name": "c2pa_python_sdk_contextual_test", + "version": "0.1.0", + }], + "format": "image/jpeg", + "title": "Test Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + }] + } + } + ] + } + + def _ctx_make_signer(self): + """Create a Signer for context tests.""" + certs_path = os.path.join( + FIXTURES_DIR, "es256_certs.pem" + ) + key_path = os.path.join( + FIXTURES_DIR, "es256_private.key" + ) + with open(certs_path, "rb") as f: + certs = f.read() + with open(key_path, "rb") as f: + key = f.read() + info = C2paSignerInfo( + alg=b"es256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com", + ) + return Signer.from_info(info) + + def _ctx_make_callback_signer(self): + """Create a callback-based Signer for context tests.""" + certs_path = os.path.join( + FIXTURES_DIR, "es256_certs.pem" + ) + key_path = os.path.join( + FIXTURES_DIR, "es256_private.key" + ) + with open(certs_path, "rb") as f: + certs = f.read() + with open(key_path, "rb") as f: + key_data = f.read() + + from cryptography.hazmat.primitives import ( + serialization, + ) + private_key = serialization.load_pem_private_key( + key_data, password=None, + backend=default_backend(), + ) + + def sign_cb(data: bytes) -> bytes: + from cryptography.hazmat.primitives.asymmetric import ( # noqa: E501 + utils as asym_utils, + ) + sig = private_key.sign( + data, ec.ECDSA(hashes.SHA256()), + ) + r, s = asym_utils.decode_dss_signature(sig) + return ( + r.to_bytes(32, byteorder='big') + + s.to_bytes(32, byteorder='big') + ) + + return Signer.from_callback( + sign_cb, + SigningAlg.ES256, + certs.decode('utf-8'), + "http://timestamp.digicert.com", + ) + + def _ctx_make_ed25519_signer(self): + """Create an ED25519 Signer for context tests.""" + with open( + os.path.join(FIXTURES_DIR, "ed25519.pub"), "rb" + ) as f: + certs = f.read() + with open( + os.path.join(FIXTURES_DIR, "ed25519.pem"), "rb" + ) as f: + key = f.read() + info = C2paSignerInfo( + alg=b"ed25519", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com", + ) + return Signer.from_info(info) + + def _ctx_make_ps256_signer(self): + """Create a PS256 Signer for context tests.""" + with open( + os.path.join(FIXTURES_DIR, "ps256.pub"), "rb" + ) as f: + certs = f.read() + with open( + os.path.join(FIXTURES_DIR, "ps256.pem"), "rb" + ) as f: + key = f.read() + info = C2paSignerInfo( + alg=b"ps256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com", + ) + return Signer.from_info(info) + + +class TestSettings(TestContextAPIs): + + def test_settings_default_construction(self): + settings = Settings() + self.assertTrue(settings.is_valid) + settings.close() + + def test_settings_set_chaining(self): + settings = Settings() + result = ( + settings.set( + "builder.thumbnail.enabled", "false" + ).set( + "builder.thumbnail.enabled", "true" + ) + ) + self.assertIs(result, settings) + settings.close() + + def test_settings_from_json(self): + settings = Settings.from_json( + '{"builder":{"thumbnail":' + '{"enabled":false}}}' + ) + self.assertTrue(settings.is_valid) + settings.close() + + def test_settings_from_dict(self): + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + self.assertTrue(settings.is_valid) + settings.close() + + def test_settings_update_json(self): + settings = Settings() + result = settings.update( + '{"builder":{"thumbnail":' + '{"enabled":false}}}' + ) + self.assertIs(result, settings) + settings.close() + + def test_settings_update_dict(self): + settings = Settings() + result = settings.update({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + self.assertIs(result, settings) + settings.close() + + def test_settings_is_valid_after_close(self): + settings = Settings() + settings.close() + self.assertFalse(settings.is_valid) + + def test_settings_raises_after_close(self): + settings = Settings() + settings.close() + with self.assertRaises(Error): + settings.set( + "builder.thumbnail.enabled", "false" + ) + + +class TestContext(TestContextAPIs): + + def test_context_default(self): + context = Context() + self.assertTrue(context.is_valid) + self.assertFalse(context.has_signer) + context.close() + + def test_context_from_settings(self): + settings = Settings() + context = Context(settings) + self.assertTrue(context.is_valid) + context.close() + settings.close() + + def test_context_from_json(self): + context = Context.from_json( + '{"builder":{"thumbnail":' + '{"enabled":false}}}' + ) + self.assertTrue(context.is_valid) + context.close() + + def test_context_from_dict(self): + context = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + self.assertTrue(context.is_valid) + context.close() + + def test_context_context_manager(self): + with Context() as context: + self.assertTrue(context.is_valid) + + def test_context_is_valid_after_close(self): + context = Context() + context.close() + self.assertFalse(context.is_valid) + + +class TestContextBuilder(TestContextAPIs): + + def test_context_builder_default(self): + context = Context.builder().build() + self.assertTrue(context.is_valid) + self.assertFalse(context.has_signer) + context.close() + + def test_context_builder_with_settings(self): + settings = Settings() + context = Context.builder().with_settings(settings).build() + self.assertTrue(context.is_valid) + context.close() + settings.close() + + def test_context_builder_with_signer(self): + signer = self._ctx_make_signer() + context = ( + Context.builder() + .with_signer(signer) + .build() + ) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + + def test_context_builder_with_settings_and_signer(self): + settings = Settings() + signer = self._ctx_make_signer() + context = ( + Context.builder() + .with_settings(settings) + .with_signer(signer) + .build() + ) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + settings.close() + + def test_context_builder_chaining_returns_self(self): + settings = Settings() + context_builder = Context.builder() + result = context_builder.with_settings(settings) + self.assertIs(result, context_builder) + context = context_builder.build() + context.close() + settings.close() + + +class TestContextWithSigner(TestContextAPIs): + + def test_context_with_signer(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + + def test_context_with_settings_and_signer(self): + settings = Settings() + signer = self._ctx_make_signer() + context = Context(settings, signer) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + settings.close() + + def test_consumed_signer_is_closed(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + self.assertEqual(signer._lifecycle_state, LifecycleState.CLOSED) + context.close() + + def test_consumed_signer_raises_on_use(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + with self.assertRaises(Error): + signer._ensure_valid_state() + context.close() + + def test_context_has_signer_flag(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + self.assertTrue(context.has_signer) + context.close() + + def test_context_no_signer_flag(self): + context = Context() + self.assertFalse(context.has_signer) + context.close() + + def test_context_from_json_with_signer(self): + signer = self._ctx_make_signer() + context = Context.from_json( + '{"builder":{"thumbnail":' + '{"enabled":false}}}', + signer, + ) + self.assertTrue(context.has_signer) + self.assertEqual(signer._lifecycle_state, LifecycleState.CLOSED) + context.close() + + +class TestReaderWithContext(TestContextAPIs): + + def test_reader_with_default_context(self): + context = Context() + with open(DEFAULT_TEST_FILE, "rb") as file_handle: + reader = Reader("image/jpeg", file_handle, context=context,) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + + def test_reader_with_settings_context(self): + settings = Settings() + context = Context(settings) + with open(DEFAULT_TEST_FILE, "rb") as file_handle: + reader = Reader("image/jpeg", file_handle, context=context,) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + settings.close() + + def test_reader_without_context(self): + with open(DEFAULT_TEST_FILE, "rb") as file_handle: + reader = Reader("image/jpeg", file_handle) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + + def test_reader_try_create_with_context(self): + context = Context() + reader = Reader.try_create(DEFAULT_TEST_FILE, context=context,) + self.assertIsNotNone(reader) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + + def test_reader_try_create_no_manifest(self): + context = Context() + reader = Reader.try_create(INGREDIENT_TEST_FILE, context=context,) + self.assertIsNone(reader) + context.close() + + def test_reader_file_path_with_context(self): + context = Context() + reader = Reader(DEFAULT_TEST_FILE, context=context,) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + + def test_reader_format_and_path_with_ctx(self): + context = Context() + reader = Reader("image/jpeg", DEFAULT_TEST_FILE, context=context) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + + def test_with_fragment_on_closed_reader_raises(self): + context = Context() + reader = Reader(DEFAULT_TEST_FILE, context=context) + reader.close() + with self.assertRaises(Error): + reader.with_fragment( + "video/mp4", + io.BytesIO(b"\x00" * 100), + io.BytesIO(b"\x00" * 100), + ) + context.close() + + def test_with_fragment_unsupported_format_raises(self): + context = Context() + reader = Reader(DEFAULT_TEST_FILE, context=context) + with self.assertRaises(Error): + reader.with_fragment( + "text/plain", + io.BytesIO(b"\x00" * 100), + io.BytesIO(b"\x00" * 100), + ) + reader.close() + context.close() + + def test_with_fragment_with_dash_fixtures(self): + context = Context() + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + with open(init_path, "rb") as init_fragment: + reader = Reader("video/mp4", init_fragment, context=context) + frag_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + with open(init_path, "rb") as init_fragment, \ + open(frag_path, "rb") as next_fragment: + reader.with_fragment("video/mp4", init_fragment, next_fragment) + reader.close() + context.close() + + +class TestBuilderWithContext(TestContextAPIs): + + def test_contextual_builder_with_default_context(self): + context = Context() + builder = Builder(self.test_manifest, context) + self.assertIsNotNone(builder) + builder.close() + context.close() + + def test_contextual_builder_with_settings_context(self): + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + context = Context(settings) + builder = Builder(self.test_manifest, context) + signer = self._ctx_make_signer() + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + builder.sign( + signer, "image/jpeg", source_file, dest_file, + ) + reader = Reader(dest_path) + manifest = reader.get_active_manifest() + self.assertIsNone( + manifest.get("thumbnail") + ) + reader.close() + builder.close() + context.close() + settings.close() + + def test_contextual_builder_from_json_with_context(self): + context = Context() + builder = Builder.from_json(self.test_manifest, context) + self.assertIsNotNone(builder) + builder.close() + context.close() + + def test_contextual_builder_sign_context_signer(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign( + "image/jpeg", + source_file, + dest_file, + ) + self.assertIsNotNone(manifest_bytes) + self.assertGreater(len(manifest_bytes), 0) + reader = Reader(dest_path) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + builder.close() + context.close() + + def test_contextual_builder_sign_signer_ovverride(self): + context_signer = self._ctx_make_signer() + context = Context(signer=context_signer) + builder = Builder( + self.test_manifest, context=context, + ) + explicit_signer = self._ctx_make_signer() + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign( + explicit_signer, + "image/jpeg", source_file, dest_file, + ) + self.assertIsNotNone(manifest_bytes) + self.assertGreater(len(manifest_bytes), 0) + builder.close() + explicit_signer.close() + context.close() + + def test_contextual_builder_sign_no_signer_raises(self): + context = Context() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + with self.assertRaises(Error): + builder.sign( + "image/jpeg", + source_file, + dest_file, + ) + builder.close() + context.close() + + def test_sign_file_with_context_signer_no_explicit_signer(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + manifest_bytes = builder.sign_file( + source_path=DEFAULT_TEST_FILE, + dest_path=dest_path, + ) + self.assertIsNotNone(manifest_bytes) + self.assertGreater(len(manifest_bytes), 0) + reader = Reader(dest_path) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + builder.close() + context.close() + + def test_sign_file_no_signer_raises(self): + context = Context() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with self.assertRaises(Error): + builder.sign_file( + source_path=DEFAULT_TEST_FILE, + dest_path=dest_path, + ) + builder.close() + context.close() + + def test_with_archive_preserves_settings(self): + """with_archive() preserves the builder's context settings. + + Settings live on the builder's context, not in the archive. + The archive only carries the manifest definition. This test + proves that a builder created with no-thumbnail settings + keeps those settings after loading an archive. + """ + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + + # Context provides the no-thumbnail setting; + # with_archive only loads the manifest definition. + builder2 = Builder({}, context) + builder2.with_archive(archive) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder2.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=Context(), + ) + manifest = reader.get_active_manifest() + self.assertIsNone( + manifest.get("thumbnail"), + "with_archive should preserve no-thumbnail setting", + ) + reader.close() + archive.close() + builder2.close() + signer.close() + context.close() + settings.close() + + def test_with_archive_replaces_definition(self): + """with_archive() restores the original builder's + manifest definition, even if something set on new Builder.""" + context = Context() + signer = self._ctx_make_signer() + original_manifest = dict(self.test_manifest) + original_manifest["title"] = "Original Title" + builder = Builder(original_manifest, context) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + + replaced_manifest = dict(self.test_manifest) + replaced_manifest["title"] = "Replaced Title" + builder2 = Builder(replaced_manifest, context) + builder2.with_archive(archive) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder2.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=Context(), + ) + json_data = reader.json() + self.assertIn("Original Title", json_data) + self.assertNotIn("Replaced Title", json_data) + reader.close() + archive.close() + builder2.close() + signer.close() + context.close() + + def test_with_archive_on_closed_builder_raises(self): + """with_archive() on a closed builder raises C2paError.""" + context = Context() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder.close() + with self.assertRaises(Error): + builder.with_archive(archive) + context.close() + + def test_from_archive_roundtrip(self): + """from_archive() can't propagate contexts.""" + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + + # from_archive creates a context-free builder + builder2 = Builder.from_archive(archive) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder2.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=Context(), + ) + manifest = reader.get_active_manifest() + # from_archive can't propagate contexts + self.assertIsNotNone( + manifest.get("thumbnail"), + "from_archive should lose settings and generate thumbnail", + ) + reader.close() + archive.close() + builder2.close() + signer.close() + context.close() + settings.close() + + +class TestContextIntegration(TestContextAPIs): + + def test_sign_no_thumbnail_via_context(self): + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + builder.sign( + signer, "image/jpeg", source_file, dest_file, + ) + reader = Reader(dest_path) + manifest = reader.get_active_manifest() + self.assertIsNone( + manifest.get("thumbnail") + ) + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_sign_read_roundtrip(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + builder.sign( + "image/jpeg", + source_file, + dest_file, + ) + reader = Reader(dest_path) + data = reader.json() + self.assertIsNotNone(data) + self.assertIn("manifests", data) + reader.close() + builder.close() + context.close() + + def test_shared_context_multi_builders(self): + context = Context() + signer1 = self._ctx_make_signer() + signer2 = self._ctx_make_signer() + + builder1 = Builder(self.test_manifest, context) + builder2 = Builder(self.test_manifest, context) + + with tempfile.TemporaryDirectory() as temp_dir: + for index, (builder, signer) in enumerate( + [(builder1, signer1), (builder2, signer2)] + ): + dest_path = os.path.join( + temp_dir, f"out{index}.jpg" + ) + with ( + open( + DEFAULT_TEST_FILE, "rb" + ) as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign( + signer, "image/jpeg", + source_file, dest_file, + ) + self.assertGreater(len(manifest_bytes), 0) + + builder1.close() + builder2.close() + signer1.close() + signer2.close() + context.close() + + def test_trusted_sign_no_thumbnail_via_context(self): + trust_dict = load_test_settings_json() + trust_dict.setdefault("builder", {})["thumbnail"] = { + "enabled": False, + } + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + builder.sign( + signer, "image/jpeg", + source_file, dest_file, + ) + reader = Reader(dest_path, context=context) + manifest = reader.get_active_manifest() + self.assertIsNone(manifest.get("thumbnail")) + validation_state = reader.get_validation_state() + self.assertEqual(validation_state, "Trusted") + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_shared_trusted_context_multi_builders(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer1 = self._ctx_make_signer() + signer2 = self._ctx_make_signer() + + builder1 = Builder( + self.test_manifest, context=context, + ) + builder2 = Builder( + self.test_manifest, context=context, + ) + + with tempfile.TemporaryDirectory() as temp_dir: + for index, (builder, signer) in enumerate( + [(builder1, signer1), (builder2, signer2)] + ): + dest_path = os.path.join( + temp_dir, f"out{index}.jpg" + ) + with ( + open( + DEFAULT_TEST_FILE, "rb" + ) as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign( + signer, "image/jpeg", + source_file, dest_file, + ) + self.assertGreater( + len(manifest_bytes), 0, + ) + reader = Reader( + dest_path, context=context, + ) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + + builder1.close() + builder2.close() + signer1.close() + signer2.close() + context.close() + settings.close() + + def test_read_validation_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + with open(DEFAULT_TEST_FILE, "rb") as f: + reader = Reader("image/jpeg", f, context=context) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + context.close() + settings.close() + + def test_sign_es256_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + open(dest_path, "w+b") as dest, + ): + builder.sign( + signer, "image/jpeg", source, dest, + ) + reader = Reader(dest_path, context=context) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_sign_ed25519_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_ed25519_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + open(dest_path, "w+b") as dest, + ): + builder.sign( + signer, "image/jpeg", source, dest, + ) + reader = Reader(dest_path, context=context) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_sign_ps256_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_ps256_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + open(dest_path, "w+b") as dest, + ): + builder.sign( + signer, "image/jpeg", source, dest, + ) + reader = Reader(dest_path, context=context) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_archive_sign_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder({}, context=context) + builder.with_archive(archive) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=context, + ) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + archive.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_archive_sign_with_ingredient_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder({}, context=context) + builder.with_archive(archive) + ingredient_json = '{"test": "ingredient"}' + with open(DEFAULT_TEST_FILE, "rb") as f: + builder.add_ingredient( + ingredient_json, "image/jpeg", f, + ) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=context, + ) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + archive.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_remote_sign_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings=settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + builder.set_no_embed() + with open(DEFAULT_TEST_FILE, "rb") as source: + with io.BytesIO() as output_buffer: + manifest_data = builder.sign( + signer, "image/jpeg", + source, output_buffer, + ) + output_buffer.seek(0) + read_buffer = io.BytesIO( + output_buffer.getvalue() + ) + reader = Reader( + "image/jpeg", read_buffer, + manifest_data, context=context, + ) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + read_buffer.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_sign_callback_signer_in_ctx(self): + signer = self._ctx_make_callback_signer() + context = Context(signer=signer) + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign( + "image/jpeg", + source_file, + dest_file, + ) + self.assertGreater(len(manifest_bytes), 0) + reader = Reader(dest_path) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + builder.close() + context.close() + + if __name__ == '__main__': - unittest.main() + unittest.main(warnings='ignore') diff --git a/tests/test_unit_tests_threaded.py b/tests/test_unit_tests_threaded.py index 14ef48fe..bfb54c8f 100644 --- a/tests/test_unit_tests_threaded.py +++ b/tests/test_unit_tests_threaded.py @@ -21,7 +21,8 @@ import asyncio import random -from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version +from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version # noqa: E501 +from c2pa import Context, Settings from c2pa.c2pa import Stream PROJECT_PATH = os.getcwd() @@ -316,6 +317,213 @@ def process_file_with_cache(filename): if errors: self.fail("\n".join(errors)) + +class TestContextualReaderWithThreads(unittest.TestCase): + def setUp(self): + self.data_dir = FIXTURES_FOLDER + self.testPath = DEFAULT_TEST_FILE + + def test_stream_read(self): + def read_metadata(): + ctx = Context() + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file, context=ctx) + json_data = reader.json() + self.assertIn("C.jpg", json_data) + return json_data + + thread1 = threading.Thread(target=read_metadata) + thread2 = threading.Thread(target=read_metadata) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + def test_stream_read_and_parse(self): + def read_and_parse(): + ctx = Context() + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file, context=ctx) + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, "C.jpg") + return manifest_store + + thread1 = threading.Thread(target=read_and_parse) + thread2 = threading.Thread(target=read_and_parse) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + def test_read_all_files(self): + """Test reading C2PA metadata from all files using context APIs.""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.pdf': 'application/pdf', + } + skip_files = {'.DS_Store'} + + def process_file(filename): + if filename in skip_files: + return None + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + ctx = Context() + with open(file_path, "rb") as file: + reader = Reader(mime_type, file, context=ctx) + json_data = reader.json() + manifest = json.loads(json_data) + if "manifests" not in manifest or "active_manifest" not in manifest: + return f"Invalid manifest structure in {filename}" + return None + except Exception as e: + return f"Failed to read metadata from {filename}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + future_to_file = { + executor.submit(process_file, filename): filename + for filename in os.listdir(reading_dir) + } + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename}: {str(e)}") + if errors: + self.fail("\n".join(errors)) + + def test_read_cached_all_files(self): + """Test reading C2PA metadata with cache using context APIs.""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.pdf': 'application/pdf', + } + skip_files = {'.DS_Store'} + + def process_file_with_cache(filename): + if filename in skip_files: + return None + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + ctx = Context() + with open(file_path, "rb") as file: + reader = Reader(mime_type, file, context=ctx) + if reader._manifest_json_str_cache is not None: + return f"JSON cache should be None initially for {filename}" + if reader._manifest_data_cache is not None: + return f"Manifest data cache should be None initially for {filename}" + json_data_1 = reader.json() + if reader._manifest_json_str_cache is None: + return f"JSON cache not set after first json() call for {filename}" + if json_data_1 != reader._manifest_json_str_cache: + return f"JSON cache doesn't match return value for {filename}" + json_data_2 = reader.json() + if json_data_1 != json_data_2: + return f"JSON inconsistency for {filename}" + if not isinstance(json_data_1, str): + return f"JSON data is not a string for {filename}" + try: + active_manifest = reader.get_active_manifest() + if not isinstance(active_manifest, dict): + return f"Active manifest not dict for {filename}" + if reader._manifest_json_str_cache is None: + return f"JSON cache not set after get_active_manifest for {filename}" + if reader._manifest_data_cache is None: + return f"Manifest data cache not set after get_active_manifest for {filename}" + active_manifest_2 = reader.get_active_manifest() + if active_manifest != active_manifest_2: + return f"Active manifest cache inconsistency for {filename}" + validation_state = reader.get_validation_state() + validation_results = reader.get_validation_results() + validation_state_2 = reader.get_validation_state() + if validation_state != validation_state_2: + return f"Validation state cache inconsistency for {filename}" + validation_results_2 = reader.get_validation_results() + if validation_results != validation_results_2: + return f"Validation results cache inconsistency for {filename}" + except KeyError: + pass + manifest = json.loads(json_data_1) + if "manifests" not in manifest: + return f"Missing 'manifests' key in {filename}" + if "active_manifest" not in manifest: + return f"Missing 'active_manifest' key in {filename}" + reader.close() + if reader._manifest_json_str_cache is not None: + return f"JSON cache not cleared for {filename}" + if reader._manifest_data_cache is not None: + return f"Manifest data cache not cleared for {filename}" + return None + except Exception as e: + return f"Failed to read cached metadata from {filename}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + future_to_file = { + executor.submit(process_file_with_cache, filename): filename + for filename in os.listdir(reading_dir) + } + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename}: {str(e)}") + if errors: + self.fail("\n".join(errors)) + + class TestBuilderWithThreads(unittest.TestCase): def setUp(self): # Use the fixtures_dir fixture to set up paths @@ -1610,252 +1818,55 @@ async def run_async_tests(): # Verify all readers completed self.assertEqual(active_readers, 0, "Not all readers completed") - def test_builder_sign_with_multiple_ingredients_from_stream(self): - """Test Builder class operations with multiple ingredients using streams.""" - # Test creating builder from JSON - builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + def test_builder_sign_with_multiple_ingredient_random_many_threads(self): + """Test Builder class operations with 12 threads, each adding 3 specific ingredients and signing a file.""" + # Number of threads to use in the test + TOTAL_THREADS_USED = 12 + + # Define the specific files to use as ingredients + # THose files should be valid to use as ingredient + ingredient_files = [ + os.path.join(self.data_dir, "A_thumbnail.jpg"), + os.path.join(self.data_dir, "C.jpg"), + os.path.join(self.data_dir, "cloud.jpg") + ] # Thread synchronization - add_errors = [] - add_lock = threading.Lock() + thread_results = {} completed_threads = 0 - completion_lock = threading.Lock() + thread_lock = threading.Lock() # Lock for thread-safe access to shared data - def add_ingredient_from_stream(ingredient_json, file_path, thread_id): + def thread_work(thread_id): nonlocal completed_threads try: - with open(file_path, 'rb') as f: - builder.add_ingredient_from_stream( - ingredient_json, "image/jpeg", f) - with add_lock: - add_errors.append(None) # Success case - except Exception as e: - with add_lock: - add_errors.append(f"Thread {thread_id} error: {str(e)}") - finally: - with completion_lock: - completed_threads += 1 - - # Create and start two threads for parallel ingredient addition - thread1 = threading.Thread( - target=add_ingredient_from_stream, - args=('{"title": "Test Ingredient Stream 1"}', self.testPath3, 1) - ) - thread2 = threading.Thread( - target=add_ingredient_from_stream, - args=('{"title": "Test Ingredient Stream 2"}', self.testPath4, 2) - ) + # Create a new builder for this thread + builder = Builder.from_json(self.manifestDefinition) - # Start both threads - thread1.start() - thread2.start() + # Add each ingredient + for i, file_path in enumerate(ingredient_files, 1): + ingredient_json = json.dumps({ + "title": f"Thread {thread_id} Ingredient {i} - {os.path.basename(file_path)}" + }) - # Wait for both threads to complete - thread1.join() - thread2.join() + with open(file_path, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) - # Check for errors during ingredient addition - if any(error for error in add_errors if error is not None): - self.fail( - "\n".join( - error for error in add_errors if error is not None)) + # Use A.jpg as the file to sign + sign_file_path = os.path.join(self.data_dir, "A.jpg") - # Verify both ingredients were added successfully - self.assertEqual( - completed_threads, - 2, - "Both threads should have completed") - self.assertEqual( - len(add_errors), - 2, - "Both threads should have completed without errors") + # Sign the file + with open(sign_file_path, "rb") as file: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", file, output) - # Now sign the manifest with the added ingredients - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) + # Ensure all data is written + output.flush() - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] + # Get the complete data + output_data = output.getvalue() - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertEqual(len(active_manifest["ingredients"]), 2) - - # Verify both ingredients exist in the array (order doesn't matter) - ingredient_titles = [ing["title"] - for ing in active_manifest["ingredients"]] - self.assertIn("Test Ingredient Stream 1", ingredient_titles) - self.assertIn("Test Ingredient Stream 2", ingredient_titles) - - builder.close() - - def test_builder_sign_with_same_ingredient_multiple_times(self): - """Test Builder class operations with the same ingredient added multiple times from different threads.""" - # Test creating builder from JSON - builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None - - # Thread synchronization - add_errors = [] - add_lock = threading.Lock() - completed_threads = 0 - completion_lock = threading.Lock() - - def add_ingredient(ingredient_json, thread_id): - nonlocal completed_threads - try: - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - with add_lock: - add_errors.append(None) # Success case - except Exception as e: - with add_lock: - add_errors.append(f"Thread {thread_id} error: {str(e)}") - finally: - with completion_lock: - completed_threads += 1 - - # Create and start 5 threads for parallel ingredient addition - threads = [] - for i in range(1, 6): - # Create unique manifest JSON for each thread - ingredient_json = json.dumps({ - "title": f"Test Ingredient Thread {i}" - }) - - thread = threading.Thread( - target=add_ingredient, - args=(ingredient_json, i) - ) - threads.append(thread) - thread.start() - - # Wait for all threads to complete - for thread in threads: - thread.join() - - # Check for errors during ingredient addition - if any(error for error in add_errors if error is not None): - self.fail( - "\n".join( - error for error in add_errors if error is not None)) - - # Verify all ingredients were added successfully - self.assertEqual( - completed_threads, - 5, - "All 5 threads should have completed") - self.assertEqual( - len(add_errors), - 5, - "All 5 threads should have completed without errors") - - # Now sign the manifest with the added ingredients - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertEqual(len(active_manifest["ingredients"]), 5) - - # Verify all ingredients exist in the array with correct thread IDs - # and unique metadata - ingredient_titles = [ing["title"] - for ing in active_manifest["ingredients"]] - - # Check that we have 5 unique titles - self.assertEqual(len(set(ingredient_titles)), 5, - "Should have 5 unique ingredient titles") - - # Verify each thread's ingredient exists with correct metadata - for i in range(1, 6): - # Find ingredients with this thread ID - thread_ingredients = [ing for ing in active_manifest["ingredients"] - if ing["title"] == f"Test Ingredient Thread {i}"] - self.assertEqual( - len(thread_ingredients), - 1, - f"Should find exactly one ingredient for thread {i}") - - builder.close() - - def test_builder_sign_with_multiple_ingredient_random_many_threads(self): - """Test Builder class operations with 12 threads, each adding 3 specific ingredients and signing a file.""" - # Number of threads to use in the test - TOTAL_THREADS_USED = 12 - - # Define the specific files to use as ingredients - # THose files should be valid to use as ingredient - ingredient_files = [ - os.path.join(self.data_dir, "A_thumbnail.jpg"), - os.path.join(self.data_dir, "C.jpg"), - os.path.join(self.data_dir, "cloud.jpg") - ] - - # Thread synchronization - thread_results = {} - completed_threads = 0 - thread_lock = threading.Lock() # Lock for thread-safe access to shared data - - def thread_work(thread_id): - nonlocal completed_threads - try: - # Create a new builder for this thread - builder = Builder.from_json(self.manifestDefinition) - - # Add each ingredient - for i, file_path in enumerate(ingredient_files, 1): - ingredient_json = json.dumps({ - "title": f"Thread {thread_id} Ingredient {i} - {os.path.basename(file_path)}" - }) - - with open(file_path, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - # Use A.jpg as the file to sign - sign_file_path = os.path.join(self.data_dir, "A.jpg") - - # Sign the file - with open(sign_file_path, "rb") as file: - output = io.BytesIO() - builder.sign(self.signer, "image/jpeg", file, output) - - # Ensure all data is written - output.flush() - - # Get the complete data - output_data = output.getvalue() - - # Create a new BytesIO with the complete data - input_stream = io.BytesIO(output_data) + # Create a new BytesIO with the complete data + input_stream = io.BytesIO(output_data) # Now read and verify the signed manifest reader = Reader("image/jpeg", input_stream) @@ -1977,5 +1988,768 @@ def thread_work(thread_id): other_manifest["active_manifest"], f"Thread {thread_id} and {other_thread_id} share the same active manifest ID") + +class TestContextualBuilderWithThreads(TestBuilderWithThreads): + """Same as TestBuilderWithThreads but using only the context APIs (Context, Builder/Reader with context=ctx).""" + + def test_sign_all_files(self): + """Test signing all files using a thread pool with Context""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + mime_types = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic', + '.heif': 'image/heif', '.avif': 'image/avif', '.tif': 'image/tiff', + '.tiff': 'image/tiff', '.mp4': 'video/mp4', '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav' + } + skip_files = {'sample3.invalid.wav'} + + def sign_file(filename, thread_id): + if filename in skip_files: + return None + file_path = os.path.join(signing_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + with open(file_path, "rb") as file: + manifest_def = self.manifestDefinition_2 if thread_id % 2 == 0 else self.manifestDefinition_1 + expected_author = "Tester Two" if thread_id % 2 == 0 else "Tester One" + ctx = Context() + builder = Builder(manifest_def, ctx) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + read_ctx = Context() + reader = Reader(mime_type, output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + expected_claim_generator = f"python_test_{2 if thread_id % 2 == 0 else 1}/0.0.1" + self.assertEqual(active_manifest["claim_generator"], expected_claim_generator) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + output.close() + return None + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign {filename} in thread {thread_id}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + future_to_file = { + executor.submit(sign_file, filename, i): (filename, i) + for i, filename in enumerate(all_files) + } + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename, thread_id = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename} in thread {thread_id}: {str(e)}") + if errors: + self.fail("\n".join(errors)) + + def test_sign_all_files_async(self): + """Test signing all files using asyncio with Context""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + mime_types = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic', + '.heif': 'image/heif', '.avif': 'image/avif', '.tif': 'image/tiff', + '.tiff': 'image/tiff', '.mp4': 'video/mp4', '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav' + } + skip_files = {'sample3.invalid.wav'} + + async def async_sign_file(filename, thread_id): + if filename in skip_files: + return None + file_path = os.path.join(signing_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + with open(file_path, "rb") as file: + manifest_def = self.manifestDefinition_2 if thread_id % 2 == 0 else self.manifestDefinition_1 + expected_author = "Tester Two" if thread_id % 2 == 0 else "Tester One" + ctx = Context() + builder = Builder(manifest_def, ctx) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + read_ctx = Context() + reader = Reader(mime_type, output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + expected_claim_generator = f"python_test_{2 if thread_id % 2 == 0 else 1}/0.0.1" + self.assertEqual(active_manifest["claim_generator"], expected_claim_generator) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + output.close() + return None + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign {filename} in thread {thread_id}: {str(e)}" + + async def run_async_tests(): + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + tasks = [asyncio.create_task(async_sign_file(f, i)) for i, f in enumerate(all_files)] + results = await asyncio.gather(*tasks, return_exceptions=True) + errors = [] + for result in results: + if isinstance(result, Exception): + errors.append(str(result)) + elif result: + errors.append(result) + if errors: + self.fail("\n".join(errors)) + asyncio.run(run_async_tests()) + + def test_parallel_manifest_writing(self): + """Test writing different manifests in parallel using context APIs""" + output1 = io.BytesIO(bytearray()) + output2 = io.BytesIO(bytearray()) + + def write_manifest(manifest_def, output_stream, thread_id): + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(manifest_def, ctx) + builder.sign(self.signer, "image/jpeg", file, output_stream) + output_stream.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output_stream, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], f"python_test_{thread_id}/0.0.1") + self.assertEqual(active_manifest["title"], f"Python Test Image {thread_id}") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], f"Tester {'One' if thread_id == 1 else 'Two'}") + break + return active_manifest + + thread1 = threading.Thread(target=write_manifest, args=(self.manifestDefinition_1, output1, 1)) + thread2 = threading.Thread(target=write_manifest, args=(self.manifestDefinition_2, output2, 2)) + thread1.start() + thread2.start() + thread2.join() + thread1.join() + output1.seek(0) + output2.seek(0) + read_ctx1 = Context() + read_ctx2 = Context() + reader1 = Reader("image/jpeg", output1, context=read_ctx1) + reader2 = Reader("image/jpeg", output2, context=read_ctx2) + manifest_store1 = json.loads(reader1.json()) + manifest_store2 = json.loads(reader2.json()) + active_manifest1 = manifest_store1["manifests"][manifest_store1["active_manifest"]] + active_manifest2 = manifest_store2["manifests"][manifest_store2["active_manifest"]] + self.assertNotEqual(active_manifest1["claim_generator"], active_manifest2["claim_generator"]) + self.assertNotEqual(active_manifest1["title"], active_manifest2["title"]) + output1.close() + output2.close() + + def test_parallel_sign_all_files_interleaved(self): + """Test signing all files with context APIs, thread pool cycling through manifest definitions""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + mime_types = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic', + '.heif': 'image/heif', '.avif': 'image/avif', '.tif': 'image/tiff', + '.tiff': 'image/tiff', '.mp4': 'video/mp4', '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav' + } + skip_files = {'sample3.invalid.wav'} + thread_counter = 0 + thread_counter_lock = threading.Lock() + thread_execution_order = [] + thread_order_lock = threading.Lock() + + def sign_file(filename, thread_id): + nonlocal thread_counter + if filename in skip_files: + return None + file_path = os.path.join(signing_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + with open(file_path, "rb") as file: + if thread_id % 3 == 0: + manifest_def = self.manifestDefinition + expected_author = "Tester" + expected_thread = "" + elif thread_id % 3 == 1: + manifest_def = self.manifestDefinition_1 + expected_author = "Tester One" + expected_thread = "1" + else: + manifest_def = self.manifestDefinition_2 + expected_author = "Tester Two" + expected_thread = "2" + with thread_counter_lock: + current_count = thread_counter + thread_counter += 1 + with thread_order_lock: + thread_execution_order.append((current_count, thread_id)) + time.sleep(0.01) + ctx = Context() + builder = Builder(manifest_def, ctx) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + read_ctx = Context() + reader = Reader(mime_type, output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + expected_claim_generator = "python_test/0.0.1" if thread_id % 3 == 0 else f"python_test_{expected_thread}/0.0.1" + self.assertEqual(active_manifest["claim_generator"], expected_claim_generator) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + output.close() + return None + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign {filename} in thread {thread_id}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + future_to_file = {executor.submit(sign_file, filename, i): (filename, i) for i, filename in enumerate(all_files)} + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename, thread_id = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename} in thread {thread_id}: {str(e)}") + max_same_thread_sequence = 3 + current_sequence = 1 + current_thread = thread_execution_order[0][1] if thread_execution_order else None + for i in range(1, len(thread_execution_order)): + if thread_execution_order[i][1] == current_thread: + current_sequence += 1 + if current_sequence > max_same_thread_sequence: + self.fail(f"Thread {current_thread} executed {current_sequence} times in sequence") + else: + current_sequence = 1 + current_thread = thread_execution_order[i][1] + if errors: + self.fail("\n".join(errors)) + + def test_concurrent_read_after_write(self): + """Test reading from a file after writing is complete, using context APIs""" + output = io.BytesIO(bytearray()) + write_complete = threading.Event() + write_errors = [] + read_errors = [] + + def write_manifest(): + try: + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + def read_manifest(): + try: + write_complete.wait() + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Read error: {str(e)}") + + read_thread = threading.Thread(target=read_manifest) + write_thread = threading.Thread(target=write_manifest) + read_thread.start() + write_thread.start() + write_thread.join() + read_thread.join() + output.close() + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + + def test_concurrent_read_write_multiple_readers(self): + """Test multiple readers reading after write, using context APIs""" + output = io.BytesIO(bytearray()) + write_complete = threading.Event() + write_errors = [] + read_errors = [] + reader_count = 3 + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() + + def write_manifest(): + try: + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + write_complete.wait() + with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + + write_thread = threading.Thread(target=write_manifest) + write_thread.start() + read_threads = [threading.Thread(target=read_manifest, args=(i,)) for i in range(reader_count)] + for t in read_threads: + t.start() + write_thread.join() + for t in read_threads: + t.join() + output.close() + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + def test_resource_contention_read(self): + """Test multiple threads reading the same file with context APIs""" + output = io.BytesIO(bytearray()) + read_errors = [] + reader_count = 5 + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() + + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + time.sleep(0.01) + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + + read_threads = [threading.Thread(target=read_manifest, args=(i,)) for i in range(reader_count)] + for t in read_threads: + t.start() + for t in read_threads: + t.join() + output.close() + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + def test_resource_contention_read_parallel(self): + """Test multiple threads starting simultaneously to read with context APIs""" + output = io.BytesIO(bytearray()) + read_errors = [] + reader_count = 5 + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() + start_barrier = threading.Barrier(reader_count) + + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + start_barrier.wait() + with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + + read_threads = [threading.Thread(target=read_manifest, args=(i,)) for i in range(reader_count)] + for t in read_threads: + t.start() + for t in read_threads: + t.join() + output.close() + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + def test_sign_all_files_twice(self): + """Test signing the same file twice with different manifests using context APIs""" + output1 = io.BytesIO(bytearray()) + output2 = io.BytesIO(bytearray()) + sign_errors = [] + thread_results = {} + thread_lock = threading.Lock() + + def sign_file(output_stream, manifest_def, thread_id): + try: + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(manifest_def, ctx) + builder.sign(self.signer, "image/jpeg", file, output_stream) + output_stream.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output_stream, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + if thread_id == 1: + expected_claim_generator = "python_test_1/0.0.1" + expected_author = "Tester One" + else: + expected_claim_generator = "python_test_2/0.0.1" + expected_author = "Tester Two" + with thread_lock: + thread_results[thread_id] = {'manifest': active_manifest} + self.assertEqual(active_manifest["claim_generator"], expected_claim_generator) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + return None + except Exception as e: + return f"Thread {thread_id} error: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + future1 = executor.submit(sign_file, output1, self.manifestDefinition_1, 1) + future2 = executor.submit(sign_file, output2, self.manifestDefinition_2, 2) + for future in concurrent.futures.as_completed([future1, future2]): + error = future.result() + if error: + sign_errors.append(error) + if sign_errors: + self.fail("\n".join(sign_errors)) + self.assertEqual(len(thread_results), 2) + output1.seek(0) + output2.seek(0) + read_ctx1 = Context() + read_ctx2 = Context() + reader1 = Reader("image/jpeg", output1, context=read_ctx1) + reader2 = Reader("image/jpeg", output2, context=read_ctx2) + manifest_store1 = json.loads(reader1.json()) + manifest_store2 = json.loads(reader2.json()) + active_manifest1 = manifest_store1["manifests"][manifest_store1["active_manifest"]] + active_manifest2 = manifest_store2["manifests"][manifest_store2["active_manifest"]] + self.assertNotEqual(active_manifest1["claim_generator"], active_manifest2["claim_generator"]) + self.assertNotEqual(active_manifest1["title"], active_manifest2["title"]) + output1.close() + output2.close() + + def test_concurrent_read_after_write_async(self): + """Test read after write using asyncio with context APIs""" + output = io.BytesIO(bytearray()) + write_complete = asyncio.Event() + write_errors = [] + read_errors = [] + write_success = False + + async def write_manifest(): + nonlocal write_success + try: + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + write_success = True + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + async def read_manifest(): + try: + await write_complete.wait() + if not write_success: + raise Exception("Write operation did not complete successfully") + self.assertGreater(len(output.getvalue()), 0) + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + self.assertIn("manifests", manifest_store) + self.assertIn("active_manifest", manifest_store) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + author_found = False + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + author_found = True + break + self.assertTrue(author_found) + except Exception as e: + read_errors.append(f"Read error: {str(e)}") + + async def run_async_tests(): + write_task = asyncio.create_task(write_manifest()) + await write_task + read_task = asyncio.create_task(read_manifest()) + await read_task + asyncio.run(run_async_tests()) + output.close() + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + + def test_resource_contention_read_parallel_async(self): + """Test multiple async tasks reading the same file with context APIs""" + output = io.BytesIO(bytearray()) + read_errors = [] + reader_count = 5 + active_readers = 0 + readers_lock = asyncio.Lock() + stream_lock = asyncio.Lock() + start_barrier = asyncio.Barrier(reader_count) + + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + async def read_manifest(reader_id): + nonlocal active_readers + try: + async with readers_lock: + active_readers += 1 + await start_barrier.wait() + async with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + async with readers_lock: + active_readers -= 1 + + async def run_async_tests(): + tasks = [asyncio.create_task(read_manifest(i)) for i in range(reader_count)] + await asyncio.gather(*tasks) + asyncio.run(run_async_tests()) + output.close() + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + def test_builder_sign_with_multiple_ingredient_random_many_threads(self): + """Test Builder with 12 threads adding ingredients and signing using context APIs""" + TOTAL_THREADS_USED = 12 + ingredient_files = [ + os.path.join(self.data_dir, "A_thumbnail.jpg"), + os.path.join(self.data_dir, "C.jpg"), + os.path.join(self.data_dir, "cloud.jpg") + ] + thread_results = {} + completed_threads = 0 + thread_lock = threading.Lock() + + def thread_work(thread_id): + nonlocal completed_threads + try: + ctx = Context() + builder = Builder.from_json(self.manifestDefinition, context=ctx) + for i, file_path in enumerate(ingredient_files, 1): + ingredient_json = json.dumps({"title": f"Thread {thread_id} Ingredient {i} - {os.path.basename(file_path)}"}) + with open(file_path, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + sign_file_path = os.path.join(self.data_dir, "A.jpg") + with open(sign_file_path, "rb") as file: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", file, output) + output.flush() + output_data = output.getvalue() + input_stream = io.BytesIO(output_data) + read_ctx = Context() + reader = Reader("image/jpeg", input_stream, context=read_ctx) + json_data = reader.json() + manifest_data = json.loads(json_data) + with thread_lock: + thread_results[thread_id] = { + 'manifest': manifest_data, + 'ingredient_files': [os.path.basename(f) for f in ingredient_files], + 'sign_file': os.path.basename(sign_file_path), + 'manifest_hash': hash(json.dumps(manifest_data, sort_keys=True)) + } + output.close() + input_stream.close() + builder.close() + except Exception as e: + with thread_lock: + thread_results[thread_id] = {'error': str(e)} + finally: + with thread_lock: + completed_threads += 1 + + threads = [threading.Thread(target=thread_work, args=(i,)) for i in range(1, TOTAL_THREADS_USED + 1)] + for t in threads: + t.start() + for t in threads: + t.join() + self.assertEqual(completed_threads, TOTAL_THREADS_USED) + self.assertEqual(len(thread_results), TOTAL_THREADS_USED) + manifest_hashes = set() + thread_manifest_data = {} + for thread_id in range(1, TOTAL_THREADS_USED + 1): + result = thread_results[thread_id] + if 'error' in result: + self.fail(f"Thread {thread_id} failed with error: {result['error']}") + manifest_data = result['manifest'] + ingredient_files_basename = result['ingredient_files'] + manifest_hash = result['manifest_hash'] + thread_manifest_data[thread_id] = manifest_data + manifest_hashes.add(manifest_hash) + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + self.assertIn("ingredients", active_manifest) + self.assertEqual(len(active_manifest["ingredients"]), 3) + ingredient_titles = [ing["title"] for ing in active_manifest["ingredients"]] + for i, file_name in enumerate(ingredient_files_basename, 1): + self.assertIn(f"Thread {thread_id} Ingredient {i} - {file_name}", ingredient_titles) + for other_thread_id in range(1, TOTAL_THREADS_USED + 1): + if other_thread_id != thread_id: + for title in ingredient_titles: + self.assertNotIn(f"Thread {other_thread_id} Ingredient", title) + self.assertEqual(len(manifest_hashes), TOTAL_THREADS_USED) + for thread_id in range(1, TOTAL_THREADS_USED + 1): + current_manifest = thread_manifest_data[thread_id] + self.assertIn("active_manifest", current_manifest) + self.assertIn("manifests", current_manifest) + for other_thread_id in range(1, TOTAL_THREADS_USED + 1): + if other_thread_id != thread_id: + self.assertNotEqual(current_manifest["active_manifest"], thread_manifest_data[other_thread_id]["active_manifest"]) + + if __name__ == '__main__': unittest.main()