From b7c0785037eb7fccdfedc59a62374f81a948af82 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:59:37 -0700 Subject: [PATCH 1/3] fix: Add agents isntructions --- .github/workflows/build.yml | 20 +- .../workflows/test-c2pa-rs-source-build.yml | 4 +- AI_WORKFLOW.md | 1075 ++ CONTRIBUTING.md | 6 +- Makefile | 24 +- docs/native-resources-management.md | 13 +- examples/README.md | 48 +- tests/test_unit_tests.py | 10156 ---------------- tests/unit/__init__.py | 3 + tests/unit/_support.py | 222 + tests/unit/test_binding_base.py | 22 + tests/unit/test_builder.py | 4631 +++++++ tests/unit/test_context.py | 1316 ++ tests/unit/test_errors.py | 254 + tests/unit/test_format_handling.py | 93 + tests/unit/test_legacy_api.py | 309 + tests/unit/test_managed_resource.py | 1816 +++ tests/unit/test_reader.py | 1215 ++ tests/unit/test_stream.py | 305 + tests/unit/test_threading_static_analysis.py | 177 + 20 files changed, 11505 insertions(+), 10204 deletions(-) create mode 100644 AI_WORKFLOW.md delete mode 100644 tests/test_unit_tests.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/_support.py create mode 100644 tests/unit/test_binding_base.py create mode 100644 tests/unit/test_builder.py create mode 100644 tests/unit/test_context.py create mode 100644 tests/unit/test_errors.py create mode 100644 tests/unit/test_format_handling.py create mode 100644 tests/unit/test_legacy_api.py create mode 100644 tests/unit/test_managed_resource.py create mode 100644 tests/unit/test_reader.py create mode 100644 tests/unit/test_stream.py create mode 100644 tests/unit/test_threading_static_analysis.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e970c93a..5ec35e1c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,11 +52,7 @@ jobs: run: python -m pip install -r requirements-dev.txt - name: Check Python syntax - run: python3 -m py_compile src/c2pa/c2pa.py - continue-on-error: true - - - name: Check code style with flake8 - run: flake8 src/c2pa/c2pa.py + run: make check-format continue-on-error: true tests-unix: @@ -116,7 +112,7 @@ jobs: python3 -c "from c2pa import C2paError; print('C2paError imported successfully')" - name: Run tests - run: python3 ./tests/test_unit_tests.py + run: python3 -W ignore -m unittest discover -s tests/unit -t . tests-windows: name: Unit tests for developer setup (Windows) @@ -202,7 +198,7 @@ jobs: python -c "from c2pa import C2paError; print('C2paError imported successfully')" - name: Run tests - run: python .\tests\test_unit_tests.py + run: python -W ignore -m unittest discover -s tests\unit -t . build-linux-wheel: name: Build Linux wheel @@ -277,7 +273,7 @@ jobs: - name: Run unittest tests on installed wheel run: | source venv/bin/activate - python ./tests/test_unit_tests.py + python -W ignore -m unittest discover -s tests/unit -t . - name: Install pytest (in venv) run: | @@ -287,7 +283,7 @@ jobs: - name: Run tests with pytest (venv) run: | source venv/bin/activate - venv/bin/pytest tests/test_unit_tests.py -v + venv/bin/pytest tests/unit -v build-windows-wheel: name: Build Windows wheel @@ -379,7 +375,7 @@ jobs: - name: Run tests with pytest (venv) run: | .\venv\Scripts\activate - .\venv\Scripts\pytest .\tests\test_unit_tests.py -v + .\venv\Scripts\pytest .\tests\unit -v build-macos-wheel: name: Build macOS wheels @@ -458,7 +454,7 @@ jobs: - name: Run unittest tests on installed wheel run: | source venv/bin/activate - python ./tests/test_unit_tests.py + python -W ignore -m unittest discover -s tests/unit -t . - name: Install pytest (in venv) run: | @@ -468,7 +464,7 @@ jobs: - name: Run tests with pytest (venv) run: | source venv/bin/activate - venv/bin/pytest tests/test_unit_tests.py -v + venv/bin/pytest tests/unit -v sdist: runs-on: ubuntu-latest diff --git a/.github/workflows/test-c2pa-rs-source-build.yml b/.github/workflows/test-c2pa-rs-source-build.yml index a6c9ab2d..618e9bb9 100644 --- a/.github/workflows/test-c2pa-rs-source-build.yml +++ b/.github/workflows/test-c2pa-rs-source-build.yml @@ -123,7 +123,7 @@ jobs: working-directory: c2pa-python env: C2PA_PREFLIGHT_RUN: "1" - run: python ./tests/test_unit_tests.py + run: python -W ignore -m unittest discover -s tests/unit -t . tests-windows: name: Unit tests (Windows, ${{ matrix.runs-on }}) @@ -205,4 +205,4 @@ jobs: working-directory: c2pa-python env: C2PA_PREFLIGHT_RUN: "1" - run: python .\tests\test_unit_tests.py + run: python -W ignore -m unittest discover -s tests\unit -t . diff --git a/AI_WORKFLOW.md b/AI_WORKFLOW.md new file mode 100644 index 00000000..b31ac141 --- /dev/null +++ b/AI_WORKFLOW.md @@ -0,0 +1,1075 @@ +# AI Workflow Guidelines for c2pa-python + +Guidance for AI assistants working with or on c2pa-python, the Python bindings for the +C2PA library. The bindings call the c2pa-rs C FFI library through `ctypes`. + +## Read first: when using the library + +Writing code that uses this `c2pa` Python package (applications, scripts, examples, tests): +read [Part 1: Using the library](#part-1-using-the-library). + +| Topic | Document | +| --- | --- | +| API walkthrough, file and stream operations | [docs/usage.md](docs/usage.md) | +| Class relationships | [docs/class-diagram.md](docs/class-diagram.md) | +| Runnable examples | [examples/README.md](examples/README.md) | +| `Context`, `Settings`, signer configuration, settings reference | [docs/context-settings.md](docs/context-settings.md) | +| Manifest stores, working stores, archives, resources, ingredients | [docs/working-stores.md](docs/working-stores.md) | +| Reading, filtering and rebuilding selected manifest parts | [docs/selective-manifests.md](docs/selective-manifests.md) | +| Builder intents and digital source types | [docs/intents.md](docs/intents.md) | + +## Read first: when developing c2pa-python + +Changing this repository: read [Part 2: Developing c2pa-python](#part-2-developing-c2pa-python), +and Part 1 for the API the repository exposes. + +| Topic | Document | +| --- | --- | +| Setup, wheels, tests, API docs | [docs/project-contributions.md](docs/project-contributions.md) | +| Native pointers, lifecycle, locking, streams | [docs/native-resources-management.md](docs/native-resources-management.md) | + +--- + +## Part 1: Using the library + +### Lifetimes and consumption + +#### ALWAYS USE `with` + +**Every `Reader`, `Builder`, `Signer`, `Context` and `Settings` goes in a `with` block. +No exceptions in new code.** + +Each of these objects holds memory allocated by the native library. Python's garbage +collector does not know that memory exists. The `with` block is what frees it, at a +known point, even when the block raises. Without it: + +- The native memory stays allocated until `__del__` runs, and when that happens is not + predictable: an object in a reference cycle waits for the cycle collector, and at + interpreter shutdown objects are collected in any order. +- A `Reader` opened from a path keeps that file open until it is freed. +- Leaks add up in loops and long-running services, where one `Reader` or `Builder` per + request without `with` grows memory with every request. + +```python +# BAD: native memory released whenever the garbage collector gets to it +reader = Reader("image.jpg") +print(reader.json()) + +builder = Builder(manifest_definition, ctx) +builder.sign_file("source.jpg", "signed.jpg", signer) +``` + +```python +# GOOD: released at the end of each block, even on error +with Reader("image.jpg") as reader: + print(reader.json()) + +with Builder(manifest_definition, ctx) as builder: + builder.sign_file("source.jpg", "signed.jpg", signer) +``` + +Rules for `with`: + +- **Nest in dependency order**: `Context` outermost, then `Signer`, then `Builder` or + `Reader`. Objects close in reverse order, inner first, so nothing outlives what it uses. + One statement works the same way, right to left: + `with Context() as ctx, Signer.from_info(info) as signer, Builder(m, ctx) as builder:` +- **Files and streams go in `with` too.** The SDK never closes a file object passed to it; + the caller that opened it closes it. + `with open("photo.jpg", "rb") as f, Reader("image/jpeg", f) as reader:` +- **Never let an object escape its block.** Returning or storing a `Reader` created in a + `with` block hands out a closed object; every method on it raises `C2paError`. Return + the data instead (`reader.json()`, `reader.get_active_manifest()`). +- **Long-lived objects** that cannot sit in one block (a `Context` stored on a service + object) use `try`/`finally` with `close()`, or close from the owner's own `close()` / + `__exit__`. `close()` can be called more than once safely. +- `close()` and `with` are equivalent. `__del__` is a fallback for mistakes, never the + plan. + +Details: [Releasing memory](docs/native-resources-management.md#releasing-memory). + +#### What consumes an object + +- A `Builder` signs once. `sign()` and `sign_file()` consume it and close it, whether + signing succeeds or fails. Create a new `Builder`, in its own `with` block, per signed + output. +- A `Signer` passed to `Context(signer=...)` or `ContextBuilder.with_signer` is consumed. + The `Context` owns it from then on; do not use the `Signer` again. `Builder.sign` + without a signer argument uses the context's signer. +- A `Signer` passed to `Builder.sign(signer, ...)` is borrowed and can sign again. +- A `Settings` passed to `Context(settings=...)` or `ContextBuilder.with_settings` is + borrowed, not consumed: the `Context` copies its configuration and the `Settings` + object stays open and reusable, unlike `Signer`. Close it yourself, or let its own + `with` block do it; the `Context` does not close it for you. +- A failed `with_archive` can consume the `Builder`; create a new one to retry. +- Any method on a closed object raises `C2paError`. + +#### Threads + +Read-only calls (`Reader.json()`, `get_active_manifest()`, ...) on one object can run from +several threads. A mutating call (`add_ingredient`, `with_archive`, `sign`, ...) on an +object another thread is using can fail with `C2paError` ("in use by another operation", +"running a mutating operation") instead of waiting. Use one `Builder` per thread, and do +not close an object another thread is still using. + +### C2PA concepts + +| Term | Meaning in this library | +| --- | --- | +| Manifest | One signed set of provenance statements about an asset: claim, assertions, ingredients, signature. | +| Manifest store | All manifests attached to an asset. | +| Active manifest | The most recent manifest in the store. Its label is the `active_manifest` field of the store JSON. | +| Assertion | One statement inside a manifest, identified by a label (example: `c2pa.actions`). | +| Action | An entry in the `c2pa.actions` assertion describing what was done to the asset (`c2pa.created`, `c2pa.edited`, ...). | +| Ingredient | An asset the current asset was made from. Added with `Builder.add_ingredient`. A `parentOf` ingredient is the asset being edited. | +| Resource | Binary data referenced by a manifest (thumbnails, icons). Added by identifier with `Builder.add_resource`; in the signed store the identifier becomes a JUMBF URI, read back with `Reader.resource_to_stream`. | +| Working store | An unsigned manifest under construction. In this library, a `Builder`. | +| Archive | A serialized working store (`application/c2pa`), saved with `Builder.to_archive` and restored with `Builder.with_archive`. | +| Signer | Certificate chain, private key (or signing callback), algorithm, optional timestamp authority URL. Wrapped by `Signer`. | +| Trust | Trust anchors and trust settings in `Settings`. A signing certificate that chains to a configured anchor makes a valid manifest `Trusted`. | +| Validation state | `Reader.get_validation_state()`: `Invalid` (a check failed), `Valid` (signature and content bindings check out), `Trusted` (valid, and the signer chains to a configured trust anchor). | +| Digital source type | How the content was made (`DIGITAL_CAPTURE`, `TRAINED_ALGORITHMIC_MEDIA`, ...). Required with `CREATE`. | + +Details: [docs/working-stores.md](docs/working-stores.md), [docs/intents.md](docs/intents.md), +[docs/selective-manifests.md](docs/selective-manifests.md), and the +[manifest store reference](https://opensource.contentauthenticity.org/docs/manifest/manifest-ref). + +### Public API + +Everything public is exported from `c2pa` (`src/c2pa/__init__.py` `__all__`). Import from +the package root only. + +| Name | Purpose | +| --- | --- | +| `Context` | Configuration passed to `Reader` and `Builder`: `Settings` plus an optional `Signer`. `Context()`, `Context(settings, signer)`, `Context.from_json`, `Context.from_dict`, `Context.builder()`. | +| `ContextBuilder` | Fluent construction: `Context.builder().with_settings(s).with_signer(sig).build()`. | +| `Settings` | Settings object: `Settings()`, `from_json`, `from_dict`, `set(path, value)`, `update(dict_or_json)`. | +| `ContextProvider` | Abstract base class for custom context providers. `Context` is the standard implementation. | +| `Reader` | Reads and validates a manifest store from a file path or stream. `json()`, `detailed_json()`, `crjson()`, `get_active_manifest()`, `get_manifest(label)`, `get_validation_state()`, `get_validation_results()`, `resource_to_stream(uri, stream)`, `is_embedded()`, `get_remote_url()`, `with_fragment(...)`, `Reader.try_create(...)`. | +| `Builder` | Working store. `Builder(manifest_json, context)`, `add_ingredient`, `add_resource`, `add_action`, `set_intent`, `set_no_embed`, `set_remote_url`, `to_archive`, `with_archive`, `write_ingredient_archive`, `add_ingredient_from_archive`, `sign`, `sign_file`. | +| `Signer` | Signing credentials. `Signer.from_info(C2paSignerInfo)`, `Signer.from_callback(callback, alg, certs, tsa_url)`. | +| `C2paSignerInfo` | Certificate chain, private key, algorithm and timestamp URL for `Signer.from_info`. | +| `Stream` | Internal wrapper around Python file-like objects. Pass ordinary file objects or `io.BytesIO` to the API instead. | +| `C2paSigningAlg` | `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512`, `ED25519`. | +| `C2paBuilderIntent` | `CREATE`, `EDIT`, `UPDATE`. | +| `C2paDigitalSourceType` | IPTC digital source types (`DIGITAL_CAPTURE`, `TRAINED_ALGORITHMIC_MEDIA`, ...). | +| `C2paError` | Base exception. Typed subclasses: `C2paError.ManifestNotFound`, `C2paError.NotSupported`, `C2paError.Signature`, `C2paError.Verify`, `C2paError.Io`, `C2paError.Json`, ... | +| `sdk_version()` | Version string of the native library. | +| `load_settings()` | Deprecated global settings. Use `Context` instead. | + +### Core patterns + +#### Read and validate an asset + +```python +from c2pa import Reader + +with Reader("path/to/image.jpg") as reader: + store_json = reader.json() + active = reader.get_active_manifest() + state = reader.get_validation_state() +``` + +From a stream, pass the MIME type first: + +```python +with open("path/to/image.jpg", "rb") as f: + with Reader("image/jpeg", f) as reader: + print(reader.json()) +``` + +An asset without C2PA data raises `C2paError.ManifestNotFound`. To check for a manifest +without exception handling, `Reader.try_create(...)` returns `None` instead. + +#### Configure with Context and Settings + +```python +from c2pa import Context, Settings, Reader + +config = { + "verify": {"verify_cert_anchors": True}, + "trust": {"trust_anchors": anchors_pem}, +} +with Settings.from_dict(config) as settings, Context(settings) as ctx: + with Reader("path/to/image.jpg", context=ctx) as reader: + print(reader.get_validation_state()) +``` + +One `Context` can be reused across many `Reader` and `Builder` instances. Settings +reference: [docs/context-settings.md](docs/context-settings.md#settings-reference). + +#### Create a signer + +From a certificate and key (`Signer.from_info`): + +```python +from c2pa import C2paSignerInfo, C2paSigningAlg, Signer + +signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, + sign_cert=cert_pem_bytes, + private_key=key_pem_bytes, + ta_url=b"http://timestamp.digicert.com", +) +with Signer.from_info(signer_info) as signer: + ... +``` + +From a callback that signs bytes (`Signer.from_callback`), for keys held in a key +management service or hardware security module: + +```python +def sign_bytes(data: bytes) -> bytes: + return private_key.sign(data, ec.ECDSA(hashes.SHA256())) + +with Signer.from_callback( + sign_bytes, C2paSigningAlg.ES256, cert_chain_pem_str, + "http://timestamp.digicert.com") as signer: + ... +``` + +The callback computes a signature and returns it. It does not call any `c2pa` API. + +#### Build and sign + +```python +from c2pa import Builder, C2paSignerInfo, C2paSigningAlg, Context, Signer + +manifest_definition = { + "claim_generator_info": [{"name": "my_app", "version": "0.1.0"}], + "title": "My image", + "assertions": [{ + "label": "c2pa.actions", + "data": {"actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + }]}, + }], +} + +signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, + sign_cert=cert_pem_bytes, + private_key=key_pem_bytes, + ta_url=b"http://timestamp.digicert.com", +) +with Context() as ctx, Signer.from_info(signer_info) as signer, \ + Builder(manifest_definition, ctx) as builder: + builder.sign_file("source.jpg", "signed.jpg", signer) +``` + +`digitalSourceType` on `c2pa.created` must match how the asset was actually made: a camera +capture is `http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture`, generative AI +output is `http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia`, and so +on. Do not copy `digitalCreation` from this example without checking it applies. When the +source type is unknown, use `http://c2pa.org/digitalsourcetype/empty` +(`C2paDigitalSourceType.EMPTY` with `set_intent`). + +With streams: + +```python +with Builder(manifest_definition, ctx) as builder: + with open("source.jpg", "rb") as source, open("signed.jpg", "w+b") as dest: + manifest_bytes = builder.sign(signer, "image/jpeg", source, dest) +``` + +`sign_file` infers the format from the file extension and raises +`C2paError.NotSupported` when it cannot. + +#### Ingredients, resources, actions, intents + +```python +with Builder(manifest_definition, ctx) as builder: + with open("parent.jpg", "rb") as parent: + builder.add_ingredient( + {"title": "parent.jpg", "relationship": "parentOf"}, "image/jpeg", parent) + with open("thumbnail.jpg", "rb") as thumb: + builder.add_resource("thumbnail", thumb) + builder.add_action({"action": "c2pa.color_adjustments"}) + builder.set_intent(C2paBuilderIntent.EDIT) + builder.sign_file("edited.jpg", "edited_signed.jpg", signer) +``` + +Intent can also come from settings (`{"builder": {"intent": "edit"}}`); `set_intent` +takes precedence. See [docs/intents.md](docs/intents.md). + +#### Archives + +```python +import io + +archive = io.BytesIO() +with Builder(manifest_definition, ctx) as builder: + with open("thumbnail.jpg", "rb") as thumb: + builder.add_resource("thumbnail", thumb) + builder.to_archive(archive) + +archive.seek(0) +with Builder({}, ctx) as restored: + restored.with_archive(archive) + restored.sign_file("source.jpg", "signed.jpg", signer) +``` + +`with_archive` keeps the `Context` of the receiving `Builder`; `Builder.from_archive` +creates a `Builder` with no context. Per-ingredient archives: +`write_ingredient_archive` / `add_ingredient_from_archive`, see +[docs/working-stores.md](docs/working-stores.md#working-with-archives). + +### Pitfalls + +#### DON'T: reuse a Builder after signing + +```python +# BAD: the Builder is closed after the first sign +builder.sign_file("a.jpg", "a_signed.jpg", signer) +builder.sign_file("b.jpg", "b_signed.jpg", signer) # C2paError +``` + +#### DO: one Builder per output + +```python +# GOOD +for src, dst in jobs: + with Builder(manifest_definition, ctx) as builder: + builder.sign_file(src, dst, signer) +``` + +#### DON'T: use a Signer after giving it to a Context + +```python +# BAD +with Context(settings, signer) as ctx: + with Builder(manifest_definition, ctx) as builder: + builder.sign_file("a.jpg", "out.jpg", signer) # C2paError: signer is consumed +``` + +#### DO: let the Context sign, or keep the Signer out of the Context + +```python +# GOOD: the Context owns the signer and signs +with Context(settings, signer) as ctx: + with Builder(manifest_definition, ctx) as builder: + builder.sign_file("a.jpg", "out.jpg") +``` + +#### DON'T: configure globally + +```python +# BAD: deprecated, process-wide +c2pa.load_settings({"builder": {"thumbnail": {"enabled": False}}}) +``` + +#### DO: pass a Context + +```python +# GOOD +with Context.from_dict({"builder": {"thumbnail": {"enabled": False}}}) as ctx: + with Builder(manifest_definition, ctx) as builder: + builder.sign_file("source.jpg", "signed.jpg", signer) +``` + +#### DON'T: treat `Valid` as trusted + +`Valid` means the signature and content bindings check out. It says nothing about who +signed. An asset signed with the test certificates in `tests/fixtures/` reads `Valid` +with default settings and `Trusted` only once trust is configured +(`tests/trust_config_test_settings.json` shows the `trust.trust_anchors` / +`trust.trust_config` shape). When the signer's identity matters, configure trust through +`Settings` and require `Trusted`. `get_validation_results()` gives the per-check detail. + +#### DON'T: call the SDK from a callback + +A signing callback, or a file-like object passed as a stream, runs while a native call on +the `Builder` or `Reader` is in progress. It must not call `c2pa` APIs. A call on the +same object is refused with `C2paError`. + +#### DON'T: catch bare `Exception` + +```python +# BAD: hides programming errors +try: + with Reader(path) as reader: + manifest = reader.get_active_manifest() +except Exception: + manifest = None +``` + +#### DO: catch the typed error + +```python +# GOOD +try: + with Reader(path) as reader: + manifest = reader.get_active_manifest() +except C2paError.ManifestNotFound: + manifest = None +``` + +#### DON'T: leave the format to guessing when it is known + +Format auto-detection for streams (`Reader(stream)`, `format=None`) is best effort. Pass +the MIME type when it is known. Supported types: `Reader.get_supported_mime_types()`, +`Builder.get_supported_mime_types()`. + +--- + +## Part 2: Developing c2pa-python + +### Repository map + +| Path | Contents | +| --- | --- | +| `src/c2pa/c2pa.py` | Public API classes and the ctypes bindings | +| `src/c2pa/lib.py` | Native library discovery and loading | +| `src/c2pa/build.py` | `download-artifacts` console entry point | +| `src/c2pa/__init__.py` | Public exports (`__all__`) | +| `src/c2pa/libs/` | Native library staged for the installed package (gitignored) | +| `artifacts/` | Downloaded or locally built native libraries per platform | +| `c2pa-native-version.txt` | Pinned c2pa-rs release tag for `make download-native-artifacts` | +| `scripts/download_artifacts.py` | Download prebuilt native libraries from c2pa-rs releases | +| `scripts/build_local_artifacts.py` | Build the native library from a local c2pa-rs checkout | +| `scripts/generate_api_docs.py` | Sphinx API documentation | +| `tests/unit/` | Unit tests, one module per area (`test_reader.py`, `test_builder.py`, `test_context.py`, ...); shared constants, helpers and `TestContextAPIs` in `_support.py` | +| `tests/test_unit_tests_threaded.py` | Concurrency tests | +| `tests/fixtures/` | Test assets, certificates and keys | +| `tests/perf/` | Memory (memray) and thread-safety harnesses, run in Docker | +| `examples/` | Runnable examples, also run by `make test` | +| `docs/` | User and developer documentation | +| `api-docs/` | Sphinx configuration and output | +| `.github/workflows/` | CI (build and test, wheels, benchmarks, c2pa-rs source build), docs publishing, issue-label automation | + +### Workflows + +All commands run from the repository root. `make` uses the active virtual environment, +then `./.venv`, then `python3`; override with `make PYTHON=...`. + +#### Set up an environment + +```bash +make create-venv && source .venv/bin/activate +make download-native-artifacts # version from c2pa-native-version.txt +make build-python # pip install -e . (also installs runtime dependencies) +``` + +Success: `python -c "import c2pa; print(c2pa.sdk_version())"` prints the native version. + +`make download-native-artifacts` is required: nothing downloads the native library +automatically. `pip install -e .` only copies it from `artifacts/` into `src/c2pa/libs/`, +and prints "No platform-specific libraries found in artifacts directory" when there is +nothing to copy. Skip the download only when building from source (next workflow). + +`make install-deps` (`requirements.txt` and `requirements-dev.txt`) is needed only for the +developer tools: pytest, flake8, autopep8 and Sphinx, used by `make check-format`, +`make format`, `pytest` and `make docs`. + +#### Use a local c2pa-rs build + +```bash +make build-from-source C2PA_RS_PATH=/path/to/c2pa-rs +make build-from-source C2PA_RS_PATH=/path/to/c2pa-rs EXTRA_BUILD_ARGS="--debug" +``` + +Needs a Rust toolchain. Builds `c2pa-c-ffi`, stages the library in `artifacts/` and +`src/c2pa/libs/`, and reinstalls the package in editable mode. + +`src/c2pa/lib.py` loads the native library; `C2PA_LIBRARY_NAME` overrides the file name. +An import error about missing required function symbols means the loaded library lacks a +name in `_REQUIRED_FUNCTIONS`: a stale copy in `src/c2pa/libs/`, or a c2pa-rs version +older than the bindings expect. + +#### Run tests + +```bash +make test # examples, unit tests, threaded tests +make test-unit # unit tests only +make test-threaded # concurrency tests only +make test-one T=tests.unit.test_reader.TestReader. # one module, class or test +pytest tests/unit -v # as CI runs them +``` + +Run new tests by name and read their own result lines; a passing suite does not show a new +test ran. + +`TestC2paSdk.test_sdk_version` fails with an assertion like `'0.91.0' not found in +'0.91.1'` when the installed native library's version does not match +`c2pa-native-version.txt`. First troubleshooting step: a clean rebuild -- +`make clean-c2pa-env install-deps download-native-artifacts build-python`, or +`make rebuild`. + +#### Lint and format + +```bash +make check-format # py_compile + flake8 --extend-ignore=E501 on src/c2pa/c2pa.py +make format # autopep8 +``` + +CI runs `make check-format` too (`.github/workflows/build.yml`), so this is the one check +to run locally before pushing; no separate command to match by hand. + +#### Benchmarks + +Run in Docker; details in [tests/perf/README.md](tests/perf/README.md). + +```bash +make perf-image # build the image once +make memory-use-bench # memray; compares against tests/perf/baseline.json +make threading-bench # harness self-test, then thread-safety scenarios +``` + +Required for changes to `ManagedResource`, locking, `Stream`, callbacks, or anything that +allocates or frees native memory. + +#### API documentation + +```bash +make docs # output in api-docs/_build/html/index.html +``` + +A public API change also updates the topic document in `docs/` and +`docs/class-diagram.md`. + +#### Versioning + +- Python package version: bump `version` in `pyproject.toml` and the `# Version:` header + in `src/c2pa/c2pa.py` together, on every change that ships. +- `c2pa-native-version.txt`: bump only when pinning a new c2pa-rs release. Not tied to + the Python package version; bump it alone, then `make download-native-artifacts` and + run the tests. + +#### Pull request titles + +Conventional commits: `feat`, `fix`, `chore`, `docs`, `test`, `refactor`, `perf`, `ci`, +`build`, `style`, `revert`, with an optional scope, for example +`feat(builder): Add set_label`. See `CONTRIBUTING.md`. + +#### Add a public API feature end to end + +1. Confirm the native function exists in the c2pa-rs version pinned in + `c2pa-native-version.txt`. +2. Bind it following [Changing the binding layer](#changing-the-binding-layer). +3. Add any new public name to `__all__` in `src/c2pa/__init__.py`. +4. Add tests to the matching module in `tests/unit/`; run them by name. +5. Document it in the matching topic document in `docs/` and in `docs/class-diagram.md`. +6. Bump the Python package version. + +### Changing the binding layer + +Rules for `src/c2pa/c2pa.py` internals: native pointers, locks, strings, errors and +callbacks. Read the `ManagedResource` class docstring and +[docs/native-resources-management.md](docs/native-resources-management.md) first. + +#### Core principle: always use the helpers + +Every use of a native handle goes through a `ManagedResource` helper. Every native result +goes through `_check_ffi_operation_result`. Every native string goes through +`_convert_to_py_string`. Before writing any ctypes pattern by hand, check whether a helper +exists for it. + +--- + +#### Reference example: `Reader.json` + +`Reader.json` shows the full path for one native function. Copy its shape. + +##### 1. Register the symbol + +Add the name to `_REQUIRED_FUNCTIONS`. Import fails if the loaded library lacks it. + +```python +_REQUIRED_FUNCTIONS = [ + ... + 'c2pa_reader_json', + ... +] +``` + +##### 2. Declare the prototype + +```python +_setup_function( + _lib.c2pa_reader_json, [ + ctypes.POINTER(C2paReader)], ctypes.c_void_p) +``` + +The return is an owned native string, so the restype is `c_void_p`. With `c_char_p`, +ctypes copies the bytes into a Python `bytes` object and discards the pointer, so it can +never be freed. + +##### 3. Declare instance attributes in `_init_attrs` + +```python +def _init_attrs(self): + super()._init_attrs() + ... + self._manifest_json_str_cache = None +``` + +`_wrap_native_handle` builds instances without running `__init__`, and `_release()` can +run on a half-built object. Every attribute goes in `_init_attrs`. + +##### 4. Write the method + +```python +def json(self) -> str: + with self._native_call(): + if self._manifest_json_str_cache is not None: + return self._manifest_json_str_cache + + result = _lib.c2pa_reader_json(self._handle) + _check_ffi_operation_result( + result, "Error during manifest parsing in Reader") + + self._manifest_json_str_cache = _convert_to_py_string(result) + return self._manifest_json_str_cache +``` + +- `with self._native_call():` validates state and reserves the handle as a shared call. + No `_ensure_valid_state()` call is needed inside it. +- `_check_ffi_operation_result(result, msg)` uses the default `not r` check for a pointer + return, reads the native error and raises a typed `C2paError`. +- `_convert_to_py_string(result)` decodes the string and frees it with + `c2pa_string_free`. + +##### 5. Test + +Add a test in `tests/unit/test_reader.py` (`TestReader` covers `json()`). Run it by name +and read its own result line. + +--- + +#### Freeing memory: the free function must match the allocator + +Memory is freed by the allocator that issued it, through the free function made for that +kind of allocation. A mismatch is undefined behavior: an abort (SIGABRT) when the +allocator detects it, heap corruption surfacing later when it does not. See +[Crashes](docs/native-resources-management.md#crashes) and +[Freeing memory](docs/native-resources-management.md#freeing-memory). + +| What native returned | Free function | Called only from | +| --- | --- | --- | +| Object handle (`C2paReader*`, `C2paBuilder*`, `C2paSigner*`, `C2paContext*`, ...) | `c2pa_free` | `ManagedResource._free_native_ptr`, via `ManagedResource` cleanup | +| C string (`char*`) | `c2pa_string_free` | `_convert_to_py_string` | +| String array plus count | `c2pa_free_string_array(arr, count)` | Next to the call that returned it, with the count native returned | +| Byte buffer plus length (manifest bytes) | `c2pa_manifest_bytes_free` | Next to the call that returned it, in a `finally` | + +Rules: + +- Native memory is never left to Python's garbage collector. It does not know the pointer exists. +- Python memory (`bytes`, `ctypes.create_string_buffer`, `(c_ubyte * n).from_buffer_copy(...)`, + callback objects) is never passed to a native free function. +- Native memory is freed exactly once. A pointer handed to a consuming call is never freed by + Python, whether the call succeeds or fails. +- Copy, then free in `finally`: + +```python +try: + result_bytes = ctypes.string_at(result_bytes_ptr, size) +finally: + _lib.c2pa_manifest_bytes_free(result_bytes_ptr) +``` + +- Anything native keeps using after the call returns (callbacks, streams) stays referenced + from a Python attribute until native is done with it. See + [Keeping references alive](docs/native-resources-management.md#keeping-references-alive). + +--- + +#### Locking rules + +Background: [Locking](docs/native-resources-management.md#locking), +[Lock ordering](docs/native-resources-management.md#lock-ordering), +[Borrowing vs consuming](docs/native-resources-management.md#borrowing-vs-consuming), +[Closing during a (native) call](docs/native-resources-management.md#closing-during-a-native-call), +[Fork safety](docs/native-resources-management.md#fork-safety). + +1. **Check and act in one guarded region.** Using a handle is two steps: validate it, then + pass it to native. Another thread can close the object between the two. The state check + and the native call always sit inside the same guard. +2. **The GIL protects nothing here.** ctypes releases the GIL for the duration of a foreign + call. Another thread runs while native code runs, including on free-threaded builds. +3. **Two idioms. Pick one per native call.** + + | Idiom | What it does | State check | + | --- | --- | --- | + | `with self._guarded_op():` / `_guarded_op(exclusive=True)` | Holds `_op_lock` across the native call | Call `self._ensure_valid_state()` inside it | + | `with self._native_call():` (shared) / `with self._exclusive_native_call():` (mutating) | Reserves the handle with in-flight counters; does not hold the lock across the call | Done by the reservation | + +4. **Choose by one question: does this native call run caller-supplied Python?** + Stream read/seek/write/flush callbacks and signer callbacks run Python on this thread + while native code is mid-call. This covers construction from streams, signing, the + `Builder` stream methods, `Reader.resource_to_stream`, and anything else taking a `Stream`. + - Yes: use `_native_call()` or `_exclusive_native_call()`. Never `_guarded_op`. + - No: default to `_guarded_op`. It holds `_op_lock` for the whole call, so no other + call on this object can interleave at all, not even another shared call. Use + `_native_call()` / `_exclusive_native_call()` instead only when something else in the + same call needs the weaker guarantee (shared calls allowed to overlap). +5. **Shared or exclusive, once rule 4 picked the reservation idiom.** A call that changes + native state for this handle uses `exclusive=True` / `_exclusive_native_call()`. A + read-only call uses the shared form. Exclusive calls refuse to start while any call is + in flight; shared calls refuse while an exclusive call is in flight. Do not add checks + of your own. (`_guarded_op`'s own `exclusive` flag follows the same split when rule 4 + picks `_guarded_op` instead.) +6. **Consuming calls use the consume helpers**, which reserve the handle as mutating for + the whole call: `_consume_and_swap`, `_consume_into`, `_consume_no_replacement`. +7. **Lock order**, outermost to innermost: a method-specific lock (for example + `Reader._fragment_lock`), then the lock of the object the method runs on, then the lock + of any object it borrows. A lock held across a native call must be one no callback path + acquires. +8. **Module-level functions** with no `ManagedResource` (for example `format_embeddable`) + wrap the native call and its result check in `with _native_section():`. +9. **Never touch `_op_lock`, `_inflight`, `_mut_inflight` or `_live_op_lock()` directly.** + +--- + +#### Callbacks: no SDK use inside a callback + +A ctypes callback (`ReadCallback`, `SeekCallback`, `WriteCallback`, `FlushCallback`, +`SignerCallback`) runs while a native call on the calling object is in flight. Its body +touches only the Python object it wraps: the file-like stream, or the user's signing +callable. + +It never calls `_lib.*` and never calls a `Reader`, `Builder`, `Signer`, `Context` or +`Settings` method. A call on the same object is refused by the in-flight counters, or, if +the outer call wrongly holds `_guarded_op`, re-enters the reentrant lock and reaches native +while native still uses the handle. + +Callbacks also: + +- Catch every exception and return the error value native expects (`-1` for the existing + callbacks). An exception escaping a ctypes callback is printed and swallowed by ctypes, + and native sees a meaningless return value. +- Validate pointer and length arguments before reading through them. +- When the callback needs its owning object (the `Stream` callbacks do), capture it through + a weak reference and check its state on every call. A strong capture forms a reference + cycle: object, ctypes callback, closure, object. + +See [Streams](docs/native-resources-management.md#streams) and +[Reentrant callbacks](docs/native-resources-management.md#reentrant-callbacks). The +existing callbacks in `Stream.__init__` and `Signer.from_callback` follow these rules. + +--- + +#### Common anti-patterns to AVOID + +##### DON'T: assign the handle directly + +```python +# BAD +ptr = _lib.c2pa_my_resource_new(arg) +if not ptr: + raise C2paError("Failed") +self._handle = ptr +self._lifecycle_state = LifecycleState.ACTIVE +``` + +##### DO: create and activate + +```python +# GOOD: validates, takes ownership, frees the pointer if any step fails +self._create_and_activate( + lambda: _lib.c2pa_my_resource_new(arg), + "Failed to create MyResource: {}") +``` + +##### DON'T: free on an error path by hand + +```python +# BAD +ptr = _lib.c2pa_reader_from_context(ctx_ptr) +try: + _check_ffi_operation_result(ptr, "Failed") +except C2paError: + _lib.c2pa_free(ptr) + raise +``` + +##### DO: let the helper own the failure path + +```python +# GOOD +self._create_and_activate( + lambda: _lib.c2pa_reader_from_context(ctx_ptr), + "Failed to create Reader: {}") +``` + +##### DON'T: read and free a native string by hand + +```python +# BAD: crashes on NULL, leaks on decode error, or frees twice +raw = ctypes.string_at(result) +_lib.c2pa_string_free(result) +return raw.decode('utf-8') +``` + +##### DO: convert + +```python +# GOOD +return _convert_to_py_string(result) +``` + +##### DON'T: check the result by hand + +```python +# BAD: untyped exception, no fallback when native set no error +result = _lib.c2pa_builder_set_remote_url(self._handle, url_bytes) +if result != 0: + raise C2paError(f"Failed: {_read_native_error()}") +``` + +##### DO: check with the helper + +```python +# GOOD: reads the error once, raises the typed subclass +_check_ffi_operation_result( + result, + Builder._ERROR_MESSAGES['url_error'], + check=lambda r: r != 0) +``` + +##### DON'T: hold the lock across a callback-driving call + +```python +# BAD: the stream callbacks run Python while _op_lock is held +with self._guarded_op(), Stream(stream) as stream_obj: + self._ensure_valid_state() + result = _lib.c2pa_reader_resource_to_stream( + self._handle, uri_str, stream_obj._stream) +``` + +##### DO: reserve instead + +```python +# GOOD: in-flight reservation, lock released during the call +with self._native_call(), Stream(stream) as stream_obj: + result = _lib.c2pa_reader_resource_to_stream( + self._handle, uri_str, stream_obj._stream) +``` + +##### DON'T: use the SDK inside a callback + +```python +# BAD: re-enters the SDK while native is mid-call on the same object +def write_callback(ctx, data, length): + reader.json() + _lib.c2pa_reader_json(reader._handle) + ... +``` + +##### DO: touch only the wrapped object + +```python +# GOOD: Stream.__init__'s write_callback +def write_callback(ctx, data, length): + s = _weak_self() + if s is None: + return -1 + if not s._initialized or s._closed: + return -1 + try: + if not data or length <= 0: + return -1 + + s._file_like_stream.write(ctypes.string_at(data, length)) + return length + except Exception: + return -1 +``` + +##### DON'T: pass a string argument unchecked + +```python +# BAD: "a\x00b" reaches native as "a" +uri_str = uri.encode('utf-8') +``` + +##### DO: check, then encode + +```python +# GOOD +_check_cstr_arg("uri", uri) +uri_str = uri.encode('utf-8') +``` + +--- + +#### Helper reference + +The [Methods to use with a `ManagedResource`](docs/native-resources-management.md#methods-to-use-with-a-managedresource) +table maps situations to creation and consumption helpers. Full list: + +##### Creation and adoption + +- `_create_and_activate(ffi_call, error_message, *, check=...)`: call a constructor, validate, take ownership; frees on failure +- `_activate(handle)`: take ownership of a validated handle (used by the helpers; rarely called directly) +- `_wrap_native_handle(handle)` (classmethod): new instance around a handle native already returned; skips `__init__` + +##### Consumption + +The consume helpers pass the handle into `ffi_call`, so it takes one argument: +`lambda h: _lib.c2pa_context_builder_build(h)`. Use `h`, the handle the helper passes in. + +- `_consume_and_swap(ffi_call, error_message)`: native consumes the handle and returns a replacement for this object +- `_consume_into(ffi_call, error_message)`: native consumes the handle and returns another object's pointer; caller owns the result +- `_consume_no_replacement(ffi_call, error_message)`: native consumes the handle, returns a status code (0 = success) +- `_teardown(free_handle=False)`: mark consumed and closed without freeing +- `_release_handle()`: consuming call failed with ownership unknown; frees eagerly (guarded), then closes + +##### Concurrency + +- `_guarded_op(*, refuse_mut=True, exclusive=False)`: hold `_op_lock` for the block; caller checks state +- `_native_call()`: shared reservation; validates state +- `_exclusive_native_call()`: mutating reservation; validates state +- `_native_section()`: module-level native call plus result check +- `_ensure_valid_state()`: raises if closed or uninitialized; use inside `_guarded_op` + +##### Argument checks + +- `_check_cstr_arg(name, value)`: rejects `None` and embedded NUL +- `_check_handle_arg(name, handle)`: rejects a null handle argument +- `_check_bytes_arg(name, buffer)`: rejects `None` and empty buffers +- `_to_utf8_bytes(data, error_context)`: str or dict (JSON-serialized) to UTF-8 bytes + +##### Results and errors + +- `_check_ffi_operation_result(result, fallback_msg, *, check=...)`: raise typed `C2paError` on failure +- `_raise_typed_c2pa_error(error_str)`: map `"Variant: message"` to a `C2paError` subclass +- `_convert_to_py_string(ptr)`: decode and free a native string; `""` for NULL + +##### Subclass hooks + +- `_init_attrs()`: declare every instance attribute; call `super()._init_attrs()` first +- `_release()`: free class-specific resources; idempotent, never raises + +--- + +#### Decision tree + +##### "Native returns a new object pointer" + +→ `self._create_and_activate(lambda: _lib.c2pa_x_new(...), "Failed to create X: {}")` + +##### "Native consumes this handle" + +1. Returns a replacement for this object? → `_consume_and_swap` +2. Returns a different object's pointer? → `_consume_into`; the new owner takes the result with `_activate` (as `Context.__init__` does with its `ContextBuilder`) or a new instance with `_wrap_native_handle` +3. Returns a status code? → `_consume_no_replacement` + +##### "Native borrows this handle" + +1. Runs caller-supplied Python (streams, signer callback)? → `_native_call()` or `_exclusive_native_call()` +2. Otherwise, mutates native state? → `_guarded_op(exclusive=True)` + `_ensure_valid_state()`, or `_exclusive_native_call()` +3. Otherwise, read-only? → `_native_call()`, or `_guarded_op()` + `_ensure_valid_state()` + +##### "I need to check the result" + +1. Pointer return? → `_check_ffi_operation_result(r, msg)` (default `not r`) +2. Status code, 0 = success? → `check=lambda r: r != 0` +3. Signed size or count, negative = error? → `check=lambda r: r < 0` + +##### "Native returns memory to Python" + +1. Object handle? → `ManagedResource` owns it +2. String? → `_convert_to_py_string` +3. String array? → read, then `c2pa_free_string_array(arr, count)` in `finally` +4. Byte buffer? → `ctypes.string_at(ptr, size)`, then `c2pa_manifest_bytes_free(ptr)` in `finally` + +##### "I take a string from the caller" + +→ type check, `_check_cstr_arg(name, value)`, then encode (`_to_utf8_bytes` for str-or-dict input) + +--- + +#### Context and settings + +`Context` is the configuration object passed to `Reader` and `Builder`. It carries a +`Settings` (verification, trust, builder, thumbnail options) and optionally a `Signer`. +Each `Context` is independent, so several configurations can coexist in one process. It +replaces the deprecated global `load_settings()`. + +Rules for binding code: + +- New constructors take a `context` argument typed as `ContextProvider`, and read + configuration from it. They never read or write global settings. +- Use the context's pointer only inside `with _context_guard(context):`. For a + `ManagedResource` context this takes a *shared* reservation (the same kind + `Reader.json()` takes on a `Reader`), not exclusive: constructing a `Reader` or + `Builder` does not mutate the `Context` itself, only reads its configuration, so + several threads can each be mid-construction from the same `Context` at once. The + reservation only stops a `close()` on that `Context` from freeing it while any of those + constructions are still reading it. A third-party `ContextProvider` gets no such + protection. +- `Context` and `Settings` are `ManagedResource` subclasses and follow every rule in this + file. +- A `Signer` passed to a `Context` is consumed: the `Signer` closes and the `Context` owns + the native signer. The signer's Python callback is copied onto the `Context` so it stays + alive as long as native can call it. +- `Reader` and `Builder` constructed from a `Context` activate the native object first, + then feed it data through a consuming call. See + [Adopting a handle](docs/native-resources-management.md#adopting-a-handle). + +See [docs/context-settings.md](docs/context-settings.md): +[Overview](docs/context-settings.md#overview), +[Using Context](docs/context-settings.md#using-context), +[Signer object on Context](docs/context-settings.md#signer-object-on-context), +[Context lifetime](docs/context-settings.md#context-lifetime), +[ContextProvider abstract base class](docs/context-settings.md#contextprovider-abstract-base-class). + +--- + +#### Error handling pattern + +Native errors are strings of the form `"VariantName: details"`, the cimpl convention. + +- `_check_ffi_operation_result` reads the native error and passes it to + `_raise_typed_c2pa_error`, which raises the matching `C2paError` subclass + (`C2paError.ManifestNotFound`, `C2paError.Io`, ...) with the full string as message. +- With no native error, the fallback message is used. A message ending in `: {}` gets + `"Unknown error"` filled in. +- Per-class fallback messages live in the class's `_ERROR_MESSAGES` dict. Add a key there + for each new method. +- Errors raised on the Python side before reaching native use the same format as native, + for example `C2paError(f"NullParameter: {name}")`. +- A new native error variant needs a `_C2pa(C2paError)` class, its attribute on + `C2paError`, and a branch in `_raise_typed_c2pa_error`. + +--- + +## Final checklist + +### Any change + +- [ ] Every `Reader`, `Builder`, `Signer`, `Context` and `Settings` used in `with`, or + explicitly `close()`d +- [ ] New tests run by name, their own result lines read +- [ ] `make test` passes +- [ ] `make check-format` passes +- [ ] All other existing unit tests still pass + +### Public API change + +- [ ] New public names in `__all__` (`src/c2pa/__init__.py`) +- [ ] Topic document in `docs/class-diagram.md` updated +- [ ] Version bumped in `pyproject.toml` and the `src/c2pa/c2pa.py` header + +### Binding change (`src/c2pa/c2pa.py` internals) + +- [ ] New native function: name in `_REQUIRED_FUNCTIONS`, `_setup_function` prototype (owned returns use `c_void_p`), fallback message in the class's `_ERROR_MESSAGES` +- [ ] Every native allocation freed by its matching free function, exactly once +- [ ] No Python memory passed to a native free; objects native keeps using referenced from an attribute +- [ ] Every handle use inside `_guarded_op`, `_native_call`, `_exclusive_native_call` or a consume helper +- [ ] No `_guarded_op` around a call that runs callbacks; `_ensure_valid_state()` inside every `_guarded_op` block +- [ ] No `_lib.*` and no SDK object use inside a callback body + +--- + +## Key files + +- `src/c2pa/c2pa.py`: public API, `ManagedResource`, binding helpers, `Reader.json` (binding reference example) +- `src/c2pa/__init__.py`: public exports +- `src/c2pa/lib.py`: native library loading +- `examples/`: runnable usage examples +- `tests/unit/`, `tests/test_unit_tests_threaded.py`: tests +- `Makefile`: every workflow command +- [docs/usage.md](docs/usage.md), [docs/context-settings.md](docs/context-settings.md), [docs/working-stores.md](docs/working-stores.md), [docs/intents.md](docs/intents.md): library usage +- [docs/native-resources-management.md](docs/native-resources-management.md): ownership, lifecycle, locking, streams, subclassing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 231055f7..52bea924 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ consensus around project direction and issue solutions within issue threads Some broad categories of work (and thus things you might expect to change) are: * We'll be reviewing and refining our APIs for ease of use and comprehension. We'd appreciate feedback on areas that you find confusing or unnecessarily difficult. -* We'll also be reviewing our APIs for compliance with best practices. +* We'll also be reviewing our APIs for compliance with best practices. * Our documentation is incomplete. We'll be working on refining the documentation. * Our testing infrastructure is incomplete. We'll be working on improving test coverage, memory efficiency, and performance benchmarks. @@ -57,6 +57,8 @@ These include such things as Rust code format, Clippy/lint checks, and unit test We encourage you to raise an issue in GitHub before starting work on a major addition to the crate. This will give us an opportunity to discuss API design and avoid duplicate efforts. +AI assistants working with or on this library follow [AI_WORKFLOW.md](AI_WORKFLOW.md). + ### Pull request titles Titles of pull requests that target a long-lived branch such as _main_ or a release-specific branch should follow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#specification). This means that the first word of the pull request title should be one of the following: @@ -100,5 +102,5 @@ feel free to reach out to existing committers to have a conversation about that. ## Security issues -Do not create a public GitHub issue for any suspected security vulnerabilities. Instead, please file an issue through [Adobe's HackerOne page](https://hackerone.com/adobe?type=team). +Do not create a public GitHub issue for any suspected security vulnerabilities. Instead, please file an issue through [Adobe's HackerOne page](https://hackerone.com/adobe?type=team). For more information on reporting security issues, see [SECURITY.md](SECURITY.md). diff --git a/Makefile b/Makefile index e53e1f2e..483c6897 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ endif # Start from clean env: Delete `.venv`, then `python3 -m venv .venv` # Pre-requisite: Python virtual environment is active (source .venv/bin/activate) -# Run Pytest tests in virtualenv: .venv/bin/pytest tests/test_unit_tests.py -v +# Run Pytest tests in virtualenv: .venv/bin/pytest tests/unit -v # Creates the local virtualenv at the canonical ./.venv path if it does not exist. # Activation must happen in shell independently: @@ -56,12 +56,28 @@ run-examples: $(PYTHON) ./examples/training.py rm -rf output/ -# Runs the examples, then the unit tests +# Runs the examples, then the unit tests, then the threaded tests +.PHONY: test test: - make run-examples - $(PYTHON) ./tests/test_unit_tests.py + $(MAKE) run-examples + $(MAKE) test-unit + $(MAKE) test-threaded + +# Runs the unit tests in tests/unit +.PHONY: test-unit +test-unit: + $(PYTHON) -W ignore -m unittest discover -s tests/unit -t . + +# Runs the concurrency tests +.PHONY: test-threaded +test-threaded: $(PYTHON) ./tests/test_unit_tests_threaded.py +# Runs one test module, class or method: make test-one T=tests.unit.test_reader.TestReader.test_stream_read +.PHONY: test-one +test-one: + $(PYTHON) -W ignore -m unittest $(T) + # Tests building and installing a local wheel package # Downloads required artifacts, builds the wheel, installs it, and verifies the installation test-local-wheel-build: diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md index d3723948..399f1a0b 100644 --- a/docs/native-resources-management.md +++ b/docs/native-resources-management.md @@ -269,7 +269,7 @@ Every subclass gets these guarantees from `ManagedResource`, and must not break | Cleanup is idempotent | Calling `close()`, or exiting a `with` block, more than once does nothing after the first time. | | Cleanup never raises | An ordinary exception during cleanup is caught and logged, never re-raised, so it cannot mask an exception the `with` block itself raised. An interpreter shutdown signal is the one exception to this: it is allowed to interrupt cleanup, since the process is already going away. | | State transitions are one-way | Lifecycle only ever moves `UNINITIALIZED` to `ACTIVE` to `CLOSED`. Nothing reactivates a closed resource. | -| Public methods check state first | Every method that uses the handle validates it before the native call, raising `C2paError` on anything but a usable `ACTIVE` resource rather than risking undefined behavior. | +| Public methods check state first | Every method that uses the handle validates it inside the same guard or reservation as the native call, and raises `C2paError` on anything but a usable `ACTIVE` resource. A native call on a freed handle is undefined behavior. | ## Keeping references alive @@ -468,10 +468,12 @@ class NativeResource(ManagedResource): self._my_stream = None def do_something(self): - # 4. 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) + # 4. Run every native call inside a guard or reservation. + # _native_call() raises C2paError if the resource is closed, + # and keeps the handle from being freed until the call returns. + # Use _exclusive_native_call() when the call changes native state. + with self._native_call(): + return _lib.c2pa_my_resource_do_something(self._handle) ``` ### Troubleshooting @@ -479,6 +481,7 @@ class NativeResource(ManagedResource): - An attribute set only in `__init__` is missing on an instance `_wrap_native_handle()` built, since that path skips `__init__`. Put attributes in `_init_attrs()` instead. - `_init_attrs()` called after an FFI call that can raise leaves `_release()` reading attributes that don't exist yet on failure. Call it right after `super().__init__()`. - Assigning `self._handle` or `self._lifecycle_state` directly skips `_activate()`'s and `_consume_and_swap()`'s checks, so bugs surface far from their cause. +- A bare `_ensure_valid_state()` followed by a native call leaves a gap: another thread's `close()` can free the handle between the check and the call. Do both inside one `with self._native_call():` block. - A raising `_release()` has its exception swallowed, visible only in logs. - `_release()` runs more than once (`close()` then `__del__`, or repeated `close()`), so it must handle an already-cleaned-up object. Set attributes to `None` after closing them. - Free through `ManagedResource`, not `c2pa_free` directly, so lifecycle checks stay in effect. diff --git a/examples/README.md b/examples/README.md index 191e88f4..0561d24c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,18 +9,18 @@ The [`examples/sign.py`](https://github.com/contentauth/c2pa-python/blob/main/ex 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`: +These statements create a `signer` and a `builder` 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`. Signing needs the output file open for both writing and reading, so it is opened in `w+b` mode: ```py manifest_definition = { // ... JSON omitted here } -builder = c2pa.Builder(manifest_definition) - -with open(fixtures_dir + "C.jpg", "rb") as source: - with open(output_dir + "C_signed.jpg", "wb") as dest: - result = builder.sign(signer, "image/jpeg", source, dest) +with c2pa.Signer.from_info(signer_info) as signer: + with c2pa.Builder(manifest_definition) as builder: + with open(fixtures_dir + "C.jpg", "rb") as source: + with open(output_dir + "C_signed.jpg", "w+b") as dest: + result = builder.sign(signer, "image/jpeg", source, dest) ``` Then these statements read and verify the signed asset: @@ -28,8 +28,8 @@ Then these statements read and verify the signed asset: ```py print("\nReading signed image metadata:") with open(output_dir + "C_signed.jpg", "rb") as file: - reader = c2pa.Reader("image/jpeg", file) - print(reader.json()) + with c2pa.Reader("image/jpeg", file) as reader: + print(reader.json()) ``` ## Adding a "do not train" assertion @@ -40,7 +40,7 @@ These statements sign the asset using a stream: ```py with open(testFile, "rb") as source_file: - with open(testOutputFile, "wb") as dest_file: + with open(testOutputFile, "w+b") as dest_file: result = builder.sign(signer, "image/jpeg", source_file, dest_file) ``` @@ -49,20 +49,22 @@ These statements verify the asset and check its attached manifest for a "do not ```py allowed = True # opt out model, assume training is ok if the assertion doesn't exist try: - # Create reader using the current API - reader = c2pa.Reader(testOutputFile) - manifest_store = json.loads(reader.json()) - - manifest = manifest_store["manifests"][manifest_store["active_manifest"]] - for assertion in manifest["assertions"]: - if assertion["label"] == "c2pa.training-mining": - if getitem(assertion, ("data","entries","c2pa.ai_training","use")) == "notAllowed": - allowed = False - - # get the ingredient thumbnail and save it to a file using resource_to_stream - uri = getitem(manifest,("ingredients", 0, "thumbnail", "identifier")) - with open(output_dir + "thumbnail_v2.jpg", "wb") as thumbnail_output: - reader.resource_to_stream(uri, thumbnail_output) + with ( + c2pa.Context() as context, + c2pa.Reader(testOutputFile, context=context) as reader, + ): + manifest_store = json.loads(reader.json()) + + manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + for assertion in manifest["assertions"]: + if assertion["label"] == "cawg.training-mining": + if getitem(assertion, ("data","entries","cawg.ai_generative_training","use")) == "notAllowed": + allowed = False + + # get the ingredient thumbnail and save it to a file using resource_to_stream + uri = getitem(manifest,("ingredients", 0, "thumbnail", "identifier")) + with open(output_dir + "thumbnail_v2.jpg", "wb") as thumbnail_output: + reader.resource_to_stream(uri, thumbnail_output) except Exception as err: sys.exit(err) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py deleted file mode 100644 index dd3c3ec3..00000000 --- a/tests/test_unit_tests.py +++ /dev/null @@ -1,10156 +0,0 @@ -# Copyright 2023 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. - -import ast -import gc -import inspect -import os -import io -import gzip -import bz2 -import lzma -import json -import re -import unittest -import ctypes -import warnings -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.backends import default_backend -import tempfile -import shutil -import subprocess -import sys -import ctypes -import threading -from unittest.mock import patch - -# Suppress deprecation warnings -warnings.simplefilter("ignore", category=DeprecationWarning) - -from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType -from c2pa import Settings, Context, ContextBuilder, ContextProvider -from c2pa.c2pa import Stream, LifecycleState, ManagedResource, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable, _get_mime_type_from_path, _encode_format, _format_ffi_arg -import c2pa.c2pa as c2pa_module -from pathlib import Path - - -PROJECT_PATH = os.getcwd() -FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") -DEFAULT_TEST_FILE_NAME = "C.jpg" -INGREDIENT_TEST_FILE_NAME = "A.jpg" -DEFAULT_TEST_FILE = os.path.join(FIXTURES_DIR, DEFAULT_TEST_FILE_NAME) -INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, INGREDIENT_TEST_FILE_NAME) -ALTERNATIVE_INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, "cloud.jpg") - - -def _patch_free(test, fn): - """Route ManagedResource._free_native_ptr to `fn` until `test` ends.""" - patcher = patch.object( - ManagedResource, '_free_native_ptr', staticmethod(fn)) - patcher.start() - test.addCleanup(patcher.stop) - - -def _fail_with_native_error(tag_bytes): - """Build a mock FFI callable that sets a native error and returns None. - """ - def _mock(*args): - c2pa_module._lib.c2pa_error_set_last(tag_bytes) - return None - return _mock - - -def load_test_settings_json(): - """ - Load default (legacy) trust configuration test settings from a - JSON config file and return its content as JSON-compatible dict. - The return value is used to load settings (thread_local) in tests. - - Returns: - dict: The parsed JSON content as a Python dictionary (JSON-compatible). - - Raises: - FileNotFoundError: If trust_config_test_settings.json is not found. - json.JSONDecodeError: If the JSON file is malformed. - """ - # Locate the file which contains default settings for tests - tests_dir = os.path.dirname(os.path.abspath(__file__)) - settings_path = os.path.join(tests_dir, 'trust_config_test_settings.json') - - # Load the located default test settings - with open(settings_path, 'r') as f: - settings_data = json.load(f) - - return settings_data - - -def parse_native_version(): - """ - Parse the expected native SDK version. - - Reads c2pa-rs-preflight-ref.txt instead of c2pa-native-version.txt when - C2PA_PREFLIGHT_RUN is set: that flag is set only by - test-c2pa-rs-source-build.yml's own "Run tests" step, because the - presence of c2pa-rs-preflight-ref.txt in the checked-out tree isn't by - itself proof of anything -- an ordinary build.yml run on a branch that - happens to carry that file (e.g. this PR) still downloads and installs - the real pinned release, not the preflight ref. - - Returns: - str: The semantic version string (e.g. "0.85.2"). - """ - repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - if os.environ.get('C2PA_PREFLIGHT_RUN'): - path = os.path.join(repo_root, 'c2pa-rs-preflight-ref.txt') - else: - path = os.path.join(repo_root, 'c2pa-native-version.txt') - with open(path, 'r') as f: - raw = f.read().strip() - # Strip the "c2pa-v" / "c2pa-rc-v" prefix to get the bare semantic version. - return raw.split('v', 1)[1] if 'v' in raw else raw - - -class TestC2paSdk(unittest.TestCase): - def test_sdk_version(self): - # This test verifies the native libraries used match the expected version. - self.assertIn(parse_native_version(), sdk_version()) - - -class TestIsReadableStream(unittest.TestCase): - """ A stream is anything Stream will accept (read/write/seek/tell/flush). - """ - - def _tmp_path(self): - return os.path.join(FIXTURES_DIR, DEFAULT_TEST_FILE_NAME) - - def test_paths_and_formats_are_not_streams(self): - for value in (None, "", "image/jpeg", "path/to/file.jpg", - Path("x.jpg")): - self.assertFalse(Stream.is_read_stream(value), repr(value)) - - def test_non_stream_objects_are_not_streams(self): - for value in (b"bytes", bytearray(b"x"), 42, object()): - self.assertFalse(Stream.is_read_stream(value), repr(value)) - - def test_object_missing_methods_is_not_a_stream(self): - class OnlyRead: - def read(self): - return b"" - self.assertFalse(Stream.is_read_stream(OnlyRead())) - - def test_read_only_object_is_not_a_stream(self): - # Has read/seek/tell but no write/flush, so not a Stream for us. - class ReadOnly: - def read(self): - return b"" - - def seek(self, *a): - return 0 - - def tell(self): - return 0 - self.assertFalse(Stream.is_read_stream(ReadOnly())) - - def test_in_memory_streams_are_streams(self): - self.assertTrue(Stream.is_read_stream(io.BytesIO(b"x"))) - self.assertTrue(Stream.is_read_stream(io.StringIO("x"))) - - def test_file_handles_are_streams(self): - path = self._tmp_path() - for opener in ( - lambda: open(path, "rb"), - lambda: open(path, "rb", buffering=0), - lambda: io.BufferedReader(io.FileIO(path, "rb")), - lambda: open(path, "r+b"), - lambda: open(path, "r"), - ): - fh = opener() - try: - self.assertTrue(Stream.is_read_stream(fh), type(fh).__name__) - finally: - fh.close() - - def test_spooled_temporary_file_is_a_stream(self): - with tempfile.SpooledTemporaryFile() as fh: - self.assertTrue(Stream.is_read_stream(fh)) - - def test_compressed_file_objects_are_streams(self): - with tempfile.TemporaryDirectory() as tmp: - for ext, opener in ( - ("gz", gzip.open), - ("bz2", bz2.open), - ("xz", lzma.open), - ): - p = os.path.join(tmp, f"f.{ext}") - with opener(p, "wb") as fh: - fh.write(b"data") - with opener(p, "rb") as fh: - self.assertTrue(Stream.is_read_stream(fh), ext) - - def test_placeholder_typed_object_with_all_methods_is_a_stream(self): - class Duck: - def read(self, *a): - return b"" - - def write(self, *a): - return 0 - - def seek(self, *a): - return 0 - - def tell(self): - return 0 - - def flush(self): - pass - self.assertTrue(Stream.is_read_stream(Duck())) - - -class TestFormatValidation(unittest.TestCase): - """Unit tests for _encode_format. - - The binding no longer validates the format against a supported list: the - native library is the authority and detects the type from the bytes, so a - given format is trimmed and passed straight through. - """ - - def test_strips_whitespace_from_query(self): - for value in (" image/jpeg", "image/jpeg ", "\timage/jpeg\n"): - self.assertEqual( - _encode_format(value, "Reader"), - b"image/jpeg") - - def test_case_normalized_to_lowercase(self): - # MIME types are case-insensitive, so the format is lowercased before - # being handed to the native library. - for value in ("IMAGE/JPEG", "Image/Jpeg"): - self.assertEqual( - _encode_format(value, "Reader"), - b"image/jpeg") - - def test_encodes_stripped_and_lowercased_key(self): - self.assertEqual( - _encode_format(" IMAGE/JPEG ", "Reader"), - b"image/jpeg") - - def test_production_supported_list_is_normalized(self): - # _get_supported_mime_types strips and lowercases every entry. - for entry in Reader.get_supported_mime_types(): - self.assertEqual(entry, entry.strip().lower()) - - def test_none_autodetects(self): - # A missing format is None (auto-detect), not the empty-bytes sentinel. - self.assertIsNone(_encode_format(None, "Reader")) - - def test_blank_autodetects(self): - for value in ("", " ", "\t\n"): - self.assertIsNone(_encode_format(value, "Reader")) - - def test_missing_format_rejected_when_autodetect_disabled(self): - for value in (None, "", " "): - with self.assertRaises(Error.NotSupported): - _encode_format(value, "Builder", allow_autodetect=False) - - def test_format_ffi_arg_maps_none_to_empty_bytes(self): - # The native library's "detect from bytes" flag is empty bytes, not a - # NULL c_char_p, so None must map to b"". - self.assertEqual(_format_ffi_arg(None), b"") - self.assertEqual(_format_ffi_arg(b"image/jpeg"), b"image/jpeg") - - def test_resolve_format_bytes(self): - # Path form derives the format from the extension. - self.assertEqual( - Reader._resolve_format_bytes("photo.jpg", None), b"image/jpeg") - # Extensionless path -> auto-detect. - self.assertIsNone( - Reader._resolve_format_bytes("no_extension", None)) - # Stream given: format_or_path is the format (or None for detect). - self.assertEqual( - Reader._resolve_format_bytes("image/jpeg", object()), b"image/jpeg") - self.assertIsNone( - Reader._resolve_format_bytes(None, object())) - - def test_unknown_format_passes_through(self): - # No supported-list check: the format is encoded and handed to the - # native library, which is the authority on validity. - self.assertEqual( - _encode_format("application/x-nope", "Reader"), - b"application/x-nope") - - def test_literal_none_string_passes_through(self): - # The string "None" is a plain string; it is trimmed, lowercased, and - # encoded like any other, not rejected in the binding. - self.assertEqual(_encode_format("None", "Reader"), b"none") - - -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 - - def test_can_retrieve_reader_supported_mimetypes(self): - result1 = Reader.get_supported_mime_types() - self.assertTrue(len(result1) > 0) - - # Cache hit - result2 = Reader.get_supported_mime_types() - self.assertTrue(len(result2) > 0) - - self.assertEqual(result1, result2) - - def test_stream_read_nothing_to_read(self): - # The ingredient test file has no manifest - # So if we instantiate directly, the Reader instance should throw - with open(INGREDIENT_TEST_FILE, "rb") as file: - with self.assertRaises(Error) as context: - reader = Reader("image/jpeg", file) - self.assertIn("ManifestNotFound: no JUMBF data found", str(context.exception)) - - def test_try_create_reader_nothing_to_read(self): - # The ingredient test file has no manifest - # So if we use Reader.try_create, in this case we'll get None - # And no error should be raised - with open(INGREDIENT_TEST_FILE, "rb") as file: - reader = Reader.try_create("image/jpeg", file) - self.assertIsNone(reader) - - def test_stream_read(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - json_data = reader.json() - self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) - - def test_try_create_reader_from_stream(self): - with open(self.testPath, "rb") as file: - reader = Reader.try_create("image/jpeg", file) - self.assertIsNotNone(reader) - json_data = reader.json() - self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) - - def test_try_create_reader_from_stream_context_manager(self): - with open(self.testPath, "rb") as file: - reader = Reader.try_create("image/jpeg", file) - self.assertIsNotNone(reader) - # Check that a Reader returned by try_create is not None, - # before using it in a context manager pattern (with) - if reader is not None: - with reader: - json_data = reader.json() - self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) - - def test_stream_read_detailed(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - json_data = reader.detailed_json() - self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) - - def test_get_active_manifest(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - active_manifest = reader.get_active_manifest() - - # Check the returned manifest label/key - expected_label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" - self.assertEqual(active_manifest["label"], expected_label) - - def test_get_manifest(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - - # Test getting manifest by the specific label - label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" - manifest = reader.get_manifest(label) - self.assertEqual(manifest["label"], label) - - # It should be the active manifest too, so cross-check - active_manifest = reader.get_active_manifest() - self.assertEqual(manifest, active_manifest) - - def test_stream_get_non_active_manifest_by_label(self): - video_path = os.path.join(FIXTURES_DIR, "video1.mp4") - with open(video_path, "rb") as file: - reader = Reader("video/mp4", file) - - non_active_label = "urn:uuid:54281c07-ad34-430e-bea5-112a18facf0b" - non_active_manifest = reader.get_manifest(non_active_label) - self.assertEqual(non_active_manifest["label"], non_active_label) - - # Verify it's not the active manifest - # (that test case has only one other manifest that is not the active manifest) - active_manifest = reader.get_active_manifest() - self.assertNotEqual(non_active_manifest, active_manifest) - self.assertNotEqual(non_active_manifest["label"], active_manifest["label"]) - - def test_stream_get_non_active_manifest_by_label_not_found(self): - video_path = os.path.join(FIXTURES_DIR, "video1.mp4") - with open(video_path, "rb") as file: - reader = Reader("video/mp4", file) - - # Try to get a manifest with a label that clearly doesn't exist... - non_existing_label = "urn:uuid:clearly-not-existing" - with self.assertRaises(KeyError): - reader.get_manifest(non_existing_label) - - def test_stream_read_get_validation_state(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - validation_state = reader.get_validation_state() - self.assertIsNotNone(validation_state) - self.assertEqual(validation_state, "Valid") - - def test_stream_read_get_validation_state_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings - result = {} - exception = {} - - def read_with_trust_config(): - try: - # Load trust configuration - settings_dict = load_test_settings_json() - - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) - - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - validation_state = reader.get_validation_state() - result['validation_state'] = validation_state - except Exception as e: - exception['error'] = e - - # Create and start thread - thread = threading.Thread(target=read_with_trust_config) - thread.start() - thread.join() - - # Check for exceptions - if 'error' in exception: - raise exception['error'] - - # Assertions run in main thread - self.assertIsNotNone(result.get('validation_state')) - # With trust configuration loaded, manifest is Trusted - self.assertEqual(result.get('validation_state'), "Trusted") - - def test_stream_read_get_validation_results(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - validation_results = reader.get_validation_results() - - self.assertIsNotNone(validation_results) - self.assertIsInstance(validation_results, dict) - - self.assertIn("activeManifest", validation_results) - active_manifest_results = validation_results["activeManifest"] - self.assertIsInstance(active_manifest_results, dict) - - def test_wrong_mimetype_corrected_from_bytes_on_stream(self): - # The binding passes the format straight through. - # The native library detects the JPEG container and ignores the mismatched format, - # so a valid asset still reads with a wrong (or bogus) mimetype. - with open(self.testPath, "rb") as file: - with Reader("mimetype/does-not-exist", file) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_stream_read_and_parse(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - manifest_store = json.loads(reader.json()) - title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] - self.assertEqual(title, DEFAULT_TEST_FILE_NAME) - - def test_stream_read_detailed_and_parse(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - manifest_store = json.loads(reader.detailed_json()) - title = manifest_store["manifests"][manifest_store["active_manifest"]]["claim"]["dc:title"] - self.assertEqual(title, DEFAULT_TEST_FILE_NAME) - - def test_stream_read_crjson_and_parse(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - crjson = reader.crjson() - self.assertTrue(crjson) - json.loads(crjson) - - def test_stream_read_crjson_path_only(self): - with Reader(self.testPath) as reader: - crjson = reader.crjson() - self.assertTrue(crjson) - json.loads(crjson) - - 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) - - def test_try_create_from_path(self): - test_path = os.path.join(self.data_dir, "C.dng") - - # Create reader with the file content - reader = Reader.try_create(test_path) - self.assertIsNotNone(reader) - # Just run and verify there is no crash - json.loads(reader.json()) - - def test_unreadable_asset_raises_not_supported(self): - # A plain-text file is not a recognizable asset. The binding no longer - # rejects it up front; the native library raises NotSupported when it - # fails to detect a container. - with tempfile.TemporaryDirectory() as tmp: - txt = os.path.join(tmp, "C.txt") - with open(txt, "w") as f: - f.write("just some plain text, not an asset\n") - with self.assertRaises(Error.NotSupported): - Reader(txt) - - def test_try_create_unreadable_asset_raises_not_supported(self): - with tempfile.TemporaryDirectory() as tmp: - txt = os.path.join(tmp, "C.txt") - with open(txt, "w") as f: - f.write("just some plain text, not an asset\n") - with self.assertRaises(Error.NotSupported): - Reader.try_create(txt) - - def test_none_format_matches_empty_string_on_stream(self): - # None and "" both request auto-detection and read the same asset. - with open(self.testPath, "rb") as file: - with Reader(None, file) as reader: - none_json = reader.json() - with open(self.testPath, "rb") as file: - with Reader("", file) as reader: - empty_json = reader.json() - self.assertEqual(none_json, empty_json) - self.assertNotIn("None", none_json[:64]) - self.assertIn(DEFAULT_TEST_FILE_NAME, none_json) - - def test_padded_format_accepted_on_stream(self): - with open(self.testPath, "rb") as file: - with Reader(" image/jpeg ", file) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_try_create_none_format_matches_empty_string(self): - with open(self.testPath, "rb") as file: - none_reader = Reader.try_create(None, file) - self.assertIsNotNone(none_reader) - with open(self.testPath, "rb") as file: - empty_reader = Reader.try_create("", file) - self.assertIsNotNone(empty_reader) - if none_reader is not None and empty_reader is not None: - self.assertEqual(none_reader.json(), empty_reader.json()) - - def test_bare_stream_matches_empty_and_none_format(self): - with open(self.testPath, "rb") as file: - with Reader(file) as reader: - bare_json = reader.json() - with open(self.testPath, "rb") as file: - with Reader("", file) as reader: - empty_json = reader.json() - with open(self.testPath, "rb") as file: - with Reader(None, file) as reader: - none_json = reader.json() - self.assertEqual(bare_json, empty_json) - self.assertEqual(bare_json, none_json) - - def test_bare_stream_keyword_form(self): - with open(self.testPath, "rb") as file: - with Reader(stream=file) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_bare_stream_in_memory(self): - with open(self.testPath, "rb") as file: - data = file.read() - with Reader(io.BytesIO(data)) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_try_create_bare_stream(self): - with open(self.testPath, "rb") as file: - reader = Reader.try_create(file) - self.assertIsNotNone(reader) - if reader is not None: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - reader.close() - - def test_try_create_bare_stream_keyword(self): - with open(self.testPath, "rb") as file: - reader = Reader.try_create(stream=file) - self.assertIsNotNone(reader) - if reader is not None: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - reader.close() - - def test_bare_stream_closed_handle_raises(self): - # Stream.is_read_stream only checks method presence - # so a closed handle is accepted as a stream and fails at read time. - file = open(self.testPath, "rb") - file.close() - with self.assertRaises(Error): - Reader(file) - - def test_bare_stream_with_manifest_data_keeps_manifest(self): - # The bare-stream shift must not swallow manifest_data. - with open(self.testPath, "rb") as file: - with Reader(file, manifest_data=None) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_path_string_still_opens_by_path(self): - with Reader(self.testPath) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_path_object_still_opens_by_path(self): - with Reader(Path(self.testPath)) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_format_plus_stream_still_means_format_and_stream(self): - with open(self.testPath, "rb") as file: - with Reader("image/jpeg", file) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - with open(self.testPath, "rb") as file: - with Reader(" image/jpeg ", file) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_format_plus_path_string_still_opens_by_path(self): - with Reader("image/jpeg", self.testPath) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_no_args_raises_clear_error(self): - with self.assertRaises(Error) as ctx: - Reader() - self.assertIn("stream", str(ctx.exception).lower()) - - def test_none_first_arg_no_stream_raises_clear_error(self): - with self.assertRaises(Error) as ctx: - Reader(None) - self.assertIn("stream", str(ctx.exception).lower()) - - def test_text_stream_matches_existing_explicit_form(self): - with open(self.testPath, "rb") as file: - data = file.read() - text = data.decode("latin-1") - - def outcome(call): - try: - r = call() - out = ("ok", r.json()[:0]) - r.close() - return out - except Exception as e: - return ("err", type(e).__name__) - bare = outcome(lambda: Reader(io.StringIO(text))) - explicit = outcome(lambda: Reader("image/jpeg", io.StringIO(text))) - self.assertEqual(bare, explicit) - - def test_read_only_object_not_treated_as_stream(self): - # read/seek/tell but no write/flush - class ReadOnly: - def read(self, *a): - return b"" - - def seek(self, *a): - return 0 - - def tell(self): - return 0 - with self.assertRaises(Error): - Reader(ReadOnly()) - - def test_unrecognized_extension_defers_to_detection(self): - with tempfile.TemporaryDirectory() as tmp: - asset = os.path.join(tmp, "C.test") - shutil.copyfile(self.testPath, asset) - with Reader(asset) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_try_create_unrecognized_extension_defers_to_detection(self): - with tempfile.TemporaryDirectory() as tmp: - asset = os.path.join(tmp, "C.test") - shutil.copyfile(self.testPath, asset) - reader = Reader.try_create(asset) - self.assertIsNotNone(reader) - if reader is not None: - with reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_stream_read_string_stream(self): - with Reader("image/jpeg", self.testPath) as reader: - json_data = reader.json() - self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) - - def test_wrong_mimetype_corrected_from_bytes_on_file(self): - # As with a stream: a valid asset opened by path still reads when the - # given mimetype is wrong, because the native library corrects it from - # the detected container. - with Reader("mimetype/does-not-exist", self.testPath) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_read_extensionless_file_path(self): - # Extensionless file triggers type auto-detection. - with tempfile.TemporaryDirectory() as tmp: - no_ext = os.path.join(tmp, "C") - shutil.copyfile(self.testPath, no_ext) - with Reader(no_ext) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_read_extensionless_stream_empty_format(self): - # An empty format string on a stream triggers auto-detection. - with open(self.testPath, "rb") as file: - with Reader("", file) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_read_wrong_extension_corrected(self): - # A JPEG saved with a .png extension is corrected from the bytes. - # (Read is possible and does not fail due to wrong type). - with tempfile.TemporaryDirectory() as tmp: - wrong = os.path.join(tmp, "C.png") - shutil.copyfile(self.testPath, wrong) - with Reader(wrong) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_read_wrong_mime_corrected_on_stream(self): - # A wrong MIME hint on a stream is corrected from the bytes. - with open(self.testPath, "rb") as file: - with Reader("image/png", file) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_explicit_empty_format_forces_detection(self): - # Passing "" works even when the correct format is supported. - with open(self.testPath, "rb") as file: - with Reader("", file) as reader: - manifest_store = json.loads(reader.json()) - self.assertIn("active_manifest", manifest_store) - - def test_autodetect_multiformat(self): - fixtures = [ - os.path.join(FIXTURES_DIR, "files-for-reading-tests", "CA.jpg"), - os.path.join(FIXTURES_DIR, "video1.mp4"), - os.path.join(FIXTURES_DIR, "files-for-reading-tests", "pdf-file.pdf"), - os.path.join(FIXTURES_DIR, "files-for-reading-tests", "sample1_signed.wav"), - ] - for path in fixtures: - with self.subTest(path=path): - with open(path, "rb") as file: - with Reader("", file) as reader: - self.assertIn("active_manifest", json.loads(reader.json())) - - def test_dng_extension_still_special_cased(self): - dng_path = os.path.join(FIXTURES_DIR, "C.dng") - with Reader(dng_path) as reader: - self.assertIn("active_manifest", json.loads(reader.json())) - - def test_garbage_stream_empty_format_raises(self): - # Unrecognized bytes with auto-detection raise rather than crash. - with self.assertRaises(Error): - Reader("", io.BytesIO(b"not an asset at all")) - - def test_empty_stream_raises(self): - # A zero-byte stream cannot be detected and raises. - with self.assertRaises(Error): - Reader("", io.BytesIO(b"")) - - def test_truncated_corrupt_jpeg_raises(self): - with open(self.testPath, "rb") as file: - data = bytearray(file.read()) - # Corrupt the body while keeping the leading JPEG magic intact. - # This will still raise errors. - for i in range(64, min(len(data), 4096)): - data[i] ^= 0xFF - with self.assertRaises(Error): - Reader("", io.BytesIO(bytes(data))) - - def test_garbage_extensionless_file_raises(self): - # Random bytes in an extensionless file raise. - with tempfile.TemporaryDirectory() as tmp: - garbage = os.path.join(tmp, "garbage") - with open(garbage, "wb") as f: - f.write(b"this is not a media asset") - with self.assertRaises(Error): - Reader(garbage) - - def test_try_create_garbage_stream_raises(self): - with self.assertRaises(Error) as context: - Reader.try_create("", io.BytesIO(b"not an asset at all")) - self.assertNotIn("ManifestNotFound", str(context.exception)) - - def test_try_create_extensionless_no_manifest_returns_none(self): - unsigned = os.path.join( - FIXTURES_DIR, "files-for-signing-tests", "earth_apollo17.jpg") - with tempfile.TemporaryDirectory() as tmp: - no_ext = os.path.join(tmp, "unsigned") - shutil.copyfile(unsigned, no_ext) - self.assertIsNone(Reader.try_create(no_ext)) - - def test_sub_magic_length_stream_raises(self): - # Fewer bytes than a full magic signature cannot be detected. - # This will raise too. - with self.assertRaises(Error): - Reader("", io.BytesIO(b"\xff\xd8\xff")) - - def test_preseeked_stream_is_rewound(self): - with open(self.testPath, "rb") as file: - data = file.read() - stream = io.BytesIO(data) - stream.seek(len(data) // 2) - with Reader("", stream) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_dng_extension_with_jpeg_bytes_corrected(self): - with tempfile.TemporaryDirectory() as tmp: - fake_dng = os.path.join(tmp, "fake.dng") - shutil.copyfile(self.testPath, fake_dng) - with Reader(fake_dng) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_stream_read_filepath_as_stream_and_parse(self): - with Reader("image/jpeg", self.testPath) as reader: - manifest_store = json.loads(reader.json()) - title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] - self.assertEqual(title, DEFAULT_TEST_FILE_NAME) - - def test_get_mime_type_from_path_dng_special_cased(self): - self.assertEqual(_get_mime_type_from_path("photo.dng"), "image/dng") - - def test_get_mime_type_from_path_uppercase_dng(self): - self.assertEqual(_get_mime_type_from_path("PHOTO.DNG"), "image/dng") - - def test_get_mime_type_from_path_jpeg(self): - self.assertEqual(_get_mime_type_from_path("photo.jpg"), "image/jpeg") - - def test_get_mime_type_from_path_unknown_extension_returns_empty(self): - # An unrecognized extension yields "" so the caller can auto-detect. - self.assertEqual(_get_mime_type_from_path("asset.unknownext"), "") - - def test_get_mime_type_from_path_no_suffix_returns_empty(self): - self.assertEqual(_get_mime_type_from_path("asset"), "") - - def test_get_mime_type_from_path_multi_dot_uses_final_suffix(self): - # Only the final suffix determines the type. - self.assertEqual( - _get_mime_type_from_path("photo.final.jpg"), "image/jpeg") - - def test_get_mime_type_from_path_compound_extension(self): - # mimetypes treats ".gz" as an encoding, not a type suffix, - # the type is derived from ".tar" - result = _get_mime_type_from_path("archive.tar.gz") - self.assertIsInstance(result, str) - self.assertNotIn(result, ("image/jpeg", "image/dng")) - - def test_get_mime_type_from_path_hidden_dotfile_returns_empty(self): - # Check how we handle files without suffix (usually hidden files) - self.assertEqual(_get_mime_type_from_path(".gitignore"), "") - - def test_get_mime_type_from_path_accepts_path_object(self): - self.assertEqual( - _get_mime_type_from_path(Path("dir/photo.jpg")), "image/jpeg") - - def test_encode_format_blank_autodetects(self): - # A blank format requests auto-detection. - self.assertIsNone(_encode_format("", "Reader")) - self.assertIsNone(_encode_format(" ", "Reader")) - - def test_encode_format_returns_bytes(self): - self.assertEqual( - _encode_format("image/jpeg", "Reader"), b"image/jpeg") - - def test_encode_format_case_normalized(self): - self.assertEqual( - _encode_format("IMAGE/JPEG", "Reader"), b"image/jpeg") - - def test_encode_format_unknown_passes_through(self): - # The native library decides validity. - self.assertEqual( - _encode_format("application/zip", "Reader"), b"application/zip") - - def test_reader_accepts_path_object(self): - with Reader(Path(self.testPath)) as reader: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - - def test_reader_double_close(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - reader.close() - # Second close should not raise an exception - reader.close() - # Verify reader is closed - with self.assertRaises(Error): - reader.json() - - def test_reader_streams_with_nested(self): - with open(self.testPath, "rb") as file: - with Reader("image/jpeg", file) as reader: - manifest_store = json.loads(reader.json()) - title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] - self.assertEqual(title, DEFAULT_TEST_FILE_NAME) - - def test_reader_close_cleanup(self): - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - # Close the reader - reader.close() - # Verify all resources are cleaned up - self.assertIsNone(reader._handle) - self.assertIsNone(reader._own_stream) - # Verify reader is marked as 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.""" - reader = Reader("image/jpeg", self.testPath) - reader.close() - with self.assertRaises(Error): - reader.resource_to_stream("", io.BytesIO(bytearray())) - - def test_read_dng_from_stream(self): - test_path = os.path.join(self.data_dir, "C.dng") - with open(test_path, "rb") as file: - file_content = file.read() - - with Reader("dng", io.BytesIO(file_content)) as reader: - # Just run and verify there is no crash - json.loads(reader.json()) - - def test_read_dng_upper_case_from_stream(self): - test_path = os.path.join(self.data_dir, "C.dng") - with open(test_path, "rb") as file: - file_content = file.read() - - with Reader("DNG", io.BytesIO(file_content)) as reader: - # Just run and verify there is no crash - json.loads(reader.json()) - - def test_read_dng_file_from_path(self): - test_path = os.path.join(self.data_dir, "C.dng") - - # Create reader with the file content - with Reader(test_path) as reader: - # Just run and verify there is no crash - json.loads(reader.json()) - - def test_read_all_files(self): - """Test reading C2PA metadata from all files in the fixtures/files-for-reading-tests directory""" - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") - - # Map of file extensions to MIME types - mime_types = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.heic': 'image/heic', - '.heif': 'image/heif', - '.avif': 'image/avif', - '.tif': 'image/tiff', - '.tiff': 'image/tiff', - '.mp4': 'video/mp4', - '.avi': 'video/x-msvideo', - '.mp3': 'audio/mpeg', - '.m4a': 'audio/mp4', - '.wav': 'audio/wav', - '.pdf': 'application/pdf', - } - - # Skip system files - skip_files = { - '.DS_Store' - } - - for filename in os.listdir(reading_dir): - if filename in skip_files: - continue - - file_path = os.path.join(reading_dir, filename) - if not os.path.isfile(file_path): - continue - - # Get file extension and corresponding MIME type - _, ext = os.path.splitext(filename) - ext = ext.lower() - if ext not in mime_types: - continue - - mime_type = mime_types[ext] - - try: - with open(file_path, "rb") as file: - reader = Reader(mime_type, file) - json_data = reader.json() - reader.close() - self.assertIsInstance(json_data, str) - # Verify the manifest contains expected fields - manifest = json.loads(json_data) - self.assertIn("manifests", manifest) - self.assertIn("active_manifest", manifest) - except Exception as e: - self.fail(f"Failed to read metadata from {filename}: {str(e)}") - - def test_try_create_all_files(self): - """Test reading C2PA metadata using Reader.try_create from all files in the fixtures/files-for-reading-tests directory""" - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") - - # Map of file extensions to MIME types - mime_types = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.heic': 'image/heic', - '.heif': 'image/heif', - '.avif': 'image/avif', - '.tif': 'image/tiff', - '.tiff': 'image/tiff', - '.mp4': 'video/mp4', - '.avi': 'video/x-msvideo', - '.mp3': 'audio/mpeg', - '.m4a': 'audio/mp4', - '.wav': 'audio/wav', - '.pdf': 'application/pdf', - } - - # Skip system files - skip_files = { - '.DS_Store' - } - - for filename in os.listdir(reading_dir): - if filename in skip_files: - continue - - file_path = os.path.join(reading_dir, filename) - if not os.path.isfile(file_path): - continue - - # Get file extension and corresponding MIME type - _, ext = os.path.splitext(filename) - ext = ext.lower() - if ext not in mime_types: - continue - - mime_type = mime_types[ext] - - try: - with open(file_path, "rb") as file: - reader = Reader.try_create(mime_type, file) - # try_create returns None if no manifest found, otherwise a Reader - self.assertIsNotNone(reader, f"Expected Reader for {filename}") - json_data = reader.json() - reader.close() - self.assertIsInstance(json_data, str) - # Verify the manifest contains expected fields - manifest = json.loads(json_data) - self.assertIn("manifests", manifest) - self.assertIn("active_manifest", manifest) - except Exception as e: - self.fail(f"Failed to read metadata from {filename}: {str(e)}") - - def test_try_create_all_files_using_extension(self): - """ - Test reading C2PA metadata using Reader.try_create - from files in the fixtures/files-for-reading-tests directory - """ - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") - - # Map of file extensions to MIME types - extensions = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - } - - # Skip system files - skip_files = { - '.DS_Store' - } - - for filename in os.listdir(reading_dir): - if filename in skip_files: - continue - - file_path = os.path.join(reading_dir, filename) - if not os.path.isfile(file_path): - continue - - # Get file extension and corresponding MIME type - _, ext = os.path.splitext(filename) - ext = ext.lower() - if ext not in extensions: - continue - - try: - with open(file_path, "rb") as file: - # Remove the leading dot - parsed_extension = ext[1:] - reader = Reader.try_create(parsed_extension, file) - # try_create returns None if no manifest found, otherwise a Reader - self.assertIsNotNone(reader, f"Expected Reader for {filename}") - json_data = reader.json() - reader.close() - self.assertIsInstance(json_data, str) - # Verify the manifest contains expected fields - manifest = json.loads(json_data) - self.assertIn("manifests", manifest) - self.assertIn("active_manifest", manifest) - except Exception as e: - self.fail(f"Failed to read metadata from {filename}: {str(e)}") - - def test_read_all_files_using_extension(self): - """Test reading C2PA metadata from files in the fixtures/files-for-reading-tests directory""" - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") - - # Map of file extensions to MIME types - extensions = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - } - - # Skip system files - skip_files = { - '.DS_Store' - } - - for filename in os.listdir(reading_dir): - if filename in skip_files: - continue - - file_path = os.path.join(reading_dir, filename) - if not os.path.isfile(file_path): - continue - - # Get file extension and corresponding MIME type - _, ext = os.path.splitext(filename) - ext = ext.lower() - if ext not in extensions: - continue - - try: - with open(file_path, "rb") as file: - # Remove the leading dot - parsed_extension = ext[1:] - reader = Reader(parsed_extension, file) - json_data = reader.json() - reader.close() - self.assertIsInstance(json_data, str) - # Verify the manifest contains expected fields - manifest = json.loads(json_data) - self.assertIn("manifests", manifest) - self.assertIn("active_manifest", manifest) - except Exception as e: - self.fail(f"Failed to read metadata from {filename}: {str(e)}") - - def test_read_cached_all_files(self): - """Test reading C2PA metadata with cache functionality from all files in the fixtures/files-for-reading-tests directory""" - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") - - # Map of file extensions to MIME types - mime_types = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.heic': 'image/heic', - '.heif': 'image/heif', - '.avif': 'image/avif', - '.tif': 'image/tiff', - '.tiff': 'image/tiff', - '.mp4': 'video/mp4', - '.avi': 'video/x-msvideo', - '.mp3': 'audio/mpeg', - '.m4a': 'audio/mp4', - '.wav': 'audio/wav', - '.pdf': 'application/pdf', - } - - # Skip system files - skip_files = { - '.DS_Store' - } - - for filename in os.listdir(reading_dir): - if filename in skip_files: - continue - - file_path = os.path.join(reading_dir, filename) - if not os.path.isfile(file_path): - continue - - # Get file extension and corresponding MIME type - _, ext = os.path.splitext(filename) - ext = ext.lower() - if ext not in mime_types: - continue - - mime_type = mime_types[ext] - - try: - with open(file_path, "rb") as file: - reader = Reader(mime_type, file) - - # Test 1: Verify cache variables are initially None - self.assertIsNone(reader._manifest_json_str_cache, f"JSON cache should be None initially for {filename}") - self.assertIsNone(reader._manifest_data_cache, f"Manifest data cache should be None initially for {filename}") - - # Test 2: Multiple calls to json() should return the same result and use cache - json_data_1 = reader.json() - self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache not set after first json() call for {filename}") - self.assertEqual(json_data_1, reader._manifest_json_str_cache, f"JSON cache doesn't match return value for {filename}") - - json_data_2 = reader.json() - self.assertEqual(json_data_1, json_data_2, f"JSON inconsistency for {filename}") - self.assertIsInstance(json_data_1, str) - - # Test 3: Test methods that use the cache - try: - # Test get_active_manifest() which uses _get_cached_manifest_data() - active_manifest = reader.get_active_manifest() - self.assertIsInstance(active_manifest, dict, f"Active manifest not dict for {filename}") - - # Test 4: Verify cache is set after calling cache-using methods - self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache not set after get_active_manifest for {filename}") - self.assertIsNotNone(reader._manifest_data_cache, f"Manifest data cache not set after get_active_manifest for {filename}") - - # Test 5: Multiple calls to cache-using methods should return the same result - active_manifest_2 = reader.get_active_manifest() - self.assertEqual(active_manifest, active_manifest_2, f"Active manifest cache inconsistency for {filename}") - - # Test get_validation_state() which uses the cache - validation_state = reader.get_validation_state() - # validation_state can be None, so just check it doesn't crash - - # Test get_validation_results() which uses the cache - validation_results = reader.get_validation_results() - # validation_results can be None, so just check it doesn't crash - - # Test 6: Multiple calls to validation methods should return the same result - validation_state_2 = reader.get_validation_state() - self.assertEqual(validation_state, validation_state_2, f"Validation state cache inconsistency for {filename}") - - validation_results_2 = reader.get_validation_results() - self.assertEqual(validation_results, validation_results_2, f"Validation results cache inconsistency for {filename}") - - except KeyError as e: - # Some files might not have active manifests or validation data - # This is expected for some test files, so we'll skip cache testing for those - pass - - # Test 7: Verify the manifest contains expected fields - manifest = json.loads(json_data_1) - self.assertIn("manifests", manifest) - self.assertIn("active_manifest", manifest) - - # Test 8: Test cache clearing on close - reader.close() - self.assertIsNone(reader._manifest_json_str_cache, f"JSON cache not cleared for {filename}") - self.assertIsNone(reader._manifest_data_cache, f"Manifest data cache not cleared for {filename}") - - except Exception as e: - self.fail(f"Failed to read cached metadata from {filename}: {str(e)}") - - def test_reader_context_manager_with_exception(self): - """Test Reader state after exception in context manager.""" - try: - with Reader(self.testPath) as reader: - # Inside context - should be valid - 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") - except ValueError: - pass - - # After exception - should still be closed - 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 lifecycle state = ACTIVE - reader = Reader.__new__(Reader) - reader._lifecycle_state = LifecycleState.ACTIVE - reader._handle = None - reader._own_stream = None - reader._backing_file = None - - with self.assertRaises(Error): - reader._ensure_valid_state() - - def test_reader_cleanup_state_transitions(self): - """Test Reader state during cleanup operations.""" - reader = Reader(self.testPath) - - reader._cleanup_resources() - self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) - self.assertIsNone(reader._handle) - self.assertIsNone(reader._own_stream) - self.assertIsNone(reader._backing_file) - - def test_reader_cleanup_idempotency(self): - """Test that cleanup operations are idempotent.""" - reader = Reader(self.testPath) - - # First cleanup - reader._cleanup_resources() - self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) - - # Second cleanup should not change state - reader._cleanup_resources() - self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) - self.assertIsNone(reader._handle) - self.assertIsNone(reader._own_stream) - self.assertIsNone(reader._backing_file) - - def test_reader_state_with_invalid_native_pointer(self): - """Test Reader state handling with invalid native pointer.""" - reader = Reader(self.testPath) - - # Simulate invalid native pointer - reader._handle = 0 - - # Operations should fail gracefully - with self.assertRaises(Error): - reader.json() - - def test_reader_is_embedded(self): - """Test the is_embedded method returns correct values for embedded and remote manifests.""" - - # Test with a fixture which has an embedded manifest - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - self.assertTrue(reader.is_embedded()) - reader.close() - - # Test with cloud.jpg fixture which has a remote manifest (not embedded) - cloud_fixture_path = os.path.join(self.data_dir, "cloud.jpg") - with Reader("image/jpeg", cloud_fixture_path) as reader: - self.assertFalse(reader.is_embedded()) - - def test_sign_and_read_is_not_embedded(self): - """Test the is_embedded method returns correct values for remote manifests.""" - - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - key = key_file.read() - - # Create signer info and signer - signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - - # Define a simple manifest - manifest_definition = { - "claim_generator": "python_test", - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - "format": "image/jpeg", - "title": "Python Test Image", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } - - # Create a temporary directory for the signed file - with tempfile.TemporaryDirectory() as temp_dir: - temp_file_path = os.path.join(temp_dir, "signed_test_file_no_embed.jpg") - - with open(self.testPath, "rb") as file: - builder = Builder(manifest_definition) - # Direct the Builder not to embed the manifest into the asset - builder.set_no_embed() - - with open(temp_file_path, "wb") as temp_file: - manifest_data = builder.sign( - signer, "image/jpeg", file, temp_file) - - with Reader("image/jpeg", temp_file_path, manifest_data) as reader: - self.assertFalse(reader.is_embedded()) - - def test_reader_get_remote_url(self): - """Test the get_remote_url method returns correct values for embedded and remote manifests.""" - - # Test get_remote_url for file with embedded manifest (should return None) - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - self.assertIsNone(reader.get_remote_url()) - reader.close() - - # Test remote manifest using cloud.jpg fixture which has a remote URL - cloud_fixture_path = os.path.join(self.data_dir, "cloud.jpg") - with Reader("image/jpeg", cloud_fixture_path) as reader: - remote_url = reader.get_remote_url() - self.assertEqual(remote_url, "https://cai-manifests.adobe.com/manifests/adobe-urn-uuid-5f37e182-3687-462e-a7fb-573462780391") - self.assertFalse(reader.is_embedded()) - - def test_stream_read_and_parse_cached(self): - """Test reading and parsing with cache verification by repeating operations multiple times""" - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) - - # Verify cache starts as None - self.assertIsNone(reader._manifest_json_str_cache, "JSON cache should be None initially") - self.assertIsNone(reader._manifest_data_cache, "Manifest data cache should be None initially") - - # First operation - should populate cache - manifest_store_1 = json.loads(reader.json()) - title_1 = manifest_store_1["manifests"][manifest_store_1["active_manifest"]]["title"] - self.assertEqual(title_1, DEFAULT_TEST_FILE_NAME) - - # Verify cache is populated after first json() call - self.assertIsNotNone(reader._manifest_json_str_cache, "JSON cache should be set after first json() call") - self.assertEqual(manifest_store_1, json.loads(reader._manifest_json_str_cache), "Cached JSON should match parsed result") - - # Repeat the same operation multiple times to verify cache usage - for i in range(5): - manifest_store = json.loads(reader.json()) - title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] - self.assertEqual(title, DEFAULT_TEST_FILE_NAME, f"Title should be consistent on iteration {i+1}") - - # Verify cache is still populated and consistent - self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache should remain set on iteration {i+1}") - self.assertEqual(manifest_store, json.loads(reader._manifest_json_str_cache), f"Cached JSON should match parsed result on iteration {i+1}") - - # Test methods that use the cache - # Test get_active_manifest() which uses _get_cached_manifest_data() - active_manifest_1 = reader.get_active_manifest() - self.assertIsInstance(active_manifest_1, dict, "Active manifest should be a dict") - - # Verify manifest data cache is populated - self.assertIsNotNone(reader._manifest_data_cache, "Manifest data cache should be set after get_active_manifest()") - - # Repeat get_active_manifest() multiple times to verify cache usage - for i in range(3): - active_manifest = reader.get_active_manifest() - self.assertEqual(active_manifest_1, active_manifest, f"Active manifest should be consistent on iteration {i+1}") - - # Verify cache remains populated - self.assertIsNotNone(reader._manifest_data_cache, f"Manifest data cache should remain set on iteration {i+1}") - - # Test get_validation_state() and get_validation_results() with cache - validation_state_1 = reader.get_validation_state() - validation_results_1 = reader.get_validation_results() - - # Repeat validation methods to verify cache usage - for i in range(3): - validation_state = reader.get_validation_state() - validation_results = reader.get_validation_results() - - self.assertEqual(validation_state_1, validation_state, f"Validation state should be consistent on iteration {i+1}") - self.assertEqual(validation_results_1, validation_results, f"Validation results should be consistent on iteration {i+1}") - - # Verify cache clearing on close - reader.close() - self.assertIsNone(reader._manifest_json_str_cache, "JSON cache should be cleared on close") - self.assertIsNone(reader._manifest_data_cache, "Manifest data cache should be cleared on close") - - # TODO: Unskip once fixed configuration to read data is clarified - # def test_read_cawg_data_file(self): - # """Test reading C2PA metadata from C_with_CAWG_data.jpg file.""" - # file_path = os.path.join(self.data_dir, "C_with_CAWG_data.jpg") - - # with open(file_path, "rb") as file: - # reader = Reader("image/jpeg", file) - # json_data = reader.json() - # self.assertIsInstance(json_data, str) - - # # Parse the JSON and verify specific fields - # manifest_data = json.loads(json_data) - - # # Verify basic manifest structure - # self.assertIn("manifests", manifest_data) - # self.assertIn("active_manifest", manifest_data) - - # # Get the active manifest - # active_manifest_id = manifest_data["active_manifest"] - # active_manifest = manifest_data["manifests"][active_manifest_id] - - # # Verify manifest is not null or empty - # assert active_manifest is not None, "Active manifest should not be null" - # assert len(active_manifest) > 0, "Active manifest should not be empty" - - -class TestBuilderWithSigner(unittest.TestCase): - def setUp(self): - # Use the fixtures_dir fixture to set up paths - self.data_dir = FIXTURES_DIR - self.testPath = DEFAULT_TEST_FILE - self.testPath2 = INGREDIENT_TEST_FILE - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: - self.certs = cert_file.read() - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - self.key = key_file.read() - - # Create a local Es256 signer with certs and a timestamp server - self.signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=self.certs, - private_key=self.key, - ta_url=b"http://timestamp.digicert.com" - ) - self.signer = Signer.from_info(self.signer_info) - - self.testPath3 = os.path.join(self.data_dir, "A_thumbnail.jpg") - self.testPath4 = ALTERNATIVE_INGREDIENT_TEST_FILE - - # Define a manifest as a dictionary - self.manifestDefinition = { - "claim_generator": "python_test", - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - "claim_version": 1, - "format": "image/jpeg", - "title": "Python Test Image", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } - - # Define a V2 manifest as a dictionary - self.manifestDefinitionV2 = { - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - # claim version 2 is the default - # "claim_version": 2, - "format": "image/jpeg", - "title": "Python Test Image V2", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } - - # Define an example ES256 callback signer - self.callback_signer_alg = "Es256" - def callback_signer_es256(data: bytes) -> bytes: - private_key = serialization.load_pem_private_key( - self.key, - password=None, - backend=default_backend() - ) - signature = private_key.sign( - data, - ec.ECDSA(hashes.SHA256()) - ) - return signature - self.callback_signer_es256 = callback_signer_es256 - - def _create_ingredient_archive(self, ingredient_json=None): - """Helper: create an ingredient archive from a single ingredient.""" - if ingredient_json is None: - ingredient_json = {"title": "photo.jpg", "relationship": "componentOf"} - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", - } - ] - }, - } - ], - } - builder = Builder.from_json(manifest) - with open(self.testPath, "rb") as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - archive = io.BytesIO() - builder.to_archive(archive) - builder.close() - archive.seek(0) - return archive - - def test_can_retrieve_builder_supported_mimetypes(self): - result1 = Builder.get_supported_mime_types() - self.assertTrue(len(result1) > 0) - - # Cache-hit - result2 = Builder.get_supported_mime_types() - self.assertTrue(len(result2) > 0) - - self.assertEqual(result1, result2) - - def test_reserve_size(self): - signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=self.certs, - private_key=self.key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - signer.reserve_size() - - def _local_signer(self): - """An es256 signer with no timestamp authority (keeps signing local). - - ta_url=None maps to a NULL pointer, which is how the native side - spells "no TSA". An empty string is not equivalent: it is passed - through as a real, empty URL and signing then fails. - """ - signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=self.certs, - private_key=self.key, - ta_url=None, - ) - signer = Signer.from_info(signer_info) - self.addCleanup(signer.close) - return signer - - def _active_signature_info(self, signed_bytes): - """signature_info of the active manifest in a signed asset.""" - signed_bytes.seek(0) - reader = Reader("image/jpeg", signed_bytes) - try: - manifest_store = json.loads(reader.json()) - finally: - reader.close() - active = manifest_store["manifests"][ - manifest_store["active_manifest"]] - return active.get("signature_info", {}) - - def test_signer_with_none_ta_url_signs_without_timestamp(self): - """ta_url=None must reach native as NULL, meaning "no TSA". - """ - builder = Builder(self.manifestDefinitionV2) - dest = io.BytesIO() - with open(self.testPath, "rb") as source: - builder.sign(self._local_signer(), "image/jpeg", source, dest) - - signature_info = self._active_signature_info(dest) - self.assertTrue(signature_info, "expected a signed active manifest") - self.assertNotIn( - "time", signature_info, - "no timestamp authority was configured, so the signature " - "must not carry a timestamp") - - def _sign_with_ta_url(self, ta_url): - """Sign with ta_url as the only variable. Returns the dest buffer.""" - signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=self.certs, - private_key=self.key, - ta_url=ta_url, - ) - signer = Signer.from_info(signer_info) - self.addCleanup(signer.close) - - builder = Builder(self.manifestDefinitionV2) - dest = io.BytesIO() - with open(self.testPath, "rb") as source: - builder.sign(signer, "image/jpeg", source, dest) - return dest - - def test_signer_with_empty_ta_url_is_not_a_no_tsa_path(self): - """An empty ta_url is a real (empty) URL, not "no TSA". - Only None means "no TSA". - """ - with self.assertRaises(Error): - self._sign_with_ta_url(b"") - - def test_empty_ta_url_is_rejected_as_a_malformed_url(self): - """b"" fails as a URL, which is why it cannot mean "no TSA". - """ - malformed = [ - (b"", "empty"), - (b" ", "whitespace"), - (b"not-a-url", "no scheme or host"), - ] - for value, why in malformed: - with self.subTest(ta_url=value, reason=why): - with self.assertRaises(Error): - self._sign_with_ta_url(value) - - # The control: same signer, same asset, only ta_url differs. - signature_info = self._active_signature_info( - self._sign_with_ta_url(None)) - self.assertNotIn("time", signature_info) - - def test_signer_with_ta_url_signs_with_timestamp(self): - """Control for the None case: a real TSA does add a timestamp. - """ - builder = Builder(self.manifestDefinitionV2) - dest = io.BytesIO() - with open(self.testPath, "rb") as source: - builder.sign(self.signer, "image/jpeg", source, dest) - - signature_info = self._active_signature_info(dest) - self.assertIn( - "time", signature_info, - "a timestamp authority was configured, so the signature " - "must carry a timestamp") - self.assertTrue(signature_info["time"]) - - def test_none_and_empty_ta_url_reach_native_differently(self): - """None must marshal to NULL, b"" to a real (empty) string. - - The native side reads a NULL ta_url as "no timestamp authority" - and any non-NULL pointer as a URL to use, including one pointing at "". - """ - def struct_for(ta_url): - return C2paSignerInfo( - alg=b"es256", - sign_cert=self.certs, - private_key=self.key, - ta_url=ta_url, - ) - - # ctypes surfaces a NULL c_char_p as None and an empty one as b"". - self.assertIsNone(struct_for(None).ta_url) - self.assertEqual(struct_for(b"").ta_url, b"") - # A str is encoded rather than passed through untouched. - self.assertEqual( - struct_for("http://timestamp.digicert.com").ta_url, - b"http://timestamp.digicert.com") - - def test_signer_rejects_non_string_ta_url(self): - """Only None, str and bytes are accepted.""" - with self.assertRaises(TypeError): - C2paSignerInfo( - alg=b"es256", - sign_cert=self.certs, - private_key=self.key, - ta_url=1234, - ) - - def test_signer_creation_error_alg(self): - signer_info = C2paSignerInfo( - alg=b"not-an-alg", - sign_cert=self.certs, - private_key=self.key, - ta_url=b"http://timestamp.digicert.com" - ) - with self.assertRaises(Error): - Signer.from_info(signer_info) - - def test_signer_from_callback_error_no_cert(self): - with self.assertRaises(Error): - Signer.from_callback( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=None, - tsa_url="http://timestamp.digicert.com" - ) - - def test_signer_from_callback_error_wrong_url(self): - with self.assertRaises(Error): - Signer.from_callback( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=None, - tsa_url="ftp://timestamp.digicert.com" - ) - - def test_reserve_size_on_closed_signer(self): - signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=self.certs, - private_key=self.key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - signer.close() - # Verify signer is closed by testing that operations fail - with self.assertRaises(Error): - signer.reserve_size() - - def test_signer_double_close(self): - signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=self.certs, - private_key=self.key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - signer.close() - # Second close should not raise an exception - signer.close() - - def test_builder_detects_malformed_json(self): - with self.assertRaises(Error): - Builder("{this is not json}") - - def test_builder_does_not_allow_sign_after_close(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) - builder.close() - with self.assertRaises(Error): - builder.sign(self.signer, "image/jpeg", file, output) - - def test_sign_rejects_none_format(self): - # sign requires an explicit format; None must raise NotSupported. - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) - with self.assertRaises(Error.NotSupported): - builder.sign(self.signer, None, file, output) - - def test_sign_accepts_padded_format(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) - manifest_bytes = builder.sign( - self.signer, " image/jpeg ", file, output) - self.assertTrue(len(manifest_bytes) > 0) - - def test_builder_does_not_allow_archiving_after_close(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - placeholder_stream = io.BytesIO(bytearray()) - builder.close() - with self.assertRaises(Error): - builder.to_archive(placeholder_stream) - - def test_builder_does_not_allow_changing_remote_url_after_close(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - builder.close() - with self.assertRaises(Error): - builder.set_remote_url("a-remote-url-that-is-not-important-in-this-tests") - - def test_builder_does_not_allow_adding_resource_after_close(self): - builder = Builder(self.manifestDefinition) - placeholder_stream = io.BytesIO(bytearray()) - builder.close() - with self.assertRaises(Error): - builder.add_resource("a-remote-url-that-is-not-important-in-this-tests", placeholder_stream) - - def test_builder_add_thumbnail_resource(self): - builder = Builder(self.manifestDefinition) - with open(self.testPath2, "rb") as thumbnail_file: - builder.add_resource("thumbnail", thumbnail_file) - builder.close() - - def test_builder_double_close(self): - builder = Builder(self.manifestDefinition) - # First close - builder.close() - # Second close should not raise an exception - builder.close() - # Verify builder is closed - with self.assertRaises(Error): - builder.set_no_embed() - - def test_streams_sign_recover_bytes_only(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - manifest_bytes = builder.sign(self.signer, "image/jpeg", file) - self.assertIsNotNone(manifest_bytes) - - def test_streams_sign_with_thumbnail_resource(self): - with open(self.testPath2, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - - with open(self.testPath2, "rb") as thumbnail_file: - builder.add_resource("thumbnail", thumbnail_file) - - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - output.close() - - def test_streams_sign_with_es256_alg_v1_manifest(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - self.assertIn("Valid", json_data) - - # Write buffer to file - # output.seek(0) - # with open('/target_path', 'wb') as f: - # f.write(output.getbuffer()) - - output.close() - - def test_streams_sign_with_es256_alg_v1_manifest_to_existing_empty_file(self): - test_file_name = os.path.join(self.data_dir, "temp_data", "temp_signing.jpg") - # Ensure tmp directory exists - os.makedirs(os.path.dirname(test_file_name), exist_ok=True) - - # Ensure the target file exists before opening it in rb+ mode - with open(test_file_name, "wb") as f: - pass # Create empty file - - try: - with open(self.testPath, "rb") as source, open(test_file_name, "w+b") as target: - builder = Builder(self.manifestDefinition) - builder.sign(self.signer, "image/jpeg", source, target) - reader = Reader("image/jpeg", target) - json_data = reader.json() - self.assertIn("Python Test", json_data) - self.assertIn("Valid", json_data) - - finally: - # Clean up... - - if os.path.exists(test_file_name): - os.remove(test_file_name) - - # Also clean up the temp directory if it's empty - temp_dir = os.path.dirname(test_file_name) - if os.path.exists(temp_dir) and not os.listdir(temp_dir): - os.rmdir(temp_dir) - - def test_streams_sign_with_es256_alg_v1_manifest_to_new_dest_file(self): - test_file_name = os.path.join(self.data_dir, "temp_data", "temp_signing.jpg") - # Ensure tmp directory exists - os.makedirs(os.path.dirname(test_file_name), exist_ok=True) - - # A new target/destination file should be created during the test run - try: - with open(self.testPath, "rb") as source, open(test_file_name, "w+b") as target: - builder = Builder(self.manifestDefinition) - builder.sign(self.signer, "image/jpeg", source, target) - reader = Reader("image/jpeg", target) - json_data = reader.json() - self.assertIn("Python Test", json_data) - self.assertIn("Valid", json_data) - - finally: - # Clean up... - - if os.path.exists(test_file_name): - os.remove(test_file_name) - - # Also clean up the temp directory if it's empty - temp_dir = os.path.dirname(test_file_name) - if os.path.exists(temp_dir) and not os.listdir(temp_dir): - os.rmdir(temp_dir) - - def test_streams_sign_with_es256_alg(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - output.close() - - def test_streams_sign_with_es256_alg_2(self): - with open(self.testPath2, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - self.assertIn("Valid", json_data) - output.close() - - def test_streams_sign_with_es256_alg_create_intent(self): - """Test signing with CREATE intent and empty manifest.""" - - with open(self.testPath2, "rb") as file: - # Start with an empty manifest - builder = Builder({}) - # Set the intent for creating new content - builder.set_intent( - C2paBuilderIntent.CREATE, - C2paDigitalSourceType.DIGITAL_CREATION - ) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_str = reader.json() - # Verify the manifest was created - self.assertIsNotNone(json_str) - - # Parse the JSON to verify the structure - manifest_data = json.loads(json_str) - active_manifest_label = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_label] - - # Check that assertions exist - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the actions assertion - actions_assertion = None - for assertion in assertions: - if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - - # Verify c2pa.created action exists and there is only one - actions = actions_assertion["data"]["actions"] - created_actions = [ - action for action in actions - if action["action"] == "c2pa.created" - ] - - self.assertEqual(len(created_actions), 1) - - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertEqual(manifest_data["validation_state"], "Valid") - output.close() - - def test_streams_sign_with_es256_alg_create_intent_2(self): - """Test signing with CREATE intent and manifestDefinitionV2.""" - - with open(self.testPath2, "rb") as file: - # Start with manifestDefinitionV2 which has predefined metadata - builder = Builder(self.manifestDefinitionV2) - # Set the intent for creating new content - # If we provided a full manifest, the digital source type from the full manifest "wins" - builder.set_intent( - C2paBuilderIntent.CREATE, - C2paDigitalSourceType.SCREEN_CAPTURE - ) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_str = reader.json() - - # Verify the manifest was created - self.assertIsNotNone(json_str) - - # Parse the JSON to verify the structure - manifest_data = json.loads(json_str) - active_manifest_label = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_label] - - # Verify title from manifestDefinitionV2 is preserved - self.assertIn("title", active_manifest) - self.assertEqual(active_manifest["title"], "Python Test Image V2") - - # Verify claim_generator_info is present - self.assertIn("claim_generator_info", active_manifest) - claim_generator_info = active_manifest["claim_generator_info"] - self.assertIsInstance(claim_generator_info, list) - self.assertGreater(len(claim_generator_info), 0) - - # Check for the custom claim generator info from manifestDefinitionV2 - has_python_test = any( - gen.get("name") == "python_test" and gen.get("version") == "0.0.1" - for gen in claim_generator_info - ) - self.assertTrue(has_python_test, "Should have python_test claim generator") - - # Verify no ingredients for CREATE intent - ingredients_manifest = active_manifest.get("ingredients", []) - self.assertEqual(len(ingredients_manifest), 0, "CREATE intent should have no ingredients") - - # Check that assertions exist - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the actions assertion - actions_assertion = None - for assertion in assertions: - if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - - # Verify c2pa.created action exists and there is only one - actions = actions_assertion["data"]["actions"] - created_actions = [ - action for action in actions - if action["action"] == "c2pa.created" - ] - - self.assertEqual(len(created_actions), 1) - - # Verify the digitalSourceType is present in the created action - created_action = created_actions[0] - self.assertIn("digitalSourceType", created_action) - self.assertIn("digitalCreation", created_action["digitalSourceType"]) - - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertEqual(manifest_data["validation_state"], "Valid") - output.close() - - def test_streams_sign_with_es256_alg_edit_intent(self): - """Test signing with EDIT intent and empty manifest.""" - - with open(self.testPath2, "rb") as file: - # Start with an empty manifest - builder = Builder({}) - # Set the intent for editing existing content - builder.set_intent(C2paBuilderIntent.EDIT) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_str = reader.json() - - # Verify the manifest was created - self.assertIsNotNone(json_str) - - # Parse the JSON to verify the structure - manifest_data = json.loads(json_str) - active_manifest_label = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_label] - - # Check that ingredients exist in the active manifest - self.assertIn("ingredients", active_manifest) - ingredients_manifest = active_manifest["ingredients"] - self.assertIsInstance(ingredients_manifest, list) - self.assertEqual(len(ingredients_manifest), 1) - - # Verify the ingredient has relationship "parentOf" - ingredient = ingredients_manifest[0] - self.assertIn("relationship", ingredient) - self.assertEqual( - ingredient["relationship"], - "parentOf" - ) - - # Check that assertions exist - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the actions assertion - actions_assertion = None - for assertion in assertions: - if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - - # Verify c2pa.opened action exists and there is only one - actions = actions_assertion["data"]["actions"] - opened_actions = [ - action for action in actions - if action["action"] == "c2pa.opened" - ] - - self.assertEqual(len(opened_actions), 1) - - # Verify the c2pa.opened action has the correct structure - opened_action = opened_actions[0] - self.assertIn("parameters", opened_action) - self.assertIn("ingredients", opened_action["parameters"]) - ingredients = opened_action["parameters"]["ingredients"] - self.assertIsInstance(ingredients, list) - self.assertGreater(len(ingredients), 0) - - # Verify each ingredient has url and hash - for ingredient in ingredients: - self.assertIn("url", ingredient) - self.assertIn("hash", ingredient) - - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertEqual(manifest_data["validation_state"], "Valid") - output.close() - - def test_streams_sign_with_es256_alg_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings - result = {} - exception = {} - - def sign_and_validate_with_trust_config(): - try: - # Load trust configuration - settings_dict = load_test_settings_json() - - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - - # Get validation state with trust config - validation_state = reader.get_validation_state() - - result['json_data'] = json_data - result['validation_state'] = validation_state - output.close() - except Exception as e: - exception['error'] = e - - # Create and start thread - thread = threading.Thread(target=sign_and_validate_with_trust_config) - thread.start() - thread.join() - - # Check for exceptions - if 'error' in exception: - raise exception['error'] - - # Assertions run in main thread - self.assertIn("Python Test", result.get('json_data', '')) - # With trust configuration loaded, validation should return "Trusted" - self.assertIsNotNone(result.get('validation_state')) - self.assertEqual(result.get('validation_state'), "Trusted") - - def test_sign_with_ed25519_alg(self): - with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "ed25519.pem"), "rb") as key_file: - key = key_file.read() - - signer_info = C2paSignerInfo( - alg=b"ed25519", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - output.close() - - def test_sign_with_ed25519_alg_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings - result = {} - exception = {} - - def sign_and_validate_with_trust_config(): - try: - # Load trust configuration - settings_dict = load_test_settings_json() - - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) - - with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "ed25519.pem"), "rb") as key_file: - key = key_file.read() - - signer_info = C2paSignerInfo( - alg=b"ed25519", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - - # Get validation state with trust config - validation_state = reader.get_validation_state() - - result['json_data'] = json_data - result['validation_state'] = validation_state - output.close() - except Exception as e: - exception['error'] = e - - # Create and start thread - thread = threading.Thread(target=sign_and_validate_with_trust_config) - thread.start() - thread.join() - - # Check for exceptions - if 'error' in exception: - raise exception['error'] - - # Assertions run in main thread - self.assertIn("Python Test", result.get('json_data', '')) - # With trust configuration loaded, validation should return "Trusted" - self.assertIsNotNone(result.get('validation_state')) - self.assertEqual(result.get('validation_state'), "Trusted") - - def test_sign_with_ed25519_alg_2(self): - with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "ed25519.pem"), "rb") as key_file: - key = key_file.read() - - signer_info = C2paSignerInfo( - alg=b"ed25519", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - - with open(self.testPath2, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - output.close() - - def test_sign_with_ps256_alg(self): - with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "ps256.pem"), "rb") as key_file: - key = key_file.read() - - signer_info = C2paSignerInfo( - alg=b"ps256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - output.close() - - def test_sign_with_ps256_alg_2(self): - with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "ps256.pem"), "rb") as key_file: - key = key_file.read() - - signer_info = C2paSignerInfo( - alg=b"ps256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - - with open(self.testPath2, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted - # self.assertNotIn("validation_status", json_data) - output.close() - - def test_sign_with_ps256_alg_2_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings - result = {} - exception = {} - - def sign_and_validate_with_trust_config(): - try: - # Load trust configuration - settings_dict = load_test_settings_json() - - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) - - with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "ps256.pem"), "rb") as key_file: - key = key_file.read() - - signer_info = C2paSignerInfo( - alg=b"ps256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - - with open(self.testPath2, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - - # Get validation state with trust config - validation_state = reader.get_validation_state() - - result['json_data'] = json_data - result['validation_state'] = validation_state - output.close() - except Exception as e: - exception['error'] = e - - # Create and start thread - thread = threading.Thread(target=sign_and_validate_with_trust_config) - thread.start() - thread.join() - - # Check for exceptions - if 'error' in exception: - raise exception['error'] - - # Assertions run in main thread - self.assertIn("Python Test", result.get('json_data', '')) - # With trust configuration loaded, validation should return "Trusted" - self.assertIsNotNone(result.get('validation_state')) - self.assertEqual(result.get('validation_state'), "Trusted") - - def test_archive_sign(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - archive = io.BytesIO(bytearray()) - builder.to_archive(archive) - builder = Builder.from_archive(archive) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - archive.close() - output.close() - - def test_archive_sign_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings - result = {} - exception = {} - - def sign_and_validate_with_trust_config(): - try: - # Load trust configuration - settings_dict = load_test_settings_json() - - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - archive = io.BytesIO(bytearray()) - builder.to_archive(archive) - builder = Builder.from_archive(archive) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - - # Get validation state with trust config - validation_state = reader.get_validation_state() - - result['json_data'] = json_data - result['validation_state'] = validation_state - archive.close() - output.close() - except Exception as e: - exception['error'] = e - - # Create and start thread - thread = threading.Thread(target=sign_and_validate_with_trust_config) - thread.start() - thread.join() - - # Check for exceptions - if 'error' in exception: - raise exception['error'] - - # Assertions run in main thread - self.assertIn("Python Test", result.get('json_data', '')) - # With trust configuration loaded, validation should return "Trusted" - self.assertIsNotNone(result.get('validation_state')) - self.assertEqual(result.get('validation_state'), "Trusted") - - def test_archive_sign_with_added_ingredient(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - archive = io.BytesIO(bytearray()) - builder.to_archive(archive) - builder = Builder.from_archive(archive) - output = io.BytesIO(bytearray()) - ingredient_json = '{"test": "ingredient"}' - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - archive.close() - output.close() - - def test_archive_sign_with_added_ingredient_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings - result = {} - exception = {} - - def sign_and_validate_with_trust_config(): - try: - # Load trust configuration - settings_dict = load_test_settings_json() - - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - archive = io.BytesIO(bytearray()) - builder.to_archive(archive) - builder = Builder.from_archive(archive) - output = io.BytesIO(bytearray()) - ingredient_json = '{"test": "ingredient"}' - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - - # Get validation state with trust config - validation_state = reader.get_validation_state() - - result['json_data'] = json_data - result['validation_state'] = validation_state - archive.close() - output.close() - except Exception as e: - exception['error'] = e - - # Create and start thread - thread = threading.Thread(target=sign_and_validate_with_trust_config) - thread.start() - thread.join() - - # Check for exceptions - if 'error' in exception: - raise exception['error'] - - # Assertions run in main thread - self.assertIn("Python Test", result.get('json_data', '')) - # With trust configuration loaded, validation should return "Trusted" - self.assertIsNotNone(result.get('validation_state')) - self.assertEqual(result.get('validation_state'), "Trusted") - - def test_write_ingredient_archive_produces_readable_archive(self): - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "0.1.0"}], - "assertions": [], - } - builder = Builder.from_json(manifest) - ingredient_json = { - "title": "A.jpg", - "relationship": "componentOf", - "instance_id": "ingredient-A", - } - with open(self.testPath, "rb") as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - archive = io.BytesIO() - builder.write_ingredient_archive("ingredient-A", archive) - builder.close() - self.assertGreater(len(archive.getvalue()), 0) - archive.close() - - def test_add_ingredient_from_archive_roundtrip(self): - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "0.1.0"}], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [{ - "action": "c2pa.created", - "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", - }] - } - } - ], - } - builder = Builder.from_json(manifest) - ingredient_json = { - "title": "A.jpg", - "relationship": "componentOf", - "instance_id": "catalog:ingredient-A", - } - with open(self.testPath, "rb") as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - archive = io.BytesIO() - builder.write_ingredient_archive("catalog:ingredient-A", archive) - builder.close() - - archive.seek(0) - builder2 = Builder.from_json(manifest) - builder2.add_ingredient_from_archive(archive) - archive.close() - - output = io.BytesIO() - with open(self.testPath, "rb") as f: - builder2.sign(self.signer, "image/jpeg", f, output) - builder2.close() - - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("A.jpg", json_data) - output.close() - - def test_add_ingredient_from_archive_preserves_instance_id(self): - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "0.1.0"}], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [{ - "action": "c2pa.created", - "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", - }] - } - } - ], - } - archive_builder = Builder.from_json(manifest) - ingredient_json = { - "title": "photo.jpg", - "relationship": "parentOf", - "instance_id": "my-ingredient", - } - with open(self.testPath, "rb") as f: - archive_builder.add_ingredient(ingredient_json, "image/jpeg", f) - archive = io.BytesIO() - archive_builder.write_ingredient_archive("my-ingredient", archive) - archive_builder.close() - - archive.seek(0) - signing_builder = Builder.from_json(manifest) - signing_builder.add_ingredient_from_archive(archive) - archive.close() - - output = io.BytesIO() - with open(self.testPath, "rb") as f: - signing_builder.sign(self.signer, "image/jpeg", f, output) - signing_builder.close() - - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("photo.jpg", json_data) - output.close() - - def test_add_ingredient_from_archive_preserves_instance_id_component_of(self): - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [{ - "action": "c2pa.created", - "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", - }] - } - } - ], - } - archive_builder = Builder.from_json(manifest) - ingredient_json = { - "title": "photo.jpg", - "relationship": "componentOf", - "instance_id": "my-ingredient", - } - with open(self.testPath, "rb") as f: - archive_builder.add_ingredient(ingredient_json, "image/jpeg", f) - archive = io.BytesIO() - archive_builder.write_ingredient_archive("my-ingredient", archive) - archive_builder.close() - - archive.seek(0) - signing_builder = Builder.from_json(manifest) - signing_builder.add_ingredient_from_archive(archive) - archive.close() - - output = io.BytesIO() - with open(self.testPath, "rb") as f: - signing_builder.sign(self.signer, "image/jpeg", f, output) - signing_builder.close() - - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("photo.jpg", json_data) - self.assertIn("componentOf", json_data) - output.close() - - def test_add_ingredient_from_archive_preserves_instance_id_input_to(self): - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [{ - "action": "c2pa.created", - "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", - }] - } - } - ], - } - archive_builder = Builder.from_json(manifest) - ingredient_json = { - "title": "photo.jpg", - "relationship": "inputTo", - "instance_id": "my-ingredient", - } - with open(self.testPath, "rb") as f: - archive_builder.add_ingredient(ingredient_json, "image/jpeg", f) - archive = io.BytesIO() - archive_builder.write_ingredient_archive("my-ingredient", archive) - archive_builder.close() - - archive.seek(0) - signing_builder = Builder.from_json(manifest) - signing_builder.add_ingredient_from_archive(archive) - archive.close() - - output = io.BytesIO() - with open(self.testPath, "rb") as f: - signing_builder.sign(self.signer, "image/jpeg", f, output) - signing_builder.close() - - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("photo.jpg", json_data) - self.assertIn("inputTo", json_data) - output.close() - - def test_add_ingredient_from_archive_roundtrip_parent_of(self): - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [{ - "action": "c2pa.created", - "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", - }] - } - } - ], - } - builder = Builder.from_json(manifest) - ingredient_json = { - "title": "A.jpg", - "relationship": "parentOf", - "instance_id": "catalog:ingredient-A", - } - with open(self.testPath, "rb") as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - archive = io.BytesIO() - builder.write_ingredient_archive("catalog:ingredient-A", archive) - builder.close() - - archive.seek(0) - builder2 = Builder.from_json(manifest) - builder2.add_ingredient_from_archive(archive) - archive.close() - - output = io.BytesIO() - with open(self.testPath, "rb") as f: - builder2.sign(self.signer, "image/jpeg", f, output) - builder2.close() - - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("A.jpg", json_data) - self.assertIn("parentOf", json_data) - output.close() - - def test_add_ingredient_from_archive_roundtrip_input_to(self): - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [{ - "action": "c2pa.created", - "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", - }] - } - } - ], - } - builder = Builder.from_json(manifest) - ingredient_json = { - "title": "A.jpg", - "relationship": "inputTo", - "instance_id": "catalog:ingredient-A", - } - with open(self.testPath, "rb") as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - archive = io.BytesIO() - builder.write_ingredient_archive("catalog:ingredient-A", archive) - builder.close() - - archive.seek(0) - builder2 = Builder.from_json(manifest) - builder2.add_ingredient_from_archive(archive) - archive.close() - - output = io.BytesIO() - with open(self.testPath, "rb") as f: - builder2.sign(self.signer, "image/jpeg", f, output) - builder2.close() - - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("A.jpg", json_data) - self.assertIn("inputTo", json_data) - output.close() - - def test_ingredient_from_archive_linked_to_opened_action(self): - ingredient_id = "xmp:iid:archive-parent-001" - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions.v2", - "data": { - "actions": [ - { - "action": "c2pa.opened", - "parameters": {"ingredientIds": [ingredient_id]}, - } - ] - }, - } - ], - } - archive_builder = Builder.from_json(manifest) - with open(self.testPath, "rb") as f: - archive_builder.add_ingredient( - {"title": "photo.jpg", "relationship": "parentOf", "instance_id": ingredient_id}, - "image/jpeg", f) - archive = io.BytesIO() - archive_builder.write_ingredient_archive(ingredient_id, archive) - archive_builder.close() - - archive.seek(0) - signing_builder = Builder.from_json(manifest) - signing_builder.add_ingredient_from_archive(archive) - archive.close() - - output = io.BytesIO() - with open(self.testPath, "rb") as f: - signing_builder.sign(self.signer, "image/jpeg", f, output) - signing_builder.close() - - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("c2pa.opened", json_data) - self.assertIn(ingredient_id, json_data) - output.close() - - def test_ingredient_from_archive_linked_to_placed_action(self): - ingredient_id = "xmp:iid:archive-component-001" - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions.v2", - "data": { - "actions": [ - { - "action": "c2pa.placed", - "parameters": {"ingredientIds": [ingredient_id]}, - } - ] - }, - } - ], - } - archive_builder = Builder.from_json(manifest) - with open(self.testPath, "rb") as f: - archive_builder.add_ingredient( - {"title": "photo.jpg", "relationship": "componentOf", "instance_id": ingredient_id}, - "image/jpeg", f) - archive = io.BytesIO() - archive_builder.write_ingredient_archive(ingredient_id, archive) - archive_builder.close() - - archive.seek(0) - signing_builder = Builder.from_json(manifest) - signing_builder.add_ingredient_from_archive(archive) - archive.close() - - output = io.BytesIO() - with open(self.testPath, "rb") as f: - signing_builder.sign(self.signer, "image/jpeg", f, output) - signing_builder.close() - - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("c2pa.placed", json_data) - self.assertIn(ingredient_id, json_data) - output.close() - - def test_ingredient_from_archive_linked_to_edited_action(self): - ingredient_id = "xmp:iid:archive-input-001" - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions.v2", - "data": { - "actions": [ - { - "action": "c2pa.edited", - "parameters": {"ingredientIds": [ingredient_id]}, - } - ] - }, - } - ], - } - archive_builder = Builder.from_json(manifest) - with open(self.testPath, "rb") as f: - archive_builder.add_ingredient( - {"title": "photo.jpg", "relationship": "inputTo", "instance_id": ingredient_id}, - "image/jpeg", f) - archive = io.BytesIO() - archive_builder.write_ingredient_archive(ingredient_id, archive) - archive_builder.close() - - archive.seek(0) - signing_builder = Builder.from_json(manifest) - signing_builder.add_ingredient_from_archive(archive) - archive.close() - - output = io.BytesIO() - with open(self.testPath, "rb") as f: - signing_builder.sign(self.signer, "image/jpeg", f, output) - signing_builder.close() - - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("c2pa.edited", json_data) - self.assertIn(ingredient_id, json_data) - output.close() - - def test_add_two_ingredient_archives_to_one_builder(self): - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [{ - "action": "c2pa.created", - "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", - }] - } - } - ], - } - archives = [] - for title, instance_id in [("A.jpg", "ingredient-A"), ("B.jpg", "ingredient-B")]: - archive_builder = Builder.from_json(manifest) - ingredient_json = { - "title": title, - "relationship": "componentOf", - "instance_id": instance_id, - } - with open(self.testPath, "rb") as f: - archive_builder.add_ingredient(ingredient_json, "image/jpeg", f) - archive = io.BytesIO() - archive_builder.write_ingredient_archive(instance_id, archive) - archive_builder.close() - archive.seek(0) - archives.append(archive) - - signing_builder = Builder.from_json(manifest) - for archive in archives: - signing_builder.add_ingredient_from_archive(archive) - archive.close() - - output = io.BytesIO() - with open(self.testPath, "rb") as f: - signing_builder.sign(self.signer, "image/jpeg", f, output) - signing_builder.close() - - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("A.jpg", json_data) - self.assertIn("B.jpg", json_data) - output.close() - - def test_write_ingredient_archive_only_contains_requested_ingredient(self): - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [{ - "action": "c2pa.created", - "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", - }] - } - } - ], - } - archive_builder = Builder.from_json(manifest) - for title, instance_id in [("A.jpg", "ingredient-A"), ("B.jpg", "ingredient-B")]: - ingredient_json = { - "title": title, - "relationship": "componentOf", - "instance_id": instance_id, - } - with open(self.testPath, "rb") as f: - archive_builder.add_ingredient(ingredient_json, "image/jpeg", f) - archive = io.BytesIO() - archive_builder.write_ingredient_archive("ingredient-A", archive) - archive_builder.close() - - archive.seek(0) - signing_builder = Builder.from_json(manifest) - signing_builder.add_ingredient_from_archive(archive) - archive.close() - - output = io.BytesIO() - with open(self.testPath, "rb") as f: - signing_builder.sign(self.signer, "image/jpeg", f, output) - signing_builder.close() - - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("A.jpg", json_data) - self.assertNotIn("B.jpg", json_data) - output.close() - - def test_write_ingredient_archive_matches_label_or_instance_id(self): - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [], - } - ingredient_json = { - "title": "photo.jpg", - "relationship": "componentOf", - "label": "my-label", - "instance_id": "my-instance-id", - } - builder = Builder.from_json(manifest) - with open(self.testPath, "rb") as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - by_label = io.BytesIO() - builder.write_ingredient_archive("my-label", by_label) - self.assertGreater(len(by_label.getvalue()), 0) - by_label.close() - - by_instance_id = io.BytesIO() - builder.write_ingredient_archive("my-instance-id", by_instance_id) - self.assertGreater(len(by_instance_id.getvalue()), 0) - by_instance_id.close() - - builder.close() - - def test_write_ingredient_archive_unknown_id_raises(self): - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [], - } - builder = Builder.from_json(manifest) - ingredient_json = { - "title": "photo.jpg", - "relationship": "componentOf", - "instance_id": "ingredient-A", - } - with open(self.testPath, "rb") as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - with self.assertRaises(Error): - builder.write_ingredient_archive("nonexistent-id", io.BytesIO()) - builder.close() - - def test_add_ingredient_from_archive_invalid_data_raises(self): - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [], - } - builder = Builder.from_json(manifest) - with self.assertRaises(Error): - builder.add_ingredient_from_archive(io.BytesIO(b"not a c2pa archive")) - with self.assertRaises(Error): - builder.add_ingredient_from_archive(io.BytesIO()) - builder.close() - - def test_ingredient_archive_methods_raise_on_closed_builder(self): - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [], - } - builder = Builder.from_json(manifest) - builder.close() - with self.assertRaises(Error): - builder.write_ingredient_archive("ingredient-A", io.BytesIO()) - with self.assertRaises(Error): - builder.add_ingredient_from_archive(io.BytesIO()) - - def test_remote_sign(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - builder.set_no_embed() - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - - output.seek(0) - # When set_no_embed() is used, no manifest should be embedded in the file - # So reading from the file should fail - with self.assertRaises(Error): - Reader("image/jpeg", output) - output.close() - - def test_remote_sign_using_returned_bytes(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - builder.set_no_embed() - with io.BytesIO() as output_buffer: - manifest_data = builder.sign( - self.signer, "image/jpeg", file, output_buffer) - output_buffer.seek(0) - read_buffer = io.BytesIO(output_buffer.getvalue()) - - with Reader("image/jpeg", read_buffer, manifest_data) as reader: - manifest_data = reader.json() - self.assertIn("Python Test", manifest_data) - - def _detached_manifest_and_asset(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - builder.set_no_embed() - with io.BytesIO() as output_buffer: - manifest = builder.sign( - self.signer, "image/jpeg", file, output_buffer) - return manifest, output_buffer.getvalue() - - def test_bare_stream_with_real_manifest_data_is_used(self): - manifest, asset = self._detached_manifest_and_asset() - with Reader(io.BytesIO(asset), manifest_data=manifest) as reader: - self.assertIn("Python Test", reader.json()) - - def test_manifest_data_keyword_with_real_bytes(self): - # manifest_data passed by keyword (not positionally) with real bytes. - manifest, asset = self._detached_manifest_and_asset() - with Reader( - "image/jpeg", io.BytesIO(asset), - manifest_data=manifest) as reader: - self.assertIn("Python Test", reader.json()) - - def test_try_create_with_manifest_data(self): - manifest, asset = self._detached_manifest_and_asset() - reader = Reader.try_create( - "image/jpeg", io.BytesIO(asset), manifest) - self.assertIsNotNone(reader) - if reader is not None: - self.assertIn("Python Test", reader.json()) - reader.close() - - def test_try_create_with_manifest_data_keyword(self): - # try_create with manifest_data passed by keyword. - manifest, asset = self._detached_manifest_and_asset() - reader = Reader.try_create( - "image/jpeg", io.BytesIO(asset), manifest_data=manifest) - self.assertIsNotNone(reader) - if reader is not None: - self.assertIn("Python Test", reader.json()) - reader.close() - - def test_bare_stream_with_context_and_manifest(self): - # Stream and real manifest and context together. - manifest, asset = self._detached_manifest_and_asset() - context = Context() - try: - with Reader(io.BytesIO(asset), manifest_data=manifest, - context=context) as reader: - self.assertIn("Python Test", reader.json()) - finally: - context.close() - - def test_try_create_format_plus_path_string(self): - with tempfile.TemporaryDirectory() as tmp: - asset = os.path.join(tmp, "signed.jpg") - with open(self.testPath, "rb") as file, open(asset, "wb") as out: - Builder(self.manifestDefinition).sign( - self.signer, "image/jpeg", file, out) - reader = Reader.try_create("image/jpeg", asset) - self.assertIsNotNone(reader) - if reader is not None: - self.assertIn("Python Test", reader.json()) - reader.close() - - def test_non_context_wrong_type_manifest_raises_typeerror(self): - with open(self.testPath, "rb") as file: - with self.assertRaises(TypeError): - Reader("image/jpeg", file, "not-bytes") - - def test_remote_sign_using_returned_bytes_V2(self): - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - builder.set_no_embed() - with io.BytesIO() as output_buffer: - manifest_data = builder.sign( - self.signer, "image/jpeg", file, output_buffer) - output_buffer.seek(0) - read_buffer = io.BytesIO(output_buffer.getvalue()) - - with Reader("image/jpeg", read_buffer, manifest_data) as reader: - manifest_data = reader.json() - self.assertIn("Python Test", manifest_data) - - def test_remote_sign_using_returned_bytes_V2_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings - result = {} - exception = {} - - def sign_and_validate_with_trust_config(): - try: - # Load trust configuration - settings_dict = load_test_settings_json() - - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - builder.set_no_embed() - with io.BytesIO() as output_buffer: - manifest_data = builder.sign( - self.signer, "image/jpeg", file, output_buffer) - output_buffer.seek(0) - read_buffer = io.BytesIO(output_buffer.getvalue()) - - with Reader("image/jpeg", read_buffer, manifest_data) as reader: - json_data = reader.json() - - # Get validation state with trust config - validation_state = reader.get_validation_state() - - result['json_data'] = json_data - result['validation_state'] = validation_state - except Exception as e: - exception['error'] = e - - # Create and start thread - thread = threading.Thread(target=sign_and_validate_with_trust_config) - thread.start() - thread.join() - - # Check for exceptions - if 'error' in exception: - raise exception['error'] - - # Assertions run in main thread - self.assertIn("Python Test", result.get('json_data', '')) - # With trust configuration loaded, validation should return "Trusted" - self.assertIsNotNone(result.get('validation_state')) - self.assertEqual(result.get('validation_state'), "Trusted") - - def test_sign_all_files(self): - """Test signing all files in both fixtures directories""" - signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") - - # Map of file extensions to MIME types - mime_types = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.heic': 'image/heic', - '.heif': 'image/heif', - '.avif': 'image/avif', - '.tif': 'image/tiff', - '.tiff': 'image/tiff', - '.mp4': 'video/mp4', - '.avi': 'video/x-msvideo', - '.mp3': 'audio/mpeg', - '.m4a': 'audio/mp4', - '.wav': 'audio/wav' - } - - # Skip files that are known to be invalid or unsupported - skip_files = { - 'sample3.invalid.wav', # Invalid file - } - - # Process both directories - for directory in [signing_dir, reading_dir]: - for filename in os.listdir(directory): - if filename in skip_files: - continue - - file_path = os.path.join(directory, filename) - if not os.path.isfile(file_path): - continue - - # Get file extension and corresponding MIME type - _, ext = os.path.splitext(filename) - ext = ext.lower() - if ext not in mime_types: - continue - - mime_type = mime_types[ext] - - try: - with open(file_path, "rb") as file: - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, mime_type, file, output) - builder.close() - output.seek(0) - reader = Reader(mime_type, output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - reader.close() - output.close() - except Error.NotSupported: - continue - except Exception as e: - self.fail(f"Failed to sign {filename}: {str(e)}") - - def test_sign_all_files_V2(self): - """Test signing all files in both fixtures directories""" - signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") - reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") - - # Map of file extensions to MIME types - mime_types = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.heic': 'image/heic', - '.heif': 'image/heif', - '.avif': 'image/avif', - '.tif': 'image/tiff', - '.tiff': 'image/tiff', - '.mp4': 'video/mp4', - '.avi': 'video/x-msvideo', - '.mp3': 'audio/mpeg', - '.m4a': 'audio/mp4', - '.wav': 'audio/wav' - } - - # Skip files that are known to be invalid or unsupported - skip_files = { - 'sample3.invalid.wav', # Invalid file - } - - # Process both directories - for directory in [signing_dir, reading_dir]: - for filename in os.listdir(directory): - if filename in skip_files: - continue - - file_path = os.path.join(directory, filename) - if not os.path.isfile(file_path): - continue - - # Get file extension and corresponding MIME type - _, ext = os.path.splitext(filename) - ext = ext.lower() - if ext not in mime_types: - continue - - mime_type = mime_types[ext] - - try: - with open(file_path, "rb") as file: - builder = Builder(self.manifestDefinitionV2) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, mime_type, file, output) - builder.close() - output.seek(0) - reader = Reader(mime_type, output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted` - self.assertIn("Valid", json_data) - reader.close() - output.close() - except Error.NotSupported: - continue - except Exception as e: - self.fail(f"Failed to sign {filename}: {str(e)}") - - def test_builder_no_added_ingredient_on_closed_builder(self): - builder = Builder(self.manifestDefinition) - - builder.close() - - with self.assertRaises(Error): - ingredient_json = '{"test": "ingredient"}' - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - def test_builder_add_ingredient(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test adding ingredient - ingredient_json = '{"test": "ingredient"}' - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - builder.close() - - def test_builder_add_ingredient_dict(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test adding ingredient with a dictionary instead of JSON string - ingredient_dict = {"test": "ingredient"} - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_dict, "image/jpeg", f) - - builder.close() - - def test_builder_add_multiple_ingredients(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test builder operations - builder.set_no_embed() - builder.set_remote_url("http://test.url") - - # Test adding ingredient - ingredient_json = '{"test": "ingredient"}' - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - # Test adding another ingredient - ingredient_json = '{"test": "ingredient2"}' - with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - builder.close() - - def test_builder_add_multiple_ingredients_2(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test builder operations - builder.set_no_embed() - builder.set_remote_url("http://test.url") - - # Test adding ingredient with a dictionary - ingredient_dict = {"test": "ingredient"} - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_dict, "image/jpeg", f) - - # Test adding another ingredient with a JSON string - ingredient_json = '{"test": "ingredient2"}' - with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - builder.close() - - def test_builder_add_multiple_ingredients_and_resources(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test builder operations - builder.set_no_embed() - builder.set_remote_url("http://test.url") - - # Test adding resource - with open(self.testPath, 'rb') as f: - builder.add_resource("test_uri_1", f) - - with open(self.testPath, 'rb') as f: - builder.add_resource("test_uri_2", f) - - with open(self.testPath, 'rb') as f: - builder.add_resource("test_uri_3", f) - - # Test adding ingredients - ingredient_json = '{"test": "ingredient"}' - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - ingredient_json = '{"test": "ingredient2"}' - with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - builder.close() - - def test_sign_with_any_format_spelling_keeps_thumbnail(self): - # The format reaches thumbnail generation, - # which matches a MIME type case sensitively, - # so an unnormalized spelling silently produces no thumbnail. - for fmt in ("image/jpeg", "IMAGE/JPEG", "Image/Jpeg", "jpg", "JPG"): - with self.subTest(format=fmt): - builder = Builder(self.manifestDefinition) - dest = io.BytesIO() - with open(self.testPath, "rb") as source: - builder.sign(self.signer, fmt, source, dest) - - dest.seek(0) - reader = Reader("image/jpeg", dest) - manifest_store = json.loads(reader.json()) - active = manifest_store["manifests"][ - manifest_store["active_manifest"]] - - self.assertIn( - "thumbnail", active, - f"signing with {fmt} dropped the claim thumbnail") - thumbnail = active["thumbnail"] - self.assertEqual(thumbnail["format"], "image/jpeg") - - # The reference must resolve to a real JPEG, not just be present. - thumbnail_bytes = io.BytesIO() - reader.resource_to_stream( - thumbnail["identifier"], thumbnail_bytes) - data = thumbnail_bytes.getvalue() - self.assertGreater( - len(data), 1000, - f"signing with {fmt} produced an empty claim thumbnail") - self.assertEqual( - data[:3], b"\xff\xd8\xff", - f"signing with {fmt} produced a thumbnail that is not a JPEG") - - def test_builder_add_multiple_ingredients_and_resources_interleaved(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - with open(self.testPath, 'rb') as f: - builder.add_resource("test_uri_1", f) - - ingredient_json = '{"test": "ingredient"}' - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - with open(self.testPath, 'rb') as f: - builder.add_resource("test_uri_2", f) - - with open(self.testPath, 'rb') as f: - builder.add_resource("test_uri_3", f) - - ingredient_json = '{"test": "ingredient2"}' - with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - builder.close() - - def test_builder_sign_with_ingredient(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test adding ingredient - ingredient_json = '{ "title": "Test Ingredient" }' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify thumbnail for manifest is here - self.assertIn("thumbnail", active_manifest) - thumbnail_data = active_manifest["thumbnail"] - self.assertIn("format", thumbnail_data) - self.assertIn("identifier", thumbnail_data) - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) - - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual(first_ingredient["title"], "Test Ingredient") - - builder.close() - - def test_builder_sign_with_ingredients_edit_intent(self): - """Test signing with EDIT intent and ingredient.""" - builder = Builder.from_json({}) - assert builder._handle is not None - - # Set the intent for editing existing content - builder.set_intent(C2paBuilderIntent.EDIT) - - # Test adding ingredient - ingredient_json = '{ "title": "Test Ingredient" }' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists with exactly 2 ingredients - self.assertIn("ingredients", active_manifest) - ingredients_manifest = active_manifest["ingredients"] - self.assertIsInstance(ingredients_manifest, list) - self.assertEqual(len(ingredients_manifest), 2, "Should have exactly two ingredients") - - # Verify the first ingredient is the one we added manually with componentOf relationship - first_ingredient = ingredients_manifest[0] - self.assertEqual(first_ingredient["title"], "Test Ingredient") - self.assertEqual(first_ingredient["format"], "image/jpeg") - self.assertIn("instance_id", first_ingredient) - self.assertIn("thumbnail", first_ingredient) - self.assertEqual(first_ingredient["thumbnail"]["format"], "image/jpeg") - self.assertIn("identifier", first_ingredient["thumbnail"]) - self.assertEqual(first_ingredient["relationship"], "componentOf") - self.assertIn("label", first_ingredient) - - # Verify the second ingredient is the auto-created parent with parentOf relationship - second_ingredient = ingredients_manifest[1] - # Parent ingredient may not have a title field, or may have an empty one - self.assertEqual(second_ingredient["format"], "image/jpeg") - self.assertIn("instance_id", second_ingredient) - self.assertIn("thumbnail", second_ingredient) - self.assertEqual(second_ingredient["thumbnail"]["format"], "image/jpeg") - self.assertIn("identifier", second_ingredient["thumbnail"]) - self.assertEqual(second_ingredient["relationship"], "parentOf") - self.assertIn("label", second_ingredient) - - # Count ingredients with parentOf relationship - should be exactly one - parent_ingredients = [ - ing for ing in ingredients_manifest - if ing.get("relationship") == "parentOf" - ] - self.assertEqual(len(parent_ingredients), 1, "Should have exactly one parentOf ingredient") - - # Check that assertions exist - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the actions assertion - actions_assertion = None - for assertion in assertions: - if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion, "Should have c2pa.actions assertion") - - # Verify exactly one c2pa.opened action exists for EDIT intent - actions = actions_assertion["data"]["actions"] - opened_actions = [ - action for action in actions - if action["action"] == "c2pa.opened" - ] - self.assertEqual(len(opened_actions), 1, "Should have exactly one c2pa.opened action") - - # Verify the c2pa.opened action has the correct structure with parameters and ingredients - opened_action = opened_actions[0] - self.assertIn("parameters", opened_action, "c2pa.opened action should have parameters") - self.assertIn("ingredients", opened_action["parameters"], "parameters should have ingredients array") - ingredients_params = opened_action["parameters"]["ingredients"] - self.assertIsInstance(ingredients_params, list) - self.assertGreater(len(ingredients_params), 0, "Should have at least one ingredient reference") - - # Verify each ingredient reference has url and hash - for ingredient_ref in ingredients_params: - self.assertIn("url", ingredient_ref, "Ingredient reference should have url") - self.assertIn("hash", ingredient_ref, "Ingredient reference should have hash") - - builder.close() - - def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): - # The following removes the manifest's thumbnail - # Settings should be loaded before the builder is created - load_settings('{"builder": { "thumbnail": {"enabled": false}}}') - - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test adding ingredient - ingredient_json = '{ "title": "Test Ingredient" }' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # There should be no thumbnail anymore here - self.assertNotIn("thumbnail", active_manifest) - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) - - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual(first_ingredient["title"], "Test Ingredient") - self.assertNotIn("thumbnail", first_ingredient) - - builder.close() - - # Settings are thread-local, so we reset to the default "true" here - load_settings('{"builder": { "thumbnail": {"enabled": true}}}') - - def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): - # The following removes the manifest's thumbnail - using dict instead of string - load_settings({"builder": {"thumbnail": {"enabled": False}}}) - - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test adding ingredient - ingredient_json = '{ "title": "Test Ingredient" }' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # There should be no thumbnail anymore here - self.assertNotIn("thumbnail", active_manifest) - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) - - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual(first_ingredient["title"], "Test Ingredient") - self.assertNotIn("thumbnail", first_ingredient) - - builder.close() - - # Settings are thread-local, so we reset to the default "true" here - using dict instead of string - load_settings({"builder": {"thumbnail": {"enabled": True}}}) - - def test_builder_sign_with_duplicate_ingredient(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test adding ingredient - ingredient_json = '{"title": "Test Ingredient"}' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - builder.add_ingredient(ingredient_json, "image/jpeg", f) - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) - - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual(first_ingredient["title"], "Test Ingredient") - - # Verify subsequent labels are unique and have a double underscore with a monotonically inc. index - second_ingredient = active_manifest["ingredients"][1] - self.assertTrue(second_ingredient["label"].endswith("__1")) - - third_ingredient = active_manifest["ingredients"][2] - self.assertTrue(third_ingredient["label"].endswith("__2")) - - builder.close() - - def test_builder_sign_with_ingredient_from_stream(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test adding ingredient using stream - ingredient_json = '{"title": "Test Ingredient Stream"}' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient_from_stream( - ingredient_json, "image/jpeg", f) - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) - - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual( - first_ingredient["title"], - "Test Ingredient Stream") - - builder.close() - - def test_builder_sign_with_ingredient_dict_from_stream(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Test adding ingredient using stream with a dictionary - ingredient_dict = {"title": "Test Ingredient Stream"} - with open(self.testPath3, 'rb') as f: - builder.add_ingredient_from_stream( - ingredient_dict, "image/jpeg", f) - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) - - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual( - first_ingredient["title"], - "Test Ingredient Stream") - - builder.close() - - def test_builder_sign_with_multiple_ingredient(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Add first ingredient - ingredient_json1 = '{"title": "Test Ingredient 1"}' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json1, "image/jpeg", f) - - # Add second ingredient - ingredient_json2 = '{"title": "Test Ingredient 2"}' - cloud_path = ALTERNATIVE_INGREDIENT_TEST_FILE - with open(cloud_path, 'rb') as f: - builder.add_ingredient(ingredient_json2, "image/jpeg", f) - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertEqual(len(active_manifest["ingredients"]), 2) - - # Verify both ingredients exist in the array (order doesn't matter) - ingredient_titles = [ing["title"] - for ing in active_manifest["ingredients"]] - self.assertIn("Test Ingredient 1", ingredient_titles) - self.assertIn("Test Ingredient 2", ingredient_titles) - - builder.close() - - def test_builder_sign_with_multiple_ingredients_from_stream(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._handle is not None - - # Add first ingredient using stream - ingredient_json1 = '{"title": "Test Ingredient Stream 1"}' - with open(self.testPath3, 'rb') as f: - builder.add_ingredient_from_stream( - ingredient_json1, "image/jpeg", f) - - # Add second ingredient using stream - ingredient_json2 = '{"title": "Test Ingredient Stream 2"}' - cloud_path = ALTERNATIVE_INGREDIENT_TEST_FILE - with open(cloud_path, 'rb') as f: - builder.add_ingredient_from_stream( - ingredient_json2, "image/jpeg", f) - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists 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_set_remote_url(self): - """Test setting the remote url of a builder.""" - builder = Builder.from_json(self.manifestDefinition) - builder.set_remote_url("http://this_does_not_exist/foo.jpg") - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - d = output.read() - self.assertIn(b'provenance="http://this_does_not_exist/foo.jpg"', d) - - def test_builder_set_remote_url_no_embed(self): - """Test setting the remote url of a builder with no embed flag.""" - - # Settings need to be loaded before the builder is created - load_settings(r'{"verify": { "remote_manifest_fetch": false} }') - - builder = Builder.from_json(self.manifestDefinition) - builder.set_no_embed() - builder.set_remote_url("http://this_does_not_exist/foo.jpg") - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - with self.assertRaises(Error) as e: - Reader("image/jpeg", output) - - self.assertIn("http://this_does_not_exist/foo.jpg", e.exception.message) - - # Return back to default settings - load_settings(r'{"verify": { "remote_manifest_fetch": true} }') - - def test_sign_single(self): - """Test signing a file using the sign_file method.""" - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) - - with open(self.testPath, "rb") as file: - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - - # Read the signed file and verify the manifest - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - output.close() - - def test_sign_mp4_video_file_single(self): - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) - - with open(os.path.join(FIXTURES_DIR, "video1.mp4"), "rb") as file: - builder.sign(self.signer, "video/mp4", file, output) - output.seek(0) - - # Read the signed file and verify the manifest - reader = Reader("video/mp4", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - output.close() - - def test_sign_mov_video_file_single(self): - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) - - with open(os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), "rb") as file: - builder.sign(self.signer, "mov", file, output) - output.seek(0) - - # Read the signed file and verify the manifest - reader = Reader("mov", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - output.close() - - def test_encode_format_builder_blank_raises(self): - with self.assertRaises(Error.NotSupported): - _encode_format("", "Builder", allow_autodetect=False) - - def test_sign_empty_format_raises(self): - builder = Builder(self.manifestDefinition) - with open(self.testPath, "rb") as file: - output = io.BytesIO() - with self.assertRaises(Error.NotSupported): - builder.sign(self.signer, "", file, output) - - def test_sign_file_extensionless_source_raises(self): - builder = Builder(self.manifestDefinition) - with tempfile.TemporaryDirectory() as tmp: - no_ext = os.path.join(tmp, "source") - shutil.copyfile(self.testPath, no_ext) - dest = os.path.join(tmp, "out.jpg") - with self.assertRaises(Error.NotSupported): - builder.sign_file(no_ext, dest, self.signer) - - def test_sign_file_unrecognized_extension_raises(self): - builder = Builder(self.manifestDefinition) - with tempfile.TemporaryDirectory() as tmp: - weird = os.path.join(tmp, "source.unknownextension") - shutil.copyfile(self.testPath, weird) - dest = os.path.join(tmp, "out.jpg") - with self.assertRaises(Error.NotSupported): - builder.sign_file(weird, dest, self.signer) - - def test_sign_file_exceptions_preservation(self): - builder = Builder(self.manifestDefinition) - with tempfile.TemporaryDirectory() as tmp: - src = os.path.join(tmp, "source.txt") - shutil.copyfile(self.testPath, src) - dest = os.path.join(tmp, "out.txt") - with self.assertRaises(Error.NotSupported): - builder.sign_file(src, dest, self.signer) - - def test_sign_file_video(self): - temp_dir = tempfile.mkdtemp() - try: - # Create a temporary output file path - output_path = os.path.join(temp_dir, "signed_output.mp4") - - # Use the sign_file method - builder = Builder(self.manifestDefinition) - builder.sign_file( - os.path.join(FIXTURES_DIR, "video1.mp4"), - output_path, - self.signer - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) - - # Read the signed file and verify the manifest - with open(output_path, "rb") as file: - reader = Reader("video/mp4", file) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - - finally: - # Clean up the temporary directory - shutil.rmtree(temp_dir) - - def test_sign_file_format_manifest_bytes_embeddable(self): - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) - - with open(self.testPath, "rb") as file: - manifest_bytes = builder.sign(self.signer, "image/jpeg", file, output) - res = format_embeddable("image/jpeg", manifest_bytes) - output.seek(0) - output.close() - - def test_builder_sign_file_callback_signer_from_callback(self): - """Test signing a file using the sign_file method with Signer.from_callback.""" - - temp_dir = tempfile.mkdtemp() - try: - - output_path = os.path.join(temp_dir, "signed_output_from_callback.jpg") - - # Will use the sign_file method - builder = Builder(self.manifestDefinition) - - # Create signer with callback using Signer.from_callback - signer = Signer.from_callback( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) - - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) - - # Verify results - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) - - # Read the signed file and verify the manifest - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - - # Parse the JSON and verify the signature algorithm - manifest_data = json.loads(json_data) - active_manifest_id = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify the signature_info contains the correct algorithm - self.assertIn("signature_info", active_manifest) - signature_info = active_manifest["signature_info"] - self.assertEqual(signature_info["alg"], self.callback_signer_alg) - - finally: - shutil.rmtree(temp_dir) - - def test_builder_sign_file_callback_signer_from_callback_V2(self): - """Test signing a file using the sign_file method with Signer.from_callback.""" - - temp_dir = tempfile.mkdtemp() - try: - - output_path = os.path.join(temp_dir, "signed_output_from_callback.jpg") - - # Will use the sign_file method - builder = Builder(self.manifestDefinitionV2) - - # Create signer with callback using Signer.from_callback - signer = Signer.from_callback( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) - - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) - - # Verify results - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) - - # Read the signed file and verify the manifest - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - - # Parse the JSON and verify the signature algorithm - manifest_data = json.loads(json_data) - active_manifest_id = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify the signature_info contains the correct algorithm - self.assertIn("signature_info", active_manifest) - signature_info = active_manifest["signature_info"] - self.assertEqual(signature_info["alg"], self.callback_signer_alg) - - finally: - shutil.rmtree(temp_dir) - - def test_builder_sign_with_native_ed25519_callback(self): - # Load Ed25519 private key (PEM) - ed25519_pem = os.path.join(FIXTURES_DIR, "ed25519.pem") - with open(ed25519_pem, "r") as f: - private_key_pem = f.read() - - # Callback here uses native function - def ed25519_callback(data: bytes) -> bytes: - return ed25519_sign(data, private_key_pem) - - # Load the certificate (PUB) - ed25519_pub = os.path.join(FIXTURES_DIR, "ed25519.pub") - with open(ed25519_pub, "r") as f: - certs_pem = f.read() - - # Create a Signer - # signer = create_signer( - # callback=ed25519_callback, - # alg=SigningAlg.ED25519, - # certs=certs_pem, - # tsa_url=None - # ) - signer = Signer.from_callback( - callback=ed25519_callback, - alg=SigningAlg.ED25519, - certs=certs_pem, - tsa_url=None - ) - - with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) - output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", file, output) - output.seek(0) - builder.close() - reader = Reader("image/jpeg", output) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - reader.close() - output.close() - - def test_signing_manifest_v2(self): - """Test signing and reading a V2 manifest. - V2 manifests have a slightly different structure. - """ - with open(self.testPath, "rb") as file: - # Create a builder with the V2 manifest definition using context manager - with Builder(self.manifestDefinitionV2) as builder: - output = io.BytesIO(bytearray()) - - # Sign as usual... - builder.sign(self.signer, "image/jpeg", file, output) - - output.seek(0) - - # Read the signed file and verify the manifest using context manager - with Reader("image/jpeg", output) as reader: - json_data = reader.json() - - # Basic verification of the manifest - self.assertIn("Python Test Image V2", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - - output.close() - - def test_builder_does_not_sign_unsupported_format(self): - with open(self.testPath, "rb") as file: - with Builder(self.manifestDefinitionV2) as builder: - output = io.BytesIO(bytearray()) - with self.assertRaises(Error.NotSupported): - builder.sign(self.signer, "mimetype/not-supported", file, output) - - def test_sign_file_mp4_video(self): - temp_dir = tempfile.mkdtemp() - try: - # Create a temporary output file path - output_path = os.path.join(temp_dir, "signed_output.mp4") - - # Use the sign_file method - builder = Builder(self.manifestDefinition) - builder.sign_file( - os.path.join(FIXTURES_DIR, "video1.mp4"), - output_path, - self.signer - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) - - # Read the signed file and verify the manifest - with open(output_path, "rb") as file: - reader = Reader("video/mp4", file) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - - finally: - # Clean up the temporary directory - shutil.rmtree(temp_dir) - - def test_sign_file_mov_video(self): - temp_dir = tempfile.mkdtemp() - try: - # Create a temporary output file path - output_path = os.path.join(temp_dir, "signed-C-recorded-as-mov.mov") - - # Use the sign_file method - builder = Builder(self.manifestDefinition) - manifest_bytes = builder.sign_file( - os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), - output_path, - self.signer - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) - - # Read the signed file and verify the manifest - with open(output_path, "rb") as file: - reader = Reader("mov", file) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - - # Verify also signed file using manifest bytes - with Reader("mov", output_path, manifest_bytes) as reader: - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - - finally: - # Clean up the temporary directory - shutil.rmtree(temp_dir) - - def test_sign_file_mov_video_V2(self): - temp_dir = tempfile.mkdtemp() - try: - # Create a temporary output file path - output_path = os.path.join(temp_dir, "signed-C-recorded-as-mov.mov") - - # Use the sign_file method - builder = Builder(self.manifestDefinitionV2) - manifest_bytes = builder.sign_file( - os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), - output_path, - self.signer - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) - - # Read the signed file and verify the manifest - with open(output_path, "rb") as file: - reader = Reader("mov", file) - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - - # Verify also signed file using manifest bytes - with Reader("mov", output_path, manifest_bytes) as reader: - json_data = reader.json() - self.assertIn("Python Test", json_data) - # Needs trust configuration to be set up to validate as Trusted, - # or validation_status on read reports `signing certificate untrusted`. - self.assertIn("Valid", json_data) - - finally: - # Clean up the temporary directory - shutil.rmtree(temp_dir) - - def test_builder_with_invalid_signer_none(self): - """Test Builder methods with None signer.""" - builder = Builder(self.manifestDefinition) - - with open(self.testPath, "rb") as file: - with self.assertRaises(Error): - builder.sign(None, "image/jpeg", file) - - def test_builder_with_invalid_signer_closed(self): - """Test Builder methods with closed signer.""" - builder = Builder(self.manifestDefinition) - - # Create and close a signer - closed_signer = Signer.from_info(self.signer_info) - closed_signer.close() - - with open(self.testPath, "rb") as file: - with self.assertRaises(Error): - builder.sign(closed_signer, "image/jpeg", file) - - def test_builder_with_invalid_signer_object(self): - """Test Builder methods with invalid signer object.""" - builder = Builder(self.manifestDefinition) - - # Use a mock object that looks like a signer but isn't - class MockSigner: - def __init__(self): - self._handle = None - - mock_signer = MockSigner() - - with open(self.testPath, "rb") as file: - with self.assertRaises(Error): - builder.sign(mock_signer, "image/jpeg", file) - - def test_builder_manifest_with_unicode_characters(self): - """Test Builder with manifest containing various Unicode characters.""" - unicode_manifest = { - "claim_generator": "python_test_unicode_テスト", - "claim_generator_info": [{ - "name": "python_test_unicode_テスト", - "version": "0.0.1", - }], - "claim_version": 1, - "format": "image/jpeg", - "title": "Python Test Image with Unicode: テスト test", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "description": "Unicode: test", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } - - builder = Builder(unicode_manifest) - - with open(self.testPath, "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() - - # Verify Unicode characters are preserved in title and description - self.assertIn("テスト", json_data) - - def test_builder_ingredient_with_special_characters(self): - """Test Builder with ingredient containing special characters.""" - special_char_ingredient = { - "title": "Test Ingredient with Special Chars: テスト", - "format": "image/jpeg", - "description": "Special characters: !@#$%^&*()_+-=[]{}|;':\",./<>?`~" - } - - builder = Builder(self.manifestDefinition) - - # Add ingredient with special characters - ingredient_json = json.dumps(special_char_ingredient) - with open(self.testPath2, "rb") as ingredient_file: - builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) - - with open(self.testPath, "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 special characters are preserved in ingredients - self.assertIn("ingredients", manifest_data["manifests"][manifest_data["active_manifest"]]) - ingredients = manifest_data["manifests"][manifest_data["active_manifest"]]["ingredients"] - self.assertEqual(len(ingredients), 1) - - ingredient = ingredients[0] - self.assertIn("テスト", ingredient["title"]) - self.assertIn("!@#$%^&*()_+-=[]{}|;':\",./<>?`~", ingredient["description"]) - - def test_builder_resource_uri_with_unicode(self): - """Test Builder with resource URI containing Unicode characters.""" - builder = Builder(self.manifestDefinition) - - # Test with resource URI containing Unicode characters - unicode_uri = "thumbnail_テスト.jpg" - with open(self.testPath3, "rb") as thumbnail_file: - builder.add_resource(unicode_uri, thumbnail_file) - - with open(self.testPath, "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() - - # Verify the resource was added (exact verification depends on implementation) - self.assertIsNotNone(json_data) - - def test_builder_initialization_failure_states(self): - """Test Builder state after initialization failures.""" - # Test with invalid JSON - with self.assertRaises(Error): - builder = Builder("{invalid json}") - - # Test with None manifest - with self.assertRaises(Error): - builder = Builder(None) - - # Test with circular reference in JSON - circular_obj = {} - circular_obj['self'] = circular_obj - with self.assertRaises(Exception) as context: - builder = Builder(circular_obj) - - def test_construction_failure_before_native_call_is_collectable(self): - """Builder(None) fails encoding the manifest before any FFI call. - A half-built instance is UNINITIALIZED with no native handle. - """ - captured = [] - real_init_attrs = Builder._init_attrs - - def recording_init_attrs(self): - real_init_attrs(self) - captured.append(self) - - Builder._init_attrs = recording_init_attrs - try: - with self.assertRaises(Error): - Builder(None) - finally: - Builder._init_attrs = real_init_attrs - - self.assertEqual(len(captured), 1, - "_init_attrs did not run during the failed construction") - instance = captured[0] - self.assertEqual(instance._lifecycle_state, LifecycleState.UNINITIALIZED) - self.assertIsNone(instance._handle) - - instance._release() - - def test_builder_state_transitions(self): - """Test Builder state transitions during lifecycle.""" - builder = Builder(self.manifestDefinition) - - # Initial state - self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) - self.assertIsNotNone(builder._handle) - - # After close - builder.close() - 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.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) - self.assertIsNotNone(builder._handle) - - # Placeholder operation - builder.set_no_embed() - - # After context exit - should be closed - 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.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) - self.assertIsNotNone(builder._handle) - raise ValueError("Test exception") - except ValueError: - pass - - # After exception - should still be closed - 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 _state = ACTIVE - builder = Builder.__new__(Builder) - builder._lifecycle_state = LifecycleState.ACTIVE - builder._handle = None - - with self.assertRaises(Error): - builder._ensure_valid_state() - - def test_builder_cleanup_state_transitions(self): - """Test Builder state during cleanup operations.""" - builder = Builder(self.manifestDefinition) - - # Test _cleanup_resources method - builder._cleanup_resources() - self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) - self.assertIsNone(builder._handle) - - def test_builder_cleanup_idempotency(self): - """Test that cleanup operations are idempotent.""" - builder = Builder(self.manifestDefinition) - - # First cleanup - builder._cleanup_resources() - self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) - - # Second cleanup should not change state - builder._cleanup_resources() - 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.""" - builder = Builder(self.manifestDefinition) - - with open(self.testPath, "rb") as file: - manifest_bytes = 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.""" - builder = Builder(self.manifestDefinition) - - # Test to_archive - with io.BytesIO() as archive_stream: - builder.to_archive(archive_stream) - - # State should still be valid - 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.""" - builder = Builder(self.manifestDefinition) - - # First close - builder.close() - self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) - self.assertIsNone(builder._handle) - - # Second close should not change state - builder.close() - 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._handle = 0 - - # Operations should fail gracefully - with self.assertRaises(Error): - builder.set_no_embed() - - def test_builder_add_action_to_manifest_no_auto_add(self): - # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - initial_manifest_definition = { - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - # claim version 2 is the default - # "claim_version": 2, - "format": "image/jpeg", - "title": "Python Test Image V2", - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } - builder = Builder.from_json(initial_manifest_definition) - - action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' - builder.add_action(action_json) - - 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 assertions object exists in active manifest - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the c2pa.actions.v2 assertion to check what we added - actions_assertion = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - self.assertIn("data", actions_assertion) - assertion_data = actions_assertion["data"] - # Verify the manifest now contains actions - self.assertIn("actions", assertion_data) - actions = assertion_data["actions"] - # Verify "c2pa.color_adjustments" action exists anywhere in the actions array - created_action_found = False - for action in actions: - if action.get("action") == "c2pa.color_adjustments": - created_action_found = True - break - - self.assertTrue(created_action_found) - - builder.close() - - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - - def test_builder_add_action_to_manifest_from_dict_no_auto_add(self): - # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - initial_manifest_definition = { - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - # claim version 2 is the default - # "claim_version": 2, - "format": "image/jpeg", - "title": "Python Test Image V2", - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } - builder = Builder.from_json(initial_manifest_definition) - - # Using a dictionary instead of a JSON string - action_dict = {"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}} - builder.add_action(action_dict) - - 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 assertions object exists in active manifest - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the c2pa.actions.v2 assertion to check what we added - actions_assertion = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - self.assertIn("data", actions_assertion) - assertion_data = actions_assertion["data"] - # Verify the manifest now contains actions - self.assertIn("actions", assertion_data) - actions = assertion_data["actions"] - # Verify "c2pa.color_adjustments" action exists anywhere in the actions array - created_action_found = False - for action in actions: - if action.get("action") == "c2pa.color_adjustments": - created_action_found = True - break - - self.assertTrue(created_action_found) - - builder.close() - - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - - def test_builder_add_action_to_manifest_with_auto_add(self): - # For testing, force settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - - initial_manifest_definition = { - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - # claim version 2 is the default - # "claim_version": 2, - "format": "image/jpeg", - "title": "Python Test Image V2", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } - builder = Builder.from_json(initial_manifest_definition) - - action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' - builder.add_action(action_json) - - 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 assertions object exists in active manifest - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the c2pa.actions.v2 assertion to check what we added - actions_assertion = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - self.assertIn("data", actions_assertion) - assertion_data = actions_assertion["data"] - # Verify the manifest now contains actions - self.assertIn("actions", assertion_data) - actions = assertion_data["actions"] - # Verify "c2pa.color_adjustments" action exists anywhere in the actions array - created_action_found = False - for action in actions: - if action.get("action") == "c2pa.color_adjustments": - created_action_found = True - break - - self.assertTrue(created_action_found) - - # Verify "c2pa.created" action exists only once in the actions array - created_count = 0 - for action in actions: - if action.get("action") == "c2pa.created": - created_count += 1 - - self.assertEqual(created_count, 1, "c2pa.created action should appear exactly once") - - builder.close() - - # Reset settings to default - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - - def test_builder_minimal_manifest_add_actions_and_sign_no_auto_add(self): - # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - initial_manifest_definition = { - "claim_generator": "python_test", - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - "format": "image/jpeg", - "title": "Python Test Image V2", - } - - builder = Builder.from_json(initial_manifest_definition) - builder.add_action('{ "action": "c2pa.created", "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}') - - 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 assertions object exists in active manifest - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the c2pa.actions.v2 assertion to look for what we added - actions_assertion = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - self.assertIn("data", actions_assertion) - assertion_data = actions_assertion["data"] - # Verify the manifest now contains actions - self.assertIn("actions", assertion_data) - actions = assertion_data["actions"] - # Verify "c2pa.created" action exists anywhere in the actions array - created_action_found = False - for action in actions: - if action.get("action") == "c2pa.created": - created_action_found = True - break - - self.assertTrue(created_action_found) - - builder.close() - - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - - def test_builder_minimal_manifest_add_actions_and_sign_with_auto_add(self): - # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - - initial_manifest_definition = { - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - "format": "image/jpeg", - "title": "Python Test Image V2", - } - - builder = Builder.from_json(initial_manifest_definition) - action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' - builder.add_action(action_json) - - 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 assertions object exists in active manifest - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the c2pa.actions.v2 assertion to look for what we added - actions_assertion = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - self.assertIn("data", actions_assertion) - assertion_data = actions_assertion["data"] - # Verify the manifest now contains actions - self.assertIn("actions", assertion_data) - actions = assertion_data["actions"] - # Verify "c2pa.created" action exists anywhere in the actions array - created_action_found = False - for action in actions: - if action.get("action") == "c2pa.created": - created_action_found = True - break - - self.assertTrue(created_action_found) - - # Verify "c2pa.color_adjustments" action also exists in the same actions array - color_adjustments_found = False - for action in actions: - if action.get("action") == "c2pa.color_adjustments": - color_adjustments_found = True - break - - self.assertTrue(color_adjustments_found) - - builder.close() - - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - - def test_builder_sign_dicts_no_auto_add(self): - # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - initial_manifest_definition = { - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - # claim version 2 is the default - # "claim_version": 2, - "format": "image/jpeg", - "title": "Python Test Image V2", - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] - } - builder = Builder.from_json(initial_manifest_definition) - - # Using a dictionary instead of a JSON string - action_dict = {"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}} - builder.add_action(action_dict) - - 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 assertions object exists in active manifest - self.assertIn("assertions", active_manifest) - assertions = active_manifest["assertions"] - - # Find the c2pa.actions.v2 assertion to check what we added - actions_assertion = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - self.assertIn("data", actions_assertion) - assertion_data = actions_assertion["data"] - # Verify the manifest now contains actions - self.assertIn("actions", assertion_data) - actions = assertion_data["actions"] - # Verify "c2pa.color_adjustments" action exists anywhere in the actions array - created_action_found = False - for action in actions: - if action.get("action") == "c2pa.color_adjustments": - created_action_found = True - break - - self.assertTrue(created_action_found) - - builder.close() - - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') - - def test_builder_opened_action_one_ingredient_no_auto_add(self): - """Test Builder with c2pa.opened action and one ingredient, following Adobe provenance patterns""" - # Disable auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - # Instance IDs for linking ingredients and actions - # This can be any unique id so the ingredient can be uniquely identified and linked to the action - parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c" - - manifestDefinition = { - "claim_generator_info": [{ - "name": "Python CAI test", - "version": "3.14.16" - }], - "title": "A title for the provenance test", - "ingredients": [ - # The parent ingredient will be added through add_ingredient - # And a properly crafted manifest json so they link - ], - "assertions": [ - { - "label": "c2pa.actions.v2", - "data": { - "actions": [ - { - "action": "c2pa.opened", - "softwareAgent": { - "name": "Opened asset", - }, - "parameters": { - "ingredientIds": [ - parent_ingredient_id - ] - }, - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" - } - ] - } - } - ] - } - - # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition - # Aka the unique parent_ingredient_id we rely on for linking - ingredient_json = { - "relationship": "parentOf", - "instance_id": parent_ingredient_id - } - # An opened ingredient is always a parent, and there can only be exactly one parent ingredient - - # Read the input file (A.jpg will be signed) - with open(self.testPath2, "rb") as test_file: - file_content = test_file.read() - - builder = Builder.from_json(manifestDefinition) - - # Add C.jpg as the parent "opened" ingredient - with open(self.testPath, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - output_buffer = io.BytesIO(bytearray()) - builder.sign( - self.signer, - "image/jpeg", - io.BytesIO(file_content), - output_buffer) - output_buffer.seek(0) - - # Read and verify the manifest - reader = Reader("image/jpeg", output_buffer) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify the ingredient instance ID is present - self.assertIn(parent_ingredient_id, json_data) - - # Verify c2pa.opened action is present - self.assertIn("c2pa.opened", json_data) - - builder.close() - - # Make sure settings are put back to the common test defaults - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - def test_builder_one_opened_one_placed_action_no_auto_add(self): - """Test Builder with c2pa.opened action where asset is its own parent ingredient""" - # Disable auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - # Instance IDs for linking ingredients and actions, - # need to be unique even if the same binary file is used, so ingredients link properly to actions - parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c" - placed_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-f3f800ebe42b" - - manifestDefinition = { - "claim_generator_info": [{ - "name": "Python CAI test", - "version": "0.2.942" - }], - "title": "A title for the provenance test", - "ingredients": [ - # The parent ingredient will be added through add_ingredient - { - # Represents the bubbled up AI asset/ingredient - "format": "jpeg", - "relationship": "componentOf", - # Instance ID must be generated to match what is in parameters ingredientIds array - "instance_id": placed_ingredient_id, - } - ], - "assertions": [ - { - "label": "c2pa.actions.v2", - "data": { - "actions": [ - { - "action": "c2pa.opened", - "softwareAgent": { - "name": "Opened asset", - }, - "parameters": { - "ingredientIds": [ - parent_ingredient_id - ] - }, - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" - }, - { - "action": "c2pa.placed", - "softwareAgent": { - "name": "Placed asset", - }, - "parameters": { - "ingredientIds": [ - placed_ingredient_id - ] - }, - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" - } - ] - } - } - ] - } - - # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition for c2pa.opened - # So that ingredients can link together. - ingredient_json = { - "relationship": "parentOf", - "when": "2025-08-07T18:01:55.934Z", - "instance_id": parent_ingredient_id - } - - # Read the input file (A.jpg will be signed) - with open(self.testPath2, "rb") as test_file: - file_content = test_file.read() - - builder = Builder.from_json(manifestDefinition) - - # An asset can be its own parent ingredient! - # We add A.jpg as its own parent ingredient - with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - output_buffer = io.BytesIO(bytearray()) - builder.sign( - self.signer, - "image/jpeg", - io.BytesIO(file_content), - output_buffer) - output_buffer.seek(0) - - # Read and verify the manifest - reader = Reader("image/jpeg", output_buffer) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify both ingredient instance IDs are present - self.assertIn(parent_ingredient_id, json_data) - self.assertIn(placed_ingredient_id, json_data) - - # Verify both actions are present - self.assertIn("c2pa.opened", json_data) - self.assertIn("c2pa.placed", json_data) - - builder.close() - - # Make sure settings are put back to the common test defaults - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - def test_builder_opened_action_multiple_ingredient_no_auto_add(self): - """Test Builder with c2pa.opened and c2pa.placed actions with multiple ingredients""" - # Disable auto-added actions, as what we are doing here can confuse auto-placements - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - # Instance IDs for linking ingredients and actions - # With multiple ingredients, we need multiple different unique ids so they each link properly - parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c" - placed_ingredient_1_id = "xmp:iid:a965983b-36fb-445a-aa80-f3f800ebe42b" - placed_ingredient_2_id = "xmp:iid:a965983b-36fb-445a-aa80-f2d712acd14c" - - manifestDefinition = { - "claim_generator_info": [{ - "name": "Python CAI test", - "version": "0.2.942" - }], - "title": "A title for the provenance test with multiple ingredients", - "ingredients": [ - # More ingredients will be added using add_ingredient - { - "format": "jpeg", - "relationship": "componentOf", - # Instance ID must be generated to match what is in parameters ingredientIds array - "instance_id": placed_ingredient_1_id, - } - ], - "assertions": [ - { - "label": "c2pa.actions.v2", - "data": { - "actions": [ - { - "action": "c2pa.opened", - "softwareAgent": { - "name": "A parent opened asset", - }, - "parameters": { - "ingredientIds": [ - parent_ingredient_id - ] - }, - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" - }, - { - "action": "c2pa.placed", - "softwareAgent": { - "name": "Component placed assets", - }, - "parameters": { - "ingredientIds": [ - placed_ingredient_1_id, - placed_ingredient_2_id - ] - }, - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" - } - ] - } - } - ] - } - - # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition, - # so that ingredients properly link with their action - ingredient_json_parent = { - "relationship": "parentOf", - "instance_id": parent_ingredient_id - } - - # The ingredient json for the placed action needs to match the instance_id in the manifestDefinition, - # so that ingredients properly link with their action - ingredient_json_placed = { - "relationship": "componentOf", - "instance_id": placed_ingredient_2_id - } - - # Read the input file (A.jpg will be signed) - with open(self.testPath2, "rb") as test_file: - file_content = test_file.read() - - builder = Builder.from_json(manifestDefinition) - - # Add C.jpg as the parent ingredient (for c2pa.opened, it's the opened asset) - with open(self.testPath, 'rb') as f1: - builder.add_ingredient(ingredient_json_parent, "image/jpeg", f1) - - # Add cloud.jpg as another placed ingredient (for instance, added on the opened asset) - with open(self.testPath4, 'rb') as f2: - builder.add_ingredient(ingredient_json_placed, "image/jpeg", f2) - - output_buffer = io.BytesIO(bytearray()) - builder.sign( - self.signer, - "image/jpeg", - io.BytesIO(file_content), - output_buffer) - output_buffer.seek(0) - - # Read and verify the manifest - reader = Reader("image/jpeg", output_buffer) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify all ingredient instance IDs are present - self.assertIn(parent_ingredient_id, json_data) - self.assertIn(placed_ingredient_1_id, json_data) - self.assertIn(placed_ingredient_2_id, json_data) - - # Verify both actions are present - self.assertIn("c2pa.opened", json_data) - self.assertIn("c2pa.placed", json_data) - - builder.close() - - # Make sure settings are put back to the common test defaults - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - def test_link_archive_label_on_signing_builder_placed(self): - """Label set on the signing builder's add_ingredient links an - ingredient archive to a c2pa.placed action.""" - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - archive = self._create_ingredient_archive() - - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions.v2", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", - }, - { - "action": "c2pa.placed", - "parameters": { - "ingredientIds": ["my-ingredient"] - }, - } - ] - }, - } - ], - } - - builder = Builder.from_json(manifest) - builder.add_ingredient( - {"title": "photo.jpg", "relationship": "componentOf", "label": "my-ingredient"}, - "application/c2pa", - archive, - ) - - with open(self.testPath, "rb") as src: - output = io.BytesIO() - builder.sign(self.signer, "image/jpeg", src, output) - output.seek(0) - - reader = Reader("image/jpeg", output) - manifest_data = json.loads(reader.json()) - active = manifest_data["active_manifest"] - assertions = manifest_data["manifests"][active]["assertions"] - - placed_action = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - for action in assertion["data"]["actions"]: - if action["action"] == "c2pa.placed": - placed_action = action - break - - self.assertIsNotNone(placed_action, "c2pa.placed action not found") - self.assertIn("parameters", placed_action) - self.assertIn("ingredients", placed_action["parameters"]) - self.assertEqual(len(placed_action["parameters"]["ingredients"]), 1) - self.assertIn( - "c2pa.ingredient.v3", - placed_action["parameters"]["ingredients"][0]["url"], - ) - - reader.close() - output.close() - archive.close() - builder.close() - - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - def test_link_archive_label_on_signing_builder_opened(self): - """Label set on the signing builder's add_ingredient links an - ingredient archive to a c2pa.opened action.""" - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - archive = self._create_ingredient_archive( - {"title": "photo.jpg", "relationship": "parentOf"} - ) - - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions.v2", - "data": { - "actions": [ - { - "action": "c2pa.opened", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", - "parameters": { - "ingredientIds": ["my-ingredient"] - }, - } - ] - }, - } - ], - } - - builder = Builder.from_json(manifest) - builder.add_ingredient( - {"title": "photo.jpg", "relationship": "parentOf", "label": "my-ingredient"}, - "application/c2pa", - archive, - ) - - with open(self.testPath, "rb") as src: - output = io.BytesIO() - builder.sign(self.signer, "image/jpeg", src, output) - output.seek(0) - - reader = Reader("image/jpeg", output) - manifest_data = json.loads(reader.json()) - active = manifest_data["active_manifest"] - assertions = manifest_data["manifests"][active]["assertions"] - - opened_action = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - for action in assertion["data"]["actions"]: - if action["action"] == "c2pa.opened": - opened_action = action - break - - self.assertIsNotNone(opened_action, "c2pa.opened action not found") - self.assertIn("parameters", opened_action) - self.assertIn("ingredients", opened_action["parameters"]) - self.assertEqual(len(opened_action["parameters"]["ingredients"]), 1) - self.assertIn( - "c2pa.ingredient.v3", - opened_action["parameters"]["ingredients"][0]["url"], - ) - - reader.close() - output.close() - archive.close() - builder.close() - - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - def test_link_archive_two_ingredients_labels(self): - """Two ingredient archives linked to two different actions via - distinct labels. Verifies no cross-linking.""" - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - archive1 = self._create_ingredient_archive( - {"title": "photo-placed.jpg", "relationship": "componentOf"} - ) - archive2 = self._create_ingredient_archive( - {"title": "photo-opened.jpg", "relationship": "parentOf"} - ) - - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions.v2", - "data": { - "actions": [ - { - "action": "c2pa.opened", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", - "parameters": { - "ingredientIds": ["ingredient-for-opened"] - }, - }, - { - "action": "c2pa.placed", - "parameters": { - "ingredientIds": ["ingredient-for-placed"] - }, - }, - ] - }, - } - ], - } - - builder = Builder.from_json(manifest) - builder.add_ingredient( - {"title": "photo-placed.jpg", "relationship": "componentOf", "label": "ingredient-for-placed"}, - "application/c2pa", - archive1, - ) - builder.add_ingredient( - {"title": "photo-opened.jpg", "relationship": "parentOf", "label": "ingredient-for-opened"}, - "application/c2pa", - archive2, - ) - - with open(self.testPath, "rb") as src: - output = io.BytesIO() - builder.sign(self.signer, "image/jpeg", src, output) - output.seek(0) - - reader = Reader("image/jpeg", output) - manifest_data = json.loads(reader.json()) - active = manifest_data["active_manifest"] - assertions = manifest_data["manifests"][active]["assertions"] - - placed_action = None - opened_action = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - for action in assertion["data"]["actions"]: - if action["action"] == "c2pa.placed": - placed_action = action - if action["action"] == "c2pa.opened": - opened_action = action - - self.assertIsNotNone(placed_action, "c2pa.placed action not found") - self.assertIsNotNone(opened_action, "c2pa.opened action not found") - - self.assertIn("ingredients", placed_action["parameters"]) - self.assertEqual(len(placed_action["parameters"]["ingredients"]), 1) - placed_url = placed_action["parameters"]["ingredients"][0]["url"] - - self.assertIn("ingredients", opened_action["parameters"]) - self.assertEqual(len(opened_action["parameters"]["ingredients"]), 1) - opened_url = opened_action["parameters"]["ingredients"][0]["url"] - - # Each action should link to a different ingredient (no cross-linking) - self.assertNotEqual(placed_url, opened_url, - "Each action should link to a different ingredient") - - reader.close() - output.close() - archive1.close() - archive2.close() - builder.close() - - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - def test_link_archive_multiple_ingredients_in_one_placed_action(self): - """A single c2pa.placed action references two componentOf ingredients - via ingredientIds with two labels.""" - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - archive1 = self._create_ingredient_archive( - {"title": "base-layer.jpg", "relationship": "componentOf"} - ) - archive2 = self._create_ingredient_archive( - {"title": "overlay-layer.jpg", "relationship": "componentOf"} - ) - - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions.v2", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", - }, - { - "action": "c2pa.placed", - "parameters": { - "ingredientIds": ["base-layer", "overlay-layer"] - }, - } - ] - }, - } - ], - } - - builder = Builder.from_json(manifest) - builder.add_ingredient( - {"title": "base-layer.jpg", "relationship": "componentOf", "label": "base-layer"}, - "application/c2pa", - archive1, - ) - builder.add_ingredient( - {"title": "overlay-layer.jpg", "relationship": "componentOf", "label": "overlay-layer"}, - "application/c2pa", - archive2, - ) - - with open(self.testPath, "rb") as src: - output = io.BytesIO() - builder.sign(self.signer, "image/jpeg", src, output) - output.seek(0) - - reader = Reader("image/jpeg", output) - manifest_data = json.loads(reader.json()) - active = manifest_data["active_manifest"] - assertions = manifest_data["manifests"][active]["assertions"] - - placed_action = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - for action in assertion["data"]["actions"]: - if action["action"] == "c2pa.placed": - placed_action = action - break - - self.assertIsNotNone(placed_action, "c2pa.placed action not found") - self.assertIn("parameters", placed_action) - self.assertIn("ingredients", placed_action["parameters"]) - ingredients = placed_action["parameters"]["ingredients"] - self.assertEqual(len(ingredients), 2, - "c2pa.placed should reference both ingredients") - - url0 = ingredients[0]["url"] - url1 = ingredients[1]["url"] - self.assertNotEqual(url0, url1, - "Each ingredient should have a distinct URL") - - reader.close() - output.close() - archive1.close() - archive2.close() - builder.close() - - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') - - def test_ingredient_fields_survive_archive(self): - archive = self._create_ingredient_archive({ - "title": "tracked-asset.jpg", - "relationship": "componentOf", - "instance_id": "tracking:project-7:asset-42", - "description": "A tracked ingredient", - "informational_URI": "https://example.com/assets/42", - }) - - reader = Reader("application/c2pa", archive) - manifest_data = json.loads(reader.json()) - active = manifest_data["active_manifest"] - ingredients = manifest_data["manifests"][active]["ingredients"] - - self.assertGreaterEqual(len(ingredients), 1) - ing = ingredients[0] - - self.assertEqual(ing["title"], "tracked-asset.jpg") - self.assertIn("instance_id", ing) - self.assertEqual(ing["instance_id"], "tracking:project-7:asset-42") - - reader.close() - archive.close() - - def test_ingredient_fields_survive_archive_then_sign(self): - """instance_id set on the archive ingredient persists through - archive then sign.""" - archive = self._create_ingredient_archive({ - "title": "tracked-asset.jpg", - "relationship": "componentOf", - "instance_id": "tracking:project-7:asset-42", - "description": "A tracked ingredient", - "informational_URI": "https://example.com/assets/42", - }) - - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", - } - ] - }, - } - ], - } - - builder = Builder.from_json(manifest) - builder.add_ingredient( - {"title": "tracked-asset.jpg", "relationship": "componentOf"}, - "application/c2pa", - archive, - ) - - with open(self.testPath, "rb") as src: - output = io.BytesIO() - builder.sign(self.signer, "image/jpeg", src, output) - output.seek(0) - - reader = Reader("image/jpeg", output) - manifest_data = json.loads(reader.json()) - active = manifest_data["active_manifest"] - ingredients = manifest_data["manifests"][active]["ingredients"] - - self.assertGreaterEqual(len(ingredients), 1) - ing = ingredients[0] - - self.assertIn("instance_id", ing) - self.assertEqual(ing["instance_id"], "tracking:project-7:asset-42") - - reader.close() - output.close() - archive.close() - builder.close() - - def test_instance_id_as_ingredient_identifier_in_catalog(self): - """Two ingredients with different instance_id values in one archive. - Read the archive back and select an ingredient by instance_id.""" - manifest = { - "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", - } - ] - }, - } - ], - } - - builder = Builder.from_json(manifest) - with open(self.testPath, "rb") as f: - builder.add_ingredient( - {"title": "photo-A.jpg", "relationship": "componentOf", - "instance_id": "catalog:photo-A"}, - "image/jpeg", f, - ) - with open(self.testPath, "rb") as f: - builder.add_ingredient( - {"title": "photo-B.jpg", "relationship": "componentOf", - "instance_id": "catalog:photo-B"}, - "image/jpeg", f, - ) - - archive = io.BytesIO() - builder.to_archive(archive) - archive.seek(0) - builder.close() - - reader = Reader("application/c2pa", archive) - manifest_data = json.loads(reader.json()) - active = manifest_data["active_manifest"] - ingredients = manifest_data["manifests"][active]["ingredients"] - - self.assertEqual(len(ingredients), 2) - - found = None - for ing in ingredients: - if ing.get("instance_id") == "catalog:photo-B": - found = ing - break - - self.assertIsNotNone(found, - "Should find ingredient by instance_id 'catalog:photo-B' in archive") - self.assertEqual(found["title"], "photo-B.jpg") - - reader.close() - archive.close() - - -class TestStream(unittest.TestCase): - def setUp(self): - self.temp_file = io.BytesIO() - self.test_data = b"Hello, World!" - self.temp_file.write(self.test_data) - self.temp_file.seek(0) - - def tearDown(self): - self.temp_file.close() - - def test_stream_initialization(self): - stream = Stream(self.temp_file) - self.assertTrue(stream.initialized) - self.assertFalse(stream.closed) - stream.close() - - def test_stream_initialization_with_invalid_object(self): - with self.assertRaises(TypeError): - Stream("not a file-like object") - - def test_stream_read(self): - stream = Stream(self.temp_file) - try: - # Create a buffer to read into - buffer = (ctypes.c_ubyte * 13)() - # Read the data - bytes_read = stream._read_cb(None, buffer, 13) - # Verify the data - self.assertEqual(bytes_read, 13) - self.assertEqual(bytes(buffer[:bytes_read]), self.test_data) - finally: - stream.close() - - def test_stream_write(self): - output = io.BytesIO() - stream = Stream(output) - try: - # Create test data - test_data = b"Test Write" - buffer = (ctypes.c_ubyte * len(test_data))(*test_data) - # Write the data - bytes_written = stream._write_cb(None, buffer, len(test_data)) - # Verify the data - self.assertEqual(bytes_written, len(test_data)) - output.seek(0) - self.assertEqual(output.read(), test_data) - finally: - stream.close() - - def test_stream_seek(self): - stream = Stream(self.temp_file) - try: - # Seek to position 7 (after "Hello, ") - new_pos = stream._seek_cb(None, 7, 0) # 0 = SEEK_SET - self.assertEqual(new_pos, 7) - # Read from new position - buffer = (ctypes.c_ubyte * 6)() - bytes_read = stream._read_cb(None, buffer, 6) - self.assertEqual(bytes(buffer[:bytes_read]), b"World!") - finally: - stream.close() - - def test_stream_flush(self): - output = io.BytesIO() - stream = Stream(output) - try: - # Write some data - test_data = b"Test Flush" - buffer = (ctypes.c_ubyte * len(test_data))(*test_data) - stream._write_cb(None, buffer, len(test_data)) - # Flush the stream - result = stream._flush_cb(None) - self.assertEqual(result, 0) - finally: - stream.close() - - def test_stream_context_manager(self): - with Stream(self.temp_file) as stream: - self.assertTrue(stream.initialized) - self.assertFalse(stream.closed) - self.assertTrue(stream.closed) - - def test_stream_double_close(self): - stream = Stream(self.temp_file) - stream.close() - # Second close should not raise an exception - stream.close() - self.assertTrue(stream.closed) - - def test_stream_read_after_close(self): - stream = Stream(self.temp_file) - # Store callbacks before closing - read_cb = stream._read_cb - stream.close() - buffer = (ctypes.c_ubyte * 13)() - # Reading from closed stream should return -1 - self.assertEqual(read_cb(None, buffer, 13), -1) - - def test_stream_write_after_close(self): - stream = Stream(self.temp_file) - # Store callbacks before closing - write_cb = stream._write_cb - stream.close() - test_data = b"Test Write" - buffer = (ctypes.c_ubyte * len(test_data))(*test_data) - # Writing to closed stream should return -1 - self.assertEqual(write_cb(None, buffer, len(test_data)), -1) - - def test_stream_seek_after_close(self): - stream = Stream(self.temp_file) - # Store callbacks before closing - seek_cb = stream._seek_cb - stream.close() - # Seeking in closed stream should return -1 - self.assertEqual(seek_cb(None, 5, 0), -1) - - def test_stream_flush_after_close(self): - stream = Stream(self.temp_file) - # Store callbacks before closing - flush_cb = stream._flush_cb - stream.close() - # Flushing closed stream should return -1 - self.assertEqual(flush_cb(None), -1) - - -class TestLegacyAPI(unittest.TestCase): - def setUp(self): - # Filter specific deprecation warnings for legacy API tests - 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 - self.testPath2 = INGREDIENT_TEST_FILE - self.testPath3 = os.path.join(self.data_dir, "A_thumbnail.jpg") - - # Load test certificates and key - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: - self.certs = cert_file.read() - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - self.key = key_file.read() - - # Create a local ES256 signer with certs and a timestamp server - self.signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=self.certs, - private_key=self.key, - ta_url=b"http://timestamp.digicert.com" - ) - self.signer = Signer.from_info(self.signer_info) - - # Define a manifest as a dictionary - self.manifestDefinition = { - "claim_generator": "python_internals_test", - "claim_generator_info": [{ - "name": "python_internals_test", - "version": "0.0.1", - }], - "claim_version": 1, - "format": "image/jpeg", - "title": "Python Test Image", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.opened" - } - ] - } - } - ] - } - - # Create temp directory for tests - self.temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(self.temp_data_dir, exist_ok=True) - - # Define an example ES256 callback signer - self.callback_signer_alg = "Es256" - def callback_signer_es256(data: bytes) -> bytes: - private_key = serialization.load_pem_private_key( - self.key, - password=None, - backend=default_backend() - ) - signature = private_key.sign( - data, - ec.ECDSA(hashes.SHA256()) - ) - return signature - self.callback_signer_es256 = callback_signer_es256 - - def tearDown(self): - """Clean up temporary files after each test.""" - if os.path.exists(self.temp_data_dir): - shutil.rmtree(self.temp_data_dir) - - def test_sign_file_callback_signer_reports_error(self): - """Test signing a file using the sign_file method with a callback that reports an error.""" - - temp_dir = tempfile.mkdtemp() - - try: - output_path = os.path.join(temp_dir, "signed_output.jpg") - - # Use the sign_file method - builder = Builder(self.manifestDefinition) - - # Define a callback that always returns None to simulate an error - def error_callback_signer(data: bytes) -> bytes: - # Could alternatively also raise an error - # raise RuntimeError("Simulated signing error") - return None - - # Create signer with error callback using create_signer function - signer = create_signer( - callback=error_callback_signer, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) - - # The signing operation should fail due to the error callback - with self.assertRaises(Error): - builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) - - finally: - shutil.rmtree(temp_dir) - - def test_sign_file_callback_signer(self): - """Test signing a file using the sign_file method.""" - - temp_dir = tempfile.mkdtemp() - - try: - output_path = os.path.join(temp_dir, "signed_output.jpg") - - # Use the sign_file method - builder = Builder(self.manifestDefinition) - - # Create signer with callback using create_signer function - signer = create_signer( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) - - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) - - # Verify results - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) - - # Read the signed file and verify the manifest - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: - json_data = reader.json() - # 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.""" - - temp_dir = tempfile.mkdtemp() - - try: - output_path = os.path.join(temp_dir, "signed_output_managed.jpg") - - # Create builder and signer with context managers - with Builder(self.manifestDefinition) as builder, create_signer( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) as signer: - - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) - - # Verify results - self.assertTrue(os.path.exists(output_path)) - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) - - # Verify signed data can be read - with open(output_path, "rb") as file: - with Reader("image/jpeg", file) as reader: - json_data = reader.json() - self.assertIn("Python Test", json_data) - # 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] - - # Verify the signature_info contains the correct algorithm - self.assertIn("signature_info", active_manifest) - signature_info = active_manifest["signature_info"] - self.assertEqual(signature_info["alg"], self.callback_signer_alg) - - finally: - shutil.rmtree(temp_dir) - - def test_sign_file_callback_signer_managed_multiple_uses(self): - """Test that a signer can be used multiple times with context managers.""" - - temp_dir = tempfile.mkdtemp() - - try: - # Create builder and signer with context managers - with Builder(self.manifestDefinition) as builder, create_signer( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) as signer: - - # First signing operation - output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") - manifest_bytes_1 = builder.sign_file( - source_path=self.testPath, - dest_path=output_path_1, - signer=signer - ) - - # Verify first signing was successful - self.assertTrue(os.path.exists(output_path_1)) - self.assertIsInstance(manifest_bytes_1, bytes) - self.assertGreater(len(manifest_bytes_1), 0) - - # Second signing operation with 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 = builder2.sign_file( - source_path=self.testPath, - dest_path=output_path_2, - signer=signer - ) - - # Verify second signing was successful - self.assertTrue(os.path.exists(output_path_2)) - self.assertIsInstance(manifest_bytes_2, bytes) - self.assertGreater(len(manifest_bytes_2), 0) - - # Verify both files contain valid C2PA data - for output_path in [output_path_1, output_path_2]: - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: - json_data = reader.json() - self.assertIn("Python Test", json_data) - # 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] - - # Verify the signature_info contains the correct algorithm - self.assertIn("signature_info", active_manifest) - signature_info = active_manifest["signature_info"] - self.assertEqual(signature_info["alg"], self.callback_signer_alg) - - finally: - shutil.rmtree(temp_dir) - - def test_create_signer_from_info(self): - """Create a Signer using the create_signer_from_info function""" - signer = create_signer_from_info(self.signer_info) - self.assertIsNotNone(signer) - - -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", - "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", - }] - } - } - ] - } - - 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_update_rejects_nul_in_json_string(self): - settings = Settings.from_dict({ - "builder": {"thumbnail": {"enabled": True}}, - }) - try: - with self.assertRaises(Error): - settings.update( - '{"verify": {"verify_after_sign": true}}\x00' - '{"builder": {"thumbnail": {"enabled": false}}}') - finally: - settings.close() - - def test_settings_is_valid_after_close(self): - settings = Settings() - settings.close() - self.assertFalse(settings.is_valid) - - def test_settings_raises_after_close(self): - settings = Settings() - settings.close() - with self.assertRaises(Error): - settings.set( - "builder.thumbnail.enabled", "false" - ) - - -class TestContext(TestContextAPIs): - - def test_context_default(self): - context = Context() - self.assertTrue(context.is_valid) - self.assertFalse(context.has_signer) - context.close() - - def test_context_from_settings(self): - settings = Settings() - context = Context(settings) - self.assertTrue(context.is_valid) - context.close() - settings.close() - - def test_context_from_json(self): - context = Context.from_json( - '{"builder":{"thumbnail":' - '{"enabled":false}}}' - ) - self.assertTrue(context.is_valid) - context.close() - - def test_context_from_dict(self): - context = Context.from_dict({ - "builder": { - "thumbnail": {"enabled": False} - } - }) - self.assertTrue(context.is_valid) - context.close() - - def test_context_context_manager(self): - with Context() as context: - self.assertTrue(context.is_valid) - - def test_context_is_valid_after_close(self): - context = Context() - context.close() - self.assertFalse(context.is_valid) - - -class TestContextBuilder(TestContextAPIs): - - def test_context_builder_default(self): - context = Context.builder().build() - self.assertTrue(context.is_valid) - self.assertFalse(context.has_signer) - context.close() - - def test_context_builder_with_settings(self): - settings = Settings() - context = Context.builder().with_settings(settings).build() - self.assertTrue(context.is_valid) - context.close() - settings.close() - - def test_context_builder_with_signer(self): - signer = self._ctx_make_signer() - context = ( - Context.builder() - .with_signer(signer) - .build() - ) - self.assertTrue(context.is_valid) - self.assertTrue(context.has_signer) - context.close() - - def test_context_builder_with_settings_and_signer(self): - settings = Settings() - signer = self._ctx_make_signer() - context = ( - Context.builder() - .with_settings(settings) - .with_signer(signer) - .build() - ) - self.assertTrue(context.is_valid) - self.assertTrue(context.has_signer) - context.close() - settings.close() - - def test_context_builder_chaining_returns_self(self): - settings = Settings() - context_builder = Context.builder() - result = context_builder.with_settings(settings) - self.assertIs(result, context_builder) - context = context_builder.build() - context.close() - settings.close() - - def test_context_builder_with_settings_last_wins(self): - """The last with_settings call determines the settings used. - - Toggles thumbnails on, off, on, off across four calls. - The last call disables thumbnails, so the signed manifest - should have no thumbnail. - """ - settings_on_1 = Settings.from_dict({ - "builder": {"thumbnail": {"enabled": True}}, - }) - settings_off_1 = Settings.from_dict({ - "builder": {"thumbnail": {"enabled": False}}, - }) - settings_on_2 = Settings.from_dict({ - "builder": {"thumbnail": {"enabled": True}}, - }) - settings_off_2 = Settings.from_dict({ - "builder": {"thumbnail": {"enabled": False}}, - }) - context = ( - Context.builder() - .with_settings(settings_on_1) - .with_settings(settings_off_1) - .with_settings(settings_on_2) - .with_settings(settings_off_2) - .build() - ) - signer = self._ctx_make_signer() - builder = Builder(self.test_manifest, context) - with tempfile.TemporaryDirectory() as temp_dir: - dest_path = os.path.join(temp_dir, "out.jpg") - with ( - open(DEFAULT_TEST_FILE, "rb") as source_file, - open(dest_path, "w+b") as dest_file, - ): - builder.sign( - signer, "image/jpeg", source_file, dest_file, - ) - reader = Reader(dest_path) - manifest = reader.get_active_manifest() - # Last settings disabled thumbnails - self.assertIsNone(manifest.get("thumbnail")) - reader.close() - builder.close() - context.close() - settings_on_1.close() - settings_off_1.close() - settings_on_2.close() - settings_off_2.close() - - -class TestContextWithSigner(TestContextAPIs): - - def test_context_with_signer(self): - signer = self._ctx_make_signer() - context = Context(signer=signer) - self.assertTrue(context.is_valid) - self.assertTrue(context.has_signer) - context.close() - - def test_context_with_settings_and_signer(self): - settings = Settings() - signer = self._ctx_make_signer() - context = Context(settings, signer) - self.assertTrue(context.is_valid) - self.assertTrue(context.has_signer) - context.close() - settings.close() - - def test_consumed_signer_is_closed(self): - signer = self._ctx_make_signer() - context = Context(signer=signer) - self.assertEqual(signer._lifecycle_state, LifecycleState.CLOSED) - context.close() - - def test_consumed_signer_raises_on_use(self): - signer = self._ctx_make_signer() - context = Context(signer=signer) - with self.assertRaises(Error): - signer._ensure_valid_state() - context.close() - - def test_context_has_signer_flag(self): - signer = self._ctx_make_signer() - context = Context(signer=signer) - self.assertTrue(context.has_signer) - context.close() - - def test_context_no_signer_flag(self): - context = Context() - self.assertFalse(context.has_signer) - context.close() - - def test_context_from_json_with_signer(self): - signer = self._ctx_make_signer() - context = Context.from_json( - '{"builder":{"thumbnail":' - '{"enabled":false}}}', - signer, - ) - self.assertTrue(context.has_signer) - self.assertEqual(signer._lifecycle_state, LifecycleState.CLOSED) - context.close() - - -class TestReaderWithContext(TestContextAPIs): - - def test_reader_with_default_context(self): - context = Context() - with open(DEFAULT_TEST_FILE, "rb") as file_handle: - reader = Reader("image/jpeg", file_handle, context=context,) - data = reader.json() - self.assertIsNotNone(data) - reader.close() - context.close() - - def test_reader_with_settings_context(self): - settings = Settings() - context = Context(settings) - with open(DEFAULT_TEST_FILE, "rb") as file_handle: - reader = Reader("image/jpeg", file_handle, context=context,) - data = reader.json() - self.assertIsNotNone(data) - reader.close() - context.close() - settings.close() - - def test_reader_without_context(self): - with open(DEFAULT_TEST_FILE, "rb") as file_handle: - reader = Reader("image/jpeg", file_handle) - data = reader.json() - self.assertIsNotNone(data) - reader.close() - - def test_reader_try_create_with_context(self): - context = Context() - reader = Reader.try_create(DEFAULT_TEST_FILE, context=context,) - self.assertIsNotNone(reader) - data = reader.json() - self.assertIsNotNone(data) - reader.close() - context.close() - - def test_reader_try_create_no_manifest(self): - context = Context() - reader = Reader.try_create(INGREDIENT_TEST_FILE, context=context,) - self.assertIsNone(reader) - context.close() - - def test_reader_file_path_with_context(self): - context = Context() - reader = Reader(DEFAULT_TEST_FILE, context=context,) - data = reader.json() - self.assertIsNotNone(data) - reader.close() - context.close() - - def test_reader_extensionless_path_with_context_autodetects(self): - context = Context() - with tempfile.TemporaryDirectory() as tmp: - no_ext = os.path.join(tmp, "asset") - shutil.copyfile(DEFAULT_TEST_FILE, no_ext) - reader = Reader(no_ext, context=context) - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - 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_reader_bare_stream_with_context(self): - # The bare-stream shift runs before the context branch, so - # Reader(fh, context=ctx) reads via context with auto-detect. - context = Context() - with open(DEFAULT_TEST_FILE, "rb") as file_handle: - reader = Reader(file_handle, context=context) - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - reader.close() - context.close() - - def test_reader_path_object_with_context(self): - context = Context() - reader = Reader(Path(DEFAULT_TEST_FILE), context=context) - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - reader.close() - context.close() - - def test_try_create_format_stream_with_context(self): - context = Context() - with open(DEFAULT_TEST_FILE, "rb") as file_handle: - reader = Reader.try_create( - "image/jpeg", file_handle, context=context) - self.assertIsNotNone(reader) - if reader is not None: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - reader.close() - context.close() - - def test_try_create_bare_stream_with_context(self): - context = Context() - with open(DEFAULT_TEST_FILE, "rb") as file_handle: - reader = Reader.try_create(file_handle, context=context) - self.assertIsNotNone(reader) - if reader is not None: - self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) - reader.close() - context.close() - - def test_context_only_no_source_raises(self): - # A context supplies settings, not the asset, - # so no path/stream is an error rather than open("None"). - context = Context() - with self.assertRaises(Error) as ctx: - Reader(context=context) - self.assertIn("stream", str(ctx.exception).lower()) - context.close() - - def test_reader_with_closed_context_raises(self): - context = Context() - context.close() - with self.assertRaises(Error): - Reader(DEFAULT_TEST_FILE, context=context) - - 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 TestSidecarReader(TestContextAPIs): - """Reader over a detached (sidecar) manifest with a Context""" - - def _make_detached_manifest(self): - """Sign DEFAULT_TEST_FILE with no-embed, return (asset_bytes, - manifest_bytes) where manifest_bytes is the "detached" sidecar - C2PA manifest.""" - - signer = self._ctx_make_signer() - with open(DEFAULT_TEST_FILE, "rb") as f: - asset_bytes = f.read() - builder = Builder(self.test_manifest) - builder.set_no_embed() - # Output is discarded: with no-embed the asset is unchanged and the - # manifest is returned by sign(). - manifest_bytes = builder.sign( - signer, "image/jpeg", io.BytesIO(asset_bytes), io.BytesIO()) - builder.close() - signer.close() - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) - return asset_bytes, manifest_bytes - - def test_reader_with_manifest_data_and_context(self): - asset_bytes, manifest_bytes = self._make_detached_manifest() - context = Context() - reader = Reader( - "image/jpeg", - io.BytesIO(asset_bytes), - manifest_bytes, - context=context, - ) - try: - data = reader.json() - self.assertTrue(data) - self.assertFalse(reader.is_embedded()) - self.assertIn("manifests", json.loads(data)) - finally: - reader.close() - context.close() - - def test_reader_manifest_data_context_invalid_manifest_raises(self): - with open(DEFAULT_TEST_FILE, "rb") as f: - asset_bytes = f.read() - context = Context() - reader = None - try: - with self.assertRaises(Error): - reader = Reader( - "image/jpeg", - io.BytesIO(asset_bytes), - b"not a real manifest", - context=context, - ) - # The consumed-pointer error branch must leave no usable handle. - if reader is not None: - self.assertEqual( - reader._lifecycle_state, LifecycleState.CLOSED) - self.assertIsNone(reader._handle) - finally: - if reader is not None: - reader.close() - context.close() - - def test_reader_manifest_data_context_wrong_type_raises(self): - with open(DEFAULT_TEST_FILE, "rb") as f: - asset_bytes = f.read() - context = Context() - try: - # Non-bytes manifest_data must raise TypeError before any native - # reader handle is allocated (no leak on this path). - with self.assertRaises(TypeError): - Reader( - "image/jpeg", - io.BytesIO(asset_bytes), - "manifest as str, not bytes", - context=context, - ) - finally: - context.close() - - def test_reader_manifest_data_context_use_after_close_raises(self): - asset_bytes, manifest_bytes = self._make_detached_manifest() - context = Context() - reader = Reader( - "image/jpeg", - io.BytesIO(asset_bytes), - manifest_bytes, - context=context, - ) - reader.close() - self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) - with self.assertRaises(Error): - reader.json() - # Idempotent close after use-after-close attempt. - reader.close() - context.close() - - def test_reader_manifest_data_context_as_context_manager(self): - asset_bytes, manifest_bytes = self._make_detached_manifest() - context = Context() - with Reader( - "image/jpeg", - io.BytesIO(asset_bytes), - manifest_bytes, - context=context, - ) as reader: - self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) - self.assertTrue(reader.json()) - self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) - 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() - - -class TestStreamReferences(unittest.TestCase): - - def test_stream_collected_after_del(self): - """Stream must be collected by reference counting.""" - import gc - import weakref - gc.collect() - garbage_before = len(gc.garbage) - - buf = io.BytesIO(b"hello world") - s = Stream(buf) - ref = weakref.ref(s) - del s - - # Trigger gc, we want to verify it's collected - # collected now (1 gc call) means no gc cycle breaker needed - # aka ref cycle did not happen. - gc.collect() - - self.assertIsNone(ref(), "Stream not collected") - self.assertEqual(len(gc.garbage), garbage_before, - "Stream added objects to gc.garbage") - - def test_stream_not_added_to_gc_garbage_list(self): - """Creating and dropping many Streams must not grow gc.garbage.""" - import gc - gc.collect() - gc.garbage.clear() - - for _ in range(20): - s = Stream(io.BytesIO(b"data")) - del s - - gc.collect() - self.assertEqual(len(gc.garbage), 0, - f"gc.garbage unexpectedly non-empty: {gc.garbage}") - - def test_callbacks_return_minus_one_after_stream_collected(self): - """Callbacks must return -1 gracefully when the Stream has been GC'd.""" - import gc - import weakref - - buf = io.BytesIO(b"test") - s = Stream(buf) - - read_cb = s._read_cb - seek_cb = s._seek_cb - write_cb = s._write_cb - flush_cb = s._flush_cb - ref = weakref.ref(s) - del s - gc.collect() - - # Stream should be gone. - self.assertIsNone(ref(), "Stream not collected before callback test") - - # All callbacks must return -1 without crashing. - self.assertEqual(read_cb(None, None, 0), -1) - self.assertEqual(seek_cb(None, 0, 0), -1) - self.assertEqual(write_cb(None, None, 0), -1) - self.assertEqual(flush_cb(None), -1) - - -class TestManagedResourceLifecycle(unittest.TestCase): - """Lifecycle primitives (_activate, _consume_and_swap, _wrap_native_handle), - the _owner_pid stamp that governs which process may free a handle, and - the ownership hand-offs between Python and the native library. - - For testing: setUp records frees instead of performing them, - so a miscount reads as memory handling issue. - Tests releasing real handles call _use_real_frees() first to - restore release behavior. - """ - - class _FakeHandleResource(ManagedResource): - """Concrete subclass with no resources of its own.""" - - class _CallbackHoldingResource(ManagedResource): - """Mimics Signer: its _release() reads an attribute that _init_attrs() - is responsible for defaulting.""" - - def _release(self): - if self._callback_cb: - self._callback_cb = None - - class _ReleaseRecordingResource(ManagedResource): - """Records _release() calls for test asserts.""" - - def __init__(self): - super().__init__() - self.release_calls = 0 - - def _release(self): - self.release_calls += 1 - - class _ExtenderResource(ManagedResource): - """For testing: An extender that owns a raw handle and wraps it via - _wrap_native_handle. It carries several attributes of its own, all - defaulted in _init_attrs (not __init__), and _release reads them, so a - missing attribute would surface as an AttributeError on test teardown. - """ - - def _init_attrs(self): - super()._init_attrs() - self.label = "extender" - self.buffer = [] - self.released = False - - def _release(self): - # Reads attributes _init_attrs is responsible for defaulting. - self.buffer.append(self.label) - self.released = True - - def setUp(self): - self.data_dir = FIXTURES_DIR - self.freed = [] - self._real_free = ManagedResource._free_native_ptr - # Registered first so it runs last, after every patch has unwound. - self.addCleanup(self._assert_free_hook_restored) - _patch_free(self, self.freed.append) - - def _assert_free_hook_restored(self): - self.assertIs( - ManagedResource._free_native_ptr, self._real_free, - "{} leaked a _free_native_ptr patch".format(self.id())) - - def _free_counts(self): - counts = {} - for handle in self.freed: - counts[handle] = counts.get(handle, 0) + 1 - return counts - - def _use_real_frees(self): - """Undo free recorder, so native handles are really freed.""" - _patch_free(self, self._real_free) - - def _make_signer(self): - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as f: - certs = f.read() - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as f: - key = f.read() - return Signer.from_info(C2paSignerInfo( - b"es256", certs, key, b"http://timestamp.digicert.com")) - - def test_release_failure_still_frees_handle(self): - res = self._CallbackHoldingResource() - # _callback_cb is never set, so _release() raises AttributeError. - res._activate(0xBBBB) - - res.close() - - self.assertEqual(self.freed, [0xBBBB], - "handle leaked when _release() raised") - self.assertIsNone(res._handle) - - def test_release_failure_is_logged(self): - res = self._CallbackHoldingResource() - res._activate(0xBBBB) - - with self.assertLogs('c2pa', level='ERROR') as captured: - res.close() - - self.assertTrue( - any('Failed to release' in line for line in captured.output), - f"_release() failure was not logged: {captured.output}") - - def test_activate_rejects_null_handle(self): - res = self._FakeHandleResource() - - with self.assertRaises(Error) as ctx: - res._activate(None) - - self.assertIn("null handle", str(ctx.exception)) - self.assertEqual(res._lifecycle_state, LifecycleState.UNINITIALIZED) - - def test_activate_rejects_double_activation(self): - res = self._FakeHandleResource() - res._activate(0x1111) - - with self.assertRaises(Error) as ctx: - res._activate(0x2222) - - self.assertIn("already activated", str(ctx.exception)) - # The first handle is still owned, and is freed exactly once. - self.assertEqual(res._handle, 0x1111) - res.close() - self.assertEqual(self.freed, [0x1111]) - - def test_activate_rejects_reactivation_after_close(self): - res = self._FakeHandleResource() - res._activate(0x3333) - res.close() - self.freed.clear() - - with self.assertRaises(Error): - res._activate(0x4444) - - # Staying CLOSED is what prevents a second free of the handle. - self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) - self.assertIsNone(res._handle) - self.assertEqual(self.freed, []) - - def test_activate_does_not_mutate_on_rejection(self): - res = self._FakeHandleResource() - res._activate(0x5555) - - with self.assertRaises(Error): - res._activate(0x6666) - - self.assertEqual(res._handle, 0x5555, - "rejected activation replaced the handle") - self.assertEqual(res._lifecycle_state, LifecycleState.ACTIVE) - - def test_consume_and_swap_requires_active_resource(self): - uninitialized = self._FakeHandleResource() - with self.assertRaises(Error): - uninitialized._consume_and_swap(lambda h: 0x1, "swap: {}") - - closed = self._FakeHandleResource() - closed._activate(0x2) - closed.close() - self.freed.clear() - with self.assertRaises(Error): - closed._consume_and_swap(lambda h: 0x3, "swap: {}") - self.assertEqual(self.freed, []) - - def test_wrap_native_handle_bypasses_init(self): - seen = [] - - class Probe(ManagedResource): - def __init__(self): - raise AssertionError("__init__ must be bypassed") - - def _init_attrs(self): - super()._init_attrs() - self._tag = 'from _init_attrs' - - def _release(self): - seen.append(self._tag) - - obj = Probe._wrap_native_handle(0xC0DE) - - self.assertEqual(obj._tag, 'from _init_attrs') - self.assertEqual(obj._lifecycle_state, LifecycleState.ACTIVE) - self.assertTrue(obj.is_valid) - self.assertTrue(hasattr(obj, '_owner_pid')) - - obj.close() - self.assertEqual(seen, ['from _init_attrs'], - "_release() could not see the class's own attrs") - - def test_wrap_native_handle_rejects_null(self): - with self.assertRaises(Error): - self._FakeHandleResource._wrap_native_handle(None) - - def test_close_after_wrap_is_idempotent(self): - obj = self._FakeHandleResource._wrap_native_handle(0xD00D) - - obj.close() - obj.close() - - self.assertEqual(self.freed, [0xD00D], "handle freed more than once") - - def test_every_construction_path_records_owner_pid(self): - pid = os.getpid() - - plain = self._FakeHandleResource() - self.assertEqual(plain._owner_pid, pid) - - activated = self._FakeHandleResource() - activated._activate(0xA1) - self.assertEqual(activated._owner_pid, pid) - - wrapped = self._FakeHandleResource._wrap_native_handle(0xA2) - self.assertEqual(wrapped._owner_pid, pid) - - # A swap keeps the original stamp: - # the replacement handle was allocated by the same process - # that created the object. - wrapped._consume_and_swap(lambda h: 0xA3, "swap: {}") - self.assertEqual(wrapped._owner_pid, pid) - - def test_foreign_child_skips_free_for_wrapped_and_swapped(self): - wrapped = self._FakeHandleResource._wrap_native_handle(0xC1) - wrapped._owner_pid = os.getpid() + 1 - wrapped.close() - - swapped = self._FakeHandleResource() - swapped._activate(0xC2) - swapped._consume_and_swap(lambda h: 0xC3, "swap: {}") - swapped._owner_pid = os.getpid() + 1 - swapped.close() - - self.assertEqual(self.freed, [], - "forked child freed a pointer its parent still owns") - # The child must not free a pointer the parent still owns. - # The child does mark its own copies closed and nulls their handles, - # which stops the child from reusing a parent-owned handle. - self.assertEqual(wrapped._lifecycle_state, LifecycleState.CLOSED) - self.assertIsNone(wrapped._handle) - self.assertEqual(swapped._lifecycle_state, LifecycleState.CLOSED) - self.assertIsNone(swapped._handle) - - # A second foreign teardown is a no-op. - wrapped.close() - swapped.close() - self.assertEqual(self.freed, []) - - def test_owning_process_frees_wrapped_and_swapped_exactly_once(self): - wrapped = self._FakeHandleResource._wrap_native_handle(0xC4) - wrapped.close() - wrapped.close() - - swapped = self._FakeHandleResource() - swapped._activate(0xC5) - swapped._consume_and_swap(lambda h: 0xC6, "swap: {}") - swapped.close() - - # 0xC5 was consumed by the test FFI swap. - # Only the replacement must be freed here. - self.assertEqual(self._free_counts(), {0xC4: 1, 0xC6: 1}) - - def test_foreign_child_skips_release(self): - foreign = self._ReleaseRecordingResource() - foreign._activate(0xD1) - foreign._owner_pid = os.getpid() + 1 - foreign.close() - self.assertEqual(foreign.release_calls, 0) - - owned = self._ReleaseRecordingResource() - owned._activate(0xD2) - owned.close() - self.assertEqual(owned.release_calls, 1) - - def test_consumed_resource_frees_nothing_in_either_process(self): - owned = self._FakeHandleResource() - owned._activate(0xE1) - owned._teardown(free_handle=False) - owned.close() - - foreign = self._FakeHandleResource() - foreign._activate(0xE2) - foreign._teardown(free_handle=False) - foreign._owner_pid = os.getpid() + 1 - foreign.close() - - self.assertEqual(self.freed, []) - - # Consuming a handle hands the native pointer to a new owner. - # The Python-side resources are still ours to free. - def test_teardown_consumed_releases_python_resources(self): - res = self._ReleaseRecordingResource() - res._activate(0xF1) - - res._teardown(free_handle=False) - - self.assertEqual(res.release_calls, 1) - self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) - self.assertIsNone(res._handle) - self.assertEqual(self.freed, []) - - def test_teardown_consumed_swallows_failing_release(self): - res = self._CallbackHoldingResource() - res._activate(0xF2) - - with self.assertLogs("c2pa", level="ERROR"): - res._teardown(free_handle=False) - - self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) - self.assertIsNone(res._handle) - - def test_teardown_consumed_in_foreign_process_skips_release(self): - res = self._ReleaseRecordingResource() - res._activate(0xF3) - res._owner_pid = os.getpid() + 1 - - res._teardown(free_handle=False) - - self.assertEqual(res.release_calls, 0) - self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) - self.assertIsNone(res._handle) - - def test_extender_wraps_handle_fully_built(self): - obj = self._ExtenderResource._wrap_native_handle(0xE0) - - # Every attribute _init_attrs defaults is present, - # even though __init__ never ran. - self.assertEqual(obj.label, "extender") - self.assertEqual(obj.buffer, []) - self.assertFalse(obj.released) - self.assertTrue(obj.is_valid) - self.assertEqual(obj._owner_pid, os.getpid()) - - # _release reads those attributes, so a missing one will raise here. - obj.close() - obj.close() - - self.assertTrue(obj.released) - self.assertEqual(obj.buffer, ["extender"]) - self.assertEqual(self.freed, [0xE0], "wrapped handle freed once") - - def test_extender_foreign_teardown_skips_native_free(self): - obj = self._ExtenderResource._wrap_native_handle(0xE1) - # Stamp a foreign owner: - # teardown runs in a process that did not create the handle, - # so it must not free the pointer or run _release. - obj._owner_pid = os.getpid() + 1 - - obj.close() - - self.assertEqual(self.freed, [], - "forked child freed a handle its parent still owns") - self.assertFalse(obj.released, "foreign teardown ran _release") - # The child marks its own copy closed and nulls the handle: - # the parent holds a separate copy and it stops the - # child reusing a parent-owned handle. - self.assertEqual(obj._lifecycle_state, LifecycleState.CLOSED) - self.assertIsNone(obj._handle) - - # A second foreign teardown stays a no-op, - # and any operation on the now-closed child copy fails. - obj.close() - self.assertEqual(self.freed, []) - with self.assertRaises(Error): - obj._ensure_valid_state() - - def test_signer_init_rejects_null_pointer(self): - with self.assertRaises(Error): - Signer(None) - - def test_builder_from_archive_wraps_handle(self): - self._use_real_frees() - archive = io.BytesIO() - Builder({"claim_generator": "test", "format": "image/jpeg"}).to_archive( - archive) - - builder = Builder.from_archive(io.BytesIO(archive.getvalue())) - - self.assertTrue(builder.is_valid) - self.assertIsNone(builder._context) - self.assertFalse(builder._has_context_signer) - builder.close() - self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) - - def test_context_build_failure_consumes_signer(self): - self._use_real_frees() - signer = self._make_signer() - real_build = c2pa_module._lib.c2pa_context_builder_build - c2pa_module._lib.c2pa_context_builder_build = lambda ptr: None - try: - with self.assertRaises(Error): - Context(signer=signer) - finally: - c2pa_module._lib.c2pa_context_builder_build = real_build - - self.assertIsNone(signer._handle, - "Signer still holds a pointer the native side freed") - self.assertEqual(signer._lifecycle_state, LifecycleState.CLOSED) - - # Nothing left to free, so close() must be a no-op. - freed = [] - with patch.object(ManagedResource, '_free_native_ptr', - staticmethod(freed.append)): - signer.close() - self.assertEqual(freed, []) - - def test_context_with_signer_consumes_it_on_success(self): - self._use_real_frees() - signer = self._make_signer() - - context = Context(signer=signer) - - self.assertTrue(context.is_valid) - self.assertIsNone(signer._handle) - self.assertEqual(signer._lifecycle_state, LifecycleState.CLOSED) - self.assertTrue(context.has_signer) - context.close() - - def test_construction_failure_leaves_nothing_to_free(self): - # Activation happens after the null check, so a failed construction - # has no handle on the object that __del__ can find. - real_new = c2pa_module._lib.c2pa_context_new - c2pa_module._lib.c2pa_context_new = lambda: None - try: - with self.assertRaises(Error): - Context() - finally: - c2pa_module._lib.c2pa_context_new = real_new - - real_json = c2pa_module._lib.c2pa_builder_from_json - c2pa_module._lib.c2pa_builder_from_json = lambda j: None - try: - with self.assertRaises(Error): - Builder({"claim_generator": "test"}) - finally: - c2pa_module._lib.c2pa_builder_from_json = real_json - - def test_context_build_null_return_frees_builder(self): - # Mock a pointer rejection. - settings = Settings() - real_build = c2pa_module._lib.c2pa_context_builder_build - c2pa_module._lib.c2pa_context_builder_build = _fail_with_native_error( - b"UntrackedPointer: mocked pre-consume rejection") - try: - with self.assertRaises(Error): - Context(settings=settings) - finally: - c2pa_module._lib.c2pa_context_builder_build = real_build - - # One free: the un-consumed builder. - # Settings borrows, so it is not freed here. - self.assertEqual(len(self.freed), 1, - "un-consumed builder leaked on build failure") - settings.close() - - def test_consume_no_replacement_marks_consumed_on_success(self): - res = self._FakeHandleResource() - res._activate(0xCAFE) - - res._consume_no_replacement(lambda h: 0, "set failed: {}") - - # Native took ownership. - self.assertEqual(self.freed, []) - self.assertIsNone(res._handle) - self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) - - def test_consume_no_replacement_retains_on_pre_consume_tag(self): - for message in ("UntrackedPointer: rejected", - "PointerInUse: 0x2a", - "Other: ForeignProcess: 0x2a"): - with self.subTest(message=message): - self.freed.clear() - res = self._FakeHandleResource() - res._activate(0xCAFE) - real_read = c2pa_module._read_native_error - c2pa_module._read_native_error = lambda: message - try: - with self.assertRaises(Error): - res._consume_no_replacement( - lambda h: -1, "set failed: {}") - finally: - c2pa_module._read_native_error = real_read - - # Rejected before ownership transferred: handle retained. - self.assertEqual(res._handle, 0xCAFE) - self.assertEqual(res._lifecycle_state, LifecycleState.ACTIVE) - self.assertEqual(self.freed, []) - res.close() - self.assertEqual(self.freed, [0xCAFE]) - - def test_consume_no_replacement_marks_consumed_on_other_error(self): - res = self._FakeHandleResource() - res._activate(0xCAFE) - real_read = c2pa_module._read_native_error - c2pa_module._read_native_error = lambda: "OtherError: boom" - try: - with self.assertRaises(Error): - res._consume_no_replacement(lambda h: -1, "set failed: {}") - finally: - c2pa_module._read_native_error = real_read - - # A non-tag error means native took ownership then failed and dropped - # the value itself: mark consumed, do not free. - self.assertEqual(self.freed, []) - self.assertIsNone(res._handle) - self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) - - def test_invoke_consume_success_does_not_consult_error_slot(self): - """A successful consuming call must not read the error slot at all: - only a failure inspects it.""" - res = self._FakeHandleResource() - res._activate(0xCAFE) - - res._consume_no_replacement(lambda h: 0, "set failed: {}") - - self.assertIsNone(c2pa_module._read_native_error()) - - def test_native_section_defers_unrelated_finalizer_free(self): - """A finalizer for a completely unrelated resource firing mid - native-call must not free immediately. - """ - victim = self._FakeHandleResource() - victim._activate(0xCAFE) - bystander = self._FakeHandleResource() - bystander._activate(0xB00B) - - def polluting_free(ptr): - self.freed.append(ptr) - # Freeing and untracked/ pointer writes its own error into the - # same thread-local slot. - c2pa_module._lib.c2pa_error_set_last( - "Other: UntrackedPointer: {:#x}".format(ptr).encode()) - return -1 - _patch_free(self, polluting_free) - - def ffi_call(handle): - nonlocal bystander - del bystander # last reference dropped: __del__ fires right here - return None # the real call failed but set no error of its own - - # A bare section: the consume needs the error section, but not a - # borrow on its own handle. _ensure_not_borrowed - # refuses a consume nested in a _native_call() on the same resource. - with c2pa_module._native_section(): - with self.assertRaises(Error): - victim._consume_no_replacement(ffi_call, "op failed: {}") - - # The bystander's free is deferred to the section close, so it - # runs after the consuming call, before the victim's free. - self.assertEqual(self.freed, [0xB00B, 0xCAFE]) - - def test_nested_native_sections_flush_only_at_outermost_close(self): - """A native-error section opened inside another, already-open one - on the same thread must not flush anything until the outermost - one closes.""" - res = self._FakeHandleResource() - res._activate(0xCAFE) - - outer = c2pa_module._native_section() - outer.__enter__() - try: - inner = c2pa_module._native_section() - inner.__enter__() - try: - res.close() - finally: - inner.__exit__(None, None, None) - # Inner closed, outer is still open: still deferred. - self.assertEqual(self.freed, [], "inner section flushed") - finally: - outer.__exit__(None, None, None) - self.assertEqual(self.freed, [0xCAFE]) - - def test_stale_error_not_misattributed_after_preset_error(self): - """A stale tag left by an earlier, unrelated call on this thread - must not be read as this call's own error.""" - # A stale tag from an earlier, unrelated call. - c2pa_module._lib.c2pa_error_set_last( - b"Other: UntrackedPointer: 0xdeadbeef") - - res = self._FakeHandleResource() - res._activate(0xCAFE) - - # Fails without setting any error of its own. - # The marker written inside _invoke_consume must have cleared - # the stale tag, so this routes to the "no error of our own" branch. - with self.assertRaises(Error): - res._consume_no_replacement(lambda h: -1, "op failed: {}") - - # A misattributed stale tag would have matched - # _PRE_CONSUME_ERROR_TAGS and left the resource ACTIVE. - self.assertEqual(self.freed, [0xCAFE]) - - -class TestManagedResourceObjects(TestContextAPIs): - """Tests native resource handling management when managed manually. - """ - - @staticmethod - def _ptr_addr(ptr): - """Address a ctypes pointer points at, or None for a null pointer. - - ctypes pointers compare by identity, not by value: two pointer objects - for the same address are unequal. Compare addresses instead. - """ - if not ptr: - return None - return ctypes.cast(ptr, ctypes.c_void_p).value - - def _instrument_frees(self): - """Record frees instead of performing them, and restore on teardown. - """ - freed = [] - _patch_free(self, freed.append) - return freed - - def _free_count(self, freed, handle): - """How many times `handle` was freed, ignoring unrelated frees.""" - target = self._ptr_addr(handle) - self.assertIsNotNone(target, "cannot count frees of a null handle") - return sum(1 for ptr in freed if self._ptr_addr(ptr) == target) - - def _make_archive(self, manifest=None): - archive = io.BytesIO() - builder = Builder(manifest or self.test_manifest) - try: - builder.to_archive(archive) - finally: - builder.close() - archive.seek(0) - return archive - - def test_settings_activation_paths(self): - pid = os.getpid() - for label, factory in ( - ("Settings()", lambda: Settings()), - ("from_json", lambda: Settings.from_json('{"version_major": 1}')), - ("from_dict", lambda: Settings.from_dict({"version_major": 1})), - ): - with self.subTest(path=label): - settings = factory() - try: - self.assertTrue(settings.is_valid) - self.assertEqual(settings._owner_pid, pid) - finally: - settings.close() - - def test_context_activation_paths(self): - pid = os.getpid() - settings = Settings.from_dict({"version_major": 1}) - try: - for label, factory in ( - # No settings and no signer takes the c2pa_context_new path. - ("Context()", lambda: Context()), - # Anything else goes through the ContextBuilder path. - ("Context(settings)", lambda: Context(settings)), - ("from_dict", lambda: Context.from_dict({"version_major": 1})), - ("builder()", - lambda: Context.builder().with_settings(settings).build()), - ): - with self.subTest(path=label): - context = factory() - try: - self.assertTrue(context.is_valid) - self.assertEqual(context._owner_pid, pid) - finally: - context.close() - finally: - settings.close() - - def test_reader_activation_paths(self): - pid = os.getpid() - context = Context() - try: - with open(DEFAULT_TEST_FILE, "rb") as f: - from_stream = Reader("image/jpeg", f) - self.addCleanup(from_stream.close) - self.assertEqual(from_stream._owner_pid, pid) - - from_path = Reader(DEFAULT_TEST_FILE) - self.addCleanup(from_path.close) - self.assertEqual(from_path._owner_pid, pid) - - with open(DEFAULT_TEST_FILE, "rb") as f: - with_context = Reader(DEFAULT_TEST_FILE, context=context) - self.addCleanup(with_context.close) - self.assertEqual(with_context._owner_pid, pid) - - with open(DEFAULT_TEST_FILE, "rb") as f: - created = Reader.try_create("image/jpeg", f) - self.addCleanup(created.close) - self.assertEqual(created._owner_pid, pid) - finally: - context.close() - - def test_builder_activation_paths(self): - pid = os.getpid() - context = Context() - try: - plain = Builder(self.test_manifest) - self.addCleanup(plain.close) - self.assertEqual(plain._owner_pid, pid) - - from_json = Builder.from_json(self.test_manifest) - self.addCleanup(from_json.close) - self.assertEqual(from_json._owner_pid, pid) - - with_context = Builder(self.test_manifest, context=context) - self.addCleanup(with_context.close) - self.assertEqual(with_context._owner_pid, pid) - - # from_archive is the only caller of _wrap_native_handle, - # which bypasses __init__ entirely - wrapped = Builder.from_archive(self._make_archive()) - self.addCleanup(wrapped.close) - self.assertEqual(wrapped._owner_pid, pid) - self.assertIsNone(wrapped._context) - self.assertFalse(wrapped._has_context_signer) - finally: - context.close() - - def test_signer_activation_paths(self): - signer = self._ctx_make_signer() - self.addCleanup(signer.close) - self.assertTrue(signer.is_valid) - self.assertEqual(signer._owner_pid, os.getpid()) - - callback_signer = self._ctx_make_callback_signer() - self.addCleanup(callback_signer.close) - self.assertEqual(callback_signer._owner_pid, os.getpid()) - - def test_builder_with_archive_swaps_the_handle(self): - context = Context() - self.addCleanup(context.close) - builder = Builder(self.test_manifest, context=context) - original_handle = builder._handle - original_stamp = builder._owner_pid - - result = builder.with_archive(self._make_archive()) - - self.assertIs(result, builder, "with_archive should return self") - self.assertNotEqual(builder._handle, original_handle, - "the native handle was not replaced") - self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) - # The replacement came from this process, the stamp still applies. - self.assertEqual(builder._owner_pid, original_stamp) - self.assertEqual(builder._owner_pid, os.getpid()) - builder.close() - - def test_reader_with_fragment_swaps_the_handle(self): - init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") - fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") - context = Context() - self.addCleanup(context.close) - - with open(init_path, "rb") as init: - reader = Reader("video/mp4", init, context=context) - self.addCleanup(reader.close) - original_handle = reader._handle - - # The Reader consumed the first handle, so the init stream is reopened. - with open(init_path, "rb") as init, open(fragment_path, "rb") as frag: - result = reader.with_fragment("video/mp4", init, frag) - - self.assertIs(result, reader, "with_fragment should return self") - self.assertNotEqual(reader._handle, original_handle, - "the native handle was not replaced") - self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) - self.assertEqual(reader._owner_pid, os.getpid()) - - def test_swapped_builder_is_freed_exactly_once(self): - context = Context() - self.addCleanup(context.close) - builder = Builder(self.test_manifest, context=context) - original_handle = builder._handle - archive = self._make_archive() - - # Instrument across the swap so a free of the consumed pointer is recorded. - freed = self._instrument_frees() - builder.with_archive(archive) - swapped_handle = builder._handle - - self.assertEqual(self._free_count(freed, original_handle), 0, - "the swap freed the consumed pointer") - - builder.close() - builder.close() - - # The replacement must be freed exactly once; a second close() is a - # no-op. We don't separately assert original_handle's count here: the - # native allocator may legally reuse the just-freed original address - # for the replacement Box (same-size free-then-alloc within one FFI - # call), so swapped_handle and original_handle can be the same - # pointer value. The check above already covers that case correctly. - self.assertEqual(self._free_count(freed, swapped_handle), 1) - - def test_repeated_swaps_on_one_builder(self): - # Each with_archive consumes the handle the previous one returned, so - # a chain of swaps is where a wrong swap surfaces: keeping the - # consumed pointer makes the next call raise UntrackedPointer from - # the native registry. - context = Context() - self.addCleanup(context.close) - builder = Builder(self.test_manifest, context=context) - self.addCleanup(builder.close) - - seen = [builder._handle] - for _ in range(5): - builder.with_archive(self._make_archive()) - self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) - seen.append(builder._handle) - - self.assertTrue(builder.is_valid) - self.assertEqual(builder._owner_pid, os.getpid()) - - def test_context_consumes_signer_but_not_settings(self): - settings = Settings.from_dict({"version_major": 1}) - signer = self._ctx_make_signer() - - context = Context(settings=settings, signer=signer) - self.addCleanup(context.close) - - # The signer pointer moved to the native context builder. - self.assertIsNone(signer._handle) - self.assertEqual(signer._lifecycle_state, LifecycleState.CLOSED) - self.assertTrue(context.has_signer) - - # Settings are copied, not consumed, so the caller still owns them. - self.assertTrue(settings.is_valid) - self.assertEqual(settings._owner_pid, os.getpid()) - settings.close() - - def test_consumed_signer_close_frees_nothing(self): - signer = self._ctx_make_signer() - # Captured before the context consumes it: - # close() nulls the handle, there is no pointer left to identify the free by. - signer_handle = signer._handle - context = Context(signer=signer) - self.addCleanup(context.close) - - freed = self._instrument_frees() - signer.close() - - self.assertEqual(self._free_count(freed, signer_handle), 0, - "closing a consumed Signer freed a pointer the " - "context now owns") - - def test_builder_with_archive_null_return_marks_consumed(self): - builder = Builder(self.test_manifest) - released_handle = builder._handle - archive = self._make_archive() - - # Mimic a non-tag error: native took ownership then failed and dropped - # the value itself, so the handle is marked consumed, not freed. - real_call = c2pa_module._lib.c2pa_builder_with_archive - c2pa_module._lib.c2pa_builder_with_archive = _fail_with_native_error( - b"Other: mocked test error") - - # Instrument before the failure... - freed = self._instrument_frees() - try: - with self.assertRaises(Error): - builder.with_archive(archive) - finally: - c2pa_module._lib.c2pa_builder_with_archive = real_call - - # Nothing left to own after failing. - self.assertIsNone(builder._handle) - self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) - - # A non-tag error marks consumed: no free (a free here would be a - # guarded no-op that dirties the error slot and races a recycled - # address in other threads). - self.assertEqual(self._free_count(freed, released_handle), 0, - "consumed handle was freed instead of marked consumed") - - # close() must not free it either. - builder.close() - self.assertEqual(self._free_count(freed, released_handle), 0, - "close() freed a handle already marked consumed") - - def test_reader_with_fragment_null_return_marks_consumed(self): - init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") - fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") - with open(init_path, "rb") as init: - reader = Reader("video/mp4", init) - released_handle = reader._handle - - # Mimic a non-tag error: native took ownership then failed and dropped - # the value itself, so the handle is marked consumed, not freed. - real_call = c2pa_module._lib.c2pa_reader_with_fragment - c2pa_module._lib.c2pa_reader_with_fragment = _fail_with_native_error( - b"Other: mocked test error") - - # Instrument before failure so any free would be counted. - freed = self._instrument_frees() - try: - with open(init_path, "rb") as init, \ - open(fragment_path, "rb") as frag: - with self.assertRaises(Error): - reader.with_fragment("video/mp4", init, frag) - finally: - c2pa_module._lib.c2pa_reader_with_fragment = real_call - - self.assertIsNone(reader._handle) - self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) - - # A non-tag error marks consumed: no free. - self.assertEqual(self._free_count(freed, released_handle), 0, - "consumed handle was freed instead of marked consumed") - - # close() must not free it either. - reader.close() - self.assertEqual(self._free_count(freed, released_handle), 0, - "close() freed a handle already marked consumed") - - def test_reader_with_fragment_ffi_raise_frees_self(self): - # If the ctypes call itself raises, the failure runs - # through the except BaseException branch, which frees the handle. - # with_fragment must free exactly once and leave nothing for close(). - init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") - fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") - with open(init_path, "rb") as init: - reader = Reader("video/mp4", init) - released_handle = reader._handle - - def _raise(*_args): - raise RuntimeError("boom") - - real_call = c2pa_module._lib.c2pa_reader_with_fragment - c2pa_module._lib.c2pa_reader_with_fragment = _raise - - # Instrument before the failure so the eager free is counted. - freed = self._instrument_frees() - try: - with open(init_path, "rb") as init, \ - open(fragment_path, "rb") as frag: - with self.assertRaises(Error): - reader.with_fragment("video/mp4", init, frag) - finally: - c2pa_module._lib.c2pa_reader_with_fragment = real_call - - self.assertIsNone(reader._handle) - self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) - - # The error path frees the old handle. - self.assertEqual(self._free_count(freed, released_handle), 1, - "error path did not free the old handle exactly once") - - # close() must not free it again. - reader.close() - self.assertEqual(self._free_count(freed, released_handle), 1, - "close() freed a handle the error path already freed") - - # Consume-and-return ownership: the native call can take the handle partway - # through the call, so a null return does not always say on its own - # whether the handle was consumed or not, warranting further checks/bookkeeping. - - @staticmethod - def _is_pre_consume_rejection(error_message): - """True if this native error means ownership never transferred. - """ - if not error_message: - return False - return ManagedResource._is_pre_consume_rejection(error_message) - - def _stale_reader_handle(self): - """A freed, untracked pointer, captured before close() nulls it. - - Take a fresh one per call: recycled addresses become tracked again. - """ - init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") - with open(init_path, "rb") as init: - victim = Reader("video/mp4", init) - stale = ctypes.cast(victim._handle, - ctypes.POINTER(c2pa_module.C2paReader)) - victim.close() - return stale - - @staticmethod - def _untracked_reader_handle(): - """A pointer the native registry never handed out. - - Rejected like a stale handle, but not a freed address, so it cannot - be recycled and start passing the registry lookup. - """ - buf = ctypes.create_string_buffer(64) - return (ctypes.cast(buf, ctypes.POINTER(c2pa_module.C2paReader)), - buf) - - def test_with_fragment_pre_consume_rejection_does_not_leak(self): - # A handle dropped on this path leaks one reader per call. - init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") - fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") - - for _ in range(25): - with open(init_path, "rb") as init: - reader = Reader("video/mp4", init) - real_handle = reader._handle - reader._handle = self._stale_reader_handle() - try: - with open(init_path, "rb") as init, \ - open(fragment_path, "rb") as frag: - with self.assertRaises(Error): - reader.with_fragment("video/mp4", init, frag) - finally: - reader._handle = real_handle - # Still owns a working handle every time round. - self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) - self.assertTrue(reader.json()) - reader.close() - - def test_reader_with_empty_manifest_data_never_calls_native(self): - """End-to-end: the guard is wired into the public path, not just - available as a helper.""" - context = Context() - self.addCleanup(context.close) - with open(os.path.join(FIXTURES_DIR, - DEFAULT_TEST_FILE_NAME), "rb") as image: - image_bytes = image.read() - - freed = self._instrument_frees() - - with self.assertRaises(Error): - Reader("image/jpeg", io.BytesIO(image_bytes), - manifest_data=b"", context=context) - - # The guard raises before the FFI call, so the reader handle is still - # the binding's to free: exactly one free, and no abandoned handle. - self.assertEqual(len(freed), 1) - - def test_check_cstr_arg_rejects_none_and_embedded_nul(self): - """Both cases would reach native as something other than the caller - passed: None as a null pointer, an embedded NUL as a short string.""" - with self.assertRaises(Error): - c2pa_module._check_cstr_arg('format', None) - - with self.assertRaises(Error): - c2pa_module._check_cstr_arg('format', "image/\x00jpeg") - - c2pa_module._check_cstr_arg('format', "image/jpeg") - c2pa_module._check_cstr_arg('format', b"") - - def test_format_embeddable_null_out_pointer_raises_not_crashes(self): - real = c2pa_module._lib.c2pa_format_embeddable - c2pa_module._lib.c2pa_format_embeddable = ( - lambda fmt, data, size, out: 128) - try: - with self.assertRaises(Error): - format_embeddable("image/jpeg", b"junk") - finally: - c2pa_module._lib.c2pa_format_embeddable = real - - def test_check_bytes_arg_rejects_none_and_empty(self): - for bad in (None, b""): - with self.assertRaises(Error): - c2pa_module._check_bytes_arg('manifest_data', bad) - - c2pa_module._check_bytes_arg('manifest_data', b"x") - - def test_check_handle_arg_rejects_null(self): - """A null handle is a NullParameter on both native versions.""" - with self.assertRaises(Error): - c2pa_module._check_handle_arg('stream', None) - - c2pa_module._check_handle_arg( - 'stream', ctypes.cast(1, ctypes.c_void_p)) - - def test_repeated_with_fragment_does_not_accumulate_streams(self): - """Repeated with_fragment Reader calls should not accumulate streams. - """ - init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") - fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") - - with open(init_path, "rb") as init: - reader = Reader("video/mp4", init) - self.addCleanup(reader.close) - - superseded = [] - for _ in range(25): - with open(init_path, "rb") as init, \ - open(fragment_path, "rb") as frag: - reader.with_fragment("video/mp4", init, frag) - self.assertLessEqual(len(reader._fragment_streams), 1) - superseded.append(reader._fragment_streams[-1]) - - # Dropping the reference is not enough: the native stream is only - # released by close(), so every superseded wrapper must be closed. - self.assertTrue(all(s.closed for s in superseded[:-1])) - - def test_with_archive_post_consume_failure_consumes_handle(self): - # Ownership taken, then the operation failed: - # The handle is gone, so close() must not free it again. - builder = Builder(json.dumps( - {"claim_generator_info": [{"name": "test", "version": "0.1"}], - "assertions": []})) - consumed_handle = builder._handle - - with self.assertRaises(Error): - builder.with_archive(io.BytesIO(b"not a valid archive")) - - self.assertIsNone(builder._handle) - self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) - - freed = self._instrument_frees() - builder.close() - self.assertEqual(self._free_count(freed, consumed_handle), 0, - "close() freed a handle the FFI already consumed") - - def test_with_fragment_marshalling_error_keeps_handle(self): - # Never reaches native code, so nothing was consumed. - init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") - with open(init_path, "rb") as init: - reader = Reader("video/mp4", init) - real_handle = reader._handle - - def _bad_marshalling(*_args): - raise ctypes.ArgumentError("wrong argument type") - - real_call = c2pa_module._lib.c2pa_reader_with_fragment - c2pa_module._lib.c2pa_reader_with_fragment = _bad_marshalling - try: - with open(init_path, "rb") as init, \ - open(init_path, "rb") as frag: - with self.assertRaises(ctypes.ArgumentError): - reader.with_fragment("video/mp4", init, frag) - finally: - c2pa_module._lib.c2pa_reader_with_fragment = real_call - - self.assertIs(reader._handle, real_handle) - self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) - self.assertTrue(reader.json(), - "a marshalling failure never reached the FFI, so " - "the reader must still be usable") - - reader.close() - - def test_unknown_failure_drops_handle_without_freeing(self): - # Ownership unknowable, so the handle is let go rather than freed. - # Needs a mock: every real null return sets an error. - init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") - fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") - with open(init_path, "rb") as init: - reader = Reader("video/mp4", init) - - consumed_handle = reader._handle - # Simulate an error being set - real_call = c2pa_module._lib.c2pa_reader_with_fragment - c2pa_module._lib.c2pa_reader_with_fragment = _fail_with_native_error( - b"Other: mocked test error") - try: - with open(init_path, "rb") as init, \ - open(fragment_path, "rb") as frag: - with self.assertRaises(Error): - reader.with_fragment("video/mp4", init, frag) - finally: - c2pa_module._lib.c2pa_reader_with_fragment = real_call - - # Unplaceable, so the handle is let go rather than freed twice. - self.assertIsNone(reader._handle) - self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) - - freed = self._instrument_frees() - reader.close() - self.assertEqual(self._free_count(freed, consumed_handle), 0, - "close() freed a handle of unknown ownership") - - def test_pre_consume_rejection_is_typed(self): - # Rejections arrive wrapped as "Other: UntrackedPointer: 0x...". - self.assertTrue(self._is_pre_consume_rejection( - "Other: UntrackedPointer: 0x600001234567")) - self.assertTrue(self._is_pre_consume_rejection( - "Other: WrongPointerType: 0x600001234567")) - self.assertFalse(self._is_pre_consume_rejection( - "Verify: invalid JUMBF header")) - self.assertFalse(self._is_pre_consume_rejection(None)) - self.assertFalse(self._is_pre_consume_rejection("")) - - def test_pre_consume_tags_still_match_the_native_wording(self): - # Classification keys on error text (the numeric code is not - # exported), so a native rename would silently misjudge ownership. - init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") - fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") - with open(init_path, "rb") as init: - reader = Reader("video/mp4", init) - real_handle = reader._handle - - reader._handle = self._stale_reader_handle() - try: - with open(init_path, "rb") as init, \ - open(fragment_path, "rb") as frag: - with self.assertRaises(Error) as caught: - reader.with_fragment("video/mp4", init, frag) - finally: - reader._handle = real_handle - - message = str(caught.exception) - self.assertTrue( - self._is_pre_consume_rejection(message), - f"rejection wording does not match _PRE_CONSUME_ERROR_TAGS, " - f"so ownership will be misjudged: {message!r}") - reader.close() - - def test_stale_handle_is_actually_rejected_every_time(self): - # A handle that stopped being rejected would quietly measure the - # success path. Uses the never-allocated buffer: freed addresses get - # recycled and start passing the registry lookup. - init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") - fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") - - for _ in range(25): - with open(init_path, "rb") as init: - reader = Reader("video/mp4", init) - real_handle = reader._handle - bogus, _buf = self._untracked_reader_handle() - reader._handle = bogus - try: - with open(init_path, "rb") as init, \ - open(fragment_path, "rb") as frag: - with self.assertRaises(Error) as caught: - reader.with_fragment("video/mp4", init, frag) - self.assertTrue( - self._is_pre_consume_rejection( - str(caught.exception)), - "the bogus handle was not rejected, so this stopped " - "exercising the pre-consume path") - finally: - reader._handle = real_handle - reader.close() - - def test_perf_scenario_bogus_handle_is_rejected(self): - # The perf scenarios use a plain buffer so looping does not swamp - # the measurement. It still has to produce a real rejection. - init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") - fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") - with open(init_path, "rb") as init: - reader = Reader("video/mp4", init) - real_handle = reader._handle - - bogus, _buf = self._untracked_reader_handle() - reader._handle = bogus - try: - with open(init_path, "rb") as init, \ - open(fragment_path, "rb") as frag: - with self.assertRaises(Error) as caught: - reader.with_fragment("video/mp4", init, frag) - finally: - reader._handle = real_handle - - self.assertTrue( - self._is_pre_consume_rejection(str(caught.exception)), - "the perf bogus handle was not rejected, so " - "with_fragment_pre_consume_rejection measures nothing") - # Handle kept, so the reader still works and frees normally. - self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) - self.assertTrue(reader.json()) - reader.close() - - def test_every_null_return_sets_its_own_error(self): - # Each null-returning path must report the error it set itself, never - # one left behind by an earlier call. - init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") - fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") - - # Set a recognizable error, so anything stale is caught by the - # assertNotIn checks. - c2pa_module._lib.c2pa_error_set_last( - b"NotSupported: planted by the test") - - # Pre-consume rejection: reports UntrackedPointer, not NotSupported. - with open(init_path, "rb") as init: - reader = Reader("video/mp4", init) - real_handle = reader._handle - reader._handle = self._stale_reader_handle() - try: - with open(init_path, "rb") as init, \ - open(fragment_path, "rb") as frag: - with self.assertRaises(Error) as caught: - reader.with_fragment("video/mp4", init, frag) - finally: - reader._handle = real_handle - self.assertIn("UntrackedPointer", str(caught.exception)) - self.assertNotIn("NotSupported", str(caught.exception)) - reader.close() - - # Post-consume failure: reports the operation error, not the - # UntrackedPointer left by the step above. - builder = Builder(json.dumps( - {"claim_generator_info": [{"name": "test", "version": "0.1"}], - "assertions": []})) - with self.assertRaises(Error) as caught: - builder.with_archive(io.BytesIO(b"not a valid archive")) - self.assertNotIn("UntrackedPointer", str(caught.exception)) - builder.close() - - def test_pre_consume_classification_holds_across_threads(self): - # The error slot is thread-local, so concurrent calls do not mask - # each other's errors and misjudge ownership. - init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") - fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") - init_bytes = open(init_path, "rb").read() - frag_bytes = open(fragment_path, "rb").read() - problems = [] - - def worker(): - for _ in range(10): - reader = Reader("video/mp4", io.BytesIO(init_bytes)) - real_handle = reader._handle - # A never-allocated buffer, not a freed pointer: with several - # threads churning the allocator, a freed address gets reused - # and starts passing the registry lookup, which would end the - # iteration in a real consume instead of a rejection. - bogus, _buf = self._untracked_reader_handle() - reader._handle = bogus - try: - reader.with_fragment("video/mp4", - io.BytesIO(init_bytes), - io.BytesIO(frag_bytes)) - problems.append("rejection did not raise") - except Error as e: - if not self._is_pre_consume_rejection(str(e)): - problems.append(f"misclassified: {str(e)[:60]}") - finally: - reader._handle = real_handle - if reader._lifecycle_state != LifecycleState.ACTIVE: - problems.append("handle dropped on a rejection") - reader.close() - - threads = [threading.Thread(target=worker) for _ in range(6)] - for t in threads: - t.start() - for t in threads: - t.join() - - self.assertEqual(problems, [], - "ownership was misjudged under concurrency") - - def test_read_native_error_returns_none_for_an_empty_message(self): - # c2pa_error() returns an owned pointer to "" when no error is set, - # never NULL, so the pointer cannot be the "is there an error" test. - c2pa_module._write_no_error_marker() - original = c2pa_module._lib.c2pa_error - empty = ctypes.create_string_buffer(b"") - - try: - c2pa_module._lib.c2pa_error = lambda: ctypes.cast( - empty, ctypes.c_void_p).value - self.assertIsNone( - c2pa_module._read_native_error(), - "an empty native message must read as None, otherwise " - "callers' 'if error:' checks are only accidentally right") - finally: - c2pa_module._lib.c2pa_error = original - - def test_null_return_with_no_native_error_is_treated_as_consumed(self): - # A null with no error of its own is the case that breaks without - # the marker: - # the slot still held whatever an unrelated, earlier call on this same - # (pooled) thread left behind, and a stale UntrackedPointer/ - # WrongPointerType tag would make this call believe it still owned a - # handle the native side already dropped. - init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") - fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") - - # A stale, unrelated tag left by a prior call on this thread. - c2pa_module._lib.c2pa_error_set_last( - b"UntrackedPointer: 0xdeadbeef") - - with open(init_path, "rb") as init: - reader = Reader("video/mp4", init) - consumed_handle = reader._handle - - real_call = c2pa_module._lib.c2pa_reader_with_fragment - # The fake native call sets no error of its own, - # the marker planted by _invoke_consume is left in the slot. - c2pa_module._lib.c2pa_reader_with_fragment = ( - lambda r, f, s, frag: None) - freed = self._instrument_frees() - try: - with open(init_path, "rb") as init, \ - open(fragment_path, "rb") as frag: - with self.assertRaises(Error): - reader.with_fragment("video/mp4", init, frag) - finally: - c2pa_module._lib.c2pa_reader_with_fragment = real_call - - # Ownership is unknown, so the handle is freed once. c2pa_free - # returns -1 if native had already taken the value. - self.assertEqual(self._free_count(freed, consumed_handle), 1) - - # Backfilling a pointer minted by a direct FFI call. Builder.from_archive - # is the only production caller of _wrap_native_handle, so these are the - # only tests that drive the primitive as the generic entry point it is. - - def _raw_builder_handle(self): - manifest = json.dumps( - {"claim_generator": "raw_ffi_test", "format": "image/jpeg"} - ).encode("utf-8") - handle = c2pa_module._lib.c2pa_builder_from_json(manifest) - self.assertTrue(handle, "the FFI did not return a builder pointer") - return handle - - def test_wrap_raw_ffi_builder_pointer(self): - builder = Builder._wrap_native_handle(self._raw_builder_handle()) - - self.assertTrue(builder.is_valid) - self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) - self.assertEqual(builder._owner_pid, os.getpid()) - - archive = io.BytesIO() - builder.to_archive(archive) - self.assertTrue(archive.getvalue()) - - builder.close() - self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) - self.assertIsNone(builder._handle) - - def test_wrap_raw_ffi_settings_pointer(self): - handle = c2pa_module._lib.c2pa_settings_new() - self.assertTrue(handle) - - settings = Settings._wrap_native_handle(handle) - try: - self.assertTrue(settings.is_valid) - self.assertEqual(settings._owner_pid, os.getpid()) - settings.set("version_major", "1") - finally: - settings.close() - - def test_wrap_raw_ffi_context_pointer(self): - handle = c2pa_module._lib.c2pa_context_new() - self.assertTrue(handle) - - context = Context._wrap_native_handle(handle) - try: - self.assertTrue(context.is_valid) - self.assertEqual(context._owner_pid, os.getpid()) - # Handing the wrapped pointer back to the FFI proves it is live. - builder = Builder(self.test_manifest, context=context) - self.assertTrue(builder.is_valid) - builder.close() - finally: - context.close() - - def test_wrapped_raw_pointer_freed_exactly_once(self): - handle = self._raw_builder_handle() - freed = self._instrument_frees() - - builder = Builder._wrap_native_handle(handle) - builder.close() - builder.close() - del builder - gc.collect() - - self.assertEqual(self._free_count(freed, handle), 1, - "wrapped handle not freed exactly once") - - def test_wrap_supplies_defaults(self): - # _init_attrs() runs on the wrap path, so an instance built around a - # raw handle still has everything the rest of the class reads. - builder = Builder._wrap_native_handle(self._raw_builder_handle()) - self.addCleanup(builder.close) - - self.assertIsNone(builder._context) - self.assertFalse(builder._has_context_signer) - - def test_init_attrs_covers_what_init_sets(self): - # Anything __init__ sets but _init_attrs() misses is absent on a - # wrapped instance, which is the trap _init_attrs() exists to close. - for cls in (Builder, Context, Reader, Signer): - with self.subTest(cls=cls.__name__): - defaulted = set(re.findall( - r"self\.(_[a-z][a-z0-9_]*)\s*=", - inspect.getsource(cls._init_attrs))) - assigned = set(re.findall( - r"self\.(_[a-z][a-z0-9_]*)\s*=", - inspect.getsource(cls.__init__))) - self.assertEqual( - assigned - defaulted, set(), - f"{cls.__name__}.__init__ sets attributes that " - f"_init_attrs() does not default") - - def test_init_attrs_overrides_chain_to_super(self): - # A subclass of these would silently lose the parent's defaults if - # the chain were broken. - for cls in (Builder, Context, Reader, Signer): - with self.subTest(cls=cls.__name__): - self.assertIn( - "super()._init_attrs()", - inspect.getsource(cls._init_attrs), - f"{cls.__name__}._init_attrs() does not chain to super()") - - def test_wrap_raw_ffi_signer_pointer(self): - # Signer._release() reads _callback_cb, so a wrap that skipped the - # defaults would fail during cleanup rather than at the wrap. - freed = self._instrument_frees() - - signer = Signer._wrap_native_handle(0xABCD) - self.assertIsNone(signer._callback_cb) - self.assertEqual(signer._owner_pid, os.getpid()) - - # Cleanup swallows a failing _release(), so the error log is the only - # way to see one. - with self.assertNoLogs("c2pa", level="ERROR"): - signer.close() - - self.assertEqual(freed, [0xABCD]) - - def test_signer_release_clears_callback(self): - signer = self._ctx_make_callback_signer() - self.assertIsNotNone(signer._callback_cb) - - signer.close() - - self.assertIsNone(signer._callback_cb) - - def test_builder_release_clears_context(self): - context = Context() - self.addCleanup(context.close) - builder = Builder(self.test_manifest, context=context) - - builder.close() - - self.assertIsNone(builder._context, - "closed Builder still pins its Context") - # The Builder does not own the Context, so it must not close it. - self.assertTrue(context.is_valid) - - def test_consumed_reader_closes_backing_file(self): - # A failed with_fragment consumes the reader. - # # Reader(path) opened the backing file itself, - # so nothing else will ever close it. - reader = Reader(DEFAULT_TEST_FILE) - backing_file = reader._backing_file - self.assertFalse(backing_file.closed) - - # Simulate an error being set - real_call = c2pa_module._lib.c2pa_reader_with_fragment - c2pa_module._lib.c2pa_reader_with_fragment = _fail_with_native_error( - b"Other: mocked test error") - try: - with open(DEFAULT_TEST_FILE, "rb") as main, \ - open(DEFAULT_TEST_FILE, "rb") as frag: - with self.assertRaises(Error): - reader.with_fragment("image/jpeg", main, frag) - finally: - c2pa_module._lib.c2pa_reader_with_fragment = real_call - - self.assertTrue(backing_file.closed, - "consumed Reader leaked its backing file") - - def test_consumed_builder_releases_context(self): - context = Context() - self.addCleanup(context.close) - builder = Builder(self.test_manifest, context=context) - archive = self._make_archive() - - # Simulate an error being set - real_call = c2pa_module._lib.c2pa_builder_with_archive - c2pa_module._lib.c2pa_builder_with_archive = _fail_with_native_error( - b"Other: mocked test error") - try: - with self.assertRaises(Error): - builder.with_archive(archive) - finally: - c2pa_module._lib.c2pa_builder_with_archive = real_call - - self.assertIsNone(builder._context, - "consumed Builder still pins its Context") - self.assertTrue(context.is_valid) - - def test_context_takes_callback_before_consuming_signer(self): - # Consuming the signer releases its callback reference, - # so the Context has to take it first or the callback dies with the signer. - signer = self._ctx_make_callback_signer() - callback = signer._callback_cb - self.assertIsNotNone(callback) - - context = Context(signer=signer) - self.addCleanup(context.close) - - self.assertIs(context._signer_callback_cb, callback) - self.assertIsNone(signer._callback_cb) - - def test_reader_close_closes_backing_file(self): - # _close_streams reads the attrs _init_attrs() defaults, so this is - # the regression guard for reading them directly. - reader = Reader(DEFAULT_TEST_FILE) - reader.json() - backing_file = reader._backing_file - self.assertIsNotNone(backing_file) - - reader.close() - - self.assertTrue(backing_file.closed, "Reader left its file open") - self.assertIsNone(reader._backing_file) - self.assertIsNone(reader._manifest_json_str_cache) - self.assertIsNone(reader._manifest_data_cache) - - def test_consumed_reader_clears_caches(self): - # Consuming marks the reader closed, so close() will not run later. - # Anything cleanup owes the object has to happen at consume time. - reader = Reader(DEFAULT_TEST_FILE) - reader.json() - self.assertIsNotNone(reader._manifest_json_str_cache) - - # Simulate an error being set - real_call = c2pa_module._lib.c2pa_reader_with_fragment - c2pa_module._lib.c2pa_reader_with_fragment = _fail_with_native_error( - b"Other: mocked test error") - try: - with open(DEFAULT_TEST_FILE, "rb") as main, \ - open(DEFAULT_TEST_FILE, "rb") as frag: - with self.assertRaises(Error): - reader.with_fragment("image/jpeg", main, frag) - finally: - c2pa_module._lib.c2pa_reader_with_fragment = real_call - - self.assertIsNone(reader._manifest_json_str_cache, - "consumed Reader kept its manifest cache") - self.assertIsNone(reader._manifest_data_cache) - - def test_reader_del_clears_caches(self): - # __del__ goes through _cleanup_resources, not close(), so cache - # clearing has to live somewhere both paths reach. - reader = Reader(DEFAULT_TEST_FILE) - reader.json() - self.assertIsNotNone(reader._manifest_json_str_cache) - - # __del__ runs _cleanup_resources directly, so drive that rather than - # dropping the reference: the assertions need the object afterwards. - reader._cleanup_resources() - - self.assertIsNone(reader._manifest_json_str_cache, - "cleanup left the manifest cache alive") - self.assertIsNone(reader._manifest_data_cache) - - def test_from_archive_frees_handle_when_wrap_fails(self): - # The wrap raising means no Python object took ownership, so - # from_archive still holds the handle and has to free it. - archive = self._make_archive() # closes a Builder; keep it off the count - freed = self._instrument_frees() - real_wrap = Builder._wrap_native_handle - - def _boom(*args, **kwargs): - raise Error("wrap failed") - - Builder._wrap_native_handle = _boom - try: - with self.assertRaises(Error): - Builder.from_archive(archive) - finally: - Builder._wrap_native_handle = real_wrap - - self.assertEqual(len(freed), 1, - "from_archive leaked the handle when the wrap failed") - - def test_sign_failure_chains_the_original_exception(self): - # The wrapper re-raises as C2paError; losing __cause__ hides which - # call actually failed. - builder = Builder(self.test_manifest) - signer = self._ctx_make_signer() - self.addCleanup(signer.close) - - sentinel = RuntimeError("native call blew up") - real_sign = c2pa_module._lib.c2pa_builder_sign - - def _boom(*args): - raise sentinel - - c2pa_module._lib.c2pa_builder_sign = _boom - try: - with self.assertRaises(Error) as ctx: - builder.sign(signer, "image/jpeg", - io.BytesIO(b"x"), io.BytesIO()) - finally: - c2pa_module._lib.c2pa_builder_sign = real_sign - - self.assertIs(ctx.exception.__cause__, sentinel, - "signing error dropped the original exception") - -class TestErrorPlumbing(unittest.TestCase): - """Covers the error helpers themselves, which had no direct tests.""" - - def _set_native_error(self, text): - c2pa_module._lib.c2pa_error_set_last(text.encode('utf-8')) - - def test_every_error_tag_maps_to_its_typed_subclass(self): - # The wire text is "Tag: message"; each tag gets its own subclass so - # callers can catch precisely. - tags = [ - "Assertion", "AssertionNotFound", "Decoding", "Encoding", - "FileNotFound", "Io", "Json", "Manifest", "ManifestNotFound", - "NotSupported", "Other", "RemoteManifest", "ResourceNotFound", - "Signature", "Verify", "UntrackedPointer", "WrongPointerType", - ] - for tag in tags: - with self.subTest(tag=tag): - expected = getattr(Error, tag) - with self.assertRaises(expected): - c2pa_module._raise_typed_c2pa_error(f"{tag}: detail") - - def test_unmapped_tag_falls_back_to_base_error(self): - with self.assertRaises(Error) as ctx: - c2pa_module._raise_typed_c2pa_error("Nonsense: detail") - # Base class only: no subclass should claim an unknown tag. - self.assertIs(type(ctx.exception), Error) - - def test_check_ffi_operation_result_raises_with_native_message(self): - self._set_native_error("Io: disk exploded") - with self.assertRaises(Error) as ctx: - c2pa_module._check_ffi_operation_result(None, "fallback text") - self.assertIn("disk exploded", str(ctx.exception)) - - def test_check_ffi_operation_result_uses_fallback_when_slot_empty(self): - # The slot is sticky and thread-local, so a fresh thread is the only - # way to observe it unset. - captured = [] - - def run(): - try: - c2pa_module._check_ffi_operation_result(None, "fallback text") - except BaseException as e: # noqa: BLE001 - reported below - captured.append(e) - - t = threading.Thread(target=run) - t.start() - t.join() - - self.assertEqual(len(captured), 1, "expected a raise on failure") - self.assertIsInstance(captured[0], Error) - self.assertIn("fallback text", str(captured[0])) - - def test_check_ffi_operation_result_passes_success_through(self): - self.assertEqual( - c2pa_module._check_ffi_operation_result(42, "unused"), 42) - - def test_stream_creation_failure_reports_a_real_message(self): - # Regression: used to raise bare Exception("...: None") instead of the - # native error message. - real = c2pa_module._lib.c2pa_create_stream - c2pa_module._lib.c2pa_create_stream = lambda *a: None - try: - # With an error set, the message must carry it. - self._set_native_error("Io: stream refused") - with self.assertRaises(Error) as ctx: - c2pa_module.Stream(io.BytesIO(b"x")) - self.assertIn("stream refused", str(ctx.exception)) - - # With the slot unset, the old code raised bare Exception. - captured = [] - - def run(): - try: - c2pa_module.Stream(io.BytesIO(b"x")) - except BaseException as e: # noqa: BLE001 - reported below - captured.append(e) - - t = threading.Thread(target=run) - t.start() - t.join() - - self.assertEqual(len(captured), 1, "expected a raise on failure") - self.assertIsInstance(captured[0], Error, "must be typed, not bare") - self.assertNotIn("None", str(captured[0])) - finally: - c2pa_module._lib.c2pa_create_stream = real - - def test_supported_mime_types_raises_instead_of_returning_empty(self): - # Regression: `if error:` was always False, so a native failure - # returned an empty list instead of raising. Only visible with the - # slot unset, hence the fresh thread. - captured = [] - - def run(): - try: - captured.append( - c2pa_module._get_supported_mime_types( - lambda count: None, None)) - except BaseException as e: # noqa: BLE001 - reported below - captured.append(e) - - t = threading.Thread(target=run) - t.start() - t.join() - - self.assertIsInstance(captured[0], Error, "failed lookup returned data") - - def test_supported_mime_types_reports_the_native_message(self): - self._set_native_error("Io: mime lookup failed") - with self.assertRaises(Error) as ctx: - c2pa_module._get_supported_mime_types(lambda count: None, None) - self.assertIn("mime lookup failed", str(ctx.exception)) - - def test_reading_an_error_does_not_leave_it_readable(self): - """An error is reportable once, by the reader that observes it. - """ - self._set_native_error("Io: read me once") - - self.assertEqual( - c2pa_module._read_native_error(), "Io: read me once") - self.assertIsNone(c2pa_module._read_native_error(), "reported twice") - - def test_import_learns_no_marker_before_first_use(self): - result = subprocess.run( - [sys.executable, "-c", - "import c2pa.c2pa as m\n" - "assert m._marker_text_for.cache_info().currsize == 0, " - "'learned at import'\n" - "m._read_native_error()\n" - "assert m._marker_text(), 'never learned on first use'\n"], - capture_output=True, text=True, timeout=120) - self.assertEqual(result.returncode, 0, result.stderr[-2000:]) - - def test_the_no_error_marker_never_reaches_a_caller(self): - """The marker is internal, not a message for users.""" - marker = c2pa_module._marker_text() - - c2pa_module._write_no_error_marker() - self.assertIsNone(c2pa_module._read_native_error()) - - c2pa_module._write_no_error_marker() - with self.assertRaises(Error) as ctx: - c2pa_module._check_ffi_operation_result(None, "fallback: {}") - self.assertNotIn(marker, str(ctx.exception)) - - def test_a_failure_after_a_null_read_does_not_inherit_the_old_message(self): - """The message surviving a NULL read must not become someone's error.""" - c2pa_module._write_no_error_marker() - c2pa_module._lib.c2pa_error_set_last(b"Io: belongs to an earlier call") - - original = c2pa_module._lib.c2pa_error - try: - c2pa_module._lib.c2pa_error = lambda: None - c2pa_module._read_native_error() - finally: - c2pa_module._lib.c2pa_error = original - - with self.assertRaises(Error) as ctx: - c2pa_module._check_ffi_operation_result( - None, "Later unrelated failure: {}") - - self.assertNotIn("belongs to an earlier call", str(ctx.exception)) - - def test_every_real_rejection_wording_is_classified_as_pre_consume(self): - """Every tag arrives bare or behind the "Other: " wrapper.""" - wrapper = c2pa_module.ManagedResource._NATIVE_ERROR_WRAPPER - classify = c2pa_module.ManagedResource._is_pre_consume_rejection - - for tag in c2pa_module.ManagedResource._PRE_CONSUME_ERROR_TAGS: - bare = f"{tag} some detail" - wrapped = f"{wrapper}{tag} some detail" - self.assertTrue(classify(bare), bare) - self.assertTrue(classify(wrapped), wrapped) - - def test_caller_text_quoting_a_tag_is_not_a_rejection(self): - """A tag inside the message body describes the caller's input. - - Native errors quote caller-supplied strings verbatim: a JSON parse - failure repeats the offending value, an Io failure names the path. - Reading one of those as a pre-consume rejection hands the resource back - as usable after native may already own and have dropped its handle. - """ - classify = c2pa_module.ManagedResource._is_pre_consume_rejection - - forged = ( - 'Json: invalid type: string "NullParameter: x", expected a ' - 'sequence at line 1 column 43', - 'Json: invalid type: string "WrongPointerType: y", expected a ' - 'sequence at line 1 column 46', - "Io: cannot open /tmp/UntrackedPointer: 0xdead.jpg", - "Other: manifest text mentions InvalidBufferSize: in passing", - ) - for message in forged: - self.assertFalse( - classify(message), - f"caller text was read as a pointer rejection: {message!r}") - - def test_a_failing_flush_does_not_strand_the_rest_of_the_queue(self): - """One resource raising must not skip the resources queued behind it. - """ - flushed = [] - - class Recorder: - def __init__(self, name, raises=None): - self.name = name - self.raises = raises - - def _maybe_flush_pending(self): - if self.raises is not None: - raise self.raises - flushed.append(self.name) - - first = Recorder("first") - middle = Recorder("middle", raises=KeyboardInterrupt()) - last = Recorder("last") - - with self.assertLogs("c2pa", level="ERROR"): - with c2pa_module._native_section(): - for resource in (first, middle, last): - c2pa_module._register_for_section_flush(resource) - - self.assertEqual(flushed, ["first", "last"]) - - def test_runtime_does_not_call_error_set_last(self): - """The marker mechanism must not depend on c2pa_error_set_last, - so this module loads against native builds that lack it.""" - for fn in (c2pa_module.ManagedResource._invoke_consume, - c2pa_module._read_native_error, - c2pa_module._write_no_error_marker): - self.assertNotIn( - 'c2pa_error_set_last', inspect.getsource(fn)) - - -class TestMarkerOutlivesPointerConsumptionSemantics(unittest.TestCase): - """The marker is needed for reasons independent of pointer ownership. - - The native error slot is sticky and thread-local, so failure paths - that carry no still need to tell an error this call set from an - earlier, unrelated call left behind. - """ - - def setUp(self): - # Leave no message from an earlier test in this thread's slot. - c2pa_module._write_no_error_marker() - - def test_settings_set_failure_reports_its_own_error(self): - settings = Settings() - self.addCleanup(settings.close) - - c2pa_module._lib.c2pa_error_set_last(b"Signature: earlier task") - self.assertEqual( - c2pa_module._read_native_error(), "Signature: earlier task") - - with self.assertRaises(Error) as ctx: - settings.set("builder.thumbnail.enabled", "not-a-json-value") - - self.assertNotIn("earlier task", str(ctx.exception)) - - def test_one_thread_marker_does_not_clear_another_threads_error(self): - """Marking on one thread must leave another thread's pending error - readable: the slot is per thread, and so is the marker.""" - set_on_worker = threading.Event() - marked_on_main = threading.Event() - seen = {} - - def worker(): - c2pa_module._lib.c2pa_error_set_last(b"Io: worker error") - set_on_worker.set() - marked_on_main.wait(5) - seen["worker"] = c2pa_module._read_native_error() - - thread = threading.Thread(target=worker, daemon=True) - thread.start() - set_on_worker.wait(5) - - c2pa_module._write_no_error_marker() - marked_on_main.set() - thread.join(5) - - self.assertEqual(seen.get("worker"), "Io: worker error") - - -class TestErrorsStillRaiseAfterCleanup(unittest.TestCase): - """Each surface that lost a _clear_error_state() call still reports.""" - - def test_reader_on_garbage_raises(self): - with self.assertRaises(Error): - Reader("image/jpeg", io.BytesIO(b"not an image")).json() - - def test_signer_from_info_with_bad_certs_raises(self): - with self.assertRaises(Error): - c2pa_module.create_signer_from_info(C2paSignerInfo( - alg=b"es256", - sign_cert=b"not a certificate", - private_key=b"not a key", - ta_url=b"", - )) - - def test_ed25519_sign_with_empty_data_raises(self): - with self.assertRaises(Error): - c2pa_module.ed25519_sign(b"", "not a key") - - -class TestConsumeOwnership(unittest.TestCase): - """Ownership of the native handle across the consuming call paths.""" - - def setUp(self): - self.freed = [] - self._real_free = ManagedResource._free_native_ptr - - def counting_free(ptr): - self.freed.append(ptr) - return self._real_free(ptr) - - _patch_free(self, counting_free) - - def test_close_called_during_parallel_call(self): - """Parallel closes handling. - """ - resource = Settings() - spare = Settings() - replacement = spare._handle - # Only test should be able to free. - spare._handle = None - spare._lifecycle_state = LifecycleState.CLOSED - self.freed.clear() - - def close_then_swap(handle): - resource.close() - return replacement - - resource._consume_and_swap(close_then_swap, "swap: {}") - - self.assertIn(replacement, self.freed) - - -class TestContextProviderContract(unittest.TestCase): - """The published ContextProvider contract is is_valid plus - execution_context, and nothing more. - """ - - class _MinimalProvider(ContextProvider): - """Implements exactly what the abstract base class declares.""" - - def __init__(self): - self._inner = Context(Settings()) - - @property - def is_valid(self): - return self._inner.is_valid - - @property - def execution_context(self): - return self._inner.execution_context - - def test_built_in_context_still_gets_in_flight_protection(self): - """The compatibility shim must not silently drop the guard for the - provider that does implement it. - """ - context = Context(Settings()) - with c2pa_module._context_guard(context): - self.assertGreater(context._inflight, 0) - - -class TestLockOrderStaticAnalysis(unittest.TestCase): - """Static analysis over the source: no threads are spawned here. - """ - - def test_no_conflicting_lock_acquisition_order(self): - """No two locks may be nested in opposite orders by different methods. - - Two methods nesting the same pair of locks in opposite order is a - AB/BA deadlock shape: thread 1 holds A and waits for B while - thread 2 holds B and waits for A. - """ - tree = ast.parse(inspect.getsource(c2pa_module)) - - # Every self._X = threading.Lock()/RLock()/Condition() assignment, - # grouped by the class that owns it. - lock_attrs_by_class = {} - for cls in ast.walk(tree): - if not isinstance(cls, ast.ClassDef): - continue - found = set() - for node in ast.walk(cls): - if not (isinstance(node, ast.Assign) - and len(node.targets) == 1): - continue - target = node.targets[0] - if not (isinstance(target, ast.Attribute) - and isinstance(target.value, ast.Name) - and target.value.id == "self"): - continue - value = node.value - if (isinstance(value, ast.Call) - and isinstance(value.func, ast.Attribute) - and value.func.attr in - ("Lock", "RLock", "Condition")): - found.add(target.attr) - if found: - lock_attrs_by_class[cls.name] = found - - def lock_name_for_with(item): - """The lock attribute a `with` item enters, or None.""" - ctx = item.context_expr - # with self._fragment_lock: - if (isinstance(ctx, ast.Attribute) - and isinstance(ctx.value, ast.Name) - and ctx.value.id == "self" - and any(ctx.attr in attrs - for attrs in lock_attrs_by_class.values())): - return ctx.attr - # with self._guarded_op(): returns _op_lock itself, and the - # accessors return the lock they are named for. - lock_by_method = { - "_guarded_op": "_op_lock", - "_live_op_lock": "_op_lock", - "_live_teardown_lock": "_teardown_lock", - } - if (isinstance(ctx, ast.Call) - and isinstance(ctx.func, ast.Attribute) - and ctx.func.attr in lock_by_method - and isinstance(ctx.func.value, ast.Name) - and ctx.func.value.id == "self"): - return lock_by_method[ctx.func.attr] - return None - - def lock_name_for_acquire(node): - """A lock taken with acquire() and released in a finally nests just - as a `with` does, so the scan has to follow it or it silently stops - seeing whole regions. - """ - call = node.value if isinstance(node, ast.Expr) else node - if isinstance(call, ast.UnaryOp) and isinstance(call.op, ast.Not): - call = call.operand - if not (isinstance(call, ast.Call) - and isinstance(call.func, ast.Attribute) - and call.func.attr == "acquire"): - return None - owner = call.func.value - if (isinstance(owner, ast.Attribute) - and isinstance(owner.value, ast.Name) - and owner.value.id == "self" - and any(owner.attr in attrs - for attrs in lock_attrs_by_class.values())): - return owner.attr - return None - - def acquires_in_test(node): - """Lock taken by `if not self._X.acquire(...)`-style guards.""" - if isinstance(node, ast.If): - return lock_name_for_acquire(node.test) - return None - - def orders_in(node, stack, pairs): - """Record (outer, inner) for every nesting this node contains.""" - if isinstance(node, (ast.With, ast.AsyncWith)): - names = [n for n in (lock_name_for_with(i) - for i in node.items) if n] - for name in names: - if stack: - pairs.add((stack[-1], name)) - stack.append(name) - for child in node.body: - orders_in(child, stack, pairs) - for _ in names: - stack.pop() - return - # A statement list can open a lock partway through via acquire(); - # everything after it in that list is nested inside. - for field, value in ast.iter_fields(node): - if not isinstance(value, list): - continue - held = [] - for child in value: - if not isinstance(child, ast.stmt): - continue - name = (lock_name_for_acquire(child) - or acquires_in_test(child)) - if name: - if stack: - pairs.add((stack[-1], name)) - stack.append(name) - held.append(name) - continue - orders_in(child, stack, pairs) - for _ in held: - stack.pop() - for child in ast.iter_child_nodes(node): - if not isinstance(child, ast.stmt): - orders_in(child, stack, pairs) - - pairs_by_method = {} - for cls in ast.walk(tree): - if not isinstance(cls, ast.ClassDef): - continue - for method in cls.body: - if not isinstance(method, (ast.FunctionDef, - ast.AsyncFunctionDef)): - continue - pairs = set() - orders_in(method, [], pairs) - if pairs: - pairs_by_method[(cls.name, method.name)] = pairs - - all_pairs = set().union(*pairs_by_method.values()) \ - if pairs_by_method else set() - conflicts = [] - for (outer, inner) in all_pairs: - # Each conflicting pair appears twice in all_pairs, once per direction. - if outer >= inner or (inner, outer) not in all_pairs: - continue - forward = [k for k, v in pairs_by_method.items() - if (outer, inner) in v] - backward = [k for k, v in pairs_by_method.items() - if (inner, outer) in v] - conflicts.append( - "{} nests {} inside {}, but {} nests {} inside {}".format( - forward[0], inner, outer, backward[0], outer, inner)) - - self.assertGreater(len(pairs_by_method), 0, "scan found no nesting") - self.assertEqual(conflicts, [], ", ".join(sorted(set(conflicts)))) - - -if __name__ == '__main__': - unittest.main(warnings='ignore') diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 00000000..ce6a8fda --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,3 @@ +import warnings + +warnings.simplefilter("ignore", category=DeprecationWarning) diff --git a/tests/unit/_support.py b/tests/unit/_support.py new file mode 100644 index 00000000..2f9afa34 --- /dev/null +++ b/tests/unit/_support.py @@ -0,0 +1,222 @@ +# Copyright 2023 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. + +import os +import json +import unittest +import c2pa.c2pa as c2pa_module +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.backends import default_backend +from unittest.mock import patch +from c2pa import C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer +from c2pa.c2pa import ManagedResource + + +TESTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PROJECT_PATH = os.getcwd() +FIXTURES_DIR = os.path.join(TESTS_DIR, "fixtures") +DEFAULT_TEST_FILE_NAME = "C.jpg" +INGREDIENT_TEST_FILE_NAME = "A.jpg" +DEFAULT_TEST_FILE = os.path.join(FIXTURES_DIR, DEFAULT_TEST_FILE_NAME) +INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, INGREDIENT_TEST_FILE_NAME) +ALTERNATIVE_INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, "cloud.jpg") + + +def _patch_free(test, fn): + """Route ManagedResource._free_native_ptr to `fn` until `test` ends.""" + patcher = patch.object( + ManagedResource, '_free_native_ptr', staticmethod(fn)) + patcher.start() + test.addCleanup(patcher.stop) + + +def _fail_with_native_error(tag_bytes): + """Build a mock FFI callable that sets a native error and returns None. + """ + def _mock(*args): + c2pa_module._lib.c2pa_error_set_last(tag_bytes) + return None + return _mock + + +def load_test_settings_json(): + """ + Load default (legacy) trust configuration test settings from a + JSON config file and return its content as JSON-compatible dict. + The return value is used to load settings (thread_local) in tests. + + Returns: + dict: The parsed JSON content as a Python dictionary (JSON-compatible). + + Raises: + FileNotFoundError: If trust_config_test_settings.json is not found. + json.JSONDecodeError: If the JSON file is malformed. + """ + # Locate the file which contains default settings for tests + settings_path = os.path.join(TESTS_DIR, 'trust_config_test_settings.json') + + # Load the located default test settings + with open(settings_path, 'r') as f: + settings_data = json.load(f) + + return settings_data + + +def parse_native_version(): + """ + Parse the expected native SDK version. + + Reads c2pa-rs-preflight-ref.txt instead of c2pa-native-version.txt when + C2PA_PREFLIGHT_RUN is set: that flag is set only by + test-c2pa-rs-source-build.yml's own "Run tests" step, because the + presence of c2pa-rs-preflight-ref.txt in the checked-out tree isn't by + itself proof of anything -- an ordinary build.yml run on a branch that + happens to carry that file (e.g. this PR) still downloads and installs + the real pinned release, not the preflight ref. + + Returns: + str: The semantic version string (e.g. "0.85.2"). + """ + repo_root = os.path.dirname(TESTS_DIR) + if os.environ.get('C2PA_PREFLIGHT_RUN'): + path = os.path.join(repo_root, 'c2pa-rs-preflight-ref.txt') + else: + path = os.path.join(repo_root, 'c2pa-native-version.txt') + with open(path, 'r') as f: + raw = f.read().strip() + # Strip the "c2pa-v" / "c2pa-rc-v" prefix to get the bare semantic version. + return raw.split('v', 1)[1] if 'v' in raw else raw + + +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", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ] + } + + 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) diff --git a/tests/unit/test_binding_base.py b/tests/unit/test_binding_base.py new file mode 100644 index 00000000..1166ec28 --- /dev/null +++ b/tests/unit/test_binding_base.py @@ -0,0 +1,22 @@ +# Copyright 2023 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. + +import unittest +from c2pa.c2pa import sdk_version +from tests.unit._support import parse_native_version + + +class TestC2paSdk(unittest.TestCase): + def test_sdk_version(self): + # This test verifies the native libraries used match the expected version. + self.assertIn(parse_native_version(), sdk_version()) diff --git a/tests/unit/test_builder.py b/tests/unit/test_builder.py new file mode 100644 index 00000000..5589596c --- /dev/null +++ b/tests/unit/test_builder.py @@ -0,0 +1,4631 @@ +# Copyright 2023 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. + +import os +import io +import json +import unittest +import tempfile +import shutil +import threading +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.backends import default_backend +from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, C2paBuilderIntent, C2paDigitalSourceType, Context +from c2pa.c2pa import LifecycleState, load_settings, ed25519_sign, format_embeddable, _encode_format +from tests.unit._support import ( + FIXTURES_DIR, + DEFAULT_TEST_FILE, + INGREDIENT_TEST_FILE, + ALTERNATIVE_INGREDIENT_TEST_FILE, + load_test_settings_json, +) + + +class TestBuilderWithSigner(unittest.TestCase): + def setUp(self): + # Use the fixtures_dir fixture to set up paths + self.data_dir = FIXTURES_DIR + self.testPath = DEFAULT_TEST_FILE + self.testPath2 = INGREDIENT_TEST_FILE + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + self.certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + self.key = key_file.read() + + # Create a local Es256 signer with certs and a timestamp server + self.signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + self.signer = Signer.from_info(self.signer_info) + + self.testPath3 = os.path.join(self.data_dir, "A_thumbnail.jpg") + self.testPath4 = ALTERNATIVE_INGREDIENT_TEST_FILE + + # Define a manifest as a dictionary + self.manifestDefinition = { + "claim_generator": "python_test", + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + "claim_version": 1, + "format": "image/jpeg", + "title": "Python Test Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + + # Define a V2 manifest as a dictionary + self.manifestDefinitionV2 = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + # claim version 2 is the default + # "claim_version": 2, + "format": "image/jpeg", + "title": "Python Test Image V2", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + + # Define an example ES256 callback signer + self.callback_signer_alg = "Es256" + def callback_signer_es256(data: bytes) -> bytes: + private_key = serialization.load_pem_private_key( + self.key, + password=None, + backend=default_backend() + ) + signature = private_key.sign( + data, + ec.ECDSA(hashes.SHA256()) + ) + return signature + self.callback_signer_es256 = callback_signer_es256 + + def _create_ingredient_archive(self, ingredient_json=None): + """Helper: create an ingredient archive from a single ingredient.""" + if ingredient_json is None: + ingredient_json = {"title": "photo.jpg", "relationship": "componentOf"} + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + } + ] + }, + } + ], + } + builder = Builder.from_json(manifest) + with open(self.testPath, "rb") as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + archive = io.BytesIO() + builder.to_archive(archive) + builder.close() + archive.seek(0) + return archive + + def test_can_retrieve_builder_supported_mimetypes(self): + result1 = Builder.get_supported_mime_types() + self.assertTrue(len(result1) > 0) + + # Cache-hit + result2 = Builder.get_supported_mime_types() + self.assertTrue(len(result2) > 0) + + self.assertEqual(result1, result2) + + def test_reserve_size(self): + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + signer.reserve_size() + + def _local_signer(self): + """An es256 signer with no timestamp authority (keeps signing local). + + ta_url=None maps to a NULL pointer, which is how the native side + spells "no TSA". An empty string is not equivalent: it is passed + through as a real, empty URL and signing then fails. + """ + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=None, + ) + signer = Signer.from_info(signer_info) + self.addCleanup(signer.close) + return signer + + def _active_signature_info(self, signed_bytes): + """signature_info of the active manifest in a signed asset.""" + signed_bytes.seek(0) + reader = Reader("image/jpeg", signed_bytes) + try: + manifest_store = json.loads(reader.json()) + finally: + reader.close() + active = manifest_store["manifests"][ + manifest_store["active_manifest"]] + return active.get("signature_info", {}) + + def test_signer_with_none_ta_url_signs_without_timestamp(self): + """ta_url=None must reach native as NULL, meaning "no TSA". + """ + builder = Builder(self.manifestDefinitionV2) + dest = io.BytesIO() + with open(self.testPath, "rb") as source: + builder.sign(self._local_signer(), "image/jpeg", source, dest) + + signature_info = self._active_signature_info(dest) + self.assertTrue(signature_info, "expected a signed active manifest") + self.assertNotIn( + "time", signature_info, + "no timestamp authority was configured, so the signature " + "must not carry a timestamp") + + def _sign_with_ta_url(self, ta_url): + """Sign with ta_url as the only variable. Returns the dest buffer.""" + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=ta_url, + ) + signer = Signer.from_info(signer_info) + self.addCleanup(signer.close) + + builder = Builder(self.manifestDefinitionV2) + dest = io.BytesIO() + with open(self.testPath, "rb") as source: + builder.sign(signer, "image/jpeg", source, dest) + return dest + + def test_signer_with_empty_ta_url_is_not_a_no_tsa_path(self): + """An empty ta_url is a real (empty) URL, not "no TSA". + Only None means "no TSA". + """ + with self.assertRaises(Error): + self._sign_with_ta_url(b"") + + def test_empty_ta_url_is_rejected_as_a_malformed_url(self): + """b"" fails as a URL, which is why it cannot mean "no TSA". + """ + malformed = [ + (b"", "empty"), + (b" ", "whitespace"), + (b"not-a-url", "no scheme or host"), + ] + for value, why in malformed: + with self.subTest(ta_url=value, reason=why): + with self.assertRaises(Error): + self._sign_with_ta_url(value) + + # The control: same signer, same asset, only ta_url differs. + signature_info = self._active_signature_info( + self._sign_with_ta_url(None)) + self.assertNotIn("time", signature_info) + + def test_signer_with_ta_url_signs_with_timestamp(self): + """Control for the None case: a real TSA does add a timestamp. + """ + builder = Builder(self.manifestDefinitionV2) + dest = io.BytesIO() + with open(self.testPath, "rb") as source: + builder.sign(self.signer, "image/jpeg", source, dest) + + signature_info = self._active_signature_info(dest) + self.assertIn( + "time", signature_info, + "a timestamp authority was configured, so the signature " + "must carry a timestamp") + self.assertTrue(signature_info["time"]) + + def test_none_and_empty_ta_url_reach_native_differently(self): + """None must marshal to NULL, b"" to a real (empty) string. + + The native side reads a NULL ta_url as "no timestamp authority" + and any non-NULL pointer as a URL to use, including one pointing at "". + """ + def struct_for(ta_url): + return C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=ta_url, + ) + + # ctypes surfaces a NULL c_char_p as None and an empty one as b"". + self.assertIsNone(struct_for(None).ta_url) + self.assertEqual(struct_for(b"").ta_url, b"") + # A str is encoded rather than passed through untouched. + self.assertEqual( + struct_for("http://timestamp.digicert.com").ta_url, + b"http://timestamp.digicert.com") + + def test_signer_rejects_non_string_ta_url(self): + """Only None, str and bytes are accepted.""" + with self.assertRaises(TypeError): + C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=1234, + ) + + def test_signer_creation_error_alg(self): + signer_info = C2paSignerInfo( + alg=b"not-an-alg", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + with self.assertRaises(Error): + Signer.from_info(signer_info) + + def test_signer_from_callback_error_no_cert(self): + with self.assertRaises(Error): + Signer.from_callback( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=None, + tsa_url="http://timestamp.digicert.com" + ) + + def test_signer_from_callback_error_wrong_url(self): + with self.assertRaises(Error): + Signer.from_callback( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=None, + tsa_url="ftp://timestamp.digicert.com" + ) + + def test_reserve_size_on_closed_signer(self): + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + signer.close() + # Verify signer is closed by testing that operations fail + with self.assertRaises(Error): + signer.reserve_size() + + def test_signer_double_close(self): + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + signer.close() + # Second close should not raise an exception + signer.close() + + def test_builder_detects_malformed_json(self): + with self.assertRaises(Error): + Builder("{this is not json}") + + def test_builder_does_not_allow_sign_after_close(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + builder.close() + with self.assertRaises(Error): + builder.sign(self.signer, "image/jpeg", file, output) + + def test_sign_rejects_none_format(self): + # sign requires an explicit format; None must raise NotSupported. + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + with self.assertRaises(Error.NotSupported): + builder.sign(self.signer, None, file, output) + + def test_sign_accepts_padded_format(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + manifest_bytes = builder.sign( + self.signer, " image/jpeg ", file, output) + self.assertTrue(len(manifest_bytes) > 0) + + def test_builder_does_not_allow_archiving_after_close(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + placeholder_stream = io.BytesIO(bytearray()) + builder.close() + with self.assertRaises(Error): + builder.to_archive(placeholder_stream) + + def test_builder_does_not_allow_changing_remote_url_after_close(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + builder.close() + with self.assertRaises(Error): + builder.set_remote_url("a-remote-url-that-is-not-important-in-this-tests") + + def test_builder_does_not_allow_adding_resource_after_close(self): + builder = Builder(self.manifestDefinition) + placeholder_stream = io.BytesIO(bytearray()) + builder.close() + with self.assertRaises(Error): + builder.add_resource("a-remote-url-that-is-not-important-in-this-tests", placeholder_stream) + + def test_builder_add_thumbnail_resource(self): + builder = Builder(self.manifestDefinition) + with open(self.testPath2, "rb") as thumbnail_file: + builder.add_resource("thumbnail", thumbnail_file) + builder.close() + + def test_builder_double_close(self): + builder = Builder(self.manifestDefinition) + # First close + builder.close() + # Second close should not raise an exception + builder.close() + # Verify builder is closed + with self.assertRaises(Error): + builder.set_no_embed() + + def test_streams_sign_recover_bytes_only(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + manifest_bytes = builder.sign(self.signer, "image/jpeg", file) + self.assertIsNotNone(manifest_bytes) + + def test_streams_sign_with_thumbnail_resource(self): + with open(self.testPath2, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + + with open(self.testPath2, "rb") as thumbnail_file: + builder.add_resource("thumbnail", thumbnail_file) + + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + output.close() + + def test_streams_sign_with_es256_alg_v1_manifest(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertIn("Valid", json_data) + + # Write buffer to file + # output.seek(0) + # with open('/target_path', 'wb') as f: + # f.write(output.getbuffer()) + + output.close() + + def test_streams_sign_with_es256_alg_v1_manifest_to_existing_empty_file(self): + test_file_name = os.path.join(self.data_dir, "temp_data", "temp_signing.jpg") + # Ensure tmp directory exists + os.makedirs(os.path.dirname(test_file_name), exist_ok=True) + + # Ensure the target file exists before opening it in rb+ mode + with open(test_file_name, "wb") as f: + pass # Create empty file + + try: + with open(self.testPath, "rb") as source, open(test_file_name, "w+b") as target: + builder = Builder(self.manifestDefinition) + builder.sign(self.signer, "image/jpeg", source, target) + reader = Reader("image/jpeg", target) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertIn("Valid", json_data) + + finally: + # Clean up... + + if os.path.exists(test_file_name): + os.remove(test_file_name) + + # Also clean up the temp directory if it's empty + temp_dir = os.path.dirname(test_file_name) + if os.path.exists(temp_dir) and not os.listdir(temp_dir): + os.rmdir(temp_dir) + + def test_streams_sign_with_es256_alg_v1_manifest_to_new_dest_file(self): + test_file_name = os.path.join(self.data_dir, "temp_data", "temp_signing.jpg") + # Ensure tmp directory exists + os.makedirs(os.path.dirname(test_file_name), exist_ok=True) + + # A new target/destination file should be created during the test run + try: + with open(self.testPath, "rb") as source, open(test_file_name, "w+b") as target: + builder = Builder(self.manifestDefinition) + builder.sign(self.signer, "image/jpeg", source, target) + reader = Reader("image/jpeg", target) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertIn("Valid", json_data) + + finally: + # Clean up... + + if os.path.exists(test_file_name): + os.remove(test_file_name) + + # Also clean up the temp directory if it's empty + temp_dir = os.path.dirname(test_file_name) + if os.path.exists(temp_dir) and not os.listdir(temp_dir): + os.rmdir(temp_dir) + + def test_streams_sign_with_es256_alg(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + output.close() + + def test_streams_sign_with_es256_alg_2(self): + with open(self.testPath2, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertIn("Valid", json_data) + output.close() + + def test_streams_sign_with_es256_alg_create_intent(self): + """Test signing with CREATE intent and empty manifest.""" + + with open(self.testPath2, "rb") as file: + # Start with an empty manifest + builder = Builder({}) + # Set the intent for creating new content + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.DIGITAL_CREATION + ) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_str = reader.json() + # Verify the manifest was created + self.assertIsNotNone(json_str) + + # Parse the JSON to verify the structure + manifest_data = json.loads(json_str) + active_manifest_label = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_label] + + # Check that assertions exist + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the actions assertion + actions_assertion = None + for assertion in assertions: + if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + + # Verify c2pa.created action exists and there is only one + actions = actions_assertion["data"]["actions"] + created_actions = [ + action for action in actions + if action["action"] == "c2pa.created" + ] + + self.assertEqual(len(created_actions), 1) + + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertEqual(manifest_data["validation_state"], "Valid") + output.close() + + def test_streams_sign_with_es256_alg_create_intent_2(self): + """Test signing with CREATE intent and manifestDefinitionV2.""" + + with open(self.testPath2, "rb") as file: + # Start with manifestDefinitionV2 which has predefined metadata + builder = Builder(self.manifestDefinitionV2) + # Set the intent for creating new content + # If we provided a full manifest, the digital source type from the full manifest "wins" + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.SCREEN_CAPTURE + ) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_str = reader.json() + + # Verify the manifest was created + self.assertIsNotNone(json_str) + + # Parse the JSON to verify the structure + manifest_data = json.loads(json_str) + active_manifest_label = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_label] + + # Verify title from manifestDefinitionV2 is preserved + self.assertIn("title", active_manifest) + self.assertEqual(active_manifest["title"], "Python Test Image V2") + + # Verify claim_generator_info is present + self.assertIn("claim_generator_info", active_manifest) + claim_generator_info = active_manifest["claim_generator_info"] + self.assertIsInstance(claim_generator_info, list) + self.assertGreater(len(claim_generator_info), 0) + + # Check for the custom claim generator info from manifestDefinitionV2 + has_python_test = any( + gen.get("name") == "python_test" and gen.get("version") == "0.0.1" + for gen in claim_generator_info + ) + self.assertTrue(has_python_test, "Should have python_test claim generator") + + # Verify no ingredients for CREATE intent + ingredients_manifest = active_manifest.get("ingredients", []) + self.assertEqual(len(ingredients_manifest), 0, "CREATE intent should have no ingredients") + + # Check that assertions exist + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the actions assertion + actions_assertion = None + for assertion in assertions: + if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + + # Verify c2pa.created action exists and there is only one + actions = actions_assertion["data"]["actions"] + created_actions = [ + action for action in actions + if action["action"] == "c2pa.created" + ] + + self.assertEqual(len(created_actions), 1) + + # Verify the digitalSourceType is present in the created action + created_action = created_actions[0] + self.assertIn("digitalSourceType", created_action) + self.assertIn("digitalCreation", created_action["digitalSourceType"]) + + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertEqual(manifest_data["validation_state"], "Valid") + output.close() + + def test_streams_sign_with_es256_alg_edit_intent(self): + """Test signing with EDIT intent and empty manifest.""" + + with open(self.testPath2, "rb") as file: + # Start with an empty manifest + builder = Builder({}) + # Set the intent for editing existing content + builder.set_intent(C2paBuilderIntent.EDIT) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_str = reader.json() + + # Verify the manifest was created + self.assertIsNotNone(json_str) + + # Parse the JSON to verify the structure + manifest_data = json.loads(json_str) + active_manifest_label = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_label] + + # Check that ingredients exist in the active manifest + self.assertIn("ingredients", active_manifest) + ingredients_manifest = active_manifest["ingredients"] + self.assertIsInstance(ingredients_manifest, list) + self.assertEqual(len(ingredients_manifest), 1) + + # Verify the ingredient has relationship "parentOf" + ingredient = ingredients_manifest[0] + self.assertIn("relationship", ingredient) + self.assertEqual( + ingredient["relationship"], + "parentOf" + ) + + # Check that assertions exist + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the actions assertion + actions_assertion = None + for assertion in assertions: + if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + + # Verify c2pa.opened action exists and there is only one + actions = actions_assertion["data"]["actions"] + opened_actions = [ + action for action in actions + if action["action"] == "c2pa.opened" + ] + + self.assertEqual(len(opened_actions), 1) + + # Verify the c2pa.opened action has the correct structure + opened_action = opened_actions[0] + self.assertIn("parameters", opened_action) + self.assertIn("ingredients", opened_action["parameters"]) + ingredients = opened_action["parameters"]["ingredients"] + self.assertIsInstance(ingredients, list) + self.assertGreater(len(ingredients), 0) + + # Verify each ingredient has url and hash + for ingredient in ingredients: + self.assertIn("url", ingredient) + self.assertIn("hash", ingredient) + + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertEqual(manifest_data["validation_state"], "Valid") + output.close() + + def test_streams_sign_with_es256_alg_with_trust_config(self): + # Run in a separate thread to isolate thread-local settings + result = {} + exception = {} + + def sign_and_validate_with_trust_config(): + try: + # Load trust configuration + settings_dict = load_test_settings_json() + + # Apply the settings (including trust configuration) + # Settings are thread-local, so they won't affect other tests + # And that is why we also run the test in its own thread, so tests are isolated + load_settings(settings_dict) + + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + + # Get validation state with trust config + validation_state = reader.get_validation_state() + + result['json_data'] = json_data + result['validation_state'] = validation_state + output.close() + except Exception as e: + exception['error'] = e + + # Create and start thread + thread = threading.Thread(target=sign_and_validate_with_trust_config) + thread.start() + thread.join() + + # Check for exceptions + if 'error' in exception: + raise exception['error'] + + # Assertions run in main thread + self.assertIn("Python Test", result.get('json_data', '')) + # With trust configuration loaded, validation should return "Trusted" + self.assertIsNotNone(result.get('validation_state')) + self.assertEqual(result.get('validation_state'), "Trusted") + + def test_sign_with_ed25519_alg(self): + with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ed25519.pem"), "rb") as key_file: + key = key_file.read() + + signer_info = C2paSignerInfo( + alg=b"ed25519", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + output.close() + + def test_sign_with_ed25519_alg_with_trust_config(self): + # Run in a separate thread to isolate thread-local settings + result = {} + exception = {} + + def sign_and_validate_with_trust_config(): + try: + # Load trust configuration + settings_dict = load_test_settings_json() + + # Apply the settings (including trust configuration) + # Settings are thread-local, so they won't affect other tests + # And that is why we also run the test in its own thread, so tests are isolated + load_settings(settings_dict) + + with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ed25519.pem"), "rb") as key_file: + key = key_file.read() + + signer_info = C2paSignerInfo( + alg=b"ed25519", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + + # Get validation state with trust config + validation_state = reader.get_validation_state() + + result['json_data'] = json_data + result['validation_state'] = validation_state + output.close() + except Exception as e: + exception['error'] = e + + # Create and start thread + thread = threading.Thread(target=sign_and_validate_with_trust_config) + thread.start() + thread.join() + + # Check for exceptions + if 'error' in exception: + raise exception['error'] + + # Assertions run in main thread + self.assertIn("Python Test", result.get('json_data', '')) + # With trust configuration loaded, validation should return "Trusted" + self.assertIsNotNone(result.get('validation_state')) + self.assertEqual(result.get('validation_state'), "Trusted") + + def test_sign_with_ed25519_alg_2(self): + with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ed25519.pem"), "rb") as key_file: + key = key_file.read() + + signer_info = C2paSignerInfo( + alg=b"ed25519", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + with open(self.testPath2, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + output.close() + + def test_sign_with_ps256_alg(self): + with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ps256.pem"), "rb") as key_file: + key = key_file.read() + + signer_info = C2paSignerInfo( + alg=b"ps256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + output.close() + + def test_sign_with_ps256_alg_2(self): + with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ps256.pem"), "rb") as key_file: + key = key_file.read() + + signer_info = C2paSignerInfo( + alg=b"ps256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + with open(self.testPath2, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted + # self.assertNotIn("validation_status", json_data) + output.close() + + def test_sign_with_ps256_alg_2_with_trust_config(self): + # Run in a separate thread to isolate thread-local settings + result = {} + exception = {} + + def sign_and_validate_with_trust_config(): + try: + # Load trust configuration + settings_dict = load_test_settings_json() + + # Apply the settings (including trust configuration) + # Settings are thread-local, so they won't affect other tests + # And that is why we also run the test in its own thread, so tests are isolated + load_settings(settings_dict) + + with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "ps256.pem"), "rb") as key_file: + key = key_file.read() + + signer_info = C2paSignerInfo( + alg=b"ps256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + with open(self.testPath2, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + + # Get validation state with trust config + validation_state = reader.get_validation_state() + + result['json_data'] = json_data + result['validation_state'] = validation_state + output.close() + except Exception as e: + exception['error'] = e + + # Create and start thread + thread = threading.Thread(target=sign_and_validate_with_trust_config) + thread.start() + thread.join() + + # Check for exceptions + if 'error' in exception: + raise exception['error'] + + # Assertions run in main thread + self.assertIn("Python Test", result.get('json_data', '')) + # With trust configuration loaded, validation should return "Trusted" + self.assertIsNotNone(result.get('validation_state')) + self.assertEqual(result.get('validation_state'), "Trusted") + + def test_archive_sign(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder.from_archive(archive) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + archive.close() + output.close() + + def test_archive_sign_with_trust_config(self): + # Run in a separate thread to isolate thread-local settings + result = {} + exception = {} + + def sign_and_validate_with_trust_config(): + try: + # Load trust configuration + settings_dict = load_test_settings_json() + + # Apply the settings (including trust configuration) + # Settings are thread-local, so they won't affect other tests + # And that is why we also run the test in its own thread, so tests are isolated + load_settings(settings_dict) + + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder.from_archive(archive) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + + # Get validation state with trust config + validation_state = reader.get_validation_state() + + result['json_data'] = json_data + result['validation_state'] = validation_state + archive.close() + output.close() + except Exception as e: + exception['error'] = e + + # Create and start thread + thread = threading.Thread(target=sign_and_validate_with_trust_config) + thread.start() + thread.join() + + # Check for exceptions + if 'error' in exception: + raise exception['error'] + + # Assertions run in main thread + self.assertIn("Python Test", result.get('json_data', '')) + # With trust configuration loaded, validation should return "Trusted" + self.assertIsNotNone(result.get('validation_state')) + self.assertEqual(result.get('validation_state'), "Trusted") + + def test_archive_sign_with_added_ingredient(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder.from_archive(archive) + output = io.BytesIO(bytearray()) + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + archive.close() + output.close() + + def test_archive_sign_with_added_ingredient_with_trust_config(self): + # Run in a separate thread to isolate thread-local settings + result = {} + exception = {} + + def sign_and_validate_with_trust_config(): + try: + # Load trust configuration + settings_dict = load_test_settings_json() + + # Apply the settings (including trust configuration) + # Settings are thread-local, so they won't affect other tests + # And that is why we also run the test in its own thread, so tests are isolated + load_settings(settings_dict) + + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder.from_archive(archive) + output = io.BytesIO(bytearray()) + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + + # Get validation state with trust config + validation_state = reader.get_validation_state() + + result['json_data'] = json_data + result['validation_state'] = validation_state + archive.close() + output.close() + except Exception as e: + exception['error'] = e + + # Create and start thread + thread = threading.Thread(target=sign_and_validate_with_trust_config) + thread.start() + thread.join() + + # Check for exceptions + if 'error' in exception: + raise exception['error'] + + # Assertions run in main thread + self.assertIn("Python Test", result.get('json_data', '')) + # With trust configuration loaded, validation should return "Trusted" + self.assertIsNotNone(result.get('validation_state')) + self.assertEqual(result.get('validation_state'), "Trusted") + + def test_write_ingredient_archive_produces_readable_archive(self): + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "0.1.0"}], + "assertions": [], + } + builder = Builder.from_json(manifest) + ingredient_json = { + "title": "A.jpg", + "relationship": "componentOf", + "instance_id": "ingredient-A", + } + with open(self.testPath, "rb") as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + archive = io.BytesIO() + builder.write_ingredient_archive("ingredient-A", archive) + builder.close() + self.assertGreater(len(archive.getvalue()), 0) + archive.close() + + def test_add_ingredient_from_archive_roundtrip(self): + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "0.1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], + } + builder = Builder.from_json(manifest) + ingredient_json = { + "title": "A.jpg", + "relationship": "componentOf", + "instance_id": "catalog:ingredient-A", + } + with open(self.testPath, "rb") as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + archive = io.BytesIO() + builder.write_ingredient_archive("catalog:ingredient-A", archive) + builder.close() + + archive.seek(0) + builder2 = Builder.from_json(manifest) + builder2.add_ingredient_from_archive(archive) + archive.close() + + output = io.BytesIO() + with open(self.testPath, "rb") as f: + builder2.sign(self.signer, "image/jpeg", f, output) + builder2.close() + + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("A.jpg", json_data) + output.close() + + def test_add_ingredient_from_archive_preserves_instance_id(self): + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "0.1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], + } + archive_builder = Builder.from_json(manifest) + ingredient_json = { + "title": "photo.jpg", + "relationship": "parentOf", + "instance_id": "my-ingredient", + } + with open(self.testPath, "rb") as f: + archive_builder.add_ingredient(ingredient_json, "image/jpeg", f) + archive = io.BytesIO() + archive_builder.write_ingredient_archive("my-ingredient", archive) + archive_builder.close() + + archive.seek(0) + signing_builder = Builder.from_json(manifest) + signing_builder.add_ingredient_from_archive(archive) + archive.close() + + output = io.BytesIO() + with open(self.testPath, "rb") as f: + signing_builder.sign(self.signer, "image/jpeg", f, output) + signing_builder.close() + + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("photo.jpg", json_data) + output.close() + + def test_add_ingredient_from_archive_preserves_instance_id_component_of(self): + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], + } + archive_builder = Builder.from_json(manifest) + ingredient_json = { + "title": "photo.jpg", + "relationship": "componentOf", + "instance_id": "my-ingredient", + } + with open(self.testPath, "rb") as f: + archive_builder.add_ingredient(ingredient_json, "image/jpeg", f) + archive = io.BytesIO() + archive_builder.write_ingredient_archive("my-ingredient", archive) + archive_builder.close() + + archive.seek(0) + signing_builder = Builder.from_json(manifest) + signing_builder.add_ingredient_from_archive(archive) + archive.close() + + output = io.BytesIO() + with open(self.testPath, "rb") as f: + signing_builder.sign(self.signer, "image/jpeg", f, output) + signing_builder.close() + + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("photo.jpg", json_data) + self.assertIn("componentOf", json_data) + output.close() + + def test_add_ingredient_from_archive_preserves_instance_id_input_to(self): + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], + } + archive_builder = Builder.from_json(manifest) + ingredient_json = { + "title": "photo.jpg", + "relationship": "inputTo", + "instance_id": "my-ingredient", + } + with open(self.testPath, "rb") as f: + archive_builder.add_ingredient(ingredient_json, "image/jpeg", f) + archive = io.BytesIO() + archive_builder.write_ingredient_archive("my-ingredient", archive) + archive_builder.close() + + archive.seek(0) + signing_builder = Builder.from_json(manifest) + signing_builder.add_ingredient_from_archive(archive) + archive.close() + + output = io.BytesIO() + with open(self.testPath, "rb") as f: + signing_builder.sign(self.signer, "image/jpeg", f, output) + signing_builder.close() + + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("photo.jpg", json_data) + self.assertIn("inputTo", json_data) + output.close() + + def test_add_ingredient_from_archive_roundtrip_parent_of(self): + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], + } + builder = Builder.from_json(manifest) + ingredient_json = { + "title": "A.jpg", + "relationship": "parentOf", + "instance_id": "catalog:ingredient-A", + } + with open(self.testPath, "rb") as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + archive = io.BytesIO() + builder.write_ingredient_archive("catalog:ingredient-A", archive) + builder.close() + + archive.seek(0) + builder2 = Builder.from_json(manifest) + builder2.add_ingredient_from_archive(archive) + archive.close() + + output = io.BytesIO() + with open(self.testPath, "rb") as f: + builder2.sign(self.signer, "image/jpeg", f, output) + builder2.close() + + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("A.jpg", json_data) + self.assertIn("parentOf", json_data) + output.close() + + def test_add_ingredient_from_archive_roundtrip_input_to(self): + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], + } + builder = Builder.from_json(manifest) + ingredient_json = { + "title": "A.jpg", + "relationship": "inputTo", + "instance_id": "catalog:ingredient-A", + } + with open(self.testPath, "rb") as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + archive = io.BytesIO() + builder.write_ingredient_archive("catalog:ingredient-A", archive) + builder.close() + + archive.seek(0) + builder2 = Builder.from_json(manifest) + builder2.add_ingredient_from_archive(archive) + archive.close() + + output = io.BytesIO() + with open(self.testPath, "rb") as f: + builder2.sign(self.signer, "image/jpeg", f, output) + builder2.close() + + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("A.jpg", json_data) + self.assertIn("inputTo", json_data) + output.close() + + def test_ingredient_from_archive_linked_to_opened_action(self): + ingredient_id = "xmp:iid:archive-parent-001" + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "parameters": {"ingredientIds": [ingredient_id]}, + } + ] + }, + } + ], + } + archive_builder = Builder.from_json(manifest) + with open(self.testPath, "rb") as f: + archive_builder.add_ingredient( + {"title": "photo.jpg", "relationship": "parentOf", "instance_id": ingredient_id}, + "image/jpeg", f) + archive = io.BytesIO() + archive_builder.write_ingredient_archive(ingredient_id, archive) + archive_builder.close() + + archive.seek(0) + signing_builder = Builder.from_json(manifest) + signing_builder.add_ingredient_from_archive(archive) + archive.close() + + output = io.BytesIO() + with open(self.testPath, "rb") as f: + signing_builder.sign(self.signer, "image/jpeg", f, output) + signing_builder.close() + + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("c2pa.opened", json_data) + self.assertIn(ingredient_id, json_data) + output.close() + + def test_ingredient_from_archive_linked_to_placed_action(self): + ingredient_id = "xmp:iid:archive-component-001" + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.placed", + "parameters": {"ingredientIds": [ingredient_id]}, + } + ] + }, + } + ], + } + archive_builder = Builder.from_json(manifest) + with open(self.testPath, "rb") as f: + archive_builder.add_ingredient( + {"title": "photo.jpg", "relationship": "componentOf", "instance_id": ingredient_id}, + "image/jpeg", f) + archive = io.BytesIO() + archive_builder.write_ingredient_archive(ingredient_id, archive) + archive_builder.close() + + archive.seek(0) + signing_builder = Builder.from_json(manifest) + signing_builder.add_ingredient_from_archive(archive) + archive.close() + + output = io.BytesIO() + with open(self.testPath, "rb") as f: + signing_builder.sign(self.signer, "image/jpeg", f, output) + signing_builder.close() + + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("c2pa.placed", json_data) + self.assertIn(ingredient_id, json_data) + output.close() + + def test_ingredient_from_archive_linked_to_edited_action(self): + ingredient_id = "xmp:iid:archive-input-001" + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.edited", + "parameters": {"ingredientIds": [ingredient_id]}, + } + ] + }, + } + ], + } + archive_builder = Builder.from_json(manifest) + with open(self.testPath, "rb") as f: + archive_builder.add_ingredient( + {"title": "photo.jpg", "relationship": "inputTo", "instance_id": ingredient_id}, + "image/jpeg", f) + archive = io.BytesIO() + archive_builder.write_ingredient_archive(ingredient_id, archive) + archive_builder.close() + + archive.seek(0) + signing_builder = Builder.from_json(manifest) + signing_builder.add_ingredient_from_archive(archive) + archive.close() + + output = io.BytesIO() + with open(self.testPath, "rb") as f: + signing_builder.sign(self.signer, "image/jpeg", f, output) + signing_builder.close() + + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("c2pa.edited", json_data) + self.assertIn(ingredient_id, json_data) + output.close() + + def test_add_two_ingredient_archives_to_one_builder(self): + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], + } + archives = [] + for title, instance_id in [("A.jpg", "ingredient-A"), ("B.jpg", "ingredient-B")]: + archive_builder = Builder.from_json(manifest) + ingredient_json = { + "title": title, + "relationship": "componentOf", + "instance_id": instance_id, + } + with open(self.testPath, "rb") as f: + archive_builder.add_ingredient(ingredient_json, "image/jpeg", f) + archive = io.BytesIO() + archive_builder.write_ingredient_archive(instance_id, archive) + archive_builder.close() + archive.seek(0) + archives.append(archive) + + signing_builder = Builder.from_json(manifest) + for archive in archives: + signing_builder.add_ingredient_from_archive(archive) + archive.close() + + output = io.BytesIO() + with open(self.testPath, "rb") as f: + signing_builder.sign(self.signer, "image/jpeg", f, output) + signing_builder.close() + + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("A.jpg", json_data) + self.assertIn("B.jpg", json_data) + output.close() + + def test_write_ingredient_archive_only_contains_requested_ingredient(self): + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], + } + archive_builder = Builder.from_json(manifest) + for title, instance_id in [("A.jpg", "ingredient-A"), ("B.jpg", "ingredient-B")]: + ingredient_json = { + "title": title, + "relationship": "componentOf", + "instance_id": instance_id, + } + with open(self.testPath, "rb") as f: + archive_builder.add_ingredient(ingredient_json, "image/jpeg", f) + archive = io.BytesIO() + archive_builder.write_ingredient_archive("ingredient-A", archive) + archive_builder.close() + + archive.seek(0) + signing_builder = Builder.from_json(manifest) + signing_builder.add_ingredient_from_archive(archive) + archive.close() + + output = io.BytesIO() + with open(self.testPath, "rb") as f: + signing_builder.sign(self.signer, "image/jpeg", f, output) + signing_builder.close() + + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("A.jpg", json_data) + self.assertNotIn("B.jpg", json_data) + output.close() + + def test_write_ingredient_archive_matches_label_or_instance_id(self): + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [], + } + ingredient_json = { + "title": "photo.jpg", + "relationship": "componentOf", + "label": "my-label", + "instance_id": "my-instance-id", + } + builder = Builder.from_json(manifest) + with open(self.testPath, "rb") as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + by_label = io.BytesIO() + builder.write_ingredient_archive("my-label", by_label) + self.assertGreater(len(by_label.getvalue()), 0) + by_label.close() + + by_instance_id = io.BytesIO() + builder.write_ingredient_archive("my-instance-id", by_instance_id) + self.assertGreater(len(by_instance_id.getvalue()), 0) + by_instance_id.close() + + builder.close() + + def test_write_ingredient_archive_unknown_id_raises(self): + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [], + } + builder = Builder.from_json(manifest) + ingredient_json = { + "title": "photo.jpg", + "relationship": "componentOf", + "instance_id": "ingredient-A", + } + with open(self.testPath, "rb") as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + with self.assertRaises(Error): + builder.write_ingredient_archive("nonexistent-id", io.BytesIO()) + builder.close() + + def test_add_ingredient_from_archive_invalid_data_raises(self): + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [], + } + builder = Builder.from_json(manifest) + with self.assertRaises(Error): + builder.add_ingredient_from_archive(io.BytesIO(b"not a c2pa archive")) + with self.assertRaises(Error): + builder.add_ingredient_from_archive(io.BytesIO()) + builder.close() + + def test_ingredient_archive_methods_raise_on_closed_builder(self): + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [], + } + builder = Builder.from_json(manifest) + builder.close() + with self.assertRaises(Error): + builder.write_ingredient_archive("ingredient-A", io.BytesIO()) + with self.assertRaises(Error): + builder.add_ingredient_from_archive(io.BytesIO()) + + def test_remote_sign(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + builder.set_no_embed() + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + + output.seek(0) + # When set_no_embed() is used, no manifest should be embedded in the file + # So reading from the file should fail + with self.assertRaises(Error): + Reader("image/jpeg", output) + output.close() + + def test_remote_sign_using_returned_bytes(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + builder.set_no_embed() + with io.BytesIO() as output_buffer: + manifest_data = builder.sign( + self.signer, "image/jpeg", file, output_buffer) + output_buffer.seek(0) + read_buffer = io.BytesIO(output_buffer.getvalue()) + + with Reader("image/jpeg", read_buffer, manifest_data) as reader: + manifest_data = reader.json() + self.assertIn("Python Test", manifest_data) + + def _detached_manifest_and_asset(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + builder.set_no_embed() + with io.BytesIO() as output_buffer: + manifest = builder.sign( + self.signer, "image/jpeg", file, output_buffer) + return manifest, output_buffer.getvalue() + + def test_bare_stream_with_real_manifest_data_is_used(self): + manifest, asset = self._detached_manifest_and_asset() + with Reader(io.BytesIO(asset), manifest_data=manifest) as reader: + self.assertIn("Python Test", reader.json()) + + def test_manifest_data_keyword_with_real_bytes(self): + # manifest_data passed by keyword (not positionally) with real bytes. + manifest, asset = self._detached_manifest_and_asset() + with Reader( + "image/jpeg", io.BytesIO(asset), + manifest_data=manifest) as reader: + self.assertIn("Python Test", reader.json()) + + def test_try_create_with_manifest_data(self): + manifest, asset = self._detached_manifest_and_asset() + reader = Reader.try_create( + "image/jpeg", io.BytesIO(asset), manifest) + self.assertIsNotNone(reader) + if reader is not None: + self.assertIn("Python Test", reader.json()) + reader.close() + + def test_try_create_with_manifest_data_keyword(self): + # try_create with manifest_data passed by keyword. + manifest, asset = self._detached_manifest_and_asset() + reader = Reader.try_create( + "image/jpeg", io.BytesIO(asset), manifest_data=manifest) + self.assertIsNotNone(reader) + if reader is not None: + self.assertIn("Python Test", reader.json()) + reader.close() + + def test_bare_stream_with_context_and_manifest(self): + # Stream and real manifest and context together. + manifest, asset = self._detached_manifest_and_asset() + context = Context() + try: + with Reader(io.BytesIO(asset), manifest_data=manifest, + context=context) as reader: + self.assertIn("Python Test", reader.json()) + finally: + context.close() + + def test_try_create_format_plus_path_string(self): + with tempfile.TemporaryDirectory() as tmp: + asset = os.path.join(tmp, "signed.jpg") + with open(self.testPath, "rb") as file, open(asset, "wb") as out: + Builder(self.manifestDefinition).sign( + self.signer, "image/jpeg", file, out) + reader = Reader.try_create("image/jpeg", asset) + self.assertIsNotNone(reader) + if reader is not None: + self.assertIn("Python Test", reader.json()) + reader.close() + + def test_non_context_wrong_type_manifest_raises_typeerror(self): + with open(self.testPath, "rb") as file: + with self.assertRaises(TypeError): + Reader("image/jpeg", file, "not-bytes") + + def test_remote_sign_using_returned_bytes_V2(self): + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + builder.set_no_embed() + with io.BytesIO() as output_buffer: + manifest_data = builder.sign( + self.signer, "image/jpeg", file, output_buffer) + output_buffer.seek(0) + read_buffer = io.BytesIO(output_buffer.getvalue()) + + with Reader("image/jpeg", read_buffer, manifest_data) as reader: + manifest_data = reader.json() + self.assertIn("Python Test", manifest_data) + + def test_remote_sign_using_returned_bytes_V2_with_trust_config(self): + # Run in a separate thread to isolate thread-local settings + result = {} + exception = {} + + def sign_and_validate_with_trust_config(): + try: + # Load trust configuration + settings_dict = load_test_settings_json() + + # Apply the settings (including trust configuration) + # Settings are thread-local, so they won't affect other tests + # And that is why we also run the test in its own thread, so tests are isolated + load_settings(settings_dict) + + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + builder.set_no_embed() + with io.BytesIO() as output_buffer: + manifest_data = builder.sign( + self.signer, "image/jpeg", file, output_buffer) + output_buffer.seek(0) + read_buffer = io.BytesIO(output_buffer.getvalue()) + + with Reader("image/jpeg", read_buffer, manifest_data) as reader: + json_data = reader.json() + + # Get validation state with trust config + validation_state = reader.get_validation_state() + + result['json_data'] = json_data + result['validation_state'] = validation_state + except Exception as e: + exception['error'] = e + + # Create and start thread + thread = threading.Thread(target=sign_and_validate_with_trust_config) + thread.start() + thread.join() + + # Check for exceptions + if 'error' in exception: + raise exception['error'] + + # Assertions run in main thread + self.assertIn("Python Test", result.get('json_data', '')) + # With trust configuration loaded, validation should return "Trusted" + self.assertIsNotNone(result.get('validation_state')) + self.assertEqual(result.get('validation_state'), "Trusted") + + def test_sign_all_files(self): + """Test signing all files in both fixtures directories""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav' + } + + # Skip files that are known to be invalid or unsupported + skip_files = { + 'sample3.invalid.wav', # Invalid file + } + + # Process both directories + for directory in [signing_dir, reading_dir]: + for filename in os.listdir(directory): + if filename in skip_files: + continue + + file_path = os.path.join(directory, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + continue + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + builder.close() + output.seek(0) + reader = Reader(mime_type, output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + reader.close() + output.close() + except Error.NotSupported: + continue + except Exception as e: + self.fail(f"Failed to sign {filename}: {str(e)}") + + def test_sign_all_files_V2(self): + """Test signing all files in both fixtures directories""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav' + } + + # Skip files that are known to be invalid or unsupported + skip_files = { + 'sample3.invalid.wav', # Invalid file + } + + # Process both directories + for directory in [signing_dir, reading_dir]: + for filename in os.listdir(directory): + if filename in skip_files: + continue + + file_path = os.path.join(directory, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + continue + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + builder = Builder(self.manifestDefinitionV2) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + builder.close() + output.seek(0) + reader = Reader(mime_type, output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted` + self.assertIn("Valid", json_data) + reader.close() + output.close() + except Error.NotSupported: + continue + except Exception as e: + self.fail(f"Failed to sign {filename}: {str(e)}") + + def test_builder_no_added_ingredient_on_closed_builder(self): + builder = Builder(self.manifestDefinition) + + builder.close() + + with self.assertRaises(Error): + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + def test_builder_add_ingredient(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Test adding ingredient + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + builder.close() + + def test_builder_add_ingredient_dict(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Test adding ingredient with a dictionary instead of JSON string + ingredient_dict = {"test": "ingredient"} + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_dict, "image/jpeg", f) + + builder.close() + + def test_builder_add_multiple_ingredients(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Test builder operations + builder.set_no_embed() + builder.set_remote_url("http://test.url") + + # Test adding ingredient + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + # Test adding another ingredient + ingredient_json = '{"test": "ingredient2"}' + with open(self.testPath2, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + builder.close() + + def test_builder_add_multiple_ingredients_2(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Test builder operations + builder.set_no_embed() + builder.set_remote_url("http://test.url") + + # Test adding ingredient with a dictionary + ingredient_dict = {"test": "ingredient"} + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_dict, "image/jpeg", f) + + # Test adding another ingredient with a JSON string + ingredient_json = '{"test": "ingredient2"}' + with open(self.testPath2, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + builder.close() + + def test_builder_add_multiple_ingredients_and_resources(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Test builder operations + builder.set_no_embed() + builder.set_remote_url("http://test.url") + + # Test adding resource + with open(self.testPath, 'rb') as f: + builder.add_resource("test_uri_1", f) + + with open(self.testPath, 'rb') as f: + builder.add_resource("test_uri_2", f) + + with open(self.testPath, 'rb') as f: + builder.add_resource("test_uri_3", f) + + # Test adding ingredients + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + ingredient_json = '{"test": "ingredient2"}' + with open(self.testPath2, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + builder.close() + + def test_sign_with_any_format_spelling_keeps_thumbnail(self): + # The format reaches thumbnail generation, + # which matches a MIME type case sensitively, + # so an unnormalized spelling silently produces no thumbnail. + for fmt in ("image/jpeg", "IMAGE/JPEG", "Image/Jpeg", "jpg", "JPG"): + with self.subTest(format=fmt): + builder = Builder(self.manifestDefinition) + dest = io.BytesIO() + with open(self.testPath, "rb") as source: + builder.sign(self.signer, fmt, source, dest) + + dest.seek(0) + reader = Reader("image/jpeg", dest) + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][ + manifest_store["active_manifest"]] + + self.assertIn( + "thumbnail", active, + f"signing with {fmt} dropped the claim thumbnail") + thumbnail = active["thumbnail"] + self.assertEqual(thumbnail["format"], "image/jpeg") + + # The reference must resolve to a real JPEG, not just be present. + thumbnail_bytes = io.BytesIO() + reader.resource_to_stream( + thumbnail["identifier"], thumbnail_bytes) + data = thumbnail_bytes.getvalue() + self.assertGreater( + len(data), 1000, + f"signing with {fmt} produced an empty claim thumbnail") + self.assertEqual( + data[:3], b"\xff\xd8\xff", + f"signing with {fmt} produced a thumbnail that is not a JPEG") + + def test_builder_add_multiple_ingredients_and_resources_interleaved(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + with open(self.testPath, 'rb') as f: + builder.add_resource("test_uri_1", f) + + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + with open(self.testPath, 'rb') as f: + builder.add_resource("test_uri_2", f) + + with open(self.testPath, 'rb') as f: + builder.add_resource("test_uri_3", f) + + ingredient_json = '{"test": "ingredient2"}' + with open(self.testPath2, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + builder.close() + + def test_builder_sign_with_ingredient(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Test adding ingredient + ingredient_json = '{ "title": "Test Ingredient" }' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify thumbnail for manifest is here + self.assertIn("thumbnail", active_manifest) + thumbnail_data = active_manifest["thumbnail"] + self.assertIn("format", thumbnail_data) + self.assertIn("identifier", thumbnail_data) + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient") + + builder.close() + + def test_builder_sign_with_ingredients_edit_intent(self): + """Test signing with EDIT intent and ingredient.""" + builder = Builder.from_json({}) + assert builder._handle is not None + + # Set the intent for editing existing content + builder.set_intent(C2paBuilderIntent.EDIT) + + # Test adding ingredient + ingredient_json = '{ "title": "Test Ingredient" }' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists with exactly 2 ingredients + self.assertIn("ingredients", active_manifest) + ingredients_manifest = active_manifest["ingredients"] + self.assertIsInstance(ingredients_manifest, list) + self.assertEqual(len(ingredients_manifest), 2, "Should have exactly two ingredients") + + # Verify the first ingredient is the one we added manually with componentOf relationship + first_ingredient = ingredients_manifest[0] + self.assertEqual(first_ingredient["title"], "Test Ingredient") + self.assertEqual(first_ingredient["format"], "image/jpeg") + self.assertIn("instance_id", first_ingredient) + self.assertIn("thumbnail", first_ingredient) + self.assertEqual(first_ingredient["thumbnail"]["format"], "image/jpeg") + self.assertIn("identifier", first_ingredient["thumbnail"]) + self.assertEqual(first_ingredient["relationship"], "componentOf") + self.assertIn("label", first_ingredient) + + # Verify the second ingredient is the auto-created parent with parentOf relationship + second_ingredient = ingredients_manifest[1] + # Parent ingredient may not have a title field, or may have an empty one + self.assertEqual(second_ingredient["format"], "image/jpeg") + self.assertIn("instance_id", second_ingredient) + self.assertIn("thumbnail", second_ingredient) + self.assertEqual(second_ingredient["thumbnail"]["format"], "image/jpeg") + self.assertIn("identifier", second_ingredient["thumbnail"]) + self.assertEqual(second_ingredient["relationship"], "parentOf") + self.assertIn("label", second_ingredient) + + # Count ingredients with parentOf relationship - should be exactly one + parent_ingredients = [ + ing for ing in ingredients_manifest + if ing.get("relationship") == "parentOf" + ] + self.assertEqual(len(parent_ingredients), 1, "Should have exactly one parentOf ingredient") + + # Check that assertions exist + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the actions assertion + actions_assertion = None + for assertion in assertions: + if assertion["label"] in ["c2pa.actions", "c2pa.actions.v2"]: + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion, "Should have c2pa.actions assertion") + + # Verify exactly one c2pa.opened action exists for EDIT intent + actions = actions_assertion["data"]["actions"] + opened_actions = [ + action for action in actions + if action["action"] == "c2pa.opened" + ] + self.assertEqual(len(opened_actions), 1, "Should have exactly one c2pa.opened action") + + # Verify the c2pa.opened action has the correct structure with parameters and ingredients + opened_action = opened_actions[0] + self.assertIn("parameters", opened_action, "c2pa.opened action should have parameters") + self.assertIn("ingredients", opened_action["parameters"], "parameters should have ingredients array") + ingredients_params = opened_action["parameters"]["ingredients"] + self.assertIsInstance(ingredients_params, list) + self.assertGreater(len(ingredients_params), 0, "Should have at least one ingredient reference") + + # Verify each ingredient reference has url and hash + for ingredient_ref in ingredients_params: + self.assertIn("url", ingredient_ref, "Ingredient reference should have url") + self.assertIn("hash", ingredient_ref, "Ingredient reference should have hash") + + builder.close() + + def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): + # The following removes the manifest's thumbnail + # Settings should be loaded before the builder is created + load_settings('{"builder": { "thumbnail": {"enabled": false}}}') + + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Test adding ingredient + ingredient_json = '{ "title": "Test Ingredient" }' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # There should be no thumbnail anymore here + self.assertNotIn("thumbnail", active_manifest) + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient") + self.assertNotIn("thumbnail", first_ingredient) + + builder.close() + + # Settings are thread-local, so we reset to the default "true" here + load_settings('{"builder": { "thumbnail": {"enabled": true}}}') + + def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): + # The following removes the manifest's thumbnail - using dict instead of string + load_settings({"builder": {"thumbnail": {"enabled": False}}}) + + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Test adding ingredient + ingredient_json = '{ "title": "Test Ingredient" }' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # There should be no thumbnail anymore here + self.assertNotIn("thumbnail", active_manifest) + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient") + self.assertNotIn("thumbnail", first_ingredient) + + builder.close() + + # Settings are thread-local, so we reset to the default "true" here - using dict instead of string + load_settings({"builder": {"thumbnail": {"enabled": True}}}) + + def test_builder_sign_with_duplicate_ingredient(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Test adding ingredient + ingredient_json = '{"title": "Test Ingredient"}' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient") + + # Verify subsequent labels are unique and have a double underscore with a monotonically inc. index + second_ingredient = active_manifest["ingredients"][1] + self.assertTrue(second_ingredient["label"].endswith("__1")) + + third_ingredient = active_manifest["ingredients"][2] + self.assertTrue(third_ingredient["label"].endswith("__2")) + + builder.close() + + def test_builder_sign_with_ingredient_from_stream(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Test adding ingredient using stream + ingredient_json = '{"title": "Test Ingredient Stream"}' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient_from_stream( + ingredient_json, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual( + first_ingredient["title"], + "Test Ingredient Stream") + + builder.close() + + def test_builder_sign_with_ingredient_dict_from_stream(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Test adding ingredient using stream with a dictionary + ingredient_dict = {"title": "Test Ingredient Stream"} + with open(self.testPath3, 'rb') as f: + builder.add_ingredient_from_stream( + ingredient_dict, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual( + first_ingredient["title"], + "Test Ingredient Stream") + + builder.close() + + def test_builder_sign_with_multiple_ingredient(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Add first ingredient + ingredient_json1 = '{"title": "Test Ingredient 1"}' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json1, "image/jpeg", f) + + # Add second ingredient + ingredient_json2 = '{"title": "Test Ingredient 2"}' + cloud_path = ALTERNATIVE_INGREDIENT_TEST_FILE + with open(cloud_path, 'rb') as f: + builder.add_ingredient(ingredient_json2, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertEqual(len(active_manifest["ingredients"]), 2) + + # Verify both ingredients exist in the array (order doesn't matter) + ingredient_titles = [ing["title"] + for ing in active_manifest["ingredients"]] + self.assertIn("Test Ingredient 1", ingredient_titles) + self.assertIn("Test Ingredient 2", ingredient_titles) + + builder.close() + + def test_builder_sign_with_multiple_ingredients_from_stream(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._handle is not None + + # Add first ingredient using stream + ingredient_json1 = '{"title": "Test Ingredient Stream 1"}' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient_from_stream( + ingredient_json1, "image/jpeg", f) + + # Add second ingredient using stream + ingredient_json2 = '{"title": "Test Ingredient Stream 2"}' + cloud_path = ALTERNATIVE_INGREDIENT_TEST_FILE + with open(cloud_path, 'rb') as f: + builder.add_ingredient_from_stream( + ingredient_json2, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists 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_set_remote_url(self): + """Test setting the remote url of a builder.""" + builder = Builder.from_json(self.manifestDefinition) + builder.set_remote_url("http://this_does_not_exist/foo.jpg") + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + d = output.read() + self.assertIn(b'provenance="http://this_does_not_exist/foo.jpg"', d) + + def test_builder_set_remote_url_no_embed(self): + """Test setting the remote url of a builder with no embed flag.""" + + # Settings need to be loaded before the builder is created + load_settings(r'{"verify": { "remote_manifest_fetch": false} }') + + builder = Builder.from_json(self.manifestDefinition) + builder.set_no_embed() + builder.set_remote_url("http://this_does_not_exist/foo.jpg") + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + with self.assertRaises(Error) as e: + Reader("image/jpeg", output) + + self.assertIn("http://this_does_not_exist/foo.jpg", e.exception.message) + + # Return back to default settings + load_settings(r'{"verify": { "remote_manifest_fetch": true} }') + + def test_sign_single(self): + """Test signing a file using the sign_file method.""" + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + + with open(self.testPath, "rb") as file: + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + # Read the signed file and verify the manifest + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + output.close() + + def test_sign_mp4_video_file_single(self): + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + + with open(os.path.join(FIXTURES_DIR, "video1.mp4"), "rb") as file: + builder.sign(self.signer, "video/mp4", file, output) + output.seek(0) + + # Read the signed file and verify the manifest + reader = Reader("video/mp4", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + output.close() + + def test_sign_mov_video_file_single(self): + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + + with open(os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), "rb") as file: + builder.sign(self.signer, "mov", file, output) + output.seek(0) + + # Read the signed file and verify the manifest + reader = Reader("mov", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + output.close() + + def test_encode_format_builder_blank_raises(self): + with self.assertRaises(Error.NotSupported): + _encode_format("", "Builder", allow_autodetect=False) + + def test_sign_empty_format_raises(self): + builder = Builder(self.manifestDefinition) + with open(self.testPath, "rb") as file: + output = io.BytesIO() + with self.assertRaises(Error.NotSupported): + builder.sign(self.signer, "", file, output) + + def test_sign_file_extensionless_source_raises(self): + builder = Builder(self.manifestDefinition) + with tempfile.TemporaryDirectory() as tmp: + no_ext = os.path.join(tmp, "source") + shutil.copyfile(self.testPath, no_ext) + dest = os.path.join(tmp, "out.jpg") + with self.assertRaises(Error.NotSupported): + builder.sign_file(no_ext, dest, self.signer) + + def test_sign_file_unrecognized_extension_raises(self): + builder = Builder(self.manifestDefinition) + with tempfile.TemporaryDirectory() as tmp: + weird = os.path.join(tmp, "source.unknownextension") + shutil.copyfile(self.testPath, weird) + dest = os.path.join(tmp, "out.jpg") + with self.assertRaises(Error.NotSupported): + builder.sign_file(weird, dest, self.signer) + + def test_sign_file_exceptions_preservation(self): + builder = Builder(self.manifestDefinition) + with tempfile.TemporaryDirectory() as tmp: + src = os.path.join(tmp, "source.txt") + shutil.copyfile(self.testPath, src) + dest = os.path.join(tmp, "out.txt") + with self.assertRaises(Error.NotSupported): + builder.sign_file(src, dest, self.signer) + + def test_sign_file_video(self): + temp_dir = tempfile.mkdtemp() + try: + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed_output.mp4") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + builder.sign_file( + os.path.join(FIXTURES_DIR, "video1.mp4"), + output_path, + self.signer + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) + + # Read the signed file and verify the manifest + with open(output_path, "rb") as file: + reader = Reader("video/mp4", file) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + + finally: + # Clean up the temporary directory + shutil.rmtree(temp_dir) + + def test_sign_file_format_manifest_bytes_embeddable(self): + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + + with open(self.testPath, "rb") as file: + manifest_bytes = builder.sign(self.signer, "image/jpeg", file, output) + res = format_embeddable("image/jpeg", manifest_bytes) + output.seek(0) + output.close() + + def test_builder_sign_file_callback_signer_from_callback(self): + """Test signing a file using the sign_file method with Signer.from_callback.""" + + temp_dir = tempfile.mkdtemp() + try: + + output_path = os.path.join(temp_dir, "signed_output_from_callback.jpg") + + # Will use the sign_file method + builder = Builder(self.manifestDefinition) + + # Create signer with callback using Signer.from_callback + signer = Signer.from_callback( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + manifest_bytes = builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) + + # Verify results + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) + + # Read the signed file and verify the manifest + with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify the signature_info contains the correct algorithm + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + + finally: + shutil.rmtree(temp_dir) + + def test_builder_sign_file_callback_signer_from_callback_V2(self): + """Test signing a file using the sign_file method with Signer.from_callback.""" + + temp_dir = tempfile.mkdtemp() + try: + + output_path = os.path.join(temp_dir, "signed_output_from_callback.jpg") + + # Will use the sign_file method + builder = Builder(self.manifestDefinitionV2) + + # Create signer with callback using Signer.from_callback + signer = Signer.from_callback( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + manifest_bytes = builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) + + # Verify results + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) + + # Read the signed file and verify the manifest + with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + + # Parse the JSON and verify the signature algorithm + manifest_data = json.loads(json_data) + active_manifest_id = manifest_data["active_manifest"] + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify the signature_info contains the correct algorithm + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + + finally: + shutil.rmtree(temp_dir) + + def test_builder_sign_with_native_ed25519_callback(self): + # Load Ed25519 private key (PEM) + ed25519_pem = os.path.join(FIXTURES_DIR, "ed25519.pem") + with open(ed25519_pem, "r") as f: + private_key_pem = f.read() + + # Callback here uses native function + def ed25519_callback(data: bytes) -> bytes: + return ed25519_sign(data, private_key_pem) + + # Load the certificate (PUB) + ed25519_pub = os.path.join(FIXTURES_DIR, "ed25519.pub") + with open(ed25519_pub, "r") as f: + certs_pem = f.read() + + # Create a Signer + # signer = create_signer( + # callback=ed25519_callback, + # alg=SigningAlg.ED25519, + # certs=certs_pem, + # tsa_url=None + # ) + signer = Signer.from_callback( + callback=ed25519_callback, + alg=SigningAlg.ED25519, + certs=certs_pem, + tsa_url=None + ) + + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + builder.sign(signer, "image/jpeg", file, output) + output.seek(0) + builder.close() + reader = Reader("image/jpeg", output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + reader.close() + output.close() + + def test_signing_manifest_v2(self): + """Test signing and reading a V2 manifest. + V2 manifests have a slightly different structure. + """ + with open(self.testPath, "rb") as file: + # Create a builder with the V2 manifest definition using context manager + with Builder(self.manifestDefinitionV2) as builder: + output = io.BytesIO(bytearray()) + + # Sign as usual... + builder.sign(self.signer, "image/jpeg", file, output) + + output.seek(0) + + # Read the signed file and verify the manifest using context manager + with Reader("image/jpeg", output) as reader: + json_data = reader.json() + + # Basic verification of the manifest + self.assertIn("Python Test Image V2", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + + output.close() + + def test_builder_does_not_sign_unsupported_format(self): + with open(self.testPath, "rb") as file: + with Builder(self.manifestDefinitionV2) as builder: + output = io.BytesIO(bytearray()) + with self.assertRaises(Error.NotSupported): + builder.sign(self.signer, "mimetype/not-supported", file, output) + + def test_sign_file_mp4_video(self): + temp_dir = tempfile.mkdtemp() + try: + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed_output.mp4") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + builder.sign_file( + os.path.join(FIXTURES_DIR, "video1.mp4"), + output_path, + self.signer + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) + + # Read the signed file and verify the manifest + with open(output_path, "rb") as file: + reader = Reader("video/mp4", file) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + + finally: + # Clean up the temporary directory + shutil.rmtree(temp_dir) + + def test_sign_file_mov_video(self): + temp_dir = tempfile.mkdtemp() + try: + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed-C-recorded-as-mov.mov") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + manifest_bytes = builder.sign_file( + os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), + output_path, + self.signer + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) + + # Read the signed file and verify the manifest + with open(output_path, "rb") as file: + reader = Reader("mov", file) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + + # Verify also signed file using manifest bytes + with Reader("mov", output_path, manifest_bytes) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + + finally: + # Clean up the temporary directory + shutil.rmtree(temp_dir) + + def test_sign_file_mov_video_V2(self): + temp_dir = tempfile.mkdtemp() + try: + # Create a temporary output file path + output_path = os.path.join(temp_dir, "signed-C-recorded-as-mov.mov") + + # Use the sign_file method + builder = Builder(self.manifestDefinitionV2) + manifest_bytes = builder.sign_file( + os.path.join(FIXTURES_DIR, "C-recorded-as-mov.mov"), + output_path, + self.signer + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) + + # Read the signed file and verify the manifest + with open(output_path, "rb") as file: + reader = Reader("mov", file) + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + + # Verify also signed file using manifest bytes + with Reader("mov", output_path, manifest_bytes) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + # Needs trust configuration to be set up to validate as Trusted, + # or validation_status on read reports `signing certificate untrusted`. + self.assertIn("Valid", json_data) + + finally: + # Clean up the temporary directory + shutil.rmtree(temp_dir) + + def test_builder_with_invalid_signer_none(self): + """Test Builder methods with None signer.""" + builder = Builder(self.manifestDefinition) + + with open(self.testPath, "rb") as file: + with self.assertRaises(Error): + builder.sign(None, "image/jpeg", file) + + def test_builder_with_invalid_signer_closed(self): + """Test Builder methods with closed signer.""" + builder = Builder(self.manifestDefinition) + + # Create and close a signer + closed_signer = Signer.from_info(self.signer_info) + closed_signer.close() + + with open(self.testPath, "rb") as file: + with self.assertRaises(Error): + builder.sign(closed_signer, "image/jpeg", file) + + def test_builder_with_invalid_signer_object(self): + """Test Builder methods with invalid signer object.""" + builder = Builder(self.manifestDefinition) + + # Use a mock object that looks like a signer but isn't + class MockSigner: + def __init__(self): + self._handle = None + + mock_signer = MockSigner() + + with open(self.testPath, "rb") as file: + with self.assertRaises(Error): + builder.sign(mock_signer, "image/jpeg", file) + + def test_builder_manifest_with_unicode_characters(self): + """Test Builder with manifest containing various Unicode characters.""" + unicode_manifest = { + "claim_generator": "python_test_unicode_テスト", + "claim_generator_info": [{ + "name": "python_test_unicode_テスト", + "version": "0.0.1", + }], + "claim_version": 1, + "format": "image/jpeg", + "title": "Python Test Image with Unicode: テスト test", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "description": "Unicode: test", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + + builder = Builder(unicode_manifest) + + with open(self.testPath, "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() + + # Verify Unicode characters are preserved in title and description + self.assertIn("テスト", json_data) + + def test_builder_ingredient_with_special_characters(self): + """Test Builder with ingredient containing special characters.""" + special_char_ingredient = { + "title": "Test Ingredient with Special Chars: テスト", + "format": "image/jpeg", + "description": "Special characters: !@#$%^&*()_+-=[]{}|;':\",./<>?`~" + } + + builder = Builder(self.manifestDefinition) + + # Add ingredient with special characters + ingredient_json = json.dumps(special_char_ingredient) + with open(self.testPath2, "rb") as ingredient_file: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) + + with open(self.testPath, "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 special characters are preserved in ingredients + self.assertIn("ingredients", manifest_data["manifests"][manifest_data["active_manifest"]]) + ingredients = manifest_data["manifests"][manifest_data["active_manifest"]]["ingredients"] + self.assertEqual(len(ingredients), 1) + + ingredient = ingredients[0] + self.assertIn("テスト", ingredient["title"]) + self.assertIn("!@#$%^&*()_+-=[]{}|;':\",./<>?`~", ingredient["description"]) + + def test_builder_resource_uri_with_unicode(self): + """Test Builder with resource URI containing Unicode characters.""" + builder = Builder(self.manifestDefinition) + + # Test with resource URI containing Unicode characters + unicode_uri = "thumbnail_テスト.jpg" + with open(self.testPath3, "rb") as thumbnail_file: + builder.add_resource(unicode_uri, thumbnail_file) + + with open(self.testPath, "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() + + # Verify the resource was added (exact verification depends on implementation) + self.assertIsNotNone(json_data) + + def test_builder_initialization_failure_states(self): + """Test Builder state after initialization failures.""" + # Test with invalid JSON + with self.assertRaises(Error): + builder = Builder("{invalid json}") + + # Test with None manifest + with self.assertRaises(Error): + builder = Builder(None) + + # Test with circular reference in JSON + circular_obj = {} + circular_obj['self'] = circular_obj + with self.assertRaises(Exception) as context: + builder = Builder(circular_obj) + + def test_construction_failure_before_native_call_is_collectable(self): + """Builder(None) fails encoding the manifest before any FFI call. + A half-built instance is UNINITIALIZED with no native handle. + """ + captured = [] + real_init_attrs = Builder._init_attrs + + def recording_init_attrs(self): + real_init_attrs(self) + captured.append(self) + + Builder._init_attrs = recording_init_attrs + try: + with self.assertRaises(Error): + Builder(None) + finally: + Builder._init_attrs = real_init_attrs + + self.assertEqual(len(captured), 1, + "_init_attrs did not run during the failed construction") + instance = captured[0] + self.assertEqual(instance._lifecycle_state, LifecycleState.UNINITIALIZED) + self.assertIsNone(instance._handle) + + instance._release() + + def test_builder_state_transitions(self): + """Test Builder state transitions during lifecycle.""" + builder = Builder(self.manifestDefinition) + + # Initial state + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) + + # After close + builder.close() + 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.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) + + # Placeholder operation + builder.set_no_embed() + + # After context exit - should be closed + 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.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) + raise ValueError("Test exception") + except ValueError: + pass + + # After exception - should still be closed + 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 _state = ACTIVE + builder = Builder.__new__(Builder) + builder._lifecycle_state = LifecycleState.ACTIVE + builder._handle = None + + with self.assertRaises(Error): + builder._ensure_valid_state() + + def test_builder_cleanup_state_transitions(self): + """Test Builder state during cleanup operations.""" + builder = Builder(self.manifestDefinition) + + # Test _cleanup_resources method + builder._cleanup_resources() + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) + + def test_builder_cleanup_idempotency(self): + """Test that cleanup operations are idempotent.""" + builder = Builder(self.manifestDefinition) + + # First cleanup + builder._cleanup_resources() + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + + # Second cleanup should not change state + builder._cleanup_resources() + 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.""" + builder = Builder(self.manifestDefinition) + + with open(self.testPath, "rb") as file: + manifest_bytes = 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.""" + builder = Builder(self.manifestDefinition) + + # Test to_archive + with io.BytesIO() as archive_stream: + builder.to_archive(archive_stream) + + # State should still be valid + 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.""" + builder = Builder(self.manifestDefinition) + + # First close + builder.close() + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) + + # Second close should not change state + builder.close() + 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._handle = 0 + + # Operations should fail gracefully + with self.assertRaises(Error): + builder.set_no_embed() + + def test_builder_add_action_to_manifest_no_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + initial_manifest_definition = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + # claim version 2 is the default + # "claim_version": 2, + "format": "image/jpeg", + "title": "Python Test Image V2", + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + builder = Builder.from_json(initial_manifest_definition) + + action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' + builder.add_action(action_json) + + 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 assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to check what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.color_adjustments" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + created_action_found = True + break + + self.assertTrue(created_action_found) + + builder.close() + + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + + def test_builder_add_action_to_manifest_from_dict_no_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + initial_manifest_definition = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + # claim version 2 is the default + # "claim_version": 2, + "format": "image/jpeg", + "title": "Python Test Image V2", + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + builder = Builder.from_json(initial_manifest_definition) + + # Using a dictionary instead of a JSON string + action_dict = {"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}} + builder.add_action(action_dict) + + 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 assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to check what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.color_adjustments" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + created_action_found = True + break + + self.assertTrue(created_action_found) + + builder.close() + + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + + def test_builder_add_action_to_manifest_with_auto_add(self): + # For testing, force settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + + initial_manifest_definition = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + # claim version 2 is the default + # "claim_version": 2, + "format": "image/jpeg", + "title": "Python Test Image V2", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + builder = Builder.from_json(initial_manifest_definition) + + action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' + builder.add_action(action_json) + + 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 assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to check what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.color_adjustments" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + created_action_found = True + break + + self.assertTrue(created_action_found) + + # Verify "c2pa.created" action exists only once in the actions array + created_count = 0 + for action in actions: + if action.get("action") == "c2pa.created": + created_count += 1 + + self.assertEqual(created_count, 1, "c2pa.created action should appear exactly once") + + builder.close() + + # Reset settings to default + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + + def test_builder_minimal_manifest_add_actions_and_sign_no_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + initial_manifest_definition = { + "claim_generator": "python_test", + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Test Image V2", + } + + builder = Builder.from_json(initial_manifest_definition) + builder.add_action('{ "action": "c2pa.created", "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}') + + 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 assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to look for what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.created" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.created": + created_action_found = True + break + + self.assertTrue(created_action_found) + + builder.close() + + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + + def test_builder_minimal_manifest_add_actions_and_sign_with_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + + initial_manifest_definition = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Test Image V2", + } + + builder = Builder.from_json(initial_manifest_definition) + action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' + builder.add_action(action_json) + + 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 assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to look for what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.created" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.created": + created_action_found = True + break + + self.assertTrue(created_action_found) + + # Verify "c2pa.color_adjustments" action also exists in the same actions array + color_adjustments_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + color_adjustments_found = True + break + + self.assertTrue(color_adjustments_found) + + builder.close() + + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + + def test_builder_sign_dicts_no_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + initial_manifest_definition = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + # claim version 2 is the default + # "claim_version": 2, + "format": "image/jpeg", + "title": "Python Test Image V2", + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + builder = Builder.from_json(initial_manifest_definition) + + # Using a dictionary instead of a JSON string + action_dict = {"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}} + builder.add_action(action_dict) + + 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 assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to check what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.color_adjustments" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + created_action_found = True + break + + self.assertTrue(created_action_found) + + builder.close() + + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + + def test_builder_opened_action_one_ingredient_no_auto_add(self): + """Test Builder with c2pa.opened action and one ingredient, following Adobe provenance patterns""" + # Disable auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + # Instance IDs for linking ingredients and actions + # This can be any unique id so the ingredient can be uniquely identified and linked to the action + parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c" + + manifestDefinition = { + "claim_generator_info": [{ + "name": "Python CAI test", + "version": "3.14.16" + }], + "title": "A title for the provenance test", + "ingredients": [ + # The parent ingredient will be added through add_ingredient + # And a properly crafted manifest json so they link + ], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "softwareAgent": { + "name": "Opened asset", + }, + "parameters": { + "ingredientIds": [ + parent_ingredient_id + ] + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + } + ] + } + } + ] + } + + # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition + # Aka the unique parent_ingredient_id we rely on for linking + ingredient_json = { + "relationship": "parentOf", + "instance_id": parent_ingredient_id + } + # An opened ingredient is always a parent, and there can only be exactly one parent ingredient + + # Read the input file (A.jpg will be signed) + with open(self.testPath2, "rb") as test_file: + file_content = test_file.read() + + builder = Builder.from_json(manifestDefinition) + + # Add C.jpg as the parent "opened" ingredient + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + output_buffer = io.BytesIO(bytearray()) + builder.sign( + self.signer, + "image/jpeg", + io.BytesIO(file_content), + output_buffer) + output_buffer.seek(0) + + # Read and verify the manifest + reader = Reader("image/jpeg", output_buffer) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify the ingredient instance ID is present + self.assertIn(parent_ingredient_id, json_data) + + # Verify c2pa.opened action is present + self.assertIn("c2pa.opened", json_data) + + builder.close() + + # Make sure settings are put back to the common test defaults + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + def test_builder_one_opened_one_placed_action_no_auto_add(self): + """Test Builder with c2pa.opened action where asset is its own parent ingredient""" + # Disable auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + # Instance IDs for linking ingredients and actions, + # need to be unique even if the same binary file is used, so ingredients link properly to actions + parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c" + placed_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-f3f800ebe42b" + + manifestDefinition = { + "claim_generator_info": [{ + "name": "Python CAI test", + "version": "0.2.942" + }], + "title": "A title for the provenance test", + "ingredients": [ + # The parent ingredient will be added through add_ingredient + { + # Represents the bubbled up AI asset/ingredient + "format": "jpeg", + "relationship": "componentOf", + # Instance ID must be generated to match what is in parameters ingredientIds array + "instance_id": placed_ingredient_id, + } + ], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "softwareAgent": { + "name": "Opened asset", + }, + "parameters": { + "ingredientIds": [ + parent_ingredient_id + ] + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + }, + { + "action": "c2pa.placed", + "softwareAgent": { + "name": "Placed asset", + }, + "parameters": { + "ingredientIds": [ + placed_ingredient_id + ] + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + } + ] + } + } + ] + } + + # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition for c2pa.opened + # So that ingredients can link together. + ingredient_json = { + "relationship": "parentOf", + "when": "2025-08-07T18:01:55.934Z", + "instance_id": parent_ingredient_id + } + + # Read the input file (A.jpg will be signed) + with open(self.testPath2, "rb") as test_file: + file_content = test_file.read() + + builder = Builder.from_json(manifestDefinition) + + # An asset can be its own parent ingredient! + # We add A.jpg as its own parent ingredient + with open(self.testPath2, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + output_buffer = io.BytesIO(bytearray()) + builder.sign( + self.signer, + "image/jpeg", + io.BytesIO(file_content), + output_buffer) + output_buffer.seek(0) + + # Read and verify the manifest + reader = Reader("image/jpeg", output_buffer) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify both ingredient instance IDs are present + self.assertIn(parent_ingredient_id, json_data) + self.assertIn(placed_ingredient_id, json_data) + + # Verify both actions are present + self.assertIn("c2pa.opened", json_data) + self.assertIn("c2pa.placed", json_data) + + builder.close() + + # Make sure settings are put back to the common test defaults + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + def test_builder_opened_action_multiple_ingredient_no_auto_add(self): + """Test Builder with c2pa.opened and c2pa.placed actions with multiple ingredients""" + # Disable auto-added actions, as what we are doing here can confuse auto-placements + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + # Instance IDs for linking ingredients and actions + # With multiple ingredients, we need multiple different unique ids so they each link properly + parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c" + placed_ingredient_1_id = "xmp:iid:a965983b-36fb-445a-aa80-f3f800ebe42b" + placed_ingredient_2_id = "xmp:iid:a965983b-36fb-445a-aa80-f2d712acd14c" + + manifestDefinition = { + "claim_generator_info": [{ + "name": "Python CAI test", + "version": "0.2.942" + }], + "title": "A title for the provenance test with multiple ingredients", + "ingredients": [ + # More ingredients will be added using add_ingredient + { + "format": "jpeg", + "relationship": "componentOf", + # Instance ID must be generated to match what is in parameters ingredientIds array + "instance_id": placed_ingredient_1_id, + } + ], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "softwareAgent": { + "name": "A parent opened asset", + }, + "parameters": { + "ingredientIds": [ + parent_ingredient_id + ] + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + }, + { + "action": "c2pa.placed", + "softwareAgent": { + "name": "Component placed assets", + }, + "parameters": { + "ingredientIds": [ + placed_ingredient_1_id, + placed_ingredient_2_id + ] + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" + } + ] + } + } + ] + } + + # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition, + # so that ingredients properly link with their action + ingredient_json_parent = { + "relationship": "parentOf", + "instance_id": parent_ingredient_id + } + + # The ingredient json for the placed action needs to match the instance_id in the manifestDefinition, + # so that ingredients properly link with their action + ingredient_json_placed = { + "relationship": "componentOf", + "instance_id": placed_ingredient_2_id + } + + # Read the input file (A.jpg will be signed) + with open(self.testPath2, "rb") as test_file: + file_content = test_file.read() + + builder = Builder.from_json(manifestDefinition) + + # Add C.jpg as the parent ingredient (for c2pa.opened, it's the opened asset) + with open(self.testPath, 'rb') as f1: + builder.add_ingredient(ingredient_json_parent, "image/jpeg", f1) + + # Add cloud.jpg as another placed ingredient (for instance, added on the opened asset) + with open(self.testPath4, 'rb') as f2: + builder.add_ingredient(ingredient_json_placed, "image/jpeg", f2) + + output_buffer = io.BytesIO(bytearray()) + builder.sign( + self.signer, + "image/jpeg", + io.BytesIO(file_content), + output_buffer) + output_buffer.seek(0) + + # Read and verify the manifest + reader = Reader("image/jpeg", output_buffer) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify all ingredient instance IDs are present + self.assertIn(parent_ingredient_id, json_data) + self.assertIn(placed_ingredient_1_id, json_data) + self.assertIn(placed_ingredient_2_id, json_data) + + # Verify both actions are present + self.assertIn("c2pa.opened", json_data) + self.assertIn("c2pa.placed", json_data) + + builder.close() + + # Make sure settings are put back to the common test defaults + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + def test_link_archive_label_on_signing_builder_placed(self): + """Label set on the signing builder's add_ingredient links an + ingredient archive to a c2pa.placed action.""" + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + archive = self._create_ingredient_archive() + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }, + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["my-ingredient"] + }, + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "photo.jpg", "relationship": "componentOf", "label": "my-ingredient"}, + "application/c2pa", + archive, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + assertions = manifest_data["manifests"][active]["assertions"] + + placed_action = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + for action in assertion["data"]["actions"]: + if action["action"] == "c2pa.placed": + placed_action = action + break + + self.assertIsNotNone(placed_action, "c2pa.placed action not found") + self.assertIn("parameters", placed_action) + self.assertIn("ingredients", placed_action["parameters"]) + self.assertEqual(len(placed_action["parameters"]["ingredients"]), 1) + self.assertIn( + "c2pa.ingredient.v3", + placed_action["parameters"]["ingredients"][0]["url"], + ) + + reader.close() + output.close() + archive.close() + builder.close() + + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + def test_link_archive_label_on_signing_builder_opened(self): + """Label set on the signing builder's add_ingredient links an + ingredient archive to a c2pa.opened action.""" + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + archive = self._create_ingredient_archive( + {"title": "photo.jpg", "relationship": "parentOf"} + ) + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": { + "ingredientIds": ["my-ingredient"] + }, + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "photo.jpg", "relationship": "parentOf", "label": "my-ingredient"}, + "application/c2pa", + archive, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + assertions = manifest_data["manifests"][active]["assertions"] + + opened_action = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + for action in assertion["data"]["actions"]: + if action["action"] == "c2pa.opened": + opened_action = action + break + + self.assertIsNotNone(opened_action, "c2pa.opened action not found") + self.assertIn("parameters", opened_action) + self.assertIn("ingredients", opened_action["parameters"]) + self.assertEqual(len(opened_action["parameters"]["ingredients"]), 1) + self.assertIn( + "c2pa.ingredient.v3", + opened_action["parameters"]["ingredients"][0]["url"], + ) + + reader.close() + output.close() + archive.close() + builder.close() + + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + def test_link_archive_two_ingredients_labels(self): + """Two ingredient archives linked to two different actions via + distinct labels. Verifies no cross-linking.""" + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + archive1 = self._create_ingredient_archive( + {"title": "photo-placed.jpg", "relationship": "componentOf"} + ) + archive2 = self._create_ingredient_archive( + {"title": "photo-opened.jpg", "relationship": "parentOf"} + ) + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": { + "ingredientIds": ["ingredient-for-opened"] + }, + }, + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["ingredient-for-placed"] + }, + }, + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "photo-placed.jpg", "relationship": "componentOf", "label": "ingredient-for-placed"}, + "application/c2pa", + archive1, + ) + builder.add_ingredient( + {"title": "photo-opened.jpg", "relationship": "parentOf", "label": "ingredient-for-opened"}, + "application/c2pa", + archive2, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + assertions = manifest_data["manifests"][active]["assertions"] + + placed_action = None + opened_action = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + for action in assertion["data"]["actions"]: + if action["action"] == "c2pa.placed": + placed_action = action + if action["action"] == "c2pa.opened": + opened_action = action + + self.assertIsNotNone(placed_action, "c2pa.placed action not found") + self.assertIsNotNone(opened_action, "c2pa.opened action not found") + + self.assertIn("ingredients", placed_action["parameters"]) + self.assertEqual(len(placed_action["parameters"]["ingredients"]), 1) + placed_url = placed_action["parameters"]["ingredients"][0]["url"] + + self.assertIn("ingredients", opened_action["parameters"]) + self.assertEqual(len(opened_action["parameters"]["ingredients"]), 1) + opened_url = opened_action["parameters"]["ingredients"][0]["url"] + + # Each action should link to a different ingredient (no cross-linking) + self.assertNotEqual(placed_url, opened_url, + "Each action should link to a different ingredient") + + reader.close() + output.close() + archive1.close() + archive2.close() + builder.close() + + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + def test_link_archive_multiple_ingredients_in_one_placed_action(self): + """A single c2pa.placed action references two componentOf ingredients + via ingredientIds with two labels.""" + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + archive1 = self._create_ingredient_archive( + {"title": "base-layer.jpg", "relationship": "componentOf"} + ) + archive2 = self._create_ingredient_archive( + {"title": "overlay-layer.jpg", "relationship": "componentOf"} + ) + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }, + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["base-layer", "overlay-layer"] + }, + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "base-layer.jpg", "relationship": "componentOf", "label": "base-layer"}, + "application/c2pa", + archive1, + ) + builder.add_ingredient( + {"title": "overlay-layer.jpg", "relationship": "componentOf", "label": "overlay-layer"}, + "application/c2pa", + archive2, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + assertions = manifest_data["manifests"][active]["assertions"] + + placed_action = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + for action in assertion["data"]["actions"]: + if action["action"] == "c2pa.placed": + placed_action = action + break + + self.assertIsNotNone(placed_action, "c2pa.placed action not found") + self.assertIn("parameters", placed_action) + self.assertIn("ingredients", placed_action["parameters"]) + ingredients = placed_action["parameters"]["ingredients"] + self.assertEqual(len(ingredients), 2, + "c2pa.placed should reference both ingredients") + + url0 = ingredients[0]["url"] + url1 = ingredients[1]["url"] + self.assertNotEqual(url0, url1, + "Each ingredient should have a distinct URL") + + reader.close() + output.close() + archive1.close() + archive2.close() + builder.close() + + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + def test_ingredient_fields_survive_archive(self): + archive = self._create_ingredient_archive({ + "title": "tracked-asset.jpg", + "relationship": "componentOf", + "instance_id": "tracking:project-7:asset-42", + "description": "A tracked ingredient", + "informational_URI": "https://example.com/assets/42", + }) + + reader = Reader("application/c2pa", archive) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + ingredients = manifest_data["manifests"][active]["ingredients"] + + self.assertGreaterEqual(len(ingredients), 1) + ing = ingredients[0] + + self.assertEqual(ing["title"], "tracked-asset.jpg") + self.assertIn("instance_id", ing) + self.assertEqual(ing["instance_id"], "tracking:project-7:asset-42") + + reader.close() + archive.close() + + def test_ingredient_fields_survive_archive_then_sign(self): + """instance_id set on the archive ingredient persists through + archive then sign.""" + archive = self._create_ingredient_archive({ + "title": "tracked-asset.jpg", + "relationship": "componentOf", + "instance_id": "tracking:project-7:asset-42", + "description": "A tracked ingredient", + "informational_URI": "https://example.com/assets/42", + }) + + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + builder.add_ingredient( + {"title": "tracked-asset.jpg", "relationship": "componentOf"}, + "application/c2pa", + archive, + ) + + with open(self.testPath, "rb") as src: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", src, output) + output.seek(0) + + reader = Reader("image/jpeg", output) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + ingredients = manifest_data["manifests"][active]["ingredients"] + + self.assertGreaterEqual(len(ingredients), 1) + ing = ingredients[0] + + self.assertIn("instance_id", ing) + self.assertEqual(ing["instance_id"], "tracking:project-7:asset-42") + + reader.close() + output.close() + archive.close() + builder.close() + + def test_instance_id_as_ingredient_identifier_in_catalog(self): + """Two ingredients with different instance_id values in one archive. + Read the archive back and select an ingredient by instance_id.""" + manifest = { + "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + } + ] + }, + } + ], + } + + builder = Builder.from_json(manifest) + with open(self.testPath, "rb") as f: + builder.add_ingredient( + {"title": "photo-A.jpg", "relationship": "componentOf", + "instance_id": "catalog:photo-A"}, + "image/jpeg", f, + ) + with open(self.testPath, "rb") as f: + builder.add_ingredient( + {"title": "photo-B.jpg", "relationship": "componentOf", + "instance_id": "catalog:photo-B"}, + "image/jpeg", f, + ) + + archive = io.BytesIO() + builder.to_archive(archive) + archive.seek(0) + builder.close() + + reader = Reader("application/c2pa", archive) + manifest_data = json.loads(reader.json()) + active = manifest_data["active_manifest"] + ingredients = manifest_data["manifests"][active]["ingredients"] + + self.assertEqual(len(ingredients), 2) + + found = None + for ing in ingredients: + if ing.get("instance_id") == "catalog:photo-B": + found = ing + break + + self.assertIsNotNone(found, + "Should find ingredient by instance_id 'catalog:photo-B' in archive") + self.assertEqual(found["title"], "photo-B.jpg") + + reader.close() + archive.close() diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py new file mode 100644 index 00000000..d0d54913 --- /dev/null +++ b/tests/unit/test_context.py @@ -0,0 +1,1316 @@ +# Copyright 2023 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. + +import os +import io +import json +import unittest +import tempfile +import shutil +import c2pa.c2pa as c2pa_module +from c2pa import Builder, C2paError as Error, Reader, Settings, Context, ContextProvider +from c2pa.c2pa import LifecycleState +from pathlib import Path +from tests.unit._support import ( + FIXTURES_DIR, + DEFAULT_TEST_FILE_NAME, + DEFAULT_TEST_FILE, + INGREDIENT_TEST_FILE, + load_test_settings_json, + TestContextAPIs, +) + + +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_update_rejects_nul_in_json_string(self): + settings = Settings.from_dict({ + "builder": {"thumbnail": {"enabled": True}}, + }) + try: + with self.assertRaises(Error): + settings.update( + '{"verify": {"verify_after_sign": true}}\x00' + '{"builder": {"thumbnail": {"enabled": false}}}') + finally: + settings.close() + + def test_settings_is_valid_after_close(self): + settings = Settings() + settings.close() + self.assertFalse(settings.is_valid) + + def test_settings_raises_after_close(self): + settings = Settings() + settings.close() + with self.assertRaises(Error): + settings.set( + "builder.thumbnail.enabled", "false" + ) + + +class TestContext(TestContextAPIs): + + def test_context_default(self): + context = Context() + self.assertTrue(context.is_valid) + self.assertFalse(context.has_signer) + context.close() + + def test_context_from_settings(self): + settings = Settings() + context = Context(settings) + self.assertTrue(context.is_valid) + context.close() + settings.close() + + def test_context_from_json(self): + context = Context.from_json( + '{"builder":{"thumbnail":' + '{"enabled":false}}}' + ) + self.assertTrue(context.is_valid) + context.close() + + def test_context_from_dict(self): + context = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + self.assertTrue(context.is_valid) + context.close() + + def test_context_context_manager(self): + with Context() as context: + self.assertTrue(context.is_valid) + + def test_context_is_valid_after_close(self): + context = Context() + context.close() + self.assertFalse(context.is_valid) + + +class TestContextBuilder(TestContextAPIs): + + def test_context_builder_default(self): + context = Context.builder().build() + self.assertTrue(context.is_valid) + self.assertFalse(context.has_signer) + context.close() + + def test_context_builder_with_settings(self): + settings = Settings() + context = Context.builder().with_settings(settings).build() + self.assertTrue(context.is_valid) + context.close() + settings.close() + + def test_context_builder_with_signer(self): + signer = self._ctx_make_signer() + context = ( + Context.builder() + .with_signer(signer) + .build() + ) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + + def test_context_builder_with_settings_and_signer(self): + settings = Settings() + signer = self._ctx_make_signer() + context = ( + Context.builder() + .with_settings(settings) + .with_signer(signer) + .build() + ) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + settings.close() + + def test_context_builder_chaining_returns_self(self): + settings = Settings() + context_builder = Context.builder() + result = context_builder.with_settings(settings) + self.assertIs(result, context_builder) + context = context_builder.build() + context.close() + settings.close() + + def test_context_builder_with_settings_last_wins(self): + """The last with_settings call determines the settings used. + + Toggles thumbnails on, off, on, off across four calls. + The last call disables thumbnails, so the signed manifest + should have no thumbnail. + """ + settings_on_1 = Settings.from_dict({ + "builder": {"thumbnail": {"enabled": True}}, + }) + settings_off_1 = Settings.from_dict({ + "builder": {"thumbnail": {"enabled": False}}, + }) + settings_on_2 = Settings.from_dict({ + "builder": {"thumbnail": {"enabled": True}}, + }) + settings_off_2 = Settings.from_dict({ + "builder": {"thumbnail": {"enabled": False}}, + }) + context = ( + Context.builder() + .with_settings(settings_on_1) + .with_settings(settings_off_1) + .with_settings(settings_on_2) + .with_settings(settings_off_2) + .build() + ) + signer = self._ctx_make_signer() + builder = Builder(self.test_manifest, context) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + builder.sign( + signer, "image/jpeg", source_file, dest_file, + ) + reader = Reader(dest_path) + manifest = reader.get_active_manifest() + # Last settings disabled thumbnails + self.assertIsNone(manifest.get("thumbnail")) + reader.close() + builder.close() + context.close() + settings_on_1.close() + settings_off_1.close() + settings_on_2.close() + settings_off_2.close() + + +class TestContextWithSigner(TestContextAPIs): + + def test_context_with_signer(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + + def test_context_with_settings_and_signer(self): + settings = Settings() + signer = self._ctx_make_signer() + context = Context(settings, signer) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + settings.close() + + def test_consumed_signer_is_closed(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + self.assertEqual(signer._lifecycle_state, LifecycleState.CLOSED) + context.close() + + def test_consumed_signer_raises_on_use(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + with self.assertRaises(Error): + signer._ensure_valid_state() + context.close() + + def test_context_has_signer_flag(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + self.assertTrue(context.has_signer) + context.close() + + def test_context_no_signer_flag(self): + context = Context() + self.assertFalse(context.has_signer) + context.close() + + def test_context_from_json_with_signer(self): + signer = self._ctx_make_signer() + context = Context.from_json( + '{"builder":{"thumbnail":' + '{"enabled":false}}}', + signer, + ) + self.assertTrue(context.has_signer) + self.assertEqual(signer._lifecycle_state, LifecycleState.CLOSED) + context.close() + + +class TestReaderWithContext(TestContextAPIs): + + def test_reader_with_default_context(self): + context = Context() + with open(DEFAULT_TEST_FILE, "rb") as file_handle: + reader = Reader("image/jpeg", file_handle, context=context,) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + + def test_reader_with_settings_context(self): + settings = Settings() + context = Context(settings) + with open(DEFAULT_TEST_FILE, "rb") as file_handle: + reader = Reader("image/jpeg", file_handle, context=context,) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + settings.close() + + def test_reader_without_context(self): + with open(DEFAULT_TEST_FILE, "rb") as file_handle: + reader = Reader("image/jpeg", file_handle) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + + def test_reader_try_create_with_context(self): + context = Context() + reader = Reader.try_create(DEFAULT_TEST_FILE, context=context,) + self.assertIsNotNone(reader) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + + def test_reader_try_create_no_manifest(self): + context = Context() + reader = Reader.try_create(INGREDIENT_TEST_FILE, context=context,) + self.assertIsNone(reader) + context.close() + + def test_reader_file_path_with_context(self): + context = Context() + reader = Reader(DEFAULT_TEST_FILE, context=context,) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + + def test_reader_extensionless_path_with_context_autodetects(self): + context = Context() + with tempfile.TemporaryDirectory() as tmp: + no_ext = os.path.join(tmp, "asset") + shutil.copyfile(DEFAULT_TEST_FILE, no_ext) + reader = Reader(no_ext, context=context) + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + 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_reader_bare_stream_with_context(self): + # The bare-stream shift runs before the context branch, so + # Reader(fh, context=ctx) reads via context with auto-detect. + context = Context() + with open(DEFAULT_TEST_FILE, "rb") as file_handle: + reader = Reader(file_handle, context=context) + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + reader.close() + context.close() + + def test_reader_path_object_with_context(self): + context = Context() + reader = Reader(Path(DEFAULT_TEST_FILE), context=context) + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + reader.close() + context.close() + + def test_try_create_format_stream_with_context(self): + context = Context() + with open(DEFAULT_TEST_FILE, "rb") as file_handle: + reader = Reader.try_create( + "image/jpeg", file_handle, context=context) + self.assertIsNotNone(reader) + if reader is not None: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + reader.close() + context.close() + + def test_try_create_bare_stream_with_context(self): + context = Context() + with open(DEFAULT_TEST_FILE, "rb") as file_handle: + reader = Reader.try_create(file_handle, context=context) + self.assertIsNotNone(reader) + if reader is not None: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + reader.close() + context.close() + + def test_context_only_no_source_raises(self): + # A context supplies settings, not the asset, + # so no path/stream is an error rather than open("None"). + context = Context() + with self.assertRaises(Error) as ctx: + Reader(context=context) + self.assertIn("stream", str(ctx.exception).lower()) + context.close() + + def test_reader_with_closed_context_raises(self): + context = Context() + context.close() + with self.assertRaises(Error): + Reader(DEFAULT_TEST_FILE, context=context) + + 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 TestSidecarReader(TestContextAPIs): + """Reader over a detached (sidecar) manifest with a Context""" + + def _make_detached_manifest(self): + """Sign DEFAULT_TEST_FILE with no-embed, return (asset_bytes, + manifest_bytes) where manifest_bytes is the "detached" sidecar + C2PA manifest.""" + + signer = self._ctx_make_signer() + with open(DEFAULT_TEST_FILE, "rb") as f: + asset_bytes = f.read() + builder = Builder(self.test_manifest) + builder.set_no_embed() + # Output is discarded: with no-embed the asset is unchanged and the + # manifest is returned by sign(). + manifest_bytes = builder.sign( + signer, "image/jpeg", io.BytesIO(asset_bytes), io.BytesIO()) + builder.close() + signer.close() + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) + return asset_bytes, manifest_bytes + + def test_reader_with_manifest_data_and_context(self): + asset_bytes, manifest_bytes = self._make_detached_manifest() + context = Context() + reader = Reader( + "image/jpeg", + io.BytesIO(asset_bytes), + manifest_bytes, + context=context, + ) + try: + data = reader.json() + self.assertTrue(data) + self.assertFalse(reader.is_embedded()) + self.assertIn("manifests", json.loads(data)) + finally: + reader.close() + context.close() + + def test_reader_manifest_data_context_invalid_manifest_raises(self): + with open(DEFAULT_TEST_FILE, "rb") as f: + asset_bytes = f.read() + context = Context() + reader = None + try: + with self.assertRaises(Error): + reader = Reader( + "image/jpeg", + io.BytesIO(asset_bytes), + b"not a real manifest", + context=context, + ) + # The consumed-pointer error branch must leave no usable handle. + if reader is not None: + self.assertEqual( + reader._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(reader._handle) + finally: + if reader is not None: + reader.close() + context.close() + + def test_reader_manifest_data_context_wrong_type_raises(self): + with open(DEFAULT_TEST_FILE, "rb") as f: + asset_bytes = f.read() + context = Context() + try: + # Non-bytes manifest_data must raise TypeError before any native + # reader handle is allocated (no leak on this path). + with self.assertRaises(TypeError): + Reader( + "image/jpeg", + io.BytesIO(asset_bytes), + "manifest as str, not bytes", + context=context, + ) + finally: + context.close() + + def test_reader_manifest_data_context_use_after_close_raises(self): + asset_bytes, manifest_bytes = self._make_detached_manifest() + context = Context() + reader = Reader( + "image/jpeg", + io.BytesIO(asset_bytes), + manifest_bytes, + context=context, + ) + reader.close() + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + with self.assertRaises(Error): + reader.json() + # Idempotent close after use-after-close attempt. + reader.close() + context.close() + + def test_reader_manifest_data_context_as_context_manager(self): + asset_bytes, manifest_bytes = self._make_detached_manifest() + context = Context() + with Reader( + "image/jpeg", + io.BytesIO(asset_bytes), + manifest_bytes, + context=context, + ) as reader: + self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) + self.assertTrue(reader.json()) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + 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() + + +class TestContextProviderContract(unittest.TestCase): + """The published ContextProvider contract is is_valid plus + execution_context, and nothing more. + """ + + class _MinimalProvider(ContextProvider): + """Implements exactly what the abstract base class declares.""" + + def __init__(self): + self._inner = Context(Settings()) + + @property + def is_valid(self): + return self._inner.is_valid + + @property + def execution_context(self): + return self._inner.execution_context + + def test_built_in_context_still_gets_in_flight_protection(self): + """The compatibility shim must not silently drop the guard for the + provider that does implement it. + """ + context = Context(Settings()) + with c2pa_module._context_guard(context): + self.assertGreater(context._inflight, 0) diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py new file mode 100644 index 00000000..a8d2070b --- /dev/null +++ b/tests/unit/test_errors.py @@ -0,0 +1,254 @@ +# Copyright 2023 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. + +import inspect +import io +import unittest +import subprocess +import sys +import threading +import c2pa.c2pa as c2pa_module +from c2pa import C2paError as Error + + +class TestErrorPlumbing(unittest.TestCase): + """Covers the error helpers themselves, which had no direct tests.""" + + def _set_native_error(self, text): + c2pa_module._lib.c2pa_error_set_last(text.encode('utf-8')) + + def test_every_error_tag_maps_to_its_typed_subclass(self): + # The wire text is "Tag: message"; each tag gets its own subclass so + # callers can catch precisely. + tags = [ + "Assertion", "AssertionNotFound", "Decoding", "Encoding", + "FileNotFound", "Io", "Json", "Manifest", "ManifestNotFound", + "NotSupported", "Other", "RemoteManifest", "ResourceNotFound", + "Signature", "Verify", "UntrackedPointer", "WrongPointerType", + ] + for tag in tags: + with self.subTest(tag=tag): + expected = getattr(Error, tag) + with self.assertRaises(expected): + c2pa_module._raise_typed_c2pa_error(f"{tag}: detail") + + def test_unmapped_tag_falls_back_to_base_error(self): + with self.assertRaises(Error) as ctx: + c2pa_module._raise_typed_c2pa_error("Nonsense: detail") + # Base class only: no subclass should claim an unknown tag. + self.assertIs(type(ctx.exception), Error) + + def test_check_ffi_operation_result_raises_with_native_message(self): + self._set_native_error("Io: disk exploded") + with self.assertRaises(Error) as ctx: + c2pa_module._check_ffi_operation_result(None, "fallback text") + self.assertIn("disk exploded", str(ctx.exception)) + + def test_check_ffi_operation_result_uses_fallback_when_slot_empty(self): + # The slot is sticky and thread-local, so a fresh thread is the only + # way to observe it unset. + captured = [] + + def run(): + try: + c2pa_module._check_ffi_operation_result(None, "fallback text") + except BaseException as e: # noqa: BLE001 - reported below + captured.append(e) + + t = threading.Thread(target=run) + t.start() + t.join() + + self.assertEqual(len(captured), 1, "expected a raise on failure") + self.assertIsInstance(captured[0], Error) + self.assertIn("fallback text", str(captured[0])) + + def test_check_ffi_operation_result_passes_success_through(self): + self.assertEqual( + c2pa_module._check_ffi_operation_result(42, "unused"), 42) + + def test_stream_creation_failure_reports_a_real_message(self): + # Regression: used to raise bare Exception("...: None") instead of the + # native error message. + real = c2pa_module._lib.c2pa_create_stream + c2pa_module._lib.c2pa_create_stream = lambda *a: None + try: + # With an error set, the message must carry it. + self._set_native_error("Io: stream refused") + with self.assertRaises(Error) as ctx: + c2pa_module.Stream(io.BytesIO(b"x")) + self.assertIn("stream refused", str(ctx.exception)) + + # With the slot unset, the old code raised bare Exception. + captured = [] + + def run(): + try: + c2pa_module.Stream(io.BytesIO(b"x")) + except BaseException as e: # noqa: BLE001 - reported below + captured.append(e) + + t = threading.Thread(target=run) + t.start() + t.join() + + self.assertEqual(len(captured), 1, "expected a raise on failure") + self.assertIsInstance(captured[0], Error, "must be typed, not bare") + self.assertNotIn("None", str(captured[0])) + finally: + c2pa_module._lib.c2pa_create_stream = real + + def test_supported_mime_types_raises_instead_of_returning_empty(self): + # Regression: `if error:` was always False, so a native failure + # returned an empty list instead of raising. Only visible with the + # slot unset, hence the fresh thread. + captured = [] + + def run(): + try: + captured.append( + c2pa_module._get_supported_mime_types( + lambda count: None, None)) + except BaseException as e: # noqa: BLE001 - reported below + captured.append(e) + + t = threading.Thread(target=run) + t.start() + t.join() + + self.assertIsInstance(captured[0], Error, "failed lookup returned data") + + def test_supported_mime_types_reports_the_native_message(self): + self._set_native_error("Io: mime lookup failed") + with self.assertRaises(Error) as ctx: + c2pa_module._get_supported_mime_types(lambda count: None, None) + self.assertIn("mime lookup failed", str(ctx.exception)) + + def test_reading_an_error_does_not_leave_it_readable(self): + """An error is reportable once, by the reader that observes it. + """ + self._set_native_error("Io: read me once") + + self.assertEqual( + c2pa_module._read_native_error(), "Io: read me once") + self.assertIsNone(c2pa_module._read_native_error(), "reported twice") + + def test_import_learns_no_marker_before_first_use(self): + result = subprocess.run( + [sys.executable, "-c", + "import c2pa.c2pa as m\n" + "assert m._marker_text_for.cache_info().currsize == 0, " + "'learned at import'\n" + "m._read_native_error()\n" + "assert m._marker_text(), 'never learned on first use'\n"], + capture_output=True, text=True, timeout=120) + self.assertEqual(result.returncode, 0, result.stderr[-2000:]) + + def test_the_no_error_marker_never_reaches_a_caller(self): + """The marker is internal, not a message for users.""" + marker = c2pa_module._marker_text() + + c2pa_module._write_no_error_marker() + self.assertIsNone(c2pa_module._read_native_error()) + + c2pa_module._write_no_error_marker() + with self.assertRaises(Error) as ctx: + c2pa_module._check_ffi_operation_result(None, "fallback: {}") + self.assertNotIn(marker, str(ctx.exception)) + + def test_a_failure_after_a_null_read_does_not_inherit_the_old_message(self): + """The message surviving a NULL read must not become someone's error.""" + c2pa_module._write_no_error_marker() + c2pa_module._lib.c2pa_error_set_last(b"Io: belongs to an earlier call") + + original = c2pa_module._lib.c2pa_error + try: + c2pa_module._lib.c2pa_error = lambda: None + c2pa_module._read_native_error() + finally: + c2pa_module._lib.c2pa_error = original + + with self.assertRaises(Error) as ctx: + c2pa_module._check_ffi_operation_result( + None, "Later unrelated failure: {}") + + self.assertNotIn("belongs to an earlier call", str(ctx.exception)) + + def test_every_real_rejection_wording_is_classified_as_pre_consume(self): + """Every tag arrives bare or behind the "Other: " wrapper.""" + wrapper = c2pa_module.ManagedResource._NATIVE_ERROR_WRAPPER + classify = c2pa_module.ManagedResource._is_pre_consume_rejection + + for tag in c2pa_module.ManagedResource._PRE_CONSUME_ERROR_TAGS: + bare = f"{tag} some detail" + wrapped = f"{wrapper}{tag} some detail" + self.assertTrue(classify(bare), bare) + self.assertTrue(classify(wrapped), wrapped) + + def test_caller_text_quoting_a_tag_is_not_a_rejection(self): + """A tag inside the message body describes the caller's input. + + Native errors quote caller-supplied strings verbatim: a JSON parse + failure repeats the offending value, an Io failure names the path. + Reading one of those as a pre-consume rejection hands the resource back + as usable after native may already own and have dropped its handle. + """ + classify = c2pa_module.ManagedResource._is_pre_consume_rejection + + forged = ( + 'Json: invalid type: string "NullParameter: x", expected a ' + 'sequence at line 1 column 43', + 'Json: invalid type: string "WrongPointerType: y", expected a ' + 'sequence at line 1 column 46', + "Io: cannot open /tmp/UntrackedPointer: 0xdead.jpg", + "Other: manifest text mentions InvalidBufferSize: in passing", + ) + for message in forged: + self.assertFalse( + classify(message), + f"caller text was read as a pointer rejection: {message!r}") + + def test_a_failing_flush_does_not_strand_the_rest_of_the_queue(self): + """One resource raising must not skip the resources queued behind it. + """ + flushed = [] + + class Recorder: + def __init__(self, name, raises=None): + self.name = name + self.raises = raises + + def _maybe_flush_pending(self): + if self.raises is not None: + raise self.raises + flushed.append(self.name) + + first = Recorder("first") + middle = Recorder("middle", raises=KeyboardInterrupt()) + last = Recorder("last") + + with self.assertLogs("c2pa", level="ERROR"): + with c2pa_module._native_section(): + for resource in (first, middle, last): + c2pa_module._register_for_section_flush(resource) + + self.assertEqual(flushed, ["first", "last"]) + + def test_runtime_does_not_call_error_set_last(self): + """The marker mechanism must not depend on c2pa_error_set_last, + so this module loads against native builds that lack it.""" + for fn in (c2pa_module.ManagedResource._invoke_consume, + c2pa_module._read_native_error, + c2pa_module._write_no_error_marker): + self.assertNotIn( + 'c2pa_error_set_last', inspect.getsource(fn)) diff --git a/tests/unit/test_format_handling.py b/tests/unit/test_format_handling.py new file mode 100644 index 00000000..f651ded0 --- /dev/null +++ b/tests/unit/test_format_handling.py @@ -0,0 +1,93 @@ +# Copyright 2023 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. + +import unittest +from c2pa import C2paError as Error, Reader +from c2pa.c2pa import _encode_format, _format_ffi_arg + + +class TestFormatValidation(unittest.TestCase): + """Unit tests for _encode_format. + + The binding no longer validates the format against a supported list: the + native library is the authority and detects the type from the bytes, so a + given format is trimmed and passed straight through. + """ + + def test_strips_whitespace_from_query(self): + for value in (" image/jpeg", "image/jpeg ", "\timage/jpeg\n"): + self.assertEqual( + _encode_format(value, "Reader"), + b"image/jpeg") + + def test_case_normalized_to_lowercase(self): + # MIME types are case-insensitive, so the format is lowercased before + # being handed to the native library. + for value in ("IMAGE/JPEG", "Image/Jpeg"): + self.assertEqual( + _encode_format(value, "Reader"), + b"image/jpeg") + + def test_encodes_stripped_and_lowercased_key(self): + self.assertEqual( + _encode_format(" IMAGE/JPEG ", "Reader"), + b"image/jpeg") + + def test_production_supported_list_is_normalized(self): + # _get_supported_mime_types strips and lowercases every entry. + for entry in Reader.get_supported_mime_types(): + self.assertEqual(entry, entry.strip().lower()) + + def test_none_autodetects(self): + # A missing format is None (auto-detect), not the empty-bytes sentinel. + self.assertIsNone(_encode_format(None, "Reader")) + + def test_blank_autodetects(self): + for value in ("", " ", "\t\n"): + self.assertIsNone(_encode_format(value, "Reader")) + + def test_missing_format_rejected_when_autodetect_disabled(self): + for value in (None, "", " "): + with self.assertRaises(Error.NotSupported): + _encode_format(value, "Builder", allow_autodetect=False) + + def test_format_ffi_arg_maps_none_to_empty_bytes(self): + # The native library's "detect from bytes" flag is empty bytes, not a + # NULL c_char_p, so None must map to b"". + self.assertEqual(_format_ffi_arg(None), b"") + self.assertEqual(_format_ffi_arg(b"image/jpeg"), b"image/jpeg") + + def test_resolve_format_bytes(self): + # Path form derives the format from the extension. + self.assertEqual( + Reader._resolve_format_bytes("photo.jpg", None), b"image/jpeg") + # Extensionless path -> auto-detect. + self.assertIsNone( + Reader._resolve_format_bytes("no_extension", None)) + # Stream given: format_or_path is the format (or None for detect). + self.assertEqual( + Reader._resolve_format_bytes("image/jpeg", object()), b"image/jpeg") + self.assertIsNone( + Reader._resolve_format_bytes(None, object())) + + def test_unknown_format_passes_through(self): + # No supported-list check: the format is encoded and handed to the + # native library, which is the authority on validity. + self.assertEqual( + _encode_format("application/x-nope", "Reader"), + b"application/x-nope") + + def test_literal_none_string_passes_through(self): + # The string "None" is a plain string; it is trimmed, lowercased, and + # encoded like any other, not rejected in the binding. + self.assertEqual(_encode_format("None", "Reader"), b"none") diff --git a/tests/unit/test_legacy_api.py b/tests/unit/test_legacy_api.py new file mode 100644 index 00000000..bf42b3a4 --- /dev/null +++ b/tests/unit/test_legacy_api.py @@ -0,0 +1,309 @@ +# Copyright 2023 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. + +import os +import json +import unittest +import warnings +import tempfile +import shutil +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.backends import default_backend +from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer +from c2pa.c2pa import create_signer, create_signer_from_info +from tests.unit._support import ( + FIXTURES_DIR, + DEFAULT_TEST_FILE, + INGREDIENT_TEST_FILE, +) + + +class TestLegacyAPI(unittest.TestCase): + def setUp(self): + # Filter specific deprecation warnings for legacy API tests + 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 + self.testPath2 = INGREDIENT_TEST_FILE + self.testPath3 = os.path.join(self.data_dir, "A_thumbnail.jpg") + + # Load test certificates and key + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + self.certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + self.key = key_file.read() + + # Create a local ES256 signer with certs and a timestamp server + self.signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + self.signer = Signer.from_info(self.signer_info) + + # Define a manifest as a dictionary + self.manifestDefinition = { + "claim_generator": "python_internals_test", + "claim_generator_info": [{ + "name": "python_internals_test", + "version": "0.0.1", + }], + "claim_version": 1, + "format": "image/jpeg", + "title": "Python Test Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.opened" + } + ] + } + } + ] + } + + # Create temp directory for tests + self.temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(self.temp_data_dir, exist_ok=True) + + # Define an example ES256 callback signer + self.callback_signer_alg = "Es256" + def callback_signer_es256(data: bytes) -> bytes: + private_key = serialization.load_pem_private_key( + self.key, + password=None, + backend=default_backend() + ) + signature = private_key.sign( + data, + ec.ECDSA(hashes.SHA256()) + ) + return signature + self.callback_signer_es256 = callback_signer_es256 + + def tearDown(self): + """Clean up temporary files after each test.""" + if os.path.exists(self.temp_data_dir): + shutil.rmtree(self.temp_data_dir) + + def test_sign_file_callback_signer_reports_error(self): + """Test signing a file using the sign_file method with a callback that reports an error.""" + + temp_dir = tempfile.mkdtemp() + + try: + output_path = os.path.join(temp_dir, "signed_output.jpg") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + + # Define a callback that always returns None to simulate an error + def error_callback_signer(data: bytes) -> bytes: + # Could alternatively also raise an error + # raise RuntimeError("Simulated signing error") + return None + + # Create signer with error callback using create_signer function + signer = create_signer( + callback=error_callback_signer, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + # The signing operation should fail due to the error callback + with self.assertRaises(Error): + builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + finally: + shutil.rmtree(temp_dir) + + def test_sign_file_callback_signer(self): + """Test signing a file using the sign_file method.""" + + temp_dir = tempfile.mkdtemp() + + try: + output_path = os.path.join(temp_dir, "signed_output.jpg") + + # Use the sign_file method + builder = Builder(self.manifestDefinition) + + # Create signer with callback using create_signer function + signer = create_signer( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) + + manifest_bytes = builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + # Verify the output file was created + self.assertTrue(os.path.exists(output_path)) + + # Verify results + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) + + # Read the signed file and verify the manifest + with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: + json_data = reader.json() + # 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.""" + + temp_dir = tempfile.mkdtemp() + + try: + output_path = os.path.join(temp_dir, "signed_output_managed.jpg") + + # Create builder and signer with context managers + with Builder(self.manifestDefinition) as builder, create_signer( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) as signer: + + manifest_bytes = builder.sign_file( + source_path=self.testPath, + dest_path=output_path, + signer=signer + ) + + # Verify results + self.assertTrue(os.path.exists(output_path)) + self.assertIsInstance(manifest_bytes, bytes) + self.assertGreater(len(manifest_bytes), 0) + + # Verify signed data can be read + with open(output_path, "rb") as file: + with Reader("image/jpeg", file) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + # 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] + + # Verify the signature_info contains the correct algorithm + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + + finally: + shutil.rmtree(temp_dir) + + def test_sign_file_callback_signer_managed_multiple_uses(self): + """Test that a signer can be used multiple times with context managers.""" + + temp_dir = tempfile.mkdtemp() + + try: + # Create builder and signer with context managers + with Builder(self.manifestDefinition) as builder, create_signer( + callback=self.callback_signer_es256, + alg=SigningAlg.ES256, + certs=self.certs.decode('utf-8'), + tsa_url="http://timestamp.digicert.com" + ) as signer: + + # First signing operation + output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") + manifest_bytes_1 = builder.sign_file( + source_path=self.testPath, + dest_path=output_path_1, + signer=signer + ) + + # Verify first signing was successful + self.assertTrue(os.path.exists(output_path_1)) + self.assertIsInstance(manifest_bytes_1, bytes) + self.assertGreater(len(manifest_bytes_1), 0) + + # Second signing operation with 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 = builder2.sign_file( + source_path=self.testPath, + dest_path=output_path_2, + signer=signer + ) + + # Verify second signing was successful + self.assertTrue(os.path.exists(output_path_2)) + self.assertIsInstance(manifest_bytes_2, bytes) + self.assertGreater(len(manifest_bytes_2), 0) + + # Verify both files contain valid C2PA data + for output_path in [output_path_1, output_path_2]: + with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: + json_data = reader.json() + self.assertIn("Python Test", json_data) + # 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] + + # Verify the signature_info contains the correct algorithm + self.assertIn("signature_info", active_manifest) + signature_info = active_manifest["signature_info"] + self.assertEqual(signature_info["alg"], self.callback_signer_alg) + + finally: + shutil.rmtree(temp_dir) + + def test_create_signer_from_info(self): + """Create a Signer using the create_signer_from_info function""" + signer = create_signer_from_info(self.signer_info) + self.assertIsNotNone(signer) diff --git a/tests/unit/test_managed_resource.py b/tests/unit/test_managed_resource.py new file mode 100644 index 00000000..3781caf1 --- /dev/null +++ b/tests/unit/test_managed_resource.py @@ -0,0 +1,1816 @@ +# Copyright 2023 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. + +import gc +import inspect +import os +import io +import json +import re +import unittest +import ctypes +import threading +import c2pa.c2pa as c2pa_module +from unittest.mock import patch +from c2pa import Builder, C2paError as Error, Reader, C2paSignerInfo, Signer, Settings, Context +from c2pa.c2pa import LifecycleState, ManagedResource, format_embeddable +from tests.unit._support import ( + FIXTURES_DIR, + DEFAULT_TEST_FILE_NAME, + DEFAULT_TEST_FILE, + _patch_free, + _fail_with_native_error, + TestContextAPIs, +) + + +class TestManagedResourceLifecycle(unittest.TestCase): + """Lifecycle primitives (_activate, _consume_and_swap, _wrap_native_handle), + the _owner_pid stamp that governs which process may free a handle, and + the ownership hand-offs between Python and the native library. + + For testing: setUp records frees instead of performing them, + so a miscount reads as memory handling issue. + Tests releasing real handles call _use_real_frees() first to + restore release behavior. + """ + + class _FakeHandleResource(ManagedResource): + """Concrete subclass with no resources of its own.""" + + class _CallbackHoldingResource(ManagedResource): + """Mimics Signer: its _release() reads an attribute that _init_attrs() + is responsible for defaulting.""" + + def _release(self): + if self._callback_cb: + self._callback_cb = None + + class _ReleaseRecordingResource(ManagedResource): + """Records _release() calls for test asserts.""" + + def __init__(self): + super().__init__() + self.release_calls = 0 + + def _release(self): + self.release_calls += 1 + + class _ExtenderResource(ManagedResource): + """For testing: An extender that owns a raw handle and wraps it via + _wrap_native_handle. It carries several attributes of its own, all + defaulted in _init_attrs (not __init__), and _release reads them, so a + missing attribute would surface as an AttributeError on test teardown. + """ + + def _init_attrs(self): + super()._init_attrs() + self.label = "extender" + self.buffer = [] + self.released = False + + def _release(self): + # Reads attributes _init_attrs is responsible for defaulting. + self.buffer.append(self.label) + self.released = True + + def setUp(self): + self.data_dir = FIXTURES_DIR + self.freed = [] + self._real_free = ManagedResource._free_native_ptr + # Registered first so it runs last, after every patch has unwound. + self.addCleanup(self._assert_free_hook_restored) + _patch_free(self, self.freed.append) + + def _assert_free_hook_restored(self): + self.assertIs( + ManagedResource._free_native_ptr, self._real_free, + "{} leaked a _free_native_ptr patch".format(self.id())) + + def _free_counts(self): + counts = {} + for handle in self.freed: + counts[handle] = counts.get(handle, 0) + 1 + return counts + + def _use_real_frees(self): + """Undo free recorder, so native handles are really freed.""" + _patch_free(self, self._real_free) + + def _make_signer(self): + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as f: + certs = f.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as f: + key = f.read() + return Signer.from_info(C2paSignerInfo( + b"es256", certs, key, b"http://timestamp.digicert.com")) + + def test_release_failure_still_frees_handle(self): + res = self._CallbackHoldingResource() + # _callback_cb is never set, so _release() raises AttributeError. + res._activate(0xBBBB) + + res.close() + + self.assertEqual(self.freed, [0xBBBB], + "handle leaked when _release() raised") + self.assertIsNone(res._handle) + + def test_release_failure_is_logged(self): + res = self._CallbackHoldingResource() + res._activate(0xBBBB) + + with self.assertLogs('c2pa', level='ERROR') as captured: + res.close() + + self.assertTrue( + any('Failed to release' in line for line in captured.output), + f"_release() failure was not logged: {captured.output}") + + def test_activate_rejects_null_handle(self): + res = self._FakeHandleResource() + + with self.assertRaises(Error) as ctx: + res._activate(None) + + self.assertIn("null handle", str(ctx.exception)) + self.assertEqual(res._lifecycle_state, LifecycleState.UNINITIALIZED) + + def test_activate_rejects_double_activation(self): + res = self._FakeHandleResource() + res._activate(0x1111) + + with self.assertRaises(Error) as ctx: + res._activate(0x2222) + + self.assertIn("already activated", str(ctx.exception)) + # The first handle is still owned, and is freed exactly once. + self.assertEqual(res._handle, 0x1111) + res.close() + self.assertEqual(self.freed, [0x1111]) + + def test_activate_rejects_reactivation_after_close(self): + res = self._FakeHandleResource() + res._activate(0x3333) + res.close() + self.freed.clear() + + with self.assertRaises(Error): + res._activate(0x4444) + + # Staying CLOSED is what prevents a second free of the handle. + self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(res._handle) + self.assertEqual(self.freed, []) + + def test_activate_does_not_mutate_on_rejection(self): + res = self._FakeHandleResource() + res._activate(0x5555) + + with self.assertRaises(Error): + res._activate(0x6666) + + self.assertEqual(res._handle, 0x5555, + "rejected activation replaced the handle") + self.assertEqual(res._lifecycle_state, LifecycleState.ACTIVE) + + def test_consume_and_swap_requires_active_resource(self): + uninitialized = self._FakeHandleResource() + with self.assertRaises(Error): + uninitialized._consume_and_swap(lambda h: 0x1, "swap: {}") + + closed = self._FakeHandleResource() + closed._activate(0x2) + closed.close() + self.freed.clear() + with self.assertRaises(Error): + closed._consume_and_swap(lambda h: 0x3, "swap: {}") + self.assertEqual(self.freed, []) + + def test_wrap_native_handle_bypasses_init(self): + seen = [] + + class Probe(ManagedResource): + def __init__(self): + raise AssertionError("__init__ must be bypassed") + + def _init_attrs(self): + super()._init_attrs() + self._tag = 'from _init_attrs' + + def _release(self): + seen.append(self._tag) + + obj = Probe._wrap_native_handle(0xC0DE) + + self.assertEqual(obj._tag, 'from _init_attrs') + self.assertEqual(obj._lifecycle_state, LifecycleState.ACTIVE) + self.assertTrue(obj.is_valid) + self.assertTrue(hasattr(obj, '_owner_pid')) + + obj.close() + self.assertEqual(seen, ['from _init_attrs'], + "_release() could not see the class's own attrs") + + def test_wrap_native_handle_rejects_null(self): + with self.assertRaises(Error): + self._FakeHandleResource._wrap_native_handle(None) + + def test_close_after_wrap_is_idempotent(self): + obj = self._FakeHandleResource._wrap_native_handle(0xD00D) + + obj.close() + obj.close() + + self.assertEqual(self.freed, [0xD00D], "handle freed more than once") + + def test_every_construction_path_records_owner_pid(self): + pid = os.getpid() + + plain = self._FakeHandleResource() + self.assertEqual(plain._owner_pid, pid) + + activated = self._FakeHandleResource() + activated._activate(0xA1) + self.assertEqual(activated._owner_pid, pid) + + wrapped = self._FakeHandleResource._wrap_native_handle(0xA2) + self.assertEqual(wrapped._owner_pid, pid) + + # A swap keeps the original stamp: + # the replacement handle was allocated by the same process + # that created the object. + wrapped._consume_and_swap(lambda h: 0xA3, "swap: {}") + self.assertEqual(wrapped._owner_pid, pid) + + def test_foreign_child_skips_free_for_wrapped_and_swapped(self): + wrapped = self._FakeHandleResource._wrap_native_handle(0xC1) + wrapped._owner_pid = os.getpid() + 1 + wrapped.close() + + swapped = self._FakeHandleResource() + swapped._activate(0xC2) + swapped._consume_and_swap(lambda h: 0xC3, "swap: {}") + swapped._owner_pid = os.getpid() + 1 + swapped.close() + + self.assertEqual(self.freed, [], + "forked child freed a pointer its parent still owns") + # The child must not free a pointer the parent still owns. + # The child does mark its own copies closed and nulls their handles, + # which stops the child from reusing a parent-owned handle. + self.assertEqual(wrapped._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(wrapped._handle) + self.assertEqual(swapped._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(swapped._handle) + + # A second foreign teardown is a no-op. + wrapped.close() + swapped.close() + self.assertEqual(self.freed, []) + + def test_owning_process_frees_wrapped_and_swapped_exactly_once(self): + wrapped = self._FakeHandleResource._wrap_native_handle(0xC4) + wrapped.close() + wrapped.close() + + swapped = self._FakeHandleResource() + swapped._activate(0xC5) + swapped._consume_and_swap(lambda h: 0xC6, "swap: {}") + swapped.close() + + # 0xC5 was consumed by the test FFI swap. + # Only the replacement must be freed here. + self.assertEqual(self._free_counts(), {0xC4: 1, 0xC6: 1}) + + def test_foreign_child_skips_release(self): + foreign = self._ReleaseRecordingResource() + foreign._activate(0xD1) + foreign._owner_pid = os.getpid() + 1 + foreign.close() + self.assertEqual(foreign.release_calls, 0) + + owned = self._ReleaseRecordingResource() + owned._activate(0xD2) + owned.close() + self.assertEqual(owned.release_calls, 1) + + def test_consumed_resource_frees_nothing_in_either_process(self): + owned = self._FakeHandleResource() + owned._activate(0xE1) + owned._teardown(free_handle=False) + owned.close() + + foreign = self._FakeHandleResource() + foreign._activate(0xE2) + foreign._teardown(free_handle=False) + foreign._owner_pid = os.getpid() + 1 + foreign.close() + + self.assertEqual(self.freed, []) + + # Consuming a handle hands the native pointer to a new owner. + # The Python-side resources are still ours to free. + def test_teardown_consumed_releases_python_resources(self): + res = self._ReleaseRecordingResource() + res._activate(0xF1) + + res._teardown(free_handle=False) + + self.assertEqual(res.release_calls, 1) + self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(res._handle) + self.assertEqual(self.freed, []) + + def test_teardown_consumed_swallows_failing_release(self): + res = self._CallbackHoldingResource() + res._activate(0xF2) + + with self.assertLogs("c2pa", level="ERROR"): + res._teardown(free_handle=False) + + self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(res._handle) + + def test_teardown_consumed_in_foreign_process_skips_release(self): + res = self._ReleaseRecordingResource() + res._activate(0xF3) + res._owner_pid = os.getpid() + 1 + + res._teardown(free_handle=False) + + self.assertEqual(res.release_calls, 0) + self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(res._handle) + + def test_extender_wraps_handle_fully_built(self): + obj = self._ExtenderResource._wrap_native_handle(0xE0) + + # Every attribute _init_attrs defaults is present, + # even though __init__ never ran. + self.assertEqual(obj.label, "extender") + self.assertEqual(obj.buffer, []) + self.assertFalse(obj.released) + self.assertTrue(obj.is_valid) + self.assertEqual(obj._owner_pid, os.getpid()) + + # _release reads those attributes, so a missing one will raise here. + obj.close() + obj.close() + + self.assertTrue(obj.released) + self.assertEqual(obj.buffer, ["extender"]) + self.assertEqual(self.freed, [0xE0], "wrapped handle freed once") + + def test_extender_foreign_teardown_skips_native_free(self): + obj = self._ExtenderResource._wrap_native_handle(0xE1) + # Stamp a foreign owner: + # teardown runs in a process that did not create the handle, + # so it must not free the pointer or run _release. + obj._owner_pid = os.getpid() + 1 + + obj.close() + + self.assertEqual(self.freed, [], + "forked child freed a handle its parent still owns") + self.assertFalse(obj.released, "foreign teardown ran _release") + # The child marks its own copy closed and nulls the handle: + # the parent holds a separate copy and it stops the + # child reusing a parent-owned handle. + self.assertEqual(obj._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(obj._handle) + + # A second foreign teardown stays a no-op, + # and any operation on the now-closed child copy fails. + obj.close() + self.assertEqual(self.freed, []) + with self.assertRaises(Error): + obj._ensure_valid_state() + + def test_signer_init_rejects_null_pointer(self): + with self.assertRaises(Error): + Signer(None) + + def test_builder_from_archive_wraps_handle(self): + self._use_real_frees() + archive = io.BytesIO() + Builder({"claim_generator": "test", "format": "image/jpeg"}).to_archive( + archive) + + builder = Builder.from_archive(io.BytesIO(archive.getvalue())) + + self.assertTrue(builder.is_valid) + self.assertIsNone(builder._context) + self.assertFalse(builder._has_context_signer) + builder.close() + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + + def test_context_build_failure_consumes_signer(self): + self._use_real_frees() + signer = self._make_signer() + real_build = c2pa_module._lib.c2pa_context_builder_build + c2pa_module._lib.c2pa_context_builder_build = lambda ptr: None + try: + with self.assertRaises(Error): + Context(signer=signer) + finally: + c2pa_module._lib.c2pa_context_builder_build = real_build + + self.assertIsNone(signer._handle, + "Signer still holds a pointer the native side freed") + self.assertEqual(signer._lifecycle_state, LifecycleState.CLOSED) + + # Nothing left to free, so close() must be a no-op. + freed = [] + with patch.object(ManagedResource, '_free_native_ptr', + staticmethod(freed.append)): + signer.close() + self.assertEqual(freed, []) + + def test_context_with_signer_consumes_it_on_success(self): + self._use_real_frees() + signer = self._make_signer() + + context = Context(signer=signer) + + self.assertTrue(context.is_valid) + self.assertIsNone(signer._handle) + self.assertEqual(signer._lifecycle_state, LifecycleState.CLOSED) + self.assertTrue(context.has_signer) + context.close() + + def test_construction_failure_leaves_nothing_to_free(self): + # Activation happens after the null check, so a failed construction + # has no handle on the object that __del__ can find. + real_new = c2pa_module._lib.c2pa_context_new + c2pa_module._lib.c2pa_context_new = lambda: None + try: + with self.assertRaises(Error): + Context() + finally: + c2pa_module._lib.c2pa_context_new = real_new + + real_json = c2pa_module._lib.c2pa_builder_from_json + c2pa_module._lib.c2pa_builder_from_json = lambda j: None + try: + with self.assertRaises(Error): + Builder({"claim_generator": "test"}) + finally: + c2pa_module._lib.c2pa_builder_from_json = real_json + + def test_context_build_null_return_frees_builder(self): + # Mock a pointer rejection. + settings = Settings() + real_build = c2pa_module._lib.c2pa_context_builder_build + c2pa_module._lib.c2pa_context_builder_build = _fail_with_native_error( + b"UntrackedPointer: mocked pre-consume rejection") + try: + with self.assertRaises(Error): + Context(settings=settings) + finally: + c2pa_module._lib.c2pa_context_builder_build = real_build + + # One free: the un-consumed builder. + # Settings borrows, so it is not freed here. + self.assertEqual(len(self.freed), 1, + "un-consumed builder leaked on build failure") + settings.close() + + def test_consume_no_replacement_marks_consumed_on_success(self): + res = self._FakeHandleResource() + res._activate(0xCAFE) + + res._consume_no_replacement(lambda h: 0, "set failed: {}") + + # Native took ownership. + self.assertEqual(self.freed, []) + self.assertIsNone(res._handle) + self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + + def test_consume_no_replacement_retains_on_pre_consume_tag(self): + for message in ("UntrackedPointer: rejected", + "PointerInUse: 0x2a", + "Other: ForeignProcess: 0x2a"): + with self.subTest(message=message): + self.freed.clear() + res = self._FakeHandleResource() + res._activate(0xCAFE) + real_read = c2pa_module._read_native_error + c2pa_module._read_native_error = lambda: message + try: + with self.assertRaises(Error): + res._consume_no_replacement( + lambda h: -1, "set failed: {}") + finally: + c2pa_module._read_native_error = real_read + + # Rejected before ownership transferred: handle retained. + self.assertEqual(res._handle, 0xCAFE) + self.assertEqual(res._lifecycle_state, LifecycleState.ACTIVE) + self.assertEqual(self.freed, []) + res.close() + self.assertEqual(self.freed, [0xCAFE]) + + def test_consume_no_replacement_marks_consumed_on_other_error(self): + res = self._FakeHandleResource() + res._activate(0xCAFE) + real_read = c2pa_module._read_native_error + c2pa_module._read_native_error = lambda: "OtherError: boom" + try: + with self.assertRaises(Error): + res._consume_no_replacement(lambda h: -1, "set failed: {}") + finally: + c2pa_module._read_native_error = real_read + + # A non-tag error means native took ownership then failed and dropped + # the value itself: mark consumed, do not free. + self.assertEqual(self.freed, []) + self.assertIsNone(res._handle) + self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + + def test_invoke_consume_success_does_not_consult_error_slot(self): + """A successful consuming call must not read the error slot at all: + only a failure inspects it.""" + res = self._FakeHandleResource() + res._activate(0xCAFE) + + res._consume_no_replacement(lambda h: 0, "set failed: {}") + + self.assertIsNone(c2pa_module._read_native_error()) + + def test_native_section_defers_unrelated_finalizer_free(self): + """A finalizer for a completely unrelated resource firing mid + native-call must not free immediately. + """ + victim = self._FakeHandleResource() + victim._activate(0xCAFE) + bystander = self._FakeHandleResource() + bystander._activate(0xB00B) + + def polluting_free(ptr): + self.freed.append(ptr) + # Freeing and untracked/ pointer writes its own error into the + # same thread-local slot. + c2pa_module._lib.c2pa_error_set_last( + "Other: UntrackedPointer: {:#x}".format(ptr).encode()) + return -1 + _patch_free(self, polluting_free) + + def ffi_call(handle): + nonlocal bystander + del bystander # last reference dropped: __del__ fires right here + return None # the real call failed but set no error of its own + + # A bare section: the consume needs the error section, but not a + # borrow on its own handle. _ensure_not_borrowed + # refuses a consume nested in a _native_call() on the same resource. + with c2pa_module._native_section(): + with self.assertRaises(Error): + victim._consume_no_replacement(ffi_call, "op failed: {}") + + # The bystander's free is deferred to the section close, so it + # runs after the consuming call, before the victim's free. + self.assertEqual(self.freed, [0xB00B, 0xCAFE]) + + def test_nested_native_sections_flush_only_at_outermost_close(self): + """A native-error section opened inside another, already-open one + on the same thread must not flush anything until the outermost + one closes.""" + res = self._FakeHandleResource() + res._activate(0xCAFE) + + outer = c2pa_module._native_section() + outer.__enter__() + try: + inner = c2pa_module._native_section() + inner.__enter__() + try: + res.close() + finally: + inner.__exit__(None, None, None) + # Inner closed, outer is still open: still deferred. + self.assertEqual(self.freed, [], "inner section flushed") + finally: + outer.__exit__(None, None, None) + self.assertEqual(self.freed, [0xCAFE]) + + def test_stale_error_not_misattributed_after_preset_error(self): + """A stale tag left by an earlier, unrelated call on this thread + must not be read as this call's own error.""" + # A stale tag from an earlier, unrelated call. + c2pa_module._lib.c2pa_error_set_last( + b"Other: UntrackedPointer: 0xdeadbeef") + + res = self._FakeHandleResource() + res._activate(0xCAFE) + + # Fails without setting any error of its own. + # The marker written inside _invoke_consume must have cleared + # the stale tag, so this routes to the "no error of our own" branch. + with self.assertRaises(Error): + res._consume_no_replacement(lambda h: -1, "op failed: {}") + + # A misattributed stale tag would have matched + # _PRE_CONSUME_ERROR_TAGS and left the resource ACTIVE. + self.assertEqual(self.freed, [0xCAFE]) + + +class TestManagedResourceObjects(TestContextAPIs): + """Tests native resource handling management when managed manually. + """ + + @staticmethod + def _ptr_addr(ptr): + """Address a ctypes pointer points at, or None for a null pointer. + + ctypes pointers compare by identity, not by value: two pointer objects + for the same address are unequal. Compare addresses instead. + """ + if not ptr: + return None + return ctypes.cast(ptr, ctypes.c_void_p).value + + def _instrument_frees(self): + """Record frees instead of performing them, and restore on teardown. + """ + freed = [] + _patch_free(self, freed.append) + return freed + + def _free_count(self, freed, handle): + """How many times `handle` was freed, ignoring unrelated frees.""" + target = self._ptr_addr(handle) + self.assertIsNotNone(target, "cannot count frees of a null handle") + return sum(1 for ptr in freed if self._ptr_addr(ptr) == target) + + def _make_archive(self, manifest=None): + archive = io.BytesIO() + builder = Builder(manifest or self.test_manifest) + try: + builder.to_archive(archive) + finally: + builder.close() + archive.seek(0) + return archive + + def test_settings_activation_paths(self): + pid = os.getpid() + for label, factory in ( + ("Settings()", lambda: Settings()), + ("from_json", lambda: Settings.from_json('{"version_major": 1}')), + ("from_dict", lambda: Settings.from_dict({"version_major": 1})), + ): + with self.subTest(path=label): + settings = factory() + try: + self.assertTrue(settings.is_valid) + self.assertEqual(settings._owner_pid, pid) + finally: + settings.close() + + def test_context_activation_paths(self): + pid = os.getpid() + settings = Settings.from_dict({"version_major": 1}) + try: + for label, factory in ( + # No settings and no signer takes the c2pa_context_new path. + ("Context()", lambda: Context()), + # Anything else goes through the ContextBuilder path. + ("Context(settings)", lambda: Context(settings)), + ("from_dict", lambda: Context.from_dict({"version_major": 1})), + ("builder()", + lambda: Context.builder().with_settings(settings).build()), + ): + with self.subTest(path=label): + context = factory() + try: + self.assertTrue(context.is_valid) + self.assertEqual(context._owner_pid, pid) + finally: + context.close() + finally: + settings.close() + + def test_reader_activation_paths(self): + pid = os.getpid() + context = Context() + try: + with open(DEFAULT_TEST_FILE, "rb") as f: + from_stream = Reader("image/jpeg", f) + self.addCleanup(from_stream.close) + self.assertEqual(from_stream._owner_pid, pid) + + from_path = Reader(DEFAULT_TEST_FILE) + self.addCleanup(from_path.close) + self.assertEqual(from_path._owner_pid, pid) + + with open(DEFAULT_TEST_FILE, "rb") as f: + with_context = Reader(DEFAULT_TEST_FILE, context=context) + self.addCleanup(with_context.close) + self.assertEqual(with_context._owner_pid, pid) + + with open(DEFAULT_TEST_FILE, "rb") as f: + created = Reader.try_create("image/jpeg", f) + self.addCleanup(created.close) + self.assertEqual(created._owner_pid, pid) + finally: + context.close() + + def test_builder_activation_paths(self): + pid = os.getpid() + context = Context() + try: + plain = Builder(self.test_manifest) + self.addCleanup(plain.close) + self.assertEqual(plain._owner_pid, pid) + + from_json = Builder.from_json(self.test_manifest) + self.addCleanup(from_json.close) + self.assertEqual(from_json._owner_pid, pid) + + with_context = Builder(self.test_manifest, context=context) + self.addCleanup(with_context.close) + self.assertEqual(with_context._owner_pid, pid) + + # from_archive is the only caller of _wrap_native_handle, + # which bypasses __init__ entirely + wrapped = Builder.from_archive(self._make_archive()) + self.addCleanup(wrapped.close) + self.assertEqual(wrapped._owner_pid, pid) + self.assertIsNone(wrapped._context) + self.assertFalse(wrapped._has_context_signer) + finally: + context.close() + + def test_signer_activation_paths(self): + signer = self._ctx_make_signer() + self.addCleanup(signer.close) + self.assertTrue(signer.is_valid) + self.assertEqual(signer._owner_pid, os.getpid()) + + callback_signer = self._ctx_make_callback_signer() + self.addCleanup(callback_signer.close) + self.assertEqual(callback_signer._owner_pid, os.getpid()) + + def test_builder_with_archive_swaps_the_handle(self): + context = Context() + self.addCleanup(context.close) + builder = Builder(self.test_manifest, context=context) + original_handle = builder._handle + original_stamp = builder._owner_pid + + result = builder.with_archive(self._make_archive()) + + self.assertIs(result, builder, "with_archive should return self") + self.assertNotEqual(builder._handle, original_handle, + "the native handle was not replaced") + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + # The replacement came from this process, the stamp still applies. + self.assertEqual(builder._owner_pid, original_stamp) + self.assertEqual(builder._owner_pid, os.getpid()) + builder.close() + + def test_reader_with_fragment_swaps_the_handle(self): + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + context = Context() + self.addCleanup(context.close) + + with open(init_path, "rb") as init: + reader = Reader("video/mp4", init, context=context) + self.addCleanup(reader.close) + original_handle = reader._handle + + # The Reader consumed the first handle, so the init stream is reopened. + with open(init_path, "rb") as init, open(fragment_path, "rb") as frag: + result = reader.with_fragment("video/mp4", init, frag) + + self.assertIs(result, reader, "with_fragment should return self") + self.assertNotEqual(reader._handle, original_handle, + "the native handle was not replaced") + self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) + self.assertEqual(reader._owner_pid, os.getpid()) + + def test_swapped_builder_is_freed_exactly_once(self): + context = Context() + self.addCleanup(context.close) + builder = Builder(self.test_manifest, context=context) + original_handle = builder._handle + archive = self._make_archive() + + # Instrument across the swap so a free of the consumed pointer is recorded. + freed = self._instrument_frees() + builder.with_archive(archive) + swapped_handle = builder._handle + + self.assertEqual(self._free_count(freed, original_handle), 0, + "the swap freed the consumed pointer") + + builder.close() + builder.close() + + # The replacement must be freed exactly once; a second close() is a + # no-op. We don't separately assert original_handle's count here: the + # native allocator may legally reuse the just-freed original address + # for the replacement Box (same-size free-then-alloc within one FFI + # call), so swapped_handle and original_handle can be the same + # pointer value. The check above already covers that case correctly. + self.assertEqual(self._free_count(freed, swapped_handle), 1) + + def test_repeated_swaps_on_one_builder(self): + # Each with_archive consumes the handle the previous one returned, so + # a chain of swaps is where a wrong swap surfaces: keeping the + # consumed pointer makes the next call raise UntrackedPointer from + # the native registry. + context = Context() + self.addCleanup(context.close) + builder = Builder(self.test_manifest, context=context) + self.addCleanup(builder.close) + + seen = [builder._handle] + for _ in range(5): + builder.with_archive(self._make_archive()) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + seen.append(builder._handle) + + self.assertTrue(builder.is_valid) + self.assertEqual(builder._owner_pid, os.getpid()) + + def test_context_consumes_signer_but_not_settings(self): + settings = Settings.from_dict({"version_major": 1}) + signer = self._ctx_make_signer() + + context = Context(settings=settings, signer=signer) + self.addCleanup(context.close) + + # The signer pointer moved to the native context builder. + self.assertIsNone(signer._handle) + self.assertEqual(signer._lifecycle_state, LifecycleState.CLOSED) + self.assertTrue(context.has_signer) + + # Settings are copied, not consumed, so the caller still owns them. + self.assertTrue(settings.is_valid) + self.assertEqual(settings._owner_pid, os.getpid()) + settings.close() + + def test_consumed_signer_close_frees_nothing(self): + signer = self._ctx_make_signer() + # Captured before the context consumes it: + # close() nulls the handle, there is no pointer left to identify the free by. + signer_handle = signer._handle + context = Context(signer=signer) + self.addCleanup(context.close) + + freed = self._instrument_frees() + signer.close() + + self.assertEqual(self._free_count(freed, signer_handle), 0, + "closing a consumed Signer freed a pointer the " + "context now owns") + + def test_builder_with_archive_null_return_marks_consumed(self): + builder = Builder(self.test_manifest) + released_handle = builder._handle + archive = self._make_archive() + + # Mimic a non-tag error: native took ownership then failed and dropped + # the value itself, so the handle is marked consumed, not freed. + real_call = c2pa_module._lib.c2pa_builder_with_archive + c2pa_module._lib.c2pa_builder_with_archive = _fail_with_native_error( + b"Other: mocked test error") + + # Instrument before the failure... + freed = self._instrument_frees() + try: + with self.assertRaises(Error): + builder.with_archive(archive) + finally: + c2pa_module._lib.c2pa_builder_with_archive = real_call + + # Nothing left to own after failing. + self.assertIsNone(builder._handle) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + + # A non-tag error marks consumed: no free (a free here would be a + # guarded no-op that dirties the error slot and races a recycled + # address in other threads). + self.assertEqual(self._free_count(freed, released_handle), 0, + "consumed handle was freed instead of marked consumed") + + # close() must not free it either. + builder.close() + self.assertEqual(self._free_count(freed, released_handle), 0, + "close() freed a handle already marked consumed") + + def test_reader_with_fragment_null_return_marks_consumed(self): + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + with open(init_path, "rb") as init: + reader = Reader("video/mp4", init) + released_handle = reader._handle + + # Mimic a non-tag error: native took ownership then failed and dropped + # the value itself, so the handle is marked consumed, not freed. + real_call = c2pa_module._lib.c2pa_reader_with_fragment + c2pa_module._lib.c2pa_reader_with_fragment = _fail_with_native_error( + b"Other: mocked test error") + + # Instrument before failure so any free would be counted. + freed = self._instrument_frees() + try: + with open(init_path, "rb") as init, \ + open(fragment_path, "rb") as frag: + with self.assertRaises(Error): + reader.with_fragment("video/mp4", init, frag) + finally: + c2pa_module._lib.c2pa_reader_with_fragment = real_call + + self.assertIsNone(reader._handle) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + + # A non-tag error marks consumed: no free. + self.assertEqual(self._free_count(freed, released_handle), 0, + "consumed handle was freed instead of marked consumed") + + # close() must not free it either. + reader.close() + self.assertEqual(self._free_count(freed, released_handle), 0, + "close() freed a handle already marked consumed") + + def test_reader_with_fragment_ffi_raise_frees_self(self): + # If the ctypes call itself raises, the failure runs + # through the except BaseException branch, which frees the handle. + # with_fragment must free exactly once and leave nothing for close(). + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + with open(init_path, "rb") as init: + reader = Reader("video/mp4", init) + released_handle = reader._handle + + def _raise(*_args): + raise RuntimeError("boom") + + real_call = c2pa_module._lib.c2pa_reader_with_fragment + c2pa_module._lib.c2pa_reader_with_fragment = _raise + + # Instrument before the failure so the eager free is counted. + freed = self._instrument_frees() + try: + with open(init_path, "rb") as init, \ + open(fragment_path, "rb") as frag: + with self.assertRaises(Error): + reader.with_fragment("video/mp4", init, frag) + finally: + c2pa_module._lib.c2pa_reader_with_fragment = real_call + + self.assertIsNone(reader._handle) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + + # The error path frees the old handle. + self.assertEqual(self._free_count(freed, released_handle), 1, + "error path did not free the old handle exactly once") + + # close() must not free it again. + reader.close() + self.assertEqual(self._free_count(freed, released_handle), 1, + "close() freed a handle the error path already freed") + + # Consume-and-return ownership: the native call can take the handle partway + # through the call, so a null return does not always say on its own + # whether the handle was consumed or not, warranting further checks/bookkeeping. + + @staticmethod + def _is_pre_consume_rejection(error_message): + """True if this native error means ownership never transferred. + """ + if not error_message: + return False + return ManagedResource._is_pre_consume_rejection(error_message) + + def _stale_reader_handle(self): + """A freed, untracked pointer, captured before close() nulls it. + + Take a fresh one per call: recycled addresses become tracked again. + """ + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + with open(init_path, "rb") as init: + victim = Reader("video/mp4", init) + stale = ctypes.cast(victim._handle, + ctypes.POINTER(c2pa_module.C2paReader)) + victim.close() + return stale + + @staticmethod + def _untracked_reader_handle(): + """A pointer the native registry never handed out. + + Rejected like a stale handle, but not a freed address, so it cannot + be recycled and start passing the registry lookup. + """ + buf = ctypes.create_string_buffer(64) + return (ctypes.cast(buf, ctypes.POINTER(c2pa_module.C2paReader)), + buf) + + def test_with_fragment_pre_consume_rejection_does_not_leak(self): + # A handle dropped on this path leaks one reader per call. + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + + for _ in range(25): + with open(init_path, "rb") as init: + reader = Reader("video/mp4", init) + real_handle = reader._handle + reader._handle = self._stale_reader_handle() + try: + with open(init_path, "rb") as init, \ + open(fragment_path, "rb") as frag: + with self.assertRaises(Error): + reader.with_fragment("video/mp4", init, frag) + finally: + reader._handle = real_handle + # Still owns a working handle every time round. + self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) + self.assertTrue(reader.json()) + reader.close() + + def test_reader_with_empty_manifest_data_never_calls_native(self): + """End-to-end: the guard is wired into the public path, not just + available as a helper.""" + context = Context() + self.addCleanup(context.close) + with open(os.path.join(FIXTURES_DIR, + DEFAULT_TEST_FILE_NAME), "rb") as image: + image_bytes = image.read() + + freed = self._instrument_frees() + + with self.assertRaises(Error): + Reader("image/jpeg", io.BytesIO(image_bytes), + manifest_data=b"", context=context) + + # The guard raises before the FFI call, so the reader handle is still + # the binding's to free: exactly one free, and no abandoned handle. + self.assertEqual(len(freed), 1) + + def test_check_cstr_arg_rejects_none_and_embedded_nul(self): + """Both cases would reach native as something other than the caller + passed: None as a null pointer, an embedded NUL as a short string.""" + with self.assertRaises(Error): + c2pa_module._check_cstr_arg('format', None) + + with self.assertRaises(Error): + c2pa_module._check_cstr_arg('format', "image/\x00jpeg") + + c2pa_module._check_cstr_arg('format', "image/jpeg") + c2pa_module._check_cstr_arg('format', b"") + + def test_format_embeddable_null_out_pointer_raises_not_crashes(self): + real = c2pa_module._lib.c2pa_format_embeddable + c2pa_module._lib.c2pa_format_embeddable = ( + lambda fmt, data, size, out: 128) + try: + with self.assertRaises(Error): + format_embeddable("image/jpeg", b"junk") + finally: + c2pa_module._lib.c2pa_format_embeddable = real + + def test_check_bytes_arg_rejects_none_and_empty(self): + for bad in (None, b""): + with self.assertRaises(Error): + c2pa_module._check_bytes_arg('manifest_data', bad) + + c2pa_module._check_bytes_arg('manifest_data', b"x") + + def test_check_handle_arg_rejects_null(self): + """A null handle is a NullParameter on both native versions.""" + with self.assertRaises(Error): + c2pa_module._check_handle_arg('stream', None) + + c2pa_module._check_handle_arg( + 'stream', ctypes.cast(1, ctypes.c_void_p)) + + def test_repeated_with_fragment_does_not_accumulate_streams(self): + """Repeated with_fragment Reader calls should not accumulate streams. + """ + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + + with open(init_path, "rb") as init: + reader = Reader("video/mp4", init) + self.addCleanup(reader.close) + + superseded = [] + for _ in range(25): + with open(init_path, "rb") as init, \ + open(fragment_path, "rb") as frag: + reader.with_fragment("video/mp4", init, frag) + self.assertLessEqual(len(reader._fragment_streams), 1) + superseded.append(reader._fragment_streams[-1]) + + # Dropping the reference is not enough: the native stream is only + # released by close(), so every superseded wrapper must be closed. + self.assertTrue(all(s.closed for s in superseded[:-1])) + + def test_with_archive_post_consume_failure_consumes_handle(self): + # Ownership taken, then the operation failed: + # The handle is gone, so close() must not free it again. + builder = Builder(json.dumps( + {"claim_generator_info": [{"name": "test", "version": "0.1"}], + "assertions": []})) + consumed_handle = builder._handle + + with self.assertRaises(Error): + builder.with_archive(io.BytesIO(b"not a valid archive")) + + self.assertIsNone(builder._handle) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + + freed = self._instrument_frees() + builder.close() + self.assertEqual(self._free_count(freed, consumed_handle), 0, + "close() freed a handle the FFI already consumed") + + def test_with_fragment_marshalling_error_keeps_handle(self): + # Never reaches native code, so nothing was consumed. + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + with open(init_path, "rb") as init: + reader = Reader("video/mp4", init) + real_handle = reader._handle + + def _bad_marshalling(*_args): + raise ctypes.ArgumentError("wrong argument type") + + real_call = c2pa_module._lib.c2pa_reader_with_fragment + c2pa_module._lib.c2pa_reader_with_fragment = _bad_marshalling + try: + with open(init_path, "rb") as init, \ + open(init_path, "rb") as frag: + with self.assertRaises(ctypes.ArgumentError): + reader.with_fragment("video/mp4", init, frag) + finally: + c2pa_module._lib.c2pa_reader_with_fragment = real_call + + self.assertIs(reader._handle, real_handle) + self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) + self.assertTrue(reader.json(), + "a marshalling failure never reached the FFI, so " + "the reader must still be usable") + + reader.close() + + def test_unknown_failure_drops_handle_without_freeing(self): + # Ownership unknowable, so the handle is let go rather than freed. + # Needs a mock: every real null return sets an error. + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + with open(init_path, "rb") as init: + reader = Reader("video/mp4", init) + + consumed_handle = reader._handle + # Simulate an error being set + real_call = c2pa_module._lib.c2pa_reader_with_fragment + c2pa_module._lib.c2pa_reader_with_fragment = _fail_with_native_error( + b"Other: mocked test error") + try: + with open(init_path, "rb") as init, \ + open(fragment_path, "rb") as frag: + with self.assertRaises(Error): + reader.with_fragment("video/mp4", init, frag) + finally: + c2pa_module._lib.c2pa_reader_with_fragment = real_call + + # Unplaceable, so the handle is let go rather than freed twice. + self.assertIsNone(reader._handle) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + + freed = self._instrument_frees() + reader.close() + self.assertEqual(self._free_count(freed, consumed_handle), 0, + "close() freed a handle of unknown ownership") + + def test_pre_consume_rejection_is_typed(self): + # Rejections arrive wrapped as "Other: UntrackedPointer: 0x...". + self.assertTrue(self._is_pre_consume_rejection( + "Other: UntrackedPointer: 0x600001234567")) + self.assertTrue(self._is_pre_consume_rejection( + "Other: WrongPointerType: 0x600001234567")) + self.assertFalse(self._is_pre_consume_rejection( + "Verify: invalid JUMBF header")) + self.assertFalse(self._is_pre_consume_rejection(None)) + self.assertFalse(self._is_pre_consume_rejection("")) + + def test_pre_consume_tags_still_match_the_native_wording(self): + # Classification keys on error text (the numeric code is not + # exported), so a native rename would silently misjudge ownership. + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + with open(init_path, "rb") as init: + reader = Reader("video/mp4", init) + real_handle = reader._handle + + reader._handle = self._stale_reader_handle() + try: + with open(init_path, "rb") as init, \ + open(fragment_path, "rb") as frag: + with self.assertRaises(Error) as caught: + reader.with_fragment("video/mp4", init, frag) + finally: + reader._handle = real_handle + + message = str(caught.exception) + self.assertTrue( + self._is_pre_consume_rejection(message), + f"rejection wording does not match _PRE_CONSUME_ERROR_TAGS, " + f"so ownership will be misjudged: {message!r}") + reader.close() + + def test_stale_handle_is_actually_rejected_every_time(self): + # A handle that stopped being rejected would quietly measure the + # success path. Uses the never-allocated buffer: freed addresses get + # recycled and start passing the registry lookup. + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + + for _ in range(25): + with open(init_path, "rb") as init: + reader = Reader("video/mp4", init) + real_handle = reader._handle + bogus, _buf = self._untracked_reader_handle() + reader._handle = bogus + try: + with open(init_path, "rb") as init, \ + open(fragment_path, "rb") as frag: + with self.assertRaises(Error) as caught: + reader.with_fragment("video/mp4", init, frag) + self.assertTrue( + self._is_pre_consume_rejection( + str(caught.exception)), + "the bogus handle was not rejected, so this stopped " + "exercising the pre-consume path") + finally: + reader._handle = real_handle + reader.close() + + def test_perf_scenario_bogus_handle_is_rejected(self): + # The perf scenarios use a plain buffer so looping does not swamp + # the measurement. It still has to produce a real rejection. + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + with open(init_path, "rb") as init: + reader = Reader("video/mp4", init) + real_handle = reader._handle + + bogus, _buf = self._untracked_reader_handle() + reader._handle = bogus + try: + with open(init_path, "rb") as init, \ + open(fragment_path, "rb") as frag: + with self.assertRaises(Error) as caught: + reader.with_fragment("video/mp4", init, frag) + finally: + reader._handle = real_handle + + self.assertTrue( + self._is_pre_consume_rejection(str(caught.exception)), + "the perf bogus handle was not rejected, so " + "with_fragment_pre_consume_rejection measures nothing") + # Handle kept, so the reader still works and frees normally. + self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) + self.assertTrue(reader.json()) + reader.close() + + def test_every_null_return_sets_its_own_error(self): + # Each null-returning path must report the error it set itself, never + # one left behind by an earlier call. + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + + # Set a recognizable error, so anything stale is caught by the + # assertNotIn checks. + c2pa_module._lib.c2pa_error_set_last( + b"NotSupported: planted by the test") + + # Pre-consume rejection: reports UntrackedPointer, not NotSupported. + with open(init_path, "rb") as init: + reader = Reader("video/mp4", init) + real_handle = reader._handle + reader._handle = self._stale_reader_handle() + try: + with open(init_path, "rb") as init, \ + open(fragment_path, "rb") as frag: + with self.assertRaises(Error) as caught: + reader.with_fragment("video/mp4", init, frag) + finally: + reader._handle = real_handle + self.assertIn("UntrackedPointer", str(caught.exception)) + self.assertNotIn("NotSupported", str(caught.exception)) + reader.close() + + # Post-consume failure: reports the operation error, not the + # UntrackedPointer left by the step above. + builder = Builder(json.dumps( + {"claim_generator_info": [{"name": "test", "version": "0.1"}], + "assertions": []})) + with self.assertRaises(Error) as caught: + builder.with_archive(io.BytesIO(b"not a valid archive")) + self.assertNotIn("UntrackedPointer", str(caught.exception)) + builder.close() + + def test_pre_consume_classification_holds_across_threads(self): + # The error slot is thread-local, so concurrent calls do not mask + # each other's errors and misjudge ownership. + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + init_bytes = open(init_path, "rb").read() + frag_bytes = open(fragment_path, "rb").read() + problems = [] + + def worker(): + for _ in range(10): + reader = Reader("video/mp4", io.BytesIO(init_bytes)) + real_handle = reader._handle + # A never-allocated buffer, not a freed pointer: with several + # threads churning the allocator, a freed address gets reused + # and starts passing the registry lookup, which would end the + # iteration in a real consume instead of a rejection. + bogus, _buf = self._untracked_reader_handle() + reader._handle = bogus + try: + reader.with_fragment("video/mp4", + io.BytesIO(init_bytes), + io.BytesIO(frag_bytes)) + problems.append("rejection did not raise") + except Error as e: + if not self._is_pre_consume_rejection(str(e)): + problems.append(f"misclassified: {str(e)[:60]}") + finally: + reader._handle = real_handle + if reader._lifecycle_state != LifecycleState.ACTIVE: + problems.append("handle dropped on a rejection") + reader.close() + + threads = [threading.Thread(target=worker) for _ in range(6)] + for t in threads: + t.start() + for t in threads: + t.join() + + self.assertEqual(problems, [], + "ownership was misjudged under concurrency") + + def test_read_native_error_returns_none_for_an_empty_message(self): + # c2pa_error() returns an owned pointer to "" when no error is set, + # never NULL, so the pointer cannot be the "is there an error" test. + c2pa_module._write_no_error_marker() + original = c2pa_module._lib.c2pa_error + empty = ctypes.create_string_buffer(b"") + + try: + c2pa_module._lib.c2pa_error = lambda: ctypes.cast( + empty, ctypes.c_void_p).value + self.assertIsNone( + c2pa_module._read_native_error(), + "an empty native message must read as None, otherwise " + "callers' 'if error:' checks are only accidentally right") + finally: + c2pa_module._lib.c2pa_error = original + + def test_null_return_with_no_native_error_is_treated_as_consumed(self): + # A null with no error of its own is the case that breaks without + # the marker: + # the slot still held whatever an unrelated, earlier call on this same + # (pooled) thread left behind, and a stale UntrackedPointer/ + # WrongPointerType tag would make this call believe it still owned a + # handle the native side already dropped. + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + fragment_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + + # A stale, unrelated tag left by a prior call on this thread. + c2pa_module._lib.c2pa_error_set_last( + b"UntrackedPointer: 0xdeadbeef") + + with open(init_path, "rb") as init: + reader = Reader("video/mp4", init) + consumed_handle = reader._handle + + real_call = c2pa_module._lib.c2pa_reader_with_fragment + # The fake native call sets no error of its own, + # the marker planted by _invoke_consume is left in the slot. + c2pa_module._lib.c2pa_reader_with_fragment = ( + lambda r, f, s, frag: None) + freed = self._instrument_frees() + try: + with open(init_path, "rb") as init, \ + open(fragment_path, "rb") as frag: + with self.assertRaises(Error): + reader.with_fragment("video/mp4", init, frag) + finally: + c2pa_module._lib.c2pa_reader_with_fragment = real_call + + # Ownership is unknown, so the handle is freed once. c2pa_free + # returns -1 if native had already taken the value. + self.assertEqual(self._free_count(freed, consumed_handle), 1) + + # Backfilling a pointer minted by a direct FFI call. Builder.from_archive + # is the only production caller of _wrap_native_handle, so these are the + # only tests that drive the primitive as the generic entry point it is. + + def _raw_builder_handle(self): + manifest = json.dumps( + {"claim_generator": "raw_ffi_test", "format": "image/jpeg"} + ).encode("utf-8") + handle = c2pa_module._lib.c2pa_builder_from_json(manifest) + self.assertTrue(handle, "the FFI did not return a builder pointer") + return handle + + def test_wrap_raw_ffi_builder_pointer(self): + builder = Builder._wrap_native_handle(self._raw_builder_handle()) + + self.assertTrue(builder.is_valid) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertEqual(builder._owner_pid, os.getpid()) + + archive = io.BytesIO() + builder.to_archive(archive) + self.assertTrue(archive.getvalue()) + + builder.close() + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) + + def test_wrap_raw_ffi_settings_pointer(self): + handle = c2pa_module._lib.c2pa_settings_new() + self.assertTrue(handle) + + settings = Settings._wrap_native_handle(handle) + try: + self.assertTrue(settings.is_valid) + self.assertEqual(settings._owner_pid, os.getpid()) + settings.set("version_major", "1") + finally: + settings.close() + + def test_wrap_raw_ffi_context_pointer(self): + handle = c2pa_module._lib.c2pa_context_new() + self.assertTrue(handle) + + context = Context._wrap_native_handle(handle) + try: + self.assertTrue(context.is_valid) + self.assertEqual(context._owner_pid, os.getpid()) + # Handing the wrapped pointer back to the FFI proves it is live. + builder = Builder(self.test_manifest, context=context) + self.assertTrue(builder.is_valid) + builder.close() + finally: + context.close() + + def test_wrapped_raw_pointer_freed_exactly_once(self): + handle = self._raw_builder_handle() + freed = self._instrument_frees() + + builder = Builder._wrap_native_handle(handle) + builder.close() + builder.close() + del builder + gc.collect() + + self.assertEqual(self._free_count(freed, handle), 1, + "wrapped handle not freed exactly once") + + def test_wrap_supplies_defaults(self): + # _init_attrs() runs on the wrap path, so an instance built around a + # raw handle still has everything the rest of the class reads. + builder = Builder._wrap_native_handle(self._raw_builder_handle()) + self.addCleanup(builder.close) + + self.assertIsNone(builder._context) + self.assertFalse(builder._has_context_signer) + + def test_init_attrs_covers_what_init_sets(self): + # Anything __init__ sets but _init_attrs() misses is absent on a + # wrapped instance, which is the trap _init_attrs() exists to close. + for cls in (Builder, Context, Reader, Signer): + with self.subTest(cls=cls.__name__): + defaulted = set(re.findall( + r"self\.(_[a-z][a-z0-9_]*)\s*=", + inspect.getsource(cls._init_attrs))) + assigned = set(re.findall( + r"self\.(_[a-z][a-z0-9_]*)\s*=", + inspect.getsource(cls.__init__))) + self.assertEqual( + assigned - defaulted, set(), + f"{cls.__name__}.__init__ sets attributes that " + f"_init_attrs() does not default") + + def test_init_attrs_overrides_chain_to_super(self): + # A subclass of these would silently lose the parent's defaults if + # the chain were broken. + for cls in (Builder, Context, Reader, Signer): + with self.subTest(cls=cls.__name__): + self.assertIn( + "super()._init_attrs()", + inspect.getsource(cls._init_attrs), + f"{cls.__name__}._init_attrs() does not chain to super()") + + def test_wrap_raw_ffi_signer_pointer(self): + # Signer._release() reads _callback_cb, so a wrap that skipped the + # defaults would fail during cleanup rather than at the wrap. + freed = self._instrument_frees() + + signer = Signer._wrap_native_handle(0xABCD) + self.assertIsNone(signer._callback_cb) + self.assertEqual(signer._owner_pid, os.getpid()) + + # Cleanup swallows a failing _release(), so the error log is the only + # way to see one. + with self.assertNoLogs("c2pa", level="ERROR"): + signer.close() + + self.assertEqual(freed, [0xABCD]) + + def test_signer_release_clears_callback(self): + signer = self._ctx_make_callback_signer() + self.assertIsNotNone(signer._callback_cb) + + signer.close() + + self.assertIsNone(signer._callback_cb) + + def test_builder_release_clears_context(self): + context = Context() + self.addCleanup(context.close) + builder = Builder(self.test_manifest, context=context) + + builder.close() + + self.assertIsNone(builder._context, + "closed Builder still pins its Context") + # The Builder does not own the Context, so it must not close it. + self.assertTrue(context.is_valid) + + def test_consumed_reader_closes_backing_file(self): + # A failed with_fragment consumes the reader. + # # Reader(path) opened the backing file itself, + # so nothing else will ever close it. + reader = Reader(DEFAULT_TEST_FILE) + backing_file = reader._backing_file + self.assertFalse(backing_file.closed) + + # Simulate an error being set + real_call = c2pa_module._lib.c2pa_reader_with_fragment + c2pa_module._lib.c2pa_reader_with_fragment = _fail_with_native_error( + b"Other: mocked test error") + try: + with open(DEFAULT_TEST_FILE, "rb") as main, \ + open(DEFAULT_TEST_FILE, "rb") as frag: + with self.assertRaises(Error): + reader.with_fragment("image/jpeg", main, frag) + finally: + c2pa_module._lib.c2pa_reader_with_fragment = real_call + + self.assertTrue(backing_file.closed, + "consumed Reader leaked its backing file") + + def test_consumed_builder_releases_context(self): + context = Context() + self.addCleanup(context.close) + builder = Builder(self.test_manifest, context=context) + archive = self._make_archive() + + # Simulate an error being set + real_call = c2pa_module._lib.c2pa_builder_with_archive + c2pa_module._lib.c2pa_builder_with_archive = _fail_with_native_error( + b"Other: mocked test error") + try: + with self.assertRaises(Error): + builder.with_archive(archive) + finally: + c2pa_module._lib.c2pa_builder_with_archive = real_call + + self.assertIsNone(builder._context, + "consumed Builder still pins its Context") + self.assertTrue(context.is_valid) + + def test_context_takes_callback_before_consuming_signer(self): + # Consuming the signer releases its callback reference, + # so the Context has to take it first or the callback dies with the signer. + signer = self._ctx_make_callback_signer() + callback = signer._callback_cb + self.assertIsNotNone(callback) + + context = Context(signer=signer) + self.addCleanup(context.close) + + self.assertIs(context._signer_callback_cb, callback) + self.assertIsNone(signer._callback_cb) + + def test_reader_close_closes_backing_file(self): + # _close_streams reads the attrs _init_attrs() defaults, so this is + # the regression guard for reading them directly. + reader = Reader(DEFAULT_TEST_FILE) + reader.json() + backing_file = reader._backing_file + self.assertIsNotNone(backing_file) + + reader.close() + + self.assertTrue(backing_file.closed, "Reader left its file open") + self.assertIsNone(reader._backing_file) + self.assertIsNone(reader._manifest_json_str_cache) + self.assertIsNone(reader._manifest_data_cache) + + def test_consumed_reader_clears_caches(self): + # Consuming marks the reader closed, so close() will not run later. + # Anything cleanup owes the object has to happen at consume time. + reader = Reader(DEFAULT_TEST_FILE) + reader.json() + self.assertIsNotNone(reader._manifest_json_str_cache) + + # Simulate an error being set + real_call = c2pa_module._lib.c2pa_reader_with_fragment + c2pa_module._lib.c2pa_reader_with_fragment = _fail_with_native_error( + b"Other: mocked test error") + try: + with open(DEFAULT_TEST_FILE, "rb") as main, \ + open(DEFAULT_TEST_FILE, "rb") as frag: + with self.assertRaises(Error): + reader.with_fragment("image/jpeg", main, frag) + finally: + c2pa_module._lib.c2pa_reader_with_fragment = real_call + + self.assertIsNone(reader._manifest_json_str_cache, + "consumed Reader kept its manifest cache") + self.assertIsNone(reader._manifest_data_cache) + + def test_reader_del_clears_caches(self): + # __del__ goes through _cleanup_resources, not close(), so cache + # clearing has to live somewhere both paths reach. + reader = Reader(DEFAULT_TEST_FILE) + reader.json() + self.assertIsNotNone(reader._manifest_json_str_cache) + + # __del__ runs _cleanup_resources directly, so drive that rather than + # dropping the reference: the assertions need the object afterwards. + reader._cleanup_resources() + + self.assertIsNone(reader._manifest_json_str_cache, + "cleanup left the manifest cache alive") + self.assertIsNone(reader._manifest_data_cache) + + def test_from_archive_frees_handle_when_wrap_fails(self): + # The wrap raising means no Python object took ownership, so + # from_archive still holds the handle and has to free it. + archive = self._make_archive() # closes a Builder; keep it off the count + freed = self._instrument_frees() + real_wrap = Builder._wrap_native_handle + + def _boom(*args, **kwargs): + raise Error("wrap failed") + + Builder._wrap_native_handle = _boom + try: + with self.assertRaises(Error): + Builder.from_archive(archive) + finally: + Builder._wrap_native_handle = real_wrap + + self.assertEqual(len(freed), 1, + "from_archive leaked the handle when the wrap failed") + + def test_sign_failure_chains_the_original_exception(self): + # The wrapper re-raises as C2paError; losing __cause__ hides which + # call actually failed. + builder = Builder(self.test_manifest) + signer = self._ctx_make_signer() + self.addCleanup(signer.close) + + sentinel = RuntimeError("native call blew up") + real_sign = c2pa_module._lib.c2pa_builder_sign + + def _boom(*args): + raise sentinel + + c2pa_module._lib.c2pa_builder_sign = _boom + try: + with self.assertRaises(Error) as ctx: + builder.sign(signer, "image/jpeg", + io.BytesIO(b"x"), io.BytesIO()) + finally: + c2pa_module._lib.c2pa_builder_sign = real_sign + + self.assertIs(ctx.exception.__cause__, sentinel, + "signing error dropped the original exception") + + +class TestMarkerOutlivesPointerConsumptionSemantics(unittest.TestCase): + """The marker is needed for reasons independent of pointer ownership. + + The native error slot is sticky and thread-local, so failure paths + that carry no still need to tell an error this call set from an + earlier, unrelated call left behind. + """ + + def setUp(self): + # Leave no message from an earlier test in this thread's slot. + c2pa_module._write_no_error_marker() + + def test_settings_set_failure_reports_its_own_error(self): + settings = Settings() + self.addCleanup(settings.close) + + c2pa_module._lib.c2pa_error_set_last(b"Signature: earlier task") + self.assertEqual( + c2pa_module._read_native_error(), "Signature: earlier task") + + with self.assertRaises(Error) as ctx: + settings.set("builder.thumbnail.enabled", "not-a-json-value") + + self.assertNotIn("earlier task", str(ctx.exception)) + + def test_one_thread_marker_does_not_clear_another_threads_error(self): + """Marking on one thread must leave another thread's pending error + readable: the slot is per thread, and so is the marker.""" + set_on_worker = threading.Event() + marked_on_main = threading.Event() + seen = {} + + def worker(): + c2pa_module._lib.c2pa_error_set_last(b"Io: worker error") + set_on_worker.set() + marked_on_main.wait(5) + seen["worker"] = c2pa_module._read_native_error() + + thread = threading.Thread(target=worker, daemon=True) + thread.start() + set_on_worker.wait(5) + + c2pa_module._write_no_error_marker() + marked_on_main.set() + thread.join(5) + + self.assertEqual(seen.get("worker"), "Io: worker error") + + +class TestErrorsStillRaiseAfterCleanup(unittest.TestCase): + """Each surface that lost a _clear_error_state() call still reports.""" + + def test_reader_on_garbage_raises(self): + with self.assertRaises(Error): + Reader("image/jpeg", io.BytesIO(b"not an image")).json() + + def test_signer_from_info_with_bad_certs_raises(self): + with self.assertRaises(Error): + c2pa_module.create_signer_from_info(C2paSignerInfo( + alg=b"es256", + sign_cert=b"not a certificate", + private_key=b"not a key", + ta_url=b"", + )) + + def test_ed25519_sign_with_empty_data_raises(self): + with self.assertRaises(Error): + c2pa_module.ed25519_sign(b"", "not a key") + + +class TestConsumeOwnership(unittest.TestCase): + """Ownership of the native handle across the consuming call paths.""" + + def setUp(self): + self.freed = [] + self._real_free = ManagedResource._free_native_ptr + + def counting_free(ptr): + self.freed.append(ptr) + return self._real_free(ptr) + + _patch_free(self, counting_free) + + def test_close_called_during_parallel_call(self): + """Parallel closes handling. + """ + resource = Settings() + spare = Settings() + replacement = spare._handle + # Only test should be able to free. + spare._handle = None + spare._lifecycle_state = LifecycleState.CLOSED + self.freed.clear() + + def close_then_swap(handle): + resource.close() + return replacement + + resource._consume_and_swap(close_then_swap, "swap: {}") + + self.assertIn(replacement, self.freed) diff --git a/tests/unit/test_reader.py b/tests/unit/test_reader.py new file mode 100644 index 00000000..8a93b76f --- /dev/null +++ b/tests/unit/test_reader.py @@ -0,0 +1,1215 @@ +# Copyright 2023 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. + +import os +import io +import json +import unittest +import warnings +import tempfile +import shutil +import threading +from c2pa import Builder, C2paError as Error, Reader, C2paSignerInfo, Signer +from c2pa.c2pa import LifecycleState, load_settings, _get_mime_type_from_path, _encode_format +from pathlib import Path +from tests.unit._support import ( + FIXTURES_DIR, + DEFAULT_TEST_FILE_NAME, + DEFAULT_TEST_FILE, + INGREDIENT_TEST_FILE, + load_test_settings_json, +) + + +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 + + def test_can_retrieve_reader_supported_mimetypes(self): + result1 = Reader.get_supported_mime_types() + self.assertTrue(len(result1) > 0) + + # Cache hit + result2 = Reader.get_supported_mime_types() + self.assertTrue(len(result2) > 0) + + self.assertEqual(result1, result2) + + def test_stream_read_nothing_to_read(self): + # The ingredient test file has no manifest + # So if we instantiate directly, the Reader instance should throw + with open(INGREDIENT_TEST_FILE, "rb") as file: + with self.assertRaises(Error) as context: + reader = Reader("image/jpeg", file) + self.assertIn("ManifestNotFound: no JUMBF data found", str(context.exception)) + + def test_try_create_reader_nothing_to_read(self): + # The ingredient test file has no manifest + # So if we use Reader.try_create, in this case we'll get None + # And no error should be raised + with open(INGREDIENT_TEST_FILE, "rb") as file: + reader = Reader.try_create("image/jpeg", file) + self.assertIsNone(reader) + + def test_stream_read(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + json_data = reader.json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + + def test_try_create_reader_from_stream(self): + with open(self.testPath, "rb") as file: + reader = Reader.try_create("image/jpeg", file) + self.assertIsNotNone(reader) + json_data = reader.json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + + def test_try_create_reader_from_stream_context_manager(self): + with open(self.testPath, "rb") as file: + reader = Reader.try_create("image/jpeg", file) + self.assertIsNotNone(reader) + # Check that a Reader returned by try_create is not None, + # before using it in a context manager pattern (with) + if reader is not None: + with reader: + json_data = reader.json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + + def test_stream_read_detailed(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + json_data = reader.detailed_json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + + def test_get_active_manifest(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + active_manifest = reader.get_active_manifest() + + # Check the returned manifest label/key + expected_label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" + self.assertEqual(active_manifest["label"], expected_label) + + def test_get_manifest(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + + # Test getting manifest by the specific label + label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" + manifest = reader.get_manifest(label) + self.assertEqual(manifest["label"], label) + + # It should be the active manifest too, so cross-check + active_manifest = reader.get_active_manifest() + self.assertEqual(manifest, active_manifest) + + def test_stream_get_non_active_manifest_by_label(self): + video_path = os.path.join(FIXTURES_DIR, "video1.mp4") + with open(video_path, "rb") as file: + reader = Reader("video/mp4", file) + + non_active_label = "urn:uuid:54281c07-ad34-430e-bea5-112a18facf0b" + non_active_manifest = reader.get_manifest(non_active_label) + self.assertEqual(non_active_manifest["label"], non_active_label) + + # Verify it's not the active manifest + # (that test case has only one other manifest that is not the active manifest) + active_manifest = reader.get_active_manifest() + self.assertNotEqual(non_active_manifest, active_manifest) + self.assertNotEqual(non_active_manifest["label"], active_manifest["label"]) + + def test_stream_get_non_active_manifest_by_label_not_found(self): + video_path = os.path.join(FIXTURES_DIR, "video1.mp4") + with open(video_path, "rb") as file: + reader = Reader("video/mp4", file) + + # Try to get a manifest with a label that clearly doesn't exist... + non_existing_label = "urn:uuid:clearly-not-existing" + with self.assertRaises(KeyError): + reader.get_manifest(non_existing_label) + + def test_stream_read_get_validation_state(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + validation_state = reader.get_validation_state() + self.assertIsNotNone(validation_state) + self.assertEqual(validation_state, "Valid") + + def test_stream_read_get_validation_state_with_trust_config(self): + # Run in a separate thread to isolate thread-local settings + result = {} + exception = {} + + def read_with_trust_config(): + try: + # Load trust configuration + settings_dict = load_test_settings_json() + + # Apply the settings (including trust configuration) + # Settings are thread-local, so they won't affect other tests + # And that is why we also run the test in its own thread, so tests are isolated + load_settings(settings_dict) + + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + validation_state = reader.get_validation_state() + result['validation_state'] = validation_state + except Exception as e: + exception['error'] = e + + # Create and start thread + thread = threading.Thread(target=read_with_trust_config) + thread.start() + thread.join() + + # Check for exceptions + if 'error' in exception: + raise exception['error'] + + # Assertions run in main thread + self.assertIsNotNone(result.get('validation_state')) + # With trust configuration loaded, manifest is Trusted + self.assertEqual(result.get('validation_state'), "Trusted") + + def test_stream_read_get_validation_results(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + validation_results = reader.get_validation_results() + + self.assertIsNotNone(validation_results) + self.assertIsInstance(validation_results, dict) + + self.assertIn("activeManifest", validation_results) + active_manifest_results = validation_results["activeManifest"] + self.assertIsInstance(active_manifest_results, dict) + + def test_wrong_mimetype_corrected_from_bytes_on_stream(self): + # The binding passes the format straight through. + # The native library detects the JPEG container and ignores the mismatched format, + # so a valid asset still reads with a wrong (or bogus) mimetype. + with open(self.testPath, "rb") as file: + with Reader("mimetype/does-not-exist", file) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_stream_read_and_parse(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, DEFAULT_TEST_FILE_NAME) + + def test_stream_read_detailed_and_parse(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + manifest_store = json.loads(reader.detailed_json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["claim"]["dc:title"] + self.assertEqual(title, DEFAULT_TEST_FILE_NAME) + + def test_stream_read_crjson_and_parse(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + crjson = reader.crjson() + self.assertTrue(crjson) + json.loads(crjson) + + def test_stream_read_crjson_path_only(self): + with Reader(self.testPath) as reader: + crjson = reader.crjson() + self.assertTrue(crjson) + json.loads(crjson) + + 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) + + def test_try_create_from_path(self): + test_path = os.path.join(self.data_dir, "C.dng") + + # Create reader with the file content + reader = Reader.try_create(test_path) + self.assertIsNotNone(reader) + # Just run and verify there is no crash + json.loads(reader.json()) + + def test_unreadable_asset_raises_not_supported(self): + # A plain-text file is not a recognizable asset. The binding no longer + # rejects it up front; the native library raises NotSupported when it + # fails to detect a container. + with tempfile.TemporaryDirectory() as tmp: + txt = os.path.join(tmp, "C.txt") + with open(txt, "w") as f: + f.write("just some plain text, not an asset\n") + with self.assertRaises(Error.NotSupported): + Reader(txt) + + def test_try_create_unreadable_asset_raises_not_supported(self): + with tempfile.TemporaryDirectory() as tmp: + txt = os.path.join(tmp, "C.txt") + with open(txt, "w") as f: + f.write("just some plain text, not an asset\n") + with self.assertRaises(Error.NotSupported): + Reader.try_create(txt) + + def test_none_format_matches_empty_string_on_stream(self): + # None and "" both request auto-detection and read the same asset. + with open(self.testPath, "rb") as file: + with Reader(None, file) as reader: + none_json = reader.json() + with open(self.testPath, "rb") as file: + with Reader("", file) as reader: + empty_json = reader.json() + self.assertEqual(none_json, empty_json) + self.assertNotIn("None", none_json[:64]) + self.assertIn(DEFAULT_TEST_FILE_NAME, none_json) + + def test_padded_format_accepted_on_stream(self): + with open(self.testPath, "rb") as file: + with Reader(" image/jpeg ", file) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_try_create_none_format_matches_empty_string(self): + with open(self.testPath, "rb") as file: + none_reader = Reader.try_create(None, file) + self.assertIsNotNone(none_reader) + with open(self.testPath, "rb") as file: + empty_reader = Reader.try_create("", file) + self.assertIsNotNone(empty_reader) + if none_reader is not None and empty_reader is not None: + self.assertEqual(none_reader.json(), empty_reader.json()) + + def test_bare_stream_matches_empty_and_none_format(self): + with open(self.testPath, "rb") as file: + with Reader(file) as reader: + bare_json = reader.json() + with open(self.testPath, "rb") as file: + with Reader("", file) as reader: + empty_json = reader.json() + with open(self.testPath, "rb") as file: + with Reader(None, file) as reader: + none_json = reader.json() + self.assertEqual(bare_json, empty_json) + self.assertEqual(bare_json, none_json) + + def test_bare_stream_keyword_form(self): + with open(self.testPath, "rb") as file: + with Reader(stream=file) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_bare_stream_in_memory(self): + with open(self.testPath, "rb") as file: + data = file.read() + with Reader(io.BytesIO(data)) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_try_create_bare_stream(self): + with open(self.testPath, "rb") as file: + reader = Reader.try_create(file) + self.assertIsNotNone(reader) + if reader is not None: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + reader.close() + + def test_try_create_bare_stream_keyword(self): + with open(self.testPath, "rb") as file: + reader = Reader.try_create(stream=file) + self.assertIsNotNone(reader) + if reader is not None: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + reader.close() + + def test_bare_stream_closed_handle_raises(self): + # Stream.is_read_stream only checks method presence + # so a closed handle is accepted as a stream and fails at read time. + file = open(self.testPath, "rb") + file.close() + with self.assertRaises(Error): + Reader(file) + + def test_bare_stream_with_manifest_data_keeps_manifest(self): + # The bare-stream shift must not swallow manifest_data. + with open(self.testPath, "rb") as file: + with Reader(file, manifest_data=None) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_path_string_still_opens_by_path(self): + with Reader(self.testPath) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_path_object_still_opens_by_path(self): + with Reader(Path(self.testPath)) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_format_plus_stream_still_means_format_and_stream(self): + with open(self.testPath, "rb") as file: + with Reader("image/jpeg", file) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + with open(self.testPath, "rb") as file: + with Reader(" image/jpeg ", file) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_format_plus_path_string_still_opens_by_path(self): + with Reader("image/jpeg", self.testPath) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_no_args_raises_clear_error(self): + with self.assertRaises(Error) as ctx: + Reader() + self.assertIn("stream", str(ctx.exception).lower()) + + def test_none_first_arg_no_stream_raises_clear_error(self): + with self.assertRaises(Error) as ctx: + Reader(None) + self.assertIn("stream", str(ctx.exception).lower()) + + def test_text_stream_matches_existing_explicit_form(self): + with open(self.testPath, "rb") as file: + data = file.read() + text = data.decode("latin-1") + + def outcome(call): + try: + r = call() + out = ("ok", r.json()[:0]) + r.close() + return out + except Exception as e: + return ("err", type(e).__name__) + bare = outcome(lambda: Reader(io.StringIO(text))) + explicit = outcome(lambda: Reader("image/jpeg", io.StringIO(text))) + self.assertEqual(bare, explicit) + + def test_read_only_object_not_treated_as_stream(self): + # read/seek/tell but no write/flush + class ReadOnly: + def read(self, *a): + return b"" + + def seek(self, *a): + return 0 + + def tell(self): + return 0 + with self.assertRaises(Error): + Reader(ReadOnly()) + + def test_unrecognized_extension_defers_to_detection(self): + with tempfile.TemporaryDirectory() as tmp: + asset = os.path.join(tmp, "C.test") + shutil.copyfile(self.testPath, asset) + with Reader(asset) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_try_create_unrecognized_extension_defers_to_detection(self): + with tempfile.TemporaryDirectory() as tmp: + asset = os.path.join(tmp, "C.test") + shutil.copyfile(self.testPath, asset) + reader = Reader.try_create(asset) + self.assertIsNotNone(reader) + if reader is not None: + with reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_stream_read_string_stream(self): + with Reader("image/jpeg", self.testPath) as reader: + json_data = reader.json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + + def test_wrong_mimetype_corrected_from_bytes_on_file(self): + # As with a stream: a valid asset opened by path still reads when the + # given mimetype is wrong, because the native library corrects it from + # the detected container. + with Reader("mimetype/does-not-exist", self.testPath) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_read_extensionless_file_path(self): + # Extensionless file triggers type auto-detection. + with tempfile.TemporaryDirectory() as tmp: + no_ext = os.path.join(tmp, "C") + shutil.copyfile(self.testPath, no_ext) + with Reader(no_ext) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_read_extensionless_stream_empty_format(self): + # An empty format string on a stream triggers auto-detection. + with open(self.testPath, "rb") as file: + with Reader("", file) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_read_wrong_extension_corrected(self): + # A JPEG saved with a .png extension is corrected from the bytes. + # (Read is possible and does not fail due to wrong type). + with tempfile.TemporaryDirectory() as tmp: + wrong = os.path.join(tmp, "C.png") + shutil.copyfile(self.testPath, wrong) + with Reader(wrong) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_read_wrong_mime_corrected_on_stream(self): + # A wrong MIME hint on a stream is corrected from the bytes. + with open(self.testPath, "rb") as file: + with Reader("image/png", file) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_explicit_empty_format_forces_detection(self): + # Passing "" works even when the correct format is supported. + with open(self.testPath, "rb") as file: + with Reader("", file) as reader: + manifest_store = json.loads(reader.json()) + self.assertIn("active_manifest", manifest_store) + + def test_autodetect_multiformat(self): + fixtures = [ + os.path.join(FIXTURES_DIR, "files-for-reading-tests", "CA.jpg"), + os.path.join(FIXTURES_DIR, "video1.mp4"), + os.path.join(FIXTURES_DIR, "files-for-reading-tests", "pdf-file.pdf"), + os.path.join(FIXTURES_DIR, "files-for-reading-tests", "sample1_signed.wav"), + ] + for path in fixtures: + with self.subTest(path=path): + with open(path, "rb") as file: + with Reader("", file) as reader: + self.assertIn("active_manifest", json.loads(reader.json())) + + def test_dng_extension_still_special_cased(self): + dng_path = os.path.join(FIXTURES_DIR, "C.dng") + with Reader(dng_path) as reader: + self.assertIn("active_manifest", json.loads(reader.json())) + + def test_garbage_stream_empty_format_raises(self): + # Unrecognized bytes with auto-detection raise rather than crash. + with self.assertRaises(Error): + Reader("", io.BytesIO(b"not an asset at all")) + + def test_empty_stream_raises(self): + # A zero-byte stream cannot be detected and raises. + with self.assertRaises(Error): + Reader("", io.BytesIO(b"")) + + def test_truncated_corrupt_jpeg_raises(self): + with open(self.testPath, "rb") as file: + data = bytearray(file.read()) + # Corrupt the body while keeping the leading JPEG magic intact. + # This will still raise errors. + for i in range(64, min(len(data), 4096)): + data[i] ^= 0xFF + with self.assertRaises(Error): + Reader("", io.BytesIO(bytes(data))) + + def test_garbage_extensionless_file_raises(self): + # Random bytes in an extensionless file raise. + with tempfile.TemporaryDirectory() as tmp: + garbage = os.path.join(tmp, "garbage") + with open(garbage, "wb") as f: + f.write(b"this is not a media asset") + with self.assertRaises(Error): + Reader(garbage) + + def test_try_create_garbage_stream_raises(self): + with self.assertRaises(Error) as context: + Reader.try_create("", io.BytesIO(b"not an asset at all")) + self.assertNotIn("ManifestNotFound", str(context.exception)) + + def test_try_create_extensionless_no_manifest_returns_none(self): + unsigned = os.path.join( + FIXTURES_DIR, "files-for-signing-tests", "earth_apollo17.jpg") + with tempfile.TemporaryDirectory() as tmp: + no_ext = os.path.join(tmp, "unsigned") + shutil.copyfile(unsigned, no_ext) + self.assertIsNone(Reader.try_create(no_ext)) + + def test_sub_magic_length_stream_raises(self): + # Fewer bytes than a full magic signature cannot be detected. + # This will raise too. + with self.assertRaises(Error): + Reader("", io.BytesIO(b"\xff\xd8\xff")) + + def test_preseeked_stream_is_rewound(self): + with open(self.testPath, "rb") as file: + data = file.read() + stream = io.BytesIO(data) + stream.seek(len(data) // 2) + with Reader("", stream) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_dng_extension_with_jpeg_bytes_corrected(self): + with tempfile.TemporaryDirectory() as tmp: + fake_dng = os.path.join(tmp, "fake.dng") + shutil.copyfile(self.testPath, fake_dng) + with Reader(fake_dng) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_stream_read_filepath_as_stream_and_parse(self): + with Reader("image/jpeg", self.testPath) as reader: + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, DEFAULT_TEST_FILE_NAME) + + def test_get_mime_type_from_path_dng_special_cased(self): + self.assertEqual(_get_mime_type_from_path("photo.dng"), "image/dng") + + def test_get_mime_type_from_path_uppercase_dng(self): + self.assertEqual(_get_mime_type_from_path("PHOTO.DNG"), "image/dng") + + def test_get_mime_type_from_path_jpeg(self): + self.assertEqual(_get_mime_type_from_path("photo.jpg"), "image/jpeg") + + def test_get_mime_type_from_path_unknown_extension_returns_empty(self): + # An unrecognized extension yields "" so the caller can auto-detect. + self.assertEqual(_get_mime_type_from_path("asset.unknownext"), "") + + def test_get_mime_type_from_path_no_suffix_returns_empty(self): + self.assertEqual(_get_mime_type_from_path("asset"), "") + + def test_get_mime_type_from_path_multi_dot_uses_final_suffix(self): + # Only the final suffix determines the type. + self.assertEqual( + _get_mime_type_from_path("photo.final.jpg"), "image/jpeg") + + def test_get_mime_type_from_path_compound_extension(self): + # mimetypes treats ".gz" as an encoding, not a type suffix, + # the type is derived from ".tar" + result = _get_mime_type_from_path("archive.tar.gz") + self.assertIsInstance(result, str) + self.assertNotIn(result, ("image/jpeg", "image/dng")) + + def test_get_mime_type_from_path_hidden_dotfile_returns_empty(self): + # Check how we handle files without suffix (usually hidden files) + self.assertEqual(_get_mime_type_from_path(".gitignore"), "") + + def test_get_mime_type_from_path_accepts_path_object(self): + self.assertEqual( + _get_mime_type_from_path(Path("dir/photo.jpg")), "image/jpeg") + + def test_encode_format_blank_autodetects(self): + # A blank format requests auto-detection. + self.assertIsNone(_encode_format("", "Reader")) + self.assertIsNone(_encode_format(" ", "Reader")) + + def test_encode_format_returns_bytes(self): + self.assertEqual( + _encode_format("image/jpeg", "Reader"), b"image/jpeg") + + def test_encode_format_case_normalized(self): + self.assertEqual( + _encode_format("IMAGE/JPEG", "Reader"), b"image/jpeg") + + def test_encode_format_unknown_passes_through(self): + # The native library decides validity. + self.assertEqual( + _encode_format("application/zip", "Reader"), b"application/zip") + + def test_reader_accepts_path_object(self): + with Reader(Path(self.testPath)) as reader: + self.assertIn(DEFAULT_TEST_FILE_NAME, reader.json()) + + def test_reader_double_close(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + reader.close() + # Second close should not raise an exception + reader.close() + # Verify reader is closed + with self.assertRaises(Error): + reader.json() + + def test_reader_streams_with_nested(self): + with open(self.testPath, "rb") as file: + with Reader("image/jpeg", file) as reader: + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, DEFAULT_TEST_FILE_NAME) + + def test_reader_close_cleanup(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + # Close the reader + reader.close() + # Verify all resources are cleaned up + self.assertIsNone(reader._handle) + self.assertIsNone(reader._own_stream) + # Verify reader is marked as 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.""" + reader = Reader("image/jpeg", self.testPath) + reader.close() + with self.assertRaises(Error): + reader.resource_to_stream("", io.BytesIO(bytearray())) + + def test_read_dng_from_stream(self): + test_path = os.path.join(self.data_dir, "C.dng") + with open(test_path, "rb") as file: + file_content = file.read() + + with Reader("dng", io.BytesIO(file_content)) as reader: + # Just run and verify there is no crash + json.loads(reader.json()) + + def test_read_dng_upper_case_from_stream(self): + test_path = os.path.join(self.data_dir, "C.dng") + with open(test_path, "rb") as file: + file_content = file.read() + + with Reader("DNG", io.BytesIO(file_content)) as reader: + # Just run and verify there is no crash + json.loads(reader.json()) + + def test_read_dng_file_from_path(self): + test_path = os.path.join(self.data_dir, "C.dng") + + # Create reader with the file content + with Reader(test_path) as reader: + # Just run and verify there is no crash + json.loads(reader.json()) + + def test_read_all_files(self): + """Test reading C2PA metadata from all files in the fixtures/files-for-reading-tests directory""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.pdf': 'application/pdf', + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + for filename in os.listdir(reading_dir): + if filename in skip_files: + continue + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + continue + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + reader = Reader(mime_type, file) + json_data = reader.json() + reader.close() + self.assertIsInstance(json_data, str) + # Verify the manifest contains expected fields + manifest = json.loads(json_data) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + except Exception as e: + self.fail(f"Failed to read metadata from {filename}: {str(e)}") + + def test_try_create_all_files(self): + """Test reading C2PA metadata using Reader.try_create from all files in the fixtures/files-for-reading-tests directory""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.pdf': 'application/pdf', + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + for filename in os.listdir(reading_dir): + if filename in skip_files: + continue + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + continue + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + reader = Reader.try_create(mime_type, file) + # try_create returns None if no manifest found, otherwise a Reader + self.assertIsNotNone(reader, f"Expected Reader for {filename}") + json_data = reader.json() + reader.close() + self.assertIsInstance(json_data, str) + # Verify the manifest contains expected fields + manifest = json.loads(json_data) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + except Exception as e: + self.fail(f"Failed to read metadata from {filename}: {str(e)}") + + def test_try_create_all_files_using_extension(self): + """ + Test reading C2PA metadata using Reader.try_create + from files in the fixtures/files-for-reading-tests directory + """ + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + extensions = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + for filename in os.listdir(reading_dir): + if filename in skip_files: + continue + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in extensions: + continue + + try: + with open(file_path, "rb") as file: + # Remove the leading dot + parsed_extension = ext[1:] + reader = Reader.try_create(parsed_extension, file) + # try_create returns None if no manifest found, otherwise a Reader + self.assertIsNotNone(reader, f"Expected Reader for {filename}") + json_data = reader.json() + reader.close() + self.assertIsInstance(json_data, str) + # Verify the manifest contains expected fields + manifest = json.loads(json_data) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + except Exception as e: + self.fail(f"Failed to read metadata from {filename}: {str(e)}") + + def test_read_all_files_using_extension(self): + """Test reading C2PA metadata from files in the fixtures/files-for-reading-tests directory""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + extensions = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + for filename in os.listdir(reading_dir): + if filename in skip_files: + continue + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in extensions: + continue + + try: + with open(file_path, "rb") as file: + # Remove the leading dot + parsed_extension = ext[1:] + reader = Reader(parsed_extension, file) + json_data = reader.json() + reader.close() + self.assertIsInstance(json_data, str) + # Verify the manifest contains expected fields + manifest = json.loads(json_data) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + except Exception as e: + self.fail(f"Failed to read metadata from {filename}: {str(e)}") + + def test_read_cached_all_files(self): + """Test reading C2PA metadata with cache functionality from all files in the fixtures/files-for-reading-tests directory""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.pdf': 'application/pdf', + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + for filename in os.listdir(reading_dir): + if filename in skip_files: + continue + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + continue + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + reader = Reader(mime_type, file) + + # Test 1: Verify cache variables are initially None + self.assertIsNone(reader._manifest_json_str_cache, f"JSON cache should be None initially for {filename}") + self.assertIsNone(reader._manifest_data_cache, f"Manifest data cache should be None initially for {filename}") + + # Test 2: Multiple calls to json() should return the same result and use cache + json_data_1 = reader.json() + self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache not set after first json() call for {filename}") + self.assertEqual(json_data_1, reader._manifest_json_str_cache, f"JSON cache doesn't match return value for {filename}") + + json_data_2 = reader.json() + self.assertEqual(json_data_1, json_data_2, f"JSON inconsistency for {filename}") + self.assertIsInstance(json_data_1, str) + + # Test 3: Test methods that use the cache + try: + # Test get_active_manifest() which uses _get_cached_manifest_data() + active_manifest = reader.get_active_manifest() + self.assertIsInstance(active_manifest, dict, f"Active manifest not dict for {filename}") + + # Test 4: Verify cache is set after calling cache-using methods + self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache not set after get_active_manifest for {filename}") + self.assertIsNotNone(reader._manifest_data_cache, f"Manifest data cache not set after get_active_manifest for {filename}") + + # Test 5: Multiple calls to cache-using methods should return the same result + active_manifest_2 = reader.get_active_manifest() + self.assertEqual(active_manifest, active_manifest_2, f"Active manifest cache inconsistency for {filename}") + + # Test get_validation_state() which uses the cache + validation_state = reader.get_validation_state() + # validation_state can be None, so just check it doesn't crash + + # Test get_validation_results() which uses the cache + validation_results = reader.get_validation_results() + # validation_results can be None, so just check it doesn't crash + + # Test 6: Multiple calls to validation methods should return the same result + validation_state_2 = reader.get_validation_state() + self.assertEqual(validation_state, validation_state_2, f"Validation state cache inconsistency for {filename}") + + validation_results_2 = reader.get_validation_results() + self.assertEqual(validation_results, validation_results_2, f"Validation results cache inconsistency for {filename}") + + except KeyError as e: + # Some files might not have active manifests or validation data + # This is expected for some test files, so we'll skip cache testing for those + pass + + # Test 7: Verify the manifest contains expected fields + manifest = json.loads(json_data_1) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + + # Test 8: Test cache clearing on close + reader.close() + self.assertIsNone(reader._manifest_json_str_cache, f"JSON cache not cleared for {filename}") + self.assertIsNone(reader._manifest_data_cache, f"Manifest data cache not cleared for {filename}") + + except Exception as e: + self.fail(f"Failed to read cached metadata from {filename}: {str(e)}") + + def test_reader_context_manager_with_exception(self): + """Test Reader state after exception in context manager.""" + try: + with Reader(self.testPath) as reader: + # Inside context - should be valid + 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") + except ValueError: + pass + + # After exception - should still be closed + 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 lifecycle state = ACTIVE + reader = Reader.__new__(Reader) + reader._lifecycle_state = LifecycleState.ACTIVE + reader._handle = None + reader._own_stream = None + reader._backing_file = None + + with self.assertRaises(Error): + reader._ensure_valid_state() + + def test_reader_cleanup_state_transitions(self): + """Test Reader state during cleanup operations.""" + reader = Reader(self.testPath) + + reader._cleanup_resources() + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(reader._handle) + self.assertIsNone(reader._own_stream) + self.assertIsNone(reader._backing_file) + + def test_reader_cleanup_idempotency(self): + """Test that cleanup operations are idempotent.""" + reader = Reader(self.testPath) + + # First cleanup + reader._cleanup_resources() + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + + # Second cleanup should not change state + reader._cleanup_resources() + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(reader._handle) + self.assertIsNone(reader._own_stream) + self.assertIsNone(reader._backing_file) + + def test_reader_state_with_invalid_native_pointer(self): + """Test Reader state handling with invalid native pointer.""" + reader = Reader(self.testPath) + + # Simulate invalid native pointer + reader._handle = 0 + + # Operations should fail gracefully + with self.assertRaises(Error): + reader.json() + + def test_reader_is_embedded(self): + """Test the is_embedded method returns correct values for embedded and remote manifests.""" + + # Test with a fixture which has an embedded manifest + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + self.assertTrue(reader.is_embedded()) + reader.close() + + # Test with cloud.jpg fixture which has a remote manifest (not embedded) + cloud_fixture_path = os.path.join(self.data_dir, "cloud.jpg") + with Reader("image/jpeg", cloud_fixture_path) as reader: + self.assertFalse(reader.is_embedded()) + + def test_sign_and_read_is_not_embedded(self): + """Test the is_embedded method returns correct values for remote manifests.""" + + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + key = key_file.read() + + # Create signer info and signer + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + # Define a simple manifest + manifest_definition = { + "claim_generator": "python_test", + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Test Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + + # Create a temporary directory for the signed file + with tempfile.TemporaryDirectory() as temp_dir: + temp_file_path = os.path.join(temp_dir, "signed_test_file_no_embed.jpg") + + with open(self.testPath, "rb") as file: + builder = Builder(manifest_definition) + # Direct the Builder not to embed the manifest into the asset + builder.set_no_embed() + + with open(temp_file_path, "wb") as temp_file: + manifest_data = builder.sign( + signer, "image/jpeg", file, temp_file) + + with Reader("image/jpeg", temp_file_path, manifest_data) as reader: + self.assertFalse(reader.is_embedded()) + + def test_reader_get_remote_url(self): + """Test the get_remote_url method returns correct values for embedded and remote manifests.""" + + # Test get_remote_url for file with embedded manifest (should return None) + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + self.assertIsNone(reader.get_remote_url()) + reader.close() + + # Test remote manifest using cloud.jpg fixture which has a remote URL + cloud_fixture_path = os.path.join(self.data_dir, "cloud.jpg") + with Reader("image/jpeg", cloud_fixture_path) as reader: + remote_url = reader.get_remote_url() + self.assertEqual(remote_url, "https://cai-manifests.adobe.com/manifests/adobe-urn-uuid-5f37e182-3687-462e-a7fb-573462780391") + self.assertFalse(reader.is_embedded()) + + def test_stream_read_and_parse_cached(self): + """Test reading and parsing with cache verification by repeating operations multiple times""" + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + + # Verify cache starts as None + self.assertIsNone(reader._manifest_json_str_cache, "JSON cache should be None initially") + self.assertIsNone(reader._manifest_data_cache, "Manifest data cache should be None initially") + + # First operation - should populate cache + manifest_store_1 = json.loads(reader.json()) + title_1 = manifest_store_1["manifests"][manifest_store_1["active_manifest"]]["title"] + self.assertEqual(title_1, DEFAULT_TEST_FILE_NAME) + + # Verify cache is populated after first json() call + self.assertIsNotNone(reader._manifest_json_str_cache, "JSON cache should be set after first json() call") + self.assertEqual(manifest_store_1, json.loads(reader._manifest_json_str_cache), "Cached JSON should match parsed result") + + # Repeat the same operation multiple times to verify cache usage + for i in range(5): + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, DEFAULT_TEST_FILE_NAME, f"Title should be consistent on iteration {i+1}") + + # Verify cache is still populated and consistent + self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache should remain set on iteration {i+1}") + self.assertEqual(manifest_store, json.loads(reader._manifest_json_str_cache), f"Cached JSON should match parsed result on iteration {i+1}") + + # Test methods that use the cache + # Test get_active_manifest() which uses _get_cached_manifest_data() + active_manifest_1 = reader.get_active_manifest() + self.assertIsInstance(active_manifest_1, dict, "Active manifest should be a dict") + + # Verify manifest data cache is populated + self.assertIsNotNone(reader._manifest_data_cache, "Manifest data cache should be set after get_active_manifest()") + + # Repeat get_active_manifest() multiple times to verify cache usage + for i in range(3): + active_manifest = reader.get_active_manifest() + self.assertEqual(active_manifest_1, active_manifest, f"Active manifest should be consistent on iteration {i+1}") + + # Verify cache remains populated + self.assertIsNotNone(reader._manifest_data_cache, f"Manifest data cache should remain set on iteration {i+1}") + + # Test get_validation_state() and get_validation_results() with cache + validation_state_1 = reader.get_validation_state() + validation_results_1 = reader.get_validation_results() + + # Repeat validation methods to verify cache usage + for i in range(3): + validation_state = reader.get_validation_state() + validation_results = reader.get_validation_results() + + self.assertEqual(validation_state_1, validation_state, f"Validation state should be consistent on iteration {i+1}") + self.assertEqual(validation_results_1, validation_results, f"Validation results should be consistent on iteration {i+1}") + + # Verify cache clearing on close + reader.close() + self.assertIsNone(reader._manifest_json_str_cache, "JSON cache should be cleared on close") + self.assertIsNone(reader._manifest_data_cache, "Manifest data cache should be cleared on close") diff --git a/tests/unit/test_stream.py b/tests/unit/test_stream.py new file mode 100644 index 00000000..c0667a24 --- /dev/null +++ b/tests/unit/test_stream.py @@ -0,0 +1,305 @@ +# Copyright 2023 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. + +import os +import io +import gzip +import bz2 +import lzma +import unittest +import ctypes +import tempfile +from c2pa.c2pa import Stream +from pathlib import Path +from tests.unit._support import ( + FIXTURES_DIR, + DEFAULT_TEST_FILE_NAME, +) + + +class TestIsReadableStream(unittest.TestCase): + """ A stream is anything Stream will accept (read/write/seek/tell/flush). + """ + + def _tmp_path(self): + return os.path.join(FIXTURES_DIR, DEFAULT_TEST_FILE_NAME) + + def test_paths_and_formats_are_not_streams(self): + for value in (None, "", "image/jpeg", "path/to/file.jpg", + Path("x.jpg")): + self.assertFalse(Stream.is_read_stream(value), repr(value)) + + def test_non_stream_objects_are_not_streams(self): + for value in (b"bytes", bytearray(b"x"), 42, object()): + self.assertFalse(Stream.is_read_stream(value), repr(value)) + + def test_object_missing_methods_is_not_a_stream(self): + class OnlyRead: + def read(self): + return b"" + self.assertFalse(Stream.is_read_stream(OnlyRead())) + + def test_read_only_object_is_not_a_stream(self): + # Has read/seek/tell but no write/flush, so not a Stream for us. + class ReadOnly: + def read(self): + return b"" + + def seek(self, *a): + return 0 + + def tell(self): + return 0 + self.assertFalse(Stream.is_read_stream(ReadOnly())) + + def test_in_memory_streams_are_streams(self): + self.assertTrue(Stream.is_read_stream(io.BytesIO(b"x"))) + self.assertTrue(Stream.is_read_stream(io.StringIO("x"))) + + def test_file_handles_are_streams(self): + path = self._tmp_path() + for opener in ( + lambda: open(path, "rb"), + lambda: open(path, "rb", buffering=0), + lambda: io.BufferedReader(io.FileIO(path, "rb")), + lambda: open(path, "r+b"), + lambda: open(path, "r"), + ): + fh = opener() + try: + self.assertTrue(Stream.is_read_stream(fh), type(fh).__name__) + finally: + fh.close() + + def test_spooled_temporary_file_is_a_stream(self): + with tempfile.SpooledTemporaryFile() as fh: + self.assertTrue(Stream.is_read_stream(fh)) + + def test_compressed_file_objects_are_streams(self): + with tempfile.TemporaryDirectory() as tmp: + for ext, opener in ( + ("gz", gzip.open), + ("bz2", bz2.open), + ("xz", lzma.open), + ): + p = os.path.join(tmp, f"f.{ext}") + with opener(p, "wb") as fh: + fh.write(b"data") + with opener(p, "rb") as fh: + self.assertTrue(Stream.is_read_stream(fh), ext) + + def test_placeholder_typed_object_with_all_methods_is_a_stream(self): + class Duck: + def read(self, *a): + return b"" + + def write(self, *a): + return 0 + + def seek(self, *a): + return 0 + + def tell(self): + return 0 + + def flush(self): + pass + self.assertTrue(Stream.is_read_stream(Duck())) + + +class TestStream(unittest.TestCase): + def setUp(self): + self.temp_file = io.BytesIO() + self.test_data = b"Hello, World!" + self.temp_file.write(self.test_data) + self.temp_file.seek(0) + + def tearDown(self): + self.temp_file.close() + + def test_stream_initialization(self): + stream = Stream(self.temp_file) + self.assertTrue(stream.initialized) + self.assertFalse(stream.closed) + stream.close() + + def test_stream_initialization_with_invalid_object(self): + with self.assertRaises(TypeError): + Stream("not a file-like object") + + def test_stream_read(self): + stream = Stream(self.temp_file) + try: + # Create a buffer to read into + buffer = (ctypes.c_ubyte * 13)() + # Read the data + bytes_read = stream._read_cb(None, buffer, 13) + # Verify the data + self.assertEqual(bytes_read, 13) + self.assertEqual(bytes(buffer[:bytes_read]), self.test_data) + finally: + stream.close() + + def test_stream_write(self): + output = io.BytesIO() + stream = Stream(output) + try: + # Create test data + test_data = b"Test Write" + buffer = (ctypes.c_ubyte * len(test_data))(*test_data) + # Write the data + bytes_written = stream._write_cb(None, buffer, len(test_data)) + # Verify the data + self.assertEqual(bytes_written, len(test_data)) + output.seek(0) + self.assertEqual(output.read(), test_data) + finally: + stream.close() + + def test_stream_seek(self): + stream = Stream(self.temp_file) + try: + # Seek to position 7 (after "Hello, ") + new_pos = stream._seek_cb(None, 7, 0) # 0 = SEEK_SET + self.assertEqual(new_pos, 7) + # Read from new position + buffer = (ctypes.c_ubyte * 6)() + bytes_read = stream._read_cb(None, buffer, 6) + self.assertEqual(bytes(buffer[:bytes_read]), b"World!") + finally: + stream.close() + + def test_stream_flush(self): + output = io.BytesIO() + stream = Stream(output) + try: + # Write some data + test_data = b"Test Flush" + buffer = (ctypes.c_ubyte * len(test_data))(*test_data) + stream._write_cb(None, buffer, len(test_data)) + # Flush the stream + result = stream._flush_cb(None) + self.assertEqual(result, 0) + finally: + stream.close() + + def test_stream_context_manager(self): + with Stream(self.temp_file) as stream: + self.assertTrue(stream.initialized) + self.assertFalse(stream.closed) + self.assertTrue(stream.closed) + + def test_stream_double_close(self): + stream = Stream(self.temp_file) + stream.close() + # Second close should not raise an exception + stream.close() + self.assertTrue(stream.closed) + + def test_stream_read_after_close(self): + stream = Stream(self.temp_file) + # Store callbacks before closing + read_cb = stream._read_cb + stream.close() + buffer = (ctypes.c_ubyte * 13)() + # Reading from closed stream should return -1 + self.assertEqual(read_cb(None, buffer, 13), -1) + + def test_stream_write_after_close(self): + stream = Stream(self.temp_file) + # Store callbacks before closing + write_cb = stream._write_cb + stream.close() + test_data = b"Test Write" + buffer = (ctypes.c_ubyte * len(test_data))(*test_data) + # Writing to closed stream should return -1 + self.assertEqual(write_cb(None, buffer, len(test_data)), -1) + + def test_stream_seek_after_close(self): + stream = Stream(self.temp_file) + # Store callbacks before closing + seek_cb = stream._seek_cb + stream.close() + # Seeking in closed stream should return -1 + self.assertEqual(seek_cb(None, 5, 0), -1) + + def test_stream_flush_after_close(self): + stream = Stream(self.temp_file) + # Store callbacks before closing + flush_cb = stream._flush_cb + stream.close() + # Flushing closed stream should return -1 + self.assertEqual(flush_cb(None), -1) + + +class TestStreamReferences(unittest.TestCase): + + def test_stream_collected_after_del(self): + """Stream must be collected by reference counting.""" + import gc + import weakref + gc.collect() + garbage_before = len(gc.garbage) + + buf = io.BytesIO(b"hello world") + s = Stream(buf) + ref = weakref.ref(s) + del s + + # Trigger gc, we want to verify it's collected + # collected now (1 gc call) means no gc cycle breaker needed + # aka ref cycle did not happen. + gc.collect() + + self.assertIsNone(ref(), "Stream not collected") + self.assertEqual(len(gc.garbage), garbage_before, + "Stream added objects to gc.garbage") + + def test_stream_not_added_to_gc_garbage_list(self): + """Creating and dropping many Streams must not grow gc.garbage.""" + import gc + gc.collect() + gc.garbage.clear() + + for _ in range(20): + s = Stream(io.BytesIO(b"data")) + del s + + gc.collect() + self.assertEqual(len(gc.garbage), 0, + f"gc.garbage unexpectedly non-empty: {gc.garbage}") + + def test_callbacks_return_minus_one_after_stream_collected(self): + """Callbacks must return -1 gracefully when the Stream has been GC'd.""" + import gc + import weakref + + buf = io.BytesIO(b"test") + s = Stream(buf) + + read_cb = s._read_cb + seek_cb = s._seek_cb + write_cb = s._write_cb + flush_cb = s._flush_cb + ref = weakref.ref(s) + del s + gc.collect() + + # Stream should be gone. + self.assertIsNone(ref(), "Stream not collected before callback test") + + # All callbacks must return -1 without crashing. + self.assertEqual(read_cb(None, None, 0), -1) + self.assertEqual(seek_cb(None, 0, 0), -1) + self.assertEqual(write_cb(None, None, 0), -1) + self.assertEqual(flush_cb(None), -1) diff --git a/tests/unit/test_threading_static_analysis.py b/tests/unit/test_threading_static_analysis.py new file mode 100644 index 00000000..db328eaf --- /dev/null +++ b/tests/unit/test_threading_static_analysis.py @@ -0,0 +1,177 @@ +# Copyright 2023 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. + +import ast +import inspect +import unittest +import c2pa.c2pa as c2pa_module + + +class TestLockOrderStaticAnalysis(unittest.TestCase): + """Static analysis over the source: no threads are spawned here. + """ + + def test_no_conflicting_lock_acquisition_order(self): + """No two locks may be nested in opposite orders by different methods. + + Two methods nesting the same pair of locks in opposite order is a + AB/BA deadlock shape: thread 1 holds A and waits for B while + thread 2 holds B and waits for A. + """ + tree = ast.parse(inspect.getsource(c2pa_module)) + + # Every self._X = threading.Lock()/RLock()/Condition() assignment, + # grouped by the class that owns it. + lock_attrs_by_class = {} + for cls in ast.walk(tree): + if not isinstance(cls, ast.ClassDef): + continue + found = set() + for node in ast.walk(cls): + if not (isinstance(node, ast.Assign) + and len(node.targets) == 1): + continue + target = node.targets[0] + if not (isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == "self"): + continue + value = node.value + if (isinstance(value, ast.Call) + and isinstance(value.func, ast.Attribute) + and value.func.attr in + ("Lock", "RLock", "Condition")): + found.add(target.attr) + if found: + lock_attrs_by_class[cls.name] = found + + def lock_name_for_with(item): + """The lock attribute a `with` item enters, or None.""" + ctx = item.context_expr + # with self._fragment_lock: + if (isinstance(ctx, ast.Attribute) + and isinstance(ctx.value, ast.Name) + and ctx.value.id == "self" + and any(ctx.attr in attrs + for attrs in lock_attrs_by_class.values())): + return ctx.attr + # with self._guarded_op(): returns _op_lock itself, and the + # accessors return the lock they are named for. + lock_by_method = { + "_guarded_op": "_op_lock", + "_live_op_lock": "_op_lock", + "_live_teardown_lock": "_teardown_lock", + } + if (isinstance(ctx, ast.Call) + and isinstance(ctx.func, ast.Attribute) + and ctx.func.attr in lock_by_method + and isinstance(ctx.func.value, ast.Name) + and ctx.func.value.id == "self"): + return lock_by_method[ctx.func.attr] + return None + + def lock_name_for_acquire(node): + """A lock taken with acquire() and released in a finally nests just + as a `with` does, so the scan has to follow it or it silently stops + seeing whole regions. + """ + call = node.value if isinstance(node, ast.Expr) else node + if isinstance(call, ast.UnaryOp) and isinstance(call.op, ast.Not): + call = call.operand + if not (isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and call.func.attr == "acquire"): + return None + owner = call.func.value + if (isinstance(owner, ast.Attribute) + and isinstance(owner.value, ast.Name) + and owner.value.id == "self" + and any(owner.attr in attrs + for attrs in lock_attrs_by_class.values())): + return owner.attr + return None + + def acquires_in_test(node): + """Lock taken by `if not self._X.acquire(...)`-style guards.""" + if isinstance(node, ast.If): + return lock_name_for_acquire(node.test) + return None + + def orders_in(node, stack, pairs): + """Record (outer, inner) for every nesting this node contains.""" + if isinstance(node, (ast.With, ast.AsyncWith)): + names = [n for n in (lock_name_for_with(i) + for i in node.items) if n] + for name in names: + if stack: + pairs.add((stack[-1], name)) + stack.append(name) + for child in node.body: + orders_in(child, stack, pairs) + for _ in names: + stack.pop() + return + # A statement list can open a lock partway through via acquire(); + # everything after it in that list is nested inside. + for field, value in ast.iter_fields(node): + if not isinstance(value, list): + continue + held = [] + for child in value: + if not isinstance(child, ast.stmt): + continue + name = (lock_name_for_acquire(child) + or acquires_in_test(child)) + if name: + if stack: + pairs.add((stack[-1], name)) + stack.append(name) + held.append(name) + continue + orders_in(child, stack, pairs) + for _ in held: + stack.pop() + for child in ast.iter_child_nodes(node): + if not isinstance(child, ast.stmt): + orders_in(child, stack, pairs) + + pairs_by_method = {} + for cls in ast.walk(tree): + if not isinstance(cls, ast.ClassDef): + continue + for method in cls.body: + if not isinstance(method, (ast.FunctionDef, + ast.AsyncFunctionDef)): + continue + pairs = set() + orders_in(method, [], pairs) + if pairs: + pairs_by_method[(cls.name, method.name)] = pairs + + all_pairs = set().union(*pairs_by_method.values()) \ + if pairs_by_method else set() + conflicts = [] + for (outer, inner) in all_pairs: + # Each conflicting pair appears twice in all_pairs, once per direction. + if outer >= inner or (inner, outer) not in all_pairs: + continue + forward = [k for k, v in pairs_by_method.items() + if (outer, inner) in v] + backward = [k for k, v in pairs_by_method.items() + if (inner, outer) in v] + conflicts.append( + "{} nests {} inside {}, but {} nests {} inside {}".format( + forward[0], inner, outer, backward[0], outer, inner)) + + self.assertGreater(len(pairs_by_method), 0, "scan found no nesting") + self.assertEqual(conflicts, [], ", ".join(sorted(set(conflicts)))) From 72d4062ade2cb435312e2eb2ac753a455b8e6423 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Nov 2026 13:53:37 -0800 Subject: [PATCH 2/3] fix: agents.md --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b090dfc9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Agent Guidelines + +Before working in this repository, read [AI_WORKFLOW.md](AI_WORKFLOW.md) and follow its guidance. + +- For repository changes, follow Part 2: Developing c2pa-python, and consult the relevant parts of Part 1 for the affected public API. +- For changes to the binding layer, follow the binding-layer rules and decision tree in AI_WORKFLOW.md. +- Before finishing, check the applicable items in AI_WORKFLOW.md's Final checklist and run the relevant project checks. From 7058ca66c142ac76dc1f0f3982a1fea9b7cce115 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Nov 2026 14:03:37 -0800 Subject: [PATCH 3/3] fix: Add agents workflow setup --- AGENTS.md | 5 +++-- AI_WORKFLOW.md | 47 +++++++++++++++++++++++++++++++---------------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b090dfc9..898e8f0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ Before working in this repository, read [AI_WORKFLOW.md](AI_WORKFLOW.md) and follow its guidance. -- For repository changes, follow Part 2: Developing c2pa-python, and consult the relevant parts of Part 1 for the affected public API. +- For library behavior or implementation changes, read all of Part 1: Using the library and Part 2: Developing c2pa-python. For documentation, test, or tooling changes, read Part 2 and relevant Part 1 sections. - For changes to the binding layer, follow the binding-layer rules and decision tree in AI_WORKFLOW.md. -- Before finishing, check the applicable items in AI_WORKFLOW.md's Final checklist and run the relevant project checks. +- Before finishing code changes, run `make test` and `make check-format`. If prerequisites prevent a check from running, report the blocker and the results of checks that did run. +- Bump the Python package version only for release-ready changes, and ask the user for confirmation before changing it. diff --git a/AI_WORKFLOW.md b/AI_WORKFLOW.md index b31ac141..4be2442d 100644 --- a/AI_WORKFLOW.md +++ b/AI_WORKFLOW.md @@ -20,8 +20,12 @@ read [Part 1: Using the library](#part-1-using-the-library). ## Read first: when developing c2pa-python -Changing this repository: read [Part 2: Developing c2pa-python](#part-2-developing-c2pa-python), -and Part 1 for the API the repository exposes. +When changing library behavior or implementation, read all of +[Part 1: Using the library](#part-1-using-the-library) and +[Part 2: Developing c2pa-python](#part-2-developing-c2pa-python) first. Part 1 explains +the library's public behavior and resource contracts; Part 2 explains repository and +binding implementation practices. For changes limited to documentation, tests, or +tooling, read Part 2 and the relevant Part 1 sections. | Topic | Document | | --- | --- | @@ -34,10 +38,12 @@ and Part 1 for the API the repository exposes. ### Lifetimes and consumption -#### ALWAYS USE `with` +#### Close every native resource -**Every `Reader`, `Builder`, `Signer`, `Context` and `Settings` goes in a `with` block. -No exceptions in new code.** +Every `Reader`, `Builder`, `Signer`, `Context` and `Settings` must be closed +deterministically. Prefer a `with` block. Use explicit `close()` only for long-lived +objects that cannot reasonably be scoped to a `with` block, and close them in `finally` +or from their owner's `close()` / `__exit__`. Each of these objects holds memory allocated by the native library. Python's garbage collector does not know that memory exists. The `with` block is what frees it, at a @@ -80,9 +86,9 @@ Rules for `with`: - **Never let an object escape its block.** Returning or storing a `Reader` created in a `with` block hands out a closed object; every method on it raises `C2paError`. Return the data instead (`reader.json()`, `reader.get_active_manifest()`). -- **Long-lived objects** that cannot sit in one block (a `Context` stored on a service - object) use `try`/`finally` with `close()`, or close from the owner's own `close()` / - `__exit__`. `close()` can be called more than once safely. +- **Long-lived objects** that cannot reasonably sit in one block (a `Context` stored on + a service object) use explicit `close()` in `try`/`finally`, or close from the owner's + own `close()` / `__exit__`. `close()` can be called more than once safely. - `close()` and `with` are equivalent. `__del__` is a fallback for mistakes, never the plan. @@ -487,8 +493,14 @@ make test-one T=tests.unit.test_reader.TestReader. # one module, cl pytest tests/unit -v # as CI runs them ``` -Run new tests by name and read their own result lines; a passing suite does not show a new -test ran. +During development, run new tests by name and read their own result lines; this confirms +the tests ran while iterating. These targeted commands are development checks, not a +replacement for final validation. Before finishing a code change, run both `make test` +and `make check-format`. + +If a required check cannot run because a prerequisite is unavailable, do not report it as +passing. State what blocked it, run the checks that are available, and report which checks +ran and their results. `TestC2paSdk.test_sdk_version` fails with an assertion like `'0.91.0' not found in '0.91.1'` when the installed native library's version does not match @@ -530,8 +542,10 @@ A public API change also updates the topic document in `docs/` and #### Versioning -- Python package version: bump `version` in `pyproject.toml` and the `# Version:` header - in `src/c2pa/c2pa.py` together, on every change that ships. +- Python package version: bump only for release-ready changes. When bumping, update + `version` in `pyproject.toml` and the `# Version:` header in `src/c2pa/c2pa.py` + together. Before making either change, ask the user for confirmation and wait for + approval. - `c2pa-native-version.txt`: bump only when pinning a new c2pa-rs release. Not tied to the Python package version; bump it alone, then `make download-native-artifacts` and run the tests. @@ -1039,12 +1053,13 @@ Native errors are strings of the form `"VariantName: details"`, the cimpl conven ### Any change -- [ ] Every `Reader`, `Builder`, `Signer`, `Context` and `Settings` used in `with`, or - explicitly `close()`d -- [ ] New tests run by name, their own result lines read +- [ ] Every `Reader`, `Builder`, `Signer`, `Context` and `Settings` is closed + deterministically: use `with`, except for long-lived objects that cannot reasonably + be scoped that way, which must be explicitly closed - [ ] `make test` passes - [ ] `make check-format` passes -- [ ] All other existing unit tests still pass +- [ ] If a required check is blocked by an unavailable prerequisite, report the reason and + the checks that were run instead; do not claim the blocked check passed ### Public API change