diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c8c75b8f..3182211e 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/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..898e8f0b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# Agent Guidelines + +Before working in this repository, read [AI_WORKFLOW.md](AI_WORKFLOW.md) and follow its guidance. + +- 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 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 new file mode 100644 index 00000000..4be2442d --- /dev/null +++ b/AI_WORKFLOW.md @@ -0,0 +1,1090 @@ +# 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 + +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 | +| --- | --- | +| 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 + +#### Close every native resource + +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 +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 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. + +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 +``` + +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 +`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 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. + +#### 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` 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 +- [ ] 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 + +- [ ] 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/Makefile b/Makefile index d4e8ae5c..afbe7236 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/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))))