diff --git a/Makefile b/Makefile index eca23ade..ba70dfb3 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ rebuild: clean-c2pa-env install-deps download-native-artifacts build-python run-examples: python3 ./examples/sign.py python3 ./examples/sign_info.py + python3 ./examples/no_thumbnails.py python3 ./examples/training.py rm -rf output/ diff --git a/README.md b/README.md index c03f7553..f4c0e210 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,22 @@ Features: - Add assertions and ingredients to assets. - Examples and unit tests to demonstrate usage. +
+ +For the best experience, read the docs on the [CAI Open Source SDK documentation website](https://opensource.contentauthenticity.org/docs/c2pa-c). + +If you want to view the documentation in GitHub, see: +- [Using the Python library](docs/usage.md) +- [Supported formats](https://github.com/contentauth/c2pa-rs/blob/main/docs/supported-formats.md) +- [Configuring the SDK using `Context` and `Settings`](docs/context-settings.md) +- [Using Builder intents](docs/intents.md) to ensure spec-compliant manifests +- Using [working stores and archvies](docs/working-stores.md) +- Selectively constructing manifests by [filtering actions and ingredients](docs/selective-manifests.md) +- [Diagram of public classes in the Python library and their relationships](docs/class-diagram.md) +- [Release notes](docs/release-notes.md) + +
+ ## Prerequisites This library requires Python version 3.10+. @@ -40,7 +56,9 @@ See the [`examples` directory](https://github.com/contentauth/c2pa-python/tree/m ## API reference documentation -See [the section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation). +Documentation is published at [github.io/c2pa-python/api/c2pa](https://contentauth.github.io/c2pa-python/api/c2pa/index.html). + +To build documentation locally, refer to [this section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation). ## Contributing diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index c9951732..5d1f9384 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.67.1 +c2pa-v0.78.4 diff --git a/docs/class-diagram.md b/docs/class-diagram.md new file mode 100644 index 00000000..5d9ad541 --- /dev/null +++ b/docs/class-diagram.md @@ -0,0 +1,135 @@ + +# Class diagram + +This diagram shows the public classes in the Python library and their relationships. + +```mermaid +classDiagram + direction LR + + class Settings { + +from_json(json_str) Settings$ + +from_dict(config) Settings$ + +set(path, value) Settings + +update(data) Settings + +close() + +is_valid bool + } + + class ContextProvider { + <> + +is_valid bool* + +execution_context* + } + + class Context { + +from_json(json_str, signer) Context$ + +from_dict(config, signer) Context$ + +has_signer bool + +is_valid bool + +close() + } + + class Reader { + +get_supported_mime_types() list~str~$ + +try_create(format_or_path, stream, manifest_data, context) Reader | None$ + +json() str + +detailed_json() str + +get_active_manifest() dict | None + +get_manifest(label) dict + +get_validation_state() str | None + +get_validation_results() dict | None + +resource_to_stream(uri, stream) int + +is_embedded() bool + +get_remote_url() str | None + +close() + } + + class Builder { + +from_json(manifest_json, context) Builder$ + +from_archive(stream) Builder$ + +get_supported_mime_types() list~str~$ + +set_no_embed() + +set_remote_url(url) + +set_intent(intent, digital_source_type) + +add_resource(uri, stream) + +add_ingredient(json, format, source) + +add_action(action_json) + +to_archive(stream) + +with_archive(stream) Builder + +sign(signer, format, source, dest) bytes + +sign(format, source, dest) bytes + +sign_file(source_path, dest_path, signer) bytes + +close() + } + + class Signer { + +from_info(signer_info) Signer$ + +from_callback(callback, alg, certs, tsa_url) Signer$ + +reserve_size() int + +close() + } + + class C2paSignerInfo { + <> + +alg + +sign_cert + +private_key + +ta_url + } + + class C2paSigningAlg { + <> + ES256 + ES384 + ES512 + PS256 + PS384 + PS512 + ED25519 + } + + class C2paBuilderIntent { + <> + CREATE + EDIT + UPDATE + } + + class C2paDigitalSourceType { + <> + DIGITAL_CAPTURE + DIGITAL_CREATION + TRAINED_ALGORITHMIC_MEDIA + ... + } + + class C2paError { + <> + +message str + } + + class C2paError_Subtypes { + <> + ManifestNotFound + NotSupported + Json + Io + Verify + Signature + ... + } + + ContextProvider <|-- Context : extends + Settings --> Context : optional input + Signer --> Context : optional, consumed + C2paSignerInfo --> Signer : creates via from_info + C2paSigningAlg --> C2paSignerInfo : alg field + C2paSigningAlg --> Signer : from_callback alg + Context --> Reader : context= + Context --> Builder : context= + Signer --> Builder : sign(signer) + C2paBuilderIntent --> Builder : set_intent + C2paDigitalSourceType --> Builder : set_intent + C2paError --> C2paError_Subtypes : subclasses +``` \ No newline at end of file diff --git a/docs/context-settings.md b/docs/context-settings.md new file mode 100644 index 00000000..1ff17dff --- /dev/null +++ b/docs/context-settings.md @@ -0,0 +1,786 @@ +# Context and settings + +This guide shows you how to configure the C2PA Python SDK using the `Context` API with declarative settings in JSON format. + +## Overview + +The `Context` class encapsulates configuration for: + +- **Settings**: Verification options, builder behavior, trust configuration, thumbnail settings, and more. +- **Signer configuration**: Optional signer credentials stored in the `Context` for reuse. +- **State isolation**: Each `Context` is independent, allowing different configurations to coexist in the same application. + +`Context` replaces the deprecated global `load_settings()` function with explicit, isolated configuration: + +- **Makes dependencies explicit**: Configuration is passed directly to `Reader` and `Builder`, not hidden in global state. +- **Enables multiple configurations**: Run different configurations simultaneously (for example, one for development with test certificates, another for production with strict validation). +- **Eliminates global state**: Each `Reader` and `Builder` gets its configuration from the `Context` you pass, avoiding subtle bugs from shared state. +- **Simplifies testing**: Create isolated configurations for tests without worrying about cleanup or interference. + +> [!NOTE] +> The deprecated `load_settings()` function still works for backward compatibility, but you are encouraged to migrate your code to use `Context`. See [Migrating from load_settings](#migrating-from-load_settings). + +## Quick start + +### Using SDK default settings + +Without additional parameters, `Context` uses [SDK default settings](#default-configuration). + +**When to use:** For quick prototyping, or when SDK defaults are acceptable (verification enabled, thumbnails enabled at 1024px, and so on). + +```py +from c2pa import Context + +ctx = Context() # Uses SDK defaults +``` + +### From a JSON string + +**When to use:** For simple configuration that doesn't need to be shared across the codebase. + +```py +ctx = Context.from_json('''{ + "verify": {"verify_after_sign": true}, + "builder": { + "thumbnail": {"enabled": false}, + "claim_generator_info": {"name": "An app", "version": "0.1.0"} + } +}''') +``` + +### From a dictionary + +**When to use:** When you want to build configuration programmatically using native Python data structures. + +```py +ctx = Context.from_dict({ + "verify": {"verify_after_sign": True}, + "builder": { + "thumbnail": {"enabled": False}, + "claim_generator_info": {"name": "An app", "version": "0.1.0"} + } +}) +``` + +### From a Settings object + +**When to use:** For configuration that needs runtime logic (conditional settings based on environment, or incremental/layered configuration). + +```py +from c2pa import Settings, Context + +settings = Settings() +settings.set("builder.thumbnail.enabled", "false") +settings.set("verify.verify_after_sign", "true") +settings.update({ + "builder": { + "claim_generator_info": {"name": "An app", "version": "0.1.0"} + } +}) + +ctx = Context(settings) +``` + +To load settings from a file: + +```py +import json + +with open("config/settings.json", "r") as f: + settings = Settings.from_json(f.read()) + +ctx = Context(settings) +``` + +## Class diagram + +```mermaid +classDiagram + class ContextProvider { + <> + +is_valid bool + +execution_context + } + + class Settings { + +set(path, value) Settings + +update(data) Settings + +from_json(json_str)$ Settings + +from_dict(config)$ Settings + +close() + } + + class Context { + +has_signer bool + +builder()$ ContextBuilder + +from_json(json_str, signer)$ Context + +from_dict(config, signer)$ Context + +close() + } + + class ContextBuilder { + +with_settings(settings) ContextBuilder + +with_signer(signer) ContextBuilder + +build() Context + } + + class Signer { + +from_info(signer_info)$ Signer + +from_callback(callback, alg, certs, tsa_url)$ Signer + +close() + } + + class Reader { + +json() str + +resource_to_stream(uri, stream) + +close() + } + + class Builder { + +add_ingredient(json, format, stream) + +sign(signer, format, source, dest) bytes + +close() + } + + ContextProvider <|-- Context + ContextBuilder --> Context : builds + Context o-- Settings : optional + Context o-- Signer : optional, consumed + Reader ..> ContextProvider : uses + Builder ..> ContextProvider : uses +``` + +## Settings API + +Create and configure settings independently of a `Context`: + +| Method | Description | +|--------|-------------| +| `Settings()` | Create default settings with SDK defaults. | +| `Settings.from_json(json_str)` | Create settings from a JSON string. Raises `C2paError` on parse error. | +| `Settings.from_dict(config)` | Create settings from a Python dictionary. | +| `set(path, value)` | Set a single value by dot-separated path (for example, `"verify.verify_after_sign"`). Value must be a string. Returns `self` for chaining. | +| `update(data)` | Merge configuration into existing settings. `data` can be a JSON string or a dict. Later keys override earlier ones. | + +The `set()` and `update()` methods can be chained for incremental configuration. When using multiple configuration methods, later calls override earlier ones (last call wins when the same setting is set multiple times). + +```py +from c2pa import Settings + +settings = Settings() +settings.set("builder.thumbnail.enabled", "false").set("verify.verify_after_sign", "true") + +settings.update({"verify": {"remote_manifest_fetch": True}}) +``` + +## Using Context + +### With Reader + +`Reader` uses `Context` to control how it validates manifests and handles remote resources: + +- **Verification behavior**: Whether to verify after reading, check trust, and so on. +- **Trust configuration**: Which certificates to trust when validating signatures. +- **Network access**: Whether to fetch remote manifests or OCSP responses. + +> [!IMPORTANT] +> `Context` is used only at construction time. `Reader` copies the configuration it needs internally, so the `Context` does not need to outlive the `Reader`. A single `Context` can be reused for multiple `Reader` instances. + +```py +ctx = Context.from_dict({"verify": {"remote_manifest_fetch": False}}) +reader = Reader("image.jpg", context=ctx) +print(reader.json()) +``` + +Reading from a stream: + +```py +with open("image.jpg", "rb") as stream: + reader = Reader("image/jpeg", stream, context=ctx) + print(reader.json()) +``` + +### With Builder + +`Builder` uses `Context` to control how it creates and signs C2PA manifests. The `Context` affects: + +- **Claim generator information**: Application name, version, and metadata embedded in the manifest. +- **Thumbnail generation**: Whether to create thumbnails, and their size, quality, and format. +- **Action tracking**: Auto-generation of actions like `c2pa.created`, `c2pa.opened`, `c2pa.placed`. +- **Intent**: The purpose of the claim (create, edit, or update). +- **Verification after signing**: Whether to validate the manifest immediately after signing. +- **Signer configuration** (optional): Credentials stored in the context for reuse. + +> [!IMPORTANT] +> `Context` is used only when constructing the `Builder`. The `Builder` copies the configuration it needs internally, so the `Context` does not need to outlive the `Builder`. A single `Context` can be reused for multiple `Builder` instances. + +```py +ctx = Context.from_dict({ + "builder": { + "claim_generator_info": {"name": "An app", "version": "0.1.0"}, + "intent": {"Create": "digitalCapture"} + } +}) + +builder = Builder(manifest_json, context=ctx) + +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +#### Context and archives + +Archives (`.c2pa` files) store only the manifest definition. They do **not** store settings or context: + +- **`Builder.from_archive(stream)`** creates a context-free builder. All settings revert to SDK defaults regardless of what context the original builder had. +- **`Builder({}, ctx).with_archive(stream)`** creates a builder with a context first, then loads the archived manifest definition into it. The context settings are preserved. + +Use `with_archive()` when your workflow depends on specific settings (thumbnails, claim generator, intent, and so on). Use `from_archive()` only for quick prototyping where SDK defaults are acceptable. + +```py +ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False}, + "claim_generator_info": {"name": "My App", "version": "0.1.0"} + } +}) + +with open("manifest.c2pa", "rb") as archive: + builder = Builder({}, context=ctx) + builder.with_archive(archive) + # builder now has the archived definition + context settings +``` + +For more details, see [Working with archives](working-stores.md#working-with-archives). + +## Settings reference + +### Structure + +The Settings JSON has this top-level structure: + +```json +{ + "version": 1, + "trust": { ... }, + "cawg_trust": { ... }, + "core": { ... }, + "verify": { ... }, + "builder": { ... }, + "signer": { ... }, + "cawg_x509_signer": { ... } +} +``` + +The settings format is **JSON** only. Pass JSON strings to `Settings.from_json()` or `Context.from_json()`, and dictionaries to `Settings.from_dict()` or `Context.from_dict()`. The `from_dict()` methods convert Python dictionaries to a format compatible with the underlying native libraries. + +Notes: +- All properties are optional. If you don't specify a value, the SDK uses the default value. +- If you specify a value of `null` (or `None` in a dict), the property is explicitly set to `null`, not the default. This distinction is important when you want to override a default behavior. +- For Boolean values, use JSON Booleans `true`/`false` in JSON strings, or Python `True`/`False` in dicts. + +The settings JSON schema is shared across all C2PA SDKs (Rust, C/C++, Python, and so on). For a complete reference to all properties, see the [SDK object reference - Settings](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema). + +| Property | Description | +|----------|-------------| +| `version` | Settings format version (integer). The default and only supported value is 1. | +| [`builder`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#buildersettings) | Configuration for Builder. | +| [`cawg_trust`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#trust) | Configuration for CAWG trust lists. | +| [`cawg_x509_signer`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#signersettings) | Configuration for the CAWG x.509 signer. | +| [`core`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#core) | Configuration for core features. | +| [`signer`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#signersettings) | Configuration for the base C2PA signer. | +| [`trust`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#trust) | Configuration for C2PA trust lists. | +| [`verify`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#verify) | Configuration for verification (validation). | + +### Default configuration + +```json +{ + "version": 1, + "builder": { + "claim_generator_info": null, + "created_assertion_labels": null, + "certificate_status_fetch": null, + "certificate_status_should_override": null, + "generate_c2pa_archive": true, + "intent": null, + "actions": { + "all_actions_included": null, + "templates": null, + "actions": null, + "auto_created_action": { + "enabled": true, + "source_type": "empty" + }, + "auto_opened_action": { + "enabled": true, + "source_type": null + }, + "auto_placed_action": { + "enabled": true, + "source_type": null + } + }, + "thumbnail": { + "enabled": true, + "ignore_errors": true, + "long_edge": 1024, + "format": null, + "prefer_smallest_format": true, + "quality": "medium" + } + }, + "cawg_trust": { + "verify_trust_list": true, + "user_anchors": null, + "trust_anchors": null, + "trust_config": null, + "allowed_list": null + }, + "cawg_x509_signer": null, + "core": { + "merkle_tree_chunk_size_in_kb": null, + "merkle_tree_max_proofs": 5, + "backing_store_memory_threshold_in_mb": 512, + "decode_identity_assertions": true, + "allowed_network_hosts": null + }, + "signer": null, + "trust": { + "user_anchors": null, + "trust_anchors": null, + "trust_config": null, + "allowed_list": null + }, + "verify": { + "verify_after_reading": true, + "verify_after_sign": true, + "verify_trust": true, + "verify_timestamp_trust": true, + "ocsp_fetch": false, + "remote_manifest_fetch": true, + "skip_ingredient_conflict_resolution": false, + "strict_v1_validation": false + } +} +``` + +### Trust + +The [`trust` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#trust) control which certificates are trusted when validating C2PA manifests. + +| Property | Type | Description | +|----------|------|-------------| +| `trust.user_anchors` | string | Additional user-provided root certificates (PEM format). Adds custom certificate authorities without replacing the SDK's built-in trust anchors. Recommended for development. | +| `trust.trust_anchors` | string | Default trust anchor root certificates (PEM format). **Replaces** the SDK's built-in trust anchors entirely. | +| `trust.trust_config` | string | Allowed Extended Key Usage (EKU) OIDs. Controls which certificate purposes are accepted (for example, `1.3.6.1.4.1.311.76.59.1.9` for document signing). | +| `trust.allowed_list` | string | Explicitly allowed certificates (PEM format). Trusted regardless of chain validation. Use for development/testing to bypass chain validation. | + +Use `user_anchors` to add your test root CA without replacing the SDK's default trust store: + +```py +with open("test-ca.pem", "r") as f: + test_root_ca = f.read() + +ctx = Context.from_dict({"trust": {"user_anchors": test_root_ca}}) +reader = Reader("signed_asset.jpg", context=ctx) +``` + +Use `allowed_list` to bypass chain validation entirely for quick testing: + +```py +with open("test_cert.pem", "r") as f: + test_cert = f.read() + +ctx = Context.from_dict({"trust": {"allowed_list": test_cert}}) +reader = Reader("signed_asset.jpg", context=ctx) +``` + +### CAWG trust + +The `cawg_trust` properties configure CAWG (Creator Assertions Working Group) validation of identity assertions in C2PA manifests. It has the same properties as [`trust`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#trust). + +> [!NOTE] +> CAWG trust settings are only used when processing identity assertions with X.509 certificates. If your workflow doesn't use CAWG identity assertions, these settings have no effect. + +### Core + +The [`core` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#core) specify core SDK behavior and performance tuning options. + +Common use cases: + +- **Performance tuning for large files**: Set `core.backing_store_memory_threshold_in_mb` to `2048` or higher when processing large video files with sufficient RAM. +- **Restricted network environments**: Set `core.allowed_network_hosts` to limit which domains the SDK can contact. + +### Verify + +The [`verify` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#verify) control how the SDK validates C2PA manifests, affecting both reading existing manifests and verifying newly signed content. + +The following properties default to `true` (verification enabled): + +- `verify_after_reading` - Automatically verify manifests when reading assets. Disable only if you want to manually control verification timing. +- `verify_after_sign` - Automatically verify manifests after signing. Recommended to keep enabled to catch signing errors immediately. +- `verify_trust` - Verify signing certificates against configured trust anchors. WARNING: Disabling makes verification non-compliant. +- `verify_timestamp_trust` - Verify timestamp authority (TSA) certificates. WARNING: Disabling makes verification non-compliant. +- `remote_manifest_fetch` - Fetch remote manifests referenced in the asset. Disable in offline or air-gapped environments. + +> [!WARNING] +> Disabling verification options can make verification non-compliant with the C2PA specification. Only modify these settings in controlled environments or when you have specific requirements. + +### Builder + +The [`builder` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#buildersettings) control how the SDK creates and embeds C2PA manifests in assets. + +#### Claim generator information + +The `claim_generator_info` object identifies your application in the C2PA manifest: + +- `name` (string, required): Your application name (for example, `"My Photo Editor"`) +- `version` (string, recommended): Application version (for example, `"2.1.0"`) +- `icon` (string, optional): Icon in C2PA format +- `operating_system` (string, optional): OS identifier, or `"auto"` to auto-detect + +#### Thumbnail settings + +The [`builder.thumbnail`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#thumbnailsettings) properties control automatic thumbnail generation: + +```py +# Disable thumbnails for batch processing +ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } +}) + +# Customize for mobile bandwidth +ctx = Context.from_dict({ + "builder": { + "thumbnail": { + "enabled": True, + "long_edge": 512, + "quality": "low", + "prefer_smallest_format": True + } + } +}) +``` + +#### Action tracking + +| Property | Type | Description | Default | +|----------|------|-------------|---------| +| `builder.actions.auto_created_action.enabled` | Boolean | Automatically add a `c2pa.created` action when creating new content. | `true` | +| `builder.actions.auto_created_action.source_type` | string | Source type for the created action. Usually `"empty"` for new content. | `"empty"` | +| `builder.actions.auto_opened_action.enabled` | Boolean | Automatically add a `c2pa.opened` action when opening/reading content. | `true` | +| `builder.actions.auto_placed_action.enabled` | Boolean | Automatically add a `c2pa.placed` action when placing content as an ingredient. | `true` | + +#### Intent + +The `builder.intent` property describes the purpose of the claim: `{"Create": "digitalCapture"}`, `{"Edit": null}`, or `{"Update": null}`. Defaults to `null`. + +#### Other builder settings + +| Property | Type | Description | Default | +|----------|------|-------------|---------| +| `builder.generate_c2pa_archive` | Boolean | Generate content in C2PA archive format. Keep enabled for standard C2PA compliance. | `true` | + +### Signer + +The [`signer` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signersettings) configure the primary C2PA signer. Set to `null` if you provide the signer at runtime. Configure as either a **local** or **remote** signer: + +- **Local signer**: For local certificate and private key access. See [signer.local](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signerlocal) in the SDK object reference. +- **Remote signer**: For private keys on a secure signing service (HSM, cloud KMS, and so on). See [signer.remote](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signerremote) in the SDK object reference. + +For details on configuring and using signers, see [Configuring signers](#configuring-signers). + +### CAWG X.509 signer + +The `cawg_x509_signer` property configures signing of identity assertions. It has the same structure as `signer` (local or remote). When both `signer` and `cawg_x509_signer` are configured, the SDK uses a dual signer: + +- Main claim signature comes from `signer` +- Identity assertions are signed with `cawg_x509_signer` + +## Configuration examples + +### Minimal configuration + +```py +ctx = Context.from_dict({ + "builder": { + "claim_generator_info": {"name": "My app", "version": "0.1"}, + "intent": {"Create": "digitalCapture"} + } +}) +``` + +### Development environment with test certificates + +During development, you often need to trust self-signed or custom CA certificates with looser verification: + +```py +with open("test-ca.pem", "r") as f: + test_ca = f.read() + +ctx = Context.from_dict({ + "trust": {"user_anchors": test_ca}, + "verify": { + "verify_after_reading": True, + "verify_after_sign": True, + "remote_manifest_fetch": False, + "ocsp_fetch": False + }, + "builder": { + "claim_generator_info": {"name": "Dev Build", "version": "dev"}, + "thumbnail": {"enabled": False} + } +}) +``` + +### Offline operation + +Disable all network-dependent features for air-gapped environments: + +```py +ctx = Context.from_dict({ + "verify": { + "remote_manifest_fetch": False, + "ocsp_fetch": False + } +}) + +reader = Reader("local_asset.jpg", context=ctx) +``` + +### Strict validation + +For certification or compliance testing, enable strict validation: + +```py +ctx = Context.from_dict({ + "verify": { + "strict_v1_validation": True, + "ocsp_fetch": True, + "verify_trust": True, + "verify_timestamp_trust": True + } +}) + +reader = Reader("asset_to_validate.jpg", context=ctx) +``` + +### Production configuration + +```py +with open("trust-anchors.pem", "r") as f: + trust_anchors = f.read() + +ctx = Context.from_dict({ + "trust": { + "trust_anchors": trust_anchors, + "trust_config": "1.3.6.1.5.5.7.3.4\n1.3.6.1.5.5.7.3.36" + }, + "core": {"backing_store_memory_threshold_in_mb": 1024}, + "builder": { + "intent": {"Create": "digitalCapture"}, + "thumbnail": {"long_edge": 512, "quality": "high"} + } +}) +``` + +### Layered configuration + +Load base configuration and apply runtime overrides: + +```py +import json + +with open("config/base.json", "r") as f: + base_config = json.load(f) + +settings = Settings.from_dict(base_config) +settings.update({"builder": {"claim_generator_info": {"version": app_version}}}) + +ctx = Context(settings) +``` + +### Configuration from environment variables + +```py +import os + +env = os.environ.get("ENVIRONMENT", "dev") + +settings = Settings() +if env == "production": + settings.update({"verify": {"strict_v1_validation": True}}) +else: + settings.update({"verify": {"remote_manifest_fetch": False}}) + +ctx = Context(settings) +``` + +## Configuring signers + +### Signing concepts + +C2PA uses a certificate-based trust model to prove who signed an asset. When creating a `Signer`, the following are key parameters: + +- **Certificate chain** (`sign_cert`): An X.509 certificate chain in PEM format. The first certificate identifies the signer; subsequent certificates form a chain up to a trusted root. Verifiers use this chain to confirm the signature comes from a trusted source. +- **Timestamp authority URL** (`ta_url`): An optional [RFC 3161](https://www.rfc-editor.org/rfc/rfc3161) timestamp server URL. When provided, the SDK requests a trusted timestamp during signing, proving _when_ the signature was made. This keeps signatures verifiable even after the signing certificate expires. + +### Signer from settings (recommended) + +Configure signer credentials directly in settings. This is the most common approach: + +```py +ctx = Context.from_dict({ + "signer": { + "local": { + "alg": "ps256", + "sign_cert": "-----BEGIN CERTIFICATE-----\nMIIExample...\n-----END CERTIFICATE-----", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIExample...\n-----END PRIVATE KEY-----", + "tsa_url": "http://timestamp.digicert.com" + } + } +}) + +builder = Builder(manifest_json, context=ctx) +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign("image/jpeg", src, dst) +``` + +### Signer on Context (signer object) + +Create a `Signer` object and pass it to the `Context`. The signer is **consumed**: the `Signer` object becomes invalid after this call and the `Context` takes ownership. + +```py +from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg + +signer_info = C2paSignerInfo( + C2paSigningAlg.ES256, cert_data, key_data, b"http://timestamp.digicert.com" +) +signer = Signer.from_info(signer_info) + +ctx = Context(settings, signer) +# signer is now invalid and must not be used directly again + +builder = Builder(manifest_json, context=ctx) +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign("image/jpeg", src, dst) +``` + +### Explicit signer at signing time + +For full programmatic control, pass a `Signer` directly to `Builder.sign()`: + +```py +signer = Signer.from_info(signer_info) +builder = Builder(manifest_json, context=ctx) + +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +### Precedence rules + +If both an explicit signer (passed to `sign()`) and a context signer are available, the explicit signer always takes precedence. + +### Remote signer + +Use a remote signer when the private key is stored on a secure signing service (HSM, cloud KMS, and so on): + +```py +ctx = Context.from_dict({ + "signer": { + "remote": { + "alg": "ps256", + "url": "https://my-signing-service.com/sign", + "sign_cert": "-----BEGIN CERTIFICATE-----\nMIIExample...\n-----END CERTIFICATE-----", + "tsa_url": "http://timestamp.digicert.com" + } + } +}) +``` + +For all `signer.local` and `signer.remote` properties, see the [SDK object reference - Settings](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signersettings). + +## Advanced topics + +### Context lifetime + +`Context` supports the `with` statement for automatic resource cleanup: + +```py +with Context() as ctx: + reader = Reader("image.jpg", context=ctx) + print(reader.json()) +# Resources are automatically released +``` + +### Reusable contexts + +You can reuse the same `Context` to create multiple readers and builders. The `Context` can be closed after construction; readers and builders it was used to create still work correctly. + +```py +ctx = Context(settings) + +builder1 = Builder(manifest1, ctx) +builder2 = Builder(manifest2, ctx) +reader = Reader("image.jpg", context=ctx) +``` + +### Multiple contexts for different purposes + +Use different `Context` objects when you need different configurations at the same time: + +```py +dev_ctx = Context(dev_settings) +prod_ctx = Context(prod_settings) + +dev_builder = Builder(manifest, dev_ctx) +prod_builder = Builder(manifest, prod_ctx) +``` + +### ContextProvider abstract base class + +`ContextProvider` is an abstract base class (ABC) that enables custom context provider implementations. Subclass it and implement the `is_valid` and `execution_context` abstract properties to create a provider that can be passed to `Reader` or `Builder` as a context. The built-in `Context` class inherits from `ContextProvider`. + +```py +from c2pa import ContextProvider, Context + +ctx = Context() +assert isinstance(ctx, ContextProvider) # True +``` + +## Migrating from load_settings + +The `load_settings()` function is deprecated. Replace it with `Settings` and `Context` APIs: + +| Aspect | `load_settings` (legacy) | `Context` | +|--------|--------------------------|-----------| +| Scope | Global state | Per Reader/Builder, passed explicitly | +| Multiple configs | Not supported | One context per configuration | +| Testing | Shared global state | Isolated contexts per test | + +**Deprecated:** + +```py +from c2pa import load_settings, Reader + +load_settings({"builder": {"thumbnail": {"enabled": False}}}) +reader = Reader("image.jpg") # uses global settings +``` + +**Current approach:** + +```py +from c2pa import Settings, Context, Reader + +ctx = Context.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +reader = Reader("image.jpg", context=ctx) +``` + +## See also + +- [Usage](usage.md): reading and signing with `Reader` and `Builder`. +- [CAI settings schema reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/): full schema reference. diff --git a/docs/intents.md b/docs/intents.md new file mode 100644 index 00000000..1126be37 --- /dev/null +++ b/docs/intents.md @@ -0,0 +1,481 @@ +# Using Builder intents + +_Intents_ enable validation, add the actions required by the C2PA specification, and help prevent invalid operations when using a `Builder`. Intents are about the operation (create, edit, update) executed on the source asset. + +## Why use intents? + +Without intents, you have to manually construct the correct manifest structure: adding the required actions (`c2pa.created` or `c2pa.opened` as the first action per the specification), setting digital source types, managing ingredients, and linking actions to ingredients. Getting any of this wrong produces a non-compliant manifest. + +With intents, the caller declares *what is being done* and `Builder` handles the rest. + +For example, without intents you have to manually wire up actions and make sure ingredients are properly linked to actions. This is especially important for `parentOf` ingredient relationships with the `c2pa.opened` action. + +```py +with Builder({ + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia", + } + ] + }, + } + ], +}) as builder: + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +But with intents, `Builder` generates the actions automatically; for example: + +```py +with Builder({}) as builder: + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.TRAINED_ALGORITHMIC_MEDIA, + ) + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +Both of these code snippets produce the same signed manifest. But with intents, `Builder` validates the setup and fills in the required structure. + +## Setting the intent + +You can set the intent on a `Builder` instance by: + +- [Using Context](#using-context) +- [Using `set_intent` on the `Builder`](#using-set_intent-on-the-builder) + +Don't set the intent using the deprecated `load_settings()` function. For existing code, see [Context and settings - Migrating from load_settings](context-settings.md#migrating-from-load_settings). + +### Using Context + +Pass the intent through a `Context` object when creating a `Builder`. This keeps intent configuration alongside other builder settings such as `claim_generator_info` and `thumbnail`. + +```py +from c2pa import Context, Builder + +ctx = Context.from_dict({ + "builder": { + "intent": {"Create": "digitalCapture"}, + "claim_generator_info": {"name": "My App", "version": "0.1.0"}, + } +}) + +with Builder({}, context=ctx) as builder: + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +You can reuse the same `Context` across multiple `Builder` instances, ensuring consistent configuration: + +```py +ctx = Context.from_dict({ + "builder": { + "intent": "edit", + "claim_generator_info": {"name": "Batch Editor"}, + } +}) + +for path in image_paths: + with Builder({}, context=ctx) as builder: + builder.sign_file(path, output_path(path), signer) +``` + +### Using `set_intent` on the Builder + +Call `set_intent` directly on a `Builder` instance for one-off operations or when the intent is determined at runtime. For example: + +```py +with Builder({}) as builder: + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.TRAINED_ALGORITHMIC_MEDIA, + ) + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +### Intent precedence + +When an intent is configured in multiple places, the most specific setting takes precedence. +If `set_intent` is called on a `Builder` instance, it takes precedence over all other sources. + +```mermaid +flowchart TD + Check{Was set_intent called + on the Builder?} + Check --> |Yes| UseSetIntent["Use set_intent value"] + Check --> |No| CheckCtx{Was a Context with + builder.intent provided?} + CheckCtx --> |Yes| UseCtx["Use Context intent"] + CheckCtx --> |No| CheckGlobal{Was load_settings called + with builder.intent?} + CheckGlobal --> |Yes| UseGlobal["Use global intent + (deprecated)"] + CheckGlobal --> |No| NoIntent["No intent set. + Caller must define actions + manually in manifest JSON."] +``` + +## How intents relate to the source stream + +The intent operates on the source passed to `sign()`, not on any ingredient added via `add_ingredient()`. + +The following diagram shows what happens at sign time for each intent: + +```mermaid +flowchart LR + subgraph CREATE + S1[source stream] --> B1[Builder] + B1 --> O1[signed output] + B1 -. adds .-> A1["c2pa.created action + + digital source type"] + end +``` + +```mermaid +flowchart LR + subgraph EDIT + S2[source stream] --> B2[Builder] + B2 --> O2[signed output] + S2 -. auto-created as .-> P2[parentOf ingredient] + P2 --> B2 + B2 -. adds .-> A2["c2pa.opened action + linked to parent"] + end +``` + +```mermaid +flowchart LR + subgraph UPDATE + S3[source stream] --> B3[Builder] + B3 --> O3[signed output] + S3 -. auto-created as .-> P3[parentOf ingredient] + P3 --> B3 + B3 -. adds .-> A3["c2pa.opened action + linked to parent"] + B3 -. restricts .-> R3[content must not change] + end +``` + +For `Edit` and `Update` intents, `Builder` looks at the source stream, and if no `parentOf` ingredient has been added manually, it automatically creates one from that stream (and adds the needed action). The source stream *becomes* the parent ingredient. If a `parentOf` ingredient has already been added manually (via `add_ingredient`), `Builder` uses that one instead and does not automatically create one from the source. + +### How intent relates to `add_ingredient` + +The `Builder` intent controls what the `Builder` does with the source stream (source asset) at sign time. The `add_ingredient` method adds other ingredients explicitly. These are separate concerns. + +```mermaid +flowchart TD + Intent["Intent + (via Context, set_intent, + or load_settings)"] --> Q{Intent type?} + Q --> |CREATE| CreateFlow["No parent allowed + Source stream is new content"] + Q --> |EDIT or UPDATE| EditFlow{Was a parentOf ingredient + added via add_ingredient?} + EditFlow --> |No| Auto["Builder auto-creates + parentOf from source stream"] + EditFlow --> |Yes| Manual["Builder uses the + manually-added parent"] + Auto --> Opened["Builder adds c2pa.opened + action linked to parent"] + Manual --> Opened + CreateFlow --> Created["Builder adds c2pa.created + action + digital source type"] + + AddIngredient["add_ingredient()"] --> IngType{relationship?} + IngType --> |parentOf| ParentIng["Overrides auto-parent + for EDIT/UPDATE"] + IngType --> |componentOf| CompIng["Additional ingredient + not affected by intent"] + ParentIng --> EditFlow +``` + +## Importing the enums + +The `C2paBuilderIntent` and `C2paDigitalSourceType` enums are available from the `c2pa` package: + +```py +from c2pa import ( + C2paBuilderIntent, + C2paDigitalSourceType, +) +``` + +### Using `set_intent` + +Use the `Builder.set_intent` method to specify the intent: + +```py +builder.set_intent(intent, digital_source_type=C2paDigitalSourceType.EMPTY) +``` + +Where: +- `intent` is one of the [intent types](#intent-types). +- `digital_source_type` is one of the [`C2paDigitalSourceType` values](#c2padigitalsourcetype) that describes how the asset was made. Required for the `Create` intent. Defaults to `EMPTY`. + +Raises `C2paError` if the intent cannot be set (for example, if a `parentOf` ingredient exists with `Create`). + +### Intent types + +Intent types can be any `C2paBuilderIntent` value: + +| Intent | Operation | Parent ingredient | Auto-generated action | +|--------|-----------|-------------------|-----------------------| +| `CREATE` | Brand-new content | Must NOT have one | `c2pa.created` | +| `EDIT` | Modifying existing content | Auto-created from the source stream if not provided | `c2pa.opened` (linked to parent) | +| `UPDATE` | Metadata-only changes | Auto-created from the source stream if not provided | `c2pa.opened` (linked to parent) | + +When configuring intent through `Context` or settings JSON, `Edit` and `Update` are specified as lowercase strings (`"edit"`, `"update"`), and `Create` as an object with the source type: `{"Create": "digitalCapture"}`. + +### C2paDigitalSourceType + +| Enum value | Description | +|------------|-------------| +| `EMPTY` | No source type specified. The default value. | +| `DIGITAL_CAPTURE` | Captured from a real-world source using a digital device | +| `TRAINED_ALGORITHMIC_MEDIA` | Created by a trained algorithm (for example, generative AI) | +| `DIGITAL_CREATION` | Created digitally (for example, drawing software) | +| `COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA` | Composite that includes trained algorithmic media | +| `ALGORITHMICALLY_ENHANCED` | Enhanced by an algorithm | +| `SCREEN_CAPTURE` | Captured from a screen | +| `VIRTUAL_RECORDING` | Recorded from a virtual environment | +| `COMPOSITE` | Composed from multiple sources | +| `COMPOSITE_CAPTURE` | Composite of captured sources | +| `COMPOSITE_SYNTHETIC` | Composite of synthetic sources | +| `DATA_DRIVEN_MEDIA` | Generated from data | +| `ALGORITHMIC_MEDIA` | Created by an algorithm | +| `HUMAN_EDITS` | Human-edited content | +| `COMPUTATIONAL_CAPTURE` | Captured with computational processing | +| `NEGATIVE_FILM` | Scanned from negative film | +| `POSITIVE_FILM` | Scanned from positive film | +| `PRINT` | Scanned from a print | +| `TRAINED_ALGORITHMIC_DATA` | Data created by a trained algorithm | + +## Choosing the right intent + +```mermaid +flowchart TD + Start([Start]) --> HasParent{Does the asset have + prior history?} + HasParent --> |No| IsNew[Brand-new content] + IsNew --> CREATE["Use Create + + C2paDigitalSourceType"] + HasParent --> |Yes| ContentChanged{Will the content + itself change?} + ContentChanged --> |Yes| EDIT[Use Edit] + ContentChanged --> |No, metadata only| UPDATE[Use Update] + ContentChanged --> |Need full manual control| MANUAL["Skip intents. + Define actions and ingredients + directly in manifest JSON."] +``` + +## Create intent + +Use the `Create` intent when the asset has no prior history. A `C2paDigitalSourceType` is required to describe how the asset was produced. `Builder` will: + +- Add a `c2pa.created` action with the specified digital source type. +- Reject the operation if a `parentOf` ingredient exists. + +### Example: New digital creation + +Using `Context`: + +```py +ctx = Context.from_dict({ + "builder": {"intent": {"Create": "digitalCreation"}} +}) + +with Builder({}, context=ctx) as builder: + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +Using `set_intent`: + +```py +with Builder({}) as builder: + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.DIGITAL_CREATION, + ) + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +### Example: Marking AI-generated content + +```py +ctx = Context.from_dict({ + "builder": {"intent": {"Create": "trainedAlgorithmicMedia"}} +}) + +with Builder({}, context=ctx) as builder: + with open("ai_output.jpg", "rb") as source, open("signed_ai_output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +### Example: Create with additional manifest metadata + +A `Context` and a manifest definition can be combined. The `Context` handles the intent; the manifest definition provides additional metadata and assertions: + +```py +ctx = Context.from_dict({ + "builder": { + "intent": {"Create": "digitalCapture"}, + "claim_generator_info": {"name": "an_app", "version": "0.1.0"}, + } +}) + +manifest_def = { + "title": "My New Image", + "assertions": [ + { + "label": "cawg.training-mining", + "data": { + "entries": { + "cawg.ai_inference": {"use": "notAllowed"}, + "cawg.ai_generative_training": {"use": "notAllowed"}, + } + }, + } + ], +} + +with Builder(manifest_def, context=ctx) as builder: + with open("photo.jpg", "rb") as source, open("signed_photo.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +## Edit intent + +Use the `Edit` intent when an existing asset is modified. With this intent, `Builder`: + +1. Checks if a `parentOf` ingredient has already been added. If not, it automatically creates one from the source stream passed to `sign()`. +2. Adds a `c2pa.opened` action linked to the parent ingredient. + +No `digital_source_type` parameter is needed. + +### Example: Editing an asset + +Using `Context`: + +```py +ctx = Context.from_dict({"builder": {"intent": "edit"}}) + +with Builder({}, context=ctx) as builder: + # The Builder reads "original.jpg" as the parent ingredient, + # then writes the new manifest into "edited.jpg" + with open("original.jpg", "rb") as source, open("edited.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +Using `set_intent`: + +```py +with Builder({}) as builder: + builder.set_intent(C2paBuilderIntent.EDIT) + with open("original.jpg", "rb") as source, open("edited.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +The resulting manifest contains one ingredient with `relationship: "parentOf"` pointing to `original.jpg` and a `c2pa.opened` action referencing that ingredient. If the source file already has a C2PA manifest, the ingredient preserves the full provenance chain. + +### Example: Editing with a manually-added parent + +To control the parent ingredient's metadata (for example, to set a title or use a different source), add it explicitly: + +```py +ctx = Context.from_dict({"builder": {"intent": "edit"}}) + +with Builder({}, context=ctx) as builder: + with open("original.jpg", "rb") as original: + builder.add_ingredient( + {"title": "Original Photo", "relationship": "parentOf"}, + "image/jpeg", + original, + ) + + with open("canvas.jpg", "rb") as source, open("edited.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +### Example: Editing with additional component ingredients + +A parent ingredient can be combined with component or input ingredients. The intent creates the `c2pa.opened` action for the parent; additional actions can reference components (`componentOf`) or inputs (`inputTo`): + +```py +ctx = Context.from_dict({"builder": {"intent": "edit"}}) + +with Builder({ + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.placed", + "parameters": {"ingredientIds": ["overlay_label"]}, + } + ] + }, + } + ], +}, context=ctx) as builder: + + # The Builder auto-creates a parent from the source stream + # and generates a c2pa.opened action for it. + + # Add a component ingredient manually. + with open("overlay.png", "rb") as overlay: + builder.add_ingredient( + { + "title": "overlay.png", + "relationship": "componentOf", + "label": "overlay_label", + }, + "image/png", + overlay, + ) + + with open("original.jpg", "rb") as source, open("composite.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +## Update intent + +Use the `Update` intent for metadata-only changes where the asset content itself is not modified. This is a restricted form of the `Edit` intent that: + +- Allows exactly one ingredient (the parent). +- Does not allow changes to the parent's hashed content. +- Produces a more compact manifest than `Edit`. + +As with `Edit` intent, `Builder` automatically creates a parent ingredient from the source stream if one is not provided. + +### Example: Adding metadata to a signed asset + +Using `Context`: + +```py +ctx = Context.from_dict({"builder": {"intent": "update"}}) + +with Builder({}, context=ctx) as builder: + with open("signed_asset.jpg", "rb") as source, open("updated_asset.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +Using `set_intent`: + +```py +with Builder({}) as builder: + builder.set_intent(C2paBuilderIntent.UPDATE) + + with open("signed_asset.jpg", "rb") as source, open("updated_asset.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md new file mode 100644 index 00000000..100936d0 --- /dev/null +++ b/docs/native-resources-management.md @@ -0,0 +1,362 @@ +# Native resource management (ManagedResource class) + +`ManagedResource` is the internal base class used by the C2PA Python SDK to wrap native (Rust/FFI) pointers. When adding new wrappers around native resources `ManagedResource` should be subclassed and follow the documented lifecycle rules. + +## Why `ManagedResource`? + +`ManagedResource` is the internal base class responsible for managing native pointers owned by the C2PA Python SDK. It guarantees: + +- Native memory is freed exactly once (no double-free). +- Resources are cleaned up deterministically via context managers or explicit `close()`. +- Ownership transfers (e.g. signer to context) are handled so the same pointer is not freed twice (and the objects/classes know which one owns what). +- Cleanup never raises (trade-off to avoid raising errors on clean-up only, but errors are logged). + +Developers wrapping new native resources must inherit from `ManagedResource` and follow the documented lifecycle rules. + +## Why is native resources management needed? + +### Native pointers in a Python wrapper + +The C2PA Python SDK is a wrapper around a native Rust library that exposes a C FFI. When the SDK creates a `Reader`, `Builder`, `Signer`, `Context`, or `Settings` object, that object holds a **pointer** to memory allocated on the native side (by the native library). + +### How Python's garbage collector works + +Python manages its own objects' memory automatically through garbage collection. In CPython (the standard interpreter), this works primarily through reference counting: each object has a counter tracking how many references point to it, and when that counter reaches zero the object is deallocated. A secondary cycle-detecting collector handles the case where objects reference each other in a loop and their counts never reach zero on their own. + +### Why garbage collection is not enough for native memory + +This system works well for pure Python objects, but native memory sits outside of it entirely. The garbage collector sees the Python wrapper object (e.g. a `Reader` instance) and tracks references to it, but it has no visibility into the native memory that the wrapper's `_handle` attribute points to. Memory allocated by native libraries is invisible to the garbage collector: it does not know the size of that native allocation, cannot tell when it is no longer needed, and will not call the native library's `c2pa_free` function to release it. If the Python wrapper of those native resources is collected without first calling `c2pa_free`, the native memory is never released and leaks. + +### Why `__del__` is not reliable enough + +Python does offer `__del__` as a hook that runs when an object is collected (finalizer), and `ManagedResource` uses it as a fallback to possibly clean up leftover resources at that point. But `__del__` cannot be relied on as the primary cleanup mechanism: its timing is unpredictable (due to being called when the garbage collection runs, which is non-deterministic itself), it may not run at all during interpreter shutdown, and other Python implementations (PyPy, GraalPy) that do not use reference counting make its behavior even less deterministic. + +In CPython, `__del__` runs synchronously when the last reference to an object disappears, which in simple cases happens at a predictable point (e.g. when a local variable goes out of scope). But if the object is part of a reference cycle, its reference count never reaches zero on its own. The cycle collector must discover and break the cycle first, and it runs periodically rather than immediately. An object caught in a cycle might sit in memory for an arbitrary amount of time before `__del__` fires. CPython's cycle collector does not guarantee an order when finalizing groups of objects in a cycle, so `__del__` methods that depend on other objects in the same cycle may find those objects already partially torn down. During interpreter shutdown, the situation is even less reliable: CPython clears module globals and may collect objects in an arbitrary order, and `__del__` methods that reference global state (like the `_lib` handle to the native library) can fail silently because those globals have already been set to `None`. PyPy and GraalPy use tracing garbage collectors (which periodically walk the object graph to find unreachable objects, rather than tracking individual reference counts) instead of reference counting, so `__del__` does not run when the last reference disappears. It runs at some later point when the GC happens to trace that region of the heap, which could be seconds or minutes later, or not at all if the process exits first. + +`ManagedResource` is the internal base class that handles managed resources, especially their lifecycle and clean-up. Every class that holds a native pointer should inherit from it. + +## Class hierarchy + +```mermaid +classDiagram + class ManagedResource { + <> + } + + class ContextProvider { + <> + } + + ManagedResource <|-- Settings + ManagedResource <|-- Context + ManagedResource <|-- Reader + ManagedResource <|-- Builder + ManagedResource <|-- Signer + + ContextProvider <|-- Context +``` + +Notes: + +- `Context` inherits from both `ManagedResource` and `ContextProvider` (Python supports multiple inheritance). +- `Settings` inherits from `ManagedResource` only. +- `ContextProvider` is an ABC (abstract base class) that requires two properties: `is_valid` and `execution_context`. The `is_valid` implementation lives on `ManagedResource`, so `Context` satisfies that part of the `ContextProvider` contract without duplicating the property. + +> [!NOTE] +> **How `is_valid` resolves across both parents for Context** +> +> Python's MRO (Method Resolution Order) is the order in which Python searches parent classes when looking up a method or property. For `Context(ManagedResource, ContextProvider)`, the MRO is `Context then ManagedResource then ContextProvider then ABC then object (base class)`. When `context.is_valid` is accessed, Python walks the MRO left-to-right and finds `ManagedResource.is_valid` first. Since `ContextProvider.is_valid` is abstract (it declares the requirement but has no implementation), `ManagedResource`'s concrete version both provides the behavior and satisfies the ABC contract. +> +> The MRO is computed using C3 linearization, which enforces two rules: children appear before their parents, and left-to-right order from the class definition is preserved. For `class Context(ManagedResource, ContextProvider)`: +> +> 1. `Context`: the class itself always comes first. +> 2. `ManagedResource` :first listed parent, nothing else requires it to appear later. +> 3. `ContextProvider`: second listed parent, must come after `ManagedResource` to preserve declaration order. +> 4. `ABC`: parent of `ContextProvider`, must come after its child. +> 5. `object`: root of everything (all objects), always last. +> +> Putting `ManagedResource` first in the declaration matters: the concrete `is_valid` implementation is found immediately during lookup, rather than hitting the abstract declaration on `ContextProvider` first. + +## Guarantees provided by ManagedResource + +`ManagedResource` provides the following guarantees, invariants must be maintained when subclassing the `ManagedResource` class in new implementation/new native resources handlers: + +| Guarantee | Description | +| --- | --- | +| **Pointer freed exactly once** | Each native pointer is passed to `c2pa_free` at most once. No leak (zero frees) and no double-free. | +| **Cleanup is idempotent** | Calling `close()` (or exiting a `with` block) multiple times is safe; after the first successful cleanup, further calls do nothing. | +| **Cleanup never raises** | The cleanup path (including `_release()` and `c2pa_free`) is wrapped so that exceptions are caught and logged, never re-raised. The original exception from the `with` block (if any) is never masked. | +| **State transitions are one-way** | Lifecycle moves only from UNINITIALIZED → ACTIVE → CLOSED. A closed resource cannot be reactivated. | +| **Ownership transfer is safe** | When a pointer is transferred elsewhere (e.g. via `_mark_consumed()`), the object stops managing it and does not call `c2pa_free` on it. | +| **Public methods validate lifecycle state** | Every public API calls `_ensure_valid_state()` before use; closed or invalid state yields `C2paError` instead of undefined behavior or crashes. | + +## Preventing garbage collection of live references + +When a Python object passes a callback or pointer to the native library, that reference must stay alive for as long as the native side might use it. Python's garbage collector has no way to know that native code is still holding a reference to a Python callback. + +The SDK solves this by storing these references as instance attributes on the owning object. For example, `Stream` stores its four callback objects (`_read_cb`, `_seek_cb`, `_write_cb`, `_flush_cb`) as instance attributes. As long as the `Stream` object is alive, its callbacks have a nonzero reference count and will not be collected. Similarly, when a `Signer` is consumed by a `Context`, the Context copies the signer's `_callback_cb` to its own `_signer_callback_cb` attribute so the callback survives even though the Signer object is now closed. + +During cleanup, `_release()` sets these attributes to `None`, which drops the reference count on the callback objects and allows them to be collected. In the cleanup sequence, `_release()` runs first, then `c2pa_free` frees the native pointer. `_release()` goes first so that subclass-specific resources (open file handles, stream wrappers) are torn down before the native pointer they depend on is freed. + +## How native memory is freed + +The native Rust library exposes a single C FFI function, `c2pa_free`, that deallocates memory it previously allocated. `ManagedResource` wraps this in a static method: + +```python +@staticmethod +def _free_native_ptr(ptr): + _lib.c2pa_free(ctypes.cast(ptr, ctypes.c_void_p)) +``` + +All native pointers are freed through this single path, regardless of which constructor created them (`c2pa_reader_from_stream`, `c2pa_builder_from_json`, `c2pa_signer_from_info`, etc.). The `ctypes.cast` to `c_void_p` is needed because the C function accepts a generic void pointer regardless of the original type. + +`ManagedResource` guarantees that `c2pa_free` is called exactly once per pointer: not zero times (leak), not twice (double-free). + +## Lifecycle states + +Each `ManagedResource` tracks its state with a `LifecycleState` enum: + +```mermaid +stateDiagram-v2 + direction LR + [*] --> UNINITIALIZED : __init__() + UNINITIALIZED --> ACTIVE : native pointer created + ACTIVE --> CLOSED : close() / __exit__ / __del__ / _mark_consumed() +``` + +- `UNINITIALIZED`: The Python object exists but the native pointer has not been set yet. This is a transient state during construction. +- `ACTIVE`: The native pointer is valid. The object can be used. +- `CLOSED`: The native pointer has been freed (or ownership was transferred). Any further use raises `C2paError`. + +The transition from ACTIVE to CLOSED is one-way. Once closed, an object cannot be reactivated. + +Every public method calls `_ensure_valid_state()` before doing any work. Besides checking the lifecycle state, this method also calls `_clear_error_state()`, which resets any stale error left over from a previous native library call. Without this, an error from one operation could leak into the next one and produce a misleading error message. + +## Ways to clean up + +### Context manager (`with` statement) + +```python +with Reader("image.jpg") as reader: + print(reader.json()) +# reader is automatically closed here, even if an exception occurs +``` + +When the `with` block exits, `__exit__` calls `close()`, which frees the native pointer. This is the safest approach because cleanup happens even if the code inside the block raises an exception. + +### Explicit `.close()` + +```python +reader = Reader("image.jpg") +try: + print(reader.json()) +finally: + reader.close() +``` + +Calling `.close()` directly is equivalent to exiting a `with` block. It is idempotent: calling it multiple times is safe and does nothing after the first call. + +### Destructor fallback (`__del__`) + +If neither of the above is used, `__del__` attempts to free the native pointer when Python garbage-collects the object. As described above, `__del__` timing is unpredictable and it may not run at all, so it is a safety net rather than a primary cleanup mechanism. + +## Error handling during cleanup + +Cleanup must never raise an exception. A failure during cleanup (for example, the native library crashing on free) should not mask the original exception that caused the `with` block to exit. `ManagedResource` enforces this: + +- `close()` delegates to `_cleanup_resources()`, which wraps the entire cleanup sequence in a try/except that catches and silences all exceptions. +- If freeing the native pointer fails, the error is logged via Python's `logging` module but not re-raised. +- The state is set to `CLOSED` as the very first step, before attempting to free anything. If cleanup fails halfway, the object is still marked closed, preventing a second attempt from doing further damage. +- Cleanup is idempotent. Calling `close()` on an already-closed object returns immediately. + +## Nesting resources + +When multiple native resources are in play at once, they can share a single `with` statement or use nested blocks. Either way, Python cleans them up in reverse order (right to left, or inner to outer). + +```python +with open("photo.jpg", "rb") as file, Reader("image/jpeg", file) as reader: + manifest = reader.json() +# reader is closed first, then file +``` + +The same can be written with nested blocks if readability is better: + +```python +with open("photo.jpg", "rb") as file: + with Reader("image/jpeg", file) as reader: + manifest = reader.json() +``` + +The order matters because resources often depend on each other. In the example above, the `Reader` holds a native pointer that references the file's data through a `Stream` wrapper. If the file handle were closed first, the native library would still hold a pointer into the stream's read callbacks, and any subsequent access (including cleanup) could read freed memory or trigger a segfault. By closing the Reader first, the native pointer is freed while the underlying file is still open and valid. Python's `with` statement guarantees this ordering: resources listed later (or nested deeper) are torn down first. + +## Reader lifecycle + +A `Reader` wraps a stream (or opens a file), passes it to the native library, and holds the returned pointer. While active, callers can use `.json()`, `.detailed_json()`, `.resource_to_stream()`, and other methods. Each of these checks state via `_ensure_valid_state()` before making the FFI call. + +```mermaid +stateDiagram-v2 + direction LR + [*] --> UNINITIALIZED : __init__() + UNINITIALIZED --> ACTIVE : Reader("image.jpg") + ACTIVE --> CLOSED : close() / exit with block + CLOSED --> [*] +``` + +While `ACTIVE`, callers can use `.json()`, `.detailed_json()`, etc. repeatedly without changing state. Calling `.close()` on an already-closed Reader is a no-op. Any other method call on a closed Reader raises `C2paError`. + +When the Reader is closed, it first releases its own resources (open file handles, stream wrappers) via `_release()`, then frees the native pointer via `c2pa_free`. + +## Builder lifecycle + +A `Builder` follows the same pattern as Reader, with one difference: **signing consumes the builder**. The native library takes ownership of the builder's pointer during the sign operation. After signing, the builder is closed and cannot be reused. + +```mermaid +stateDiagram-v2 + direction LR + [*] --> UNINITIALIZED : __init__() + UNINITIALIZED --> ACTIVE : Builder.from_json(manifest) + ACTIVE --> CLOSED : .sign() or close() + CLOSED --> [*] + + note left of CLOSED + .sign() consumes the pointer + close() frees it + end note +``` + +While `ACTIVE`, callers can use `.add_ingredient()`, `.add_action()`, etc. repeatedly. `.sign()` consumes the native pointer (ownership transfers to the native library), so the Builder cannot be reused afterward. Closing without signing frees the pointer normally. + +After `.sign()`, the builder calls `_mark_consumed()`, which sets the handle to `None` and the state to `CLOSED`. Because the native library now owns the pointer, `ManagedResource` does not call `c2pa_free`. That would double-free memory the native library already manages. + +## Ownership transfer + +Some operations transfer a native pointer from one object to another. When this happens, the original object must stop managing the pointer (e.g. so it is not freed twice). + +`_mark_consumed()` handles this. It sets `_handle = None` and `_lifecycle_state = CLOSED` in one step. + +There are two cases where this is relevant: + +- When a `Signer` is passed to a `Context`, the Context takes ownership of the Signer's native pointer. The Signer is marked consumed and must not be used again. + +- When `Builder.sign()` is called, the native library consumes the Builder's pointer. The Builder marks itself consumed regardless of whether the sign operation succeeds or fails, because in both cases the native library has taken the pointer. + +## Consume-and-return + +`_mark_consumed()` closes an object permanently. A different pattern is needed when the native library must replace an object's internal state without discarding the Python-side object. This happens with fragmented media: `Reader.with_fragment()` feeds a new BMFF fragment (used in DASH/HLS streaming) into an existing Reader, and the native library must rebuild its internal representation to account for the new data. The native API does this by consuming the old pointer and returning a new one. Creating a fresh `Reader` from scratch would not work because the native library needs the accumulated state from prior fragments. + +`Builder.with_archive()` follows the same pattern: it loads an archive into an existing Builder, replacing the manifest definition while preserving the Builder's context and settings. + +In both cases the FFI call consumes the current pointer and returns a replacement: + +```mermaid +stateDiagram-v2 + state "ACTIVE (ptr A)" as A + state "ACTIVE (ptr B)" as B + + A --> B : C FFI call consumes ptr A, returns ptr B + note right of B + Same Python object, + new native pointer + end note +``` + +```python +# Reader.with_fragment() internally does: +new_ptr = _lib.c2pa_reader_with_fragment(self._handle, ...) +# self._handle (old pointer) is now invalid +self._handle = new_ptr +``` + +The object stays `ACTIVE` throughout because the Python-side object is still valid: it has a live native pointer, its public methods still work, and callers may continue using it (e.g. reading the updated manifest or feeding in another fragment). The lifecycle state does not change because from `ManagedResource`'s perspective nothing has closed. Only the underlying native pointer has been swapped. This is different from `_mark_consumed()`, where the object transitions to `CLOSED` and becomes unusable. The old pointer must not be freed by `ManagedResource` because the native library already consumed it as part of the FFI call. + +## Subclass-specific cleanup with `_release()` + +Each subclass can override `_release()` to clean up its own resources before the native pointer is freed. The base implementation does nothing. + +Examples from the codebase: + +| Class | What `_release()` cleans up | +| --- | --- | +| Reader | Closes owned file handles and stream wrappers | +| Context | Drops the reference to the signer callback | +| Signer | Drops the reference to the signing callback | +| Settings | (no override, nothing extra to clean up) | +| Builder | (no override, nothing extra to clean up) | + +The cleanup order matters: `_release()` runs first (closing streams, dropping callbacks), then `c2pa_free` frees the native pointer. This order prevents the native library from accessing Python objects that no longer exist. + +## Why is `Stream` not a `ManagedResource`? + +`Stream` wraps a Python stream-like object (file stream or memory stream) so the native library can read from and write to it via callbacks. It does not inherit from `ManagedResource`, and it uses `c2pa_release_stream()` instead of `c2pa_free()` for cleanup. + +The reason is that ownership runs in the opposite direction. A `Reader` or `Builder` holds a native resource that Python code calls methods on. A `Stream` holds a native handle that the native library calls *back into* (read, seek, write, flush). The native library needs a different release function to tear down the callback machinery. + +`Stream` tracks its own state with `_closed` and `_initialized` flags rather than `LifecycleState`, but it supports the same three cleanup paths: context manager, explicit `.close()`, and `__del__` fallback. + +## Implementing a subclass of `ManagedResource` + +To wrap a new native resource, inherit from `ManagedResource` and follow these rules: + +```python +class NativeResource(ManagedResource): + def __init__(self, arg): + super().__init__() + + # 1. Initialize ALL instance attributes before any code + # that can raise. If __init__ fails partway through, + # __del__ will call _release(), which accesses these + # attributes. If they don't exist, _release() raises AttributeError. + self._my_stream = None + self._my_cache = None + + # 2. Create the native pointer. + ptr = _lib.c2pa_my_resource_new(arg) + _check_ffi_operation_result(ptr, "Failed to create MyResource") + + # 3. Only set _handle and activate AFTER the FFI call + # succeeded. If it raised, _lifecycle_state stays + # UNINITIALIZED and cleanup won't try to free a + # pointer that doesn't exist. + self._handle = ptr + self._lifecycle_state = LifecycleState.ACTIVE + + def _release(self): + # 4. Clean up class-specific resources. + # Never let this method raise. Must be idempotent. + # + # Consider defining a simple lifecycle for native resources + # so _release() can check whether they are releasable + # before attempting cleanup. The if-guard below + # verifies the stream exists and has not + # already been released. The try/except is a fallback + # that silences unexpected errors from .close(). + if self._my_stream: + try: + self._my_stream.close() + except Exception: + logger.error("Failed to close MyResource stream") + finally: + self._my_stream = None + + def do_something(self): + # 5. Check state at the start of every public method. + # This raises C2paError if the resource is closed. + self._ensure_valid_state() + return _lib.c2pa_my_resource_do_something(self._handle) +``` + +### Troubleshooting + +- If `self._my_callback = None` is set after the FFI call that can raise, and the call fails, `_release()` will try to access `self._my_callback` and crash with `AttributeError`. Always initialize attributes right after `super().__init__()`. + +- If `_lifecycle_state = ACTIVE` is set before the FFI call and the call fails, cleanup will try to free a null or invalid pointer. Activation should happen only after a valid handle exists. + +- If `_release()` raises, the exception is silently swallowed by `_cleanup_resources()`. It will not be visible unless logs are checked. Define a lifecycle for managed resources so `_release()` can check whether they need releasing. Wrap the actual release call in try/except as a fallback for unexpected failures. + +- `_release()` can be called more than once (via `close()` then `__del__`, or multiple `close()` calls). Make sure it handles being called on an already-cleaned-up object. Setting attributes to `None` after closing them is the standard pattern. + +- Calling `c2pa_free` directly is not recommended. `ManagedResource` handles this. If the pointer is freed manually and `ManagedResource` frees it again, the process crashes (double-free). + +- If a subclass inherits from both `ManagedResource` and an ABC like `ContextProvider`, and both define a property with the same name (e.g. `is_valid`), Python resolves it using the MRO. The parent listed first in the class definition wins. If the ABC is listed first, Python finds the abstract property before the concrete one and raises `TypeError: Can't instantiate abstract class`. Always list the class with the concrete implementation first (e.g. `class Context(ManagedResource, ContextProvider)`, not `class Context(ContextProvider, ManagedResource)`). + +- If two parent classes define the same method or property with different concrete implementations, the MRO silently picks the first one. This can cause subtle bugs where the wrong implementation is used. When combining multiple inheritance with shared property names, verify the MRO with `ClassName.__mro__` or `ClassName.mro()` to confirm the expected resolution order. diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md new file mode 100644 index 00000000..3fd2d538 --- /dev/null +++ b/docs/selective-manifests.md @@ -0,0 +1,804 @@ +# Selective manifest construction + +You can use `Builder` and `Reader` together to selectively construct manifests—keeping only the parts you need and omitting the rest. This is useful when you don't want to include all ingredients in a working store (for example, when some ingredient assets are not visible). + +This process is best described as *filtering* or *rebuilding* a working store: + +1. Read an existing manifest. +2. Choose which elements to retain. +3. Build a new manifest containing only those elements. + +A manifest is a signed data structure attached to an asset that records provenance and which source assets (ingredients) contributed to it. It contains assertions (statements about the asset), ingredients (references to other assets), and references to binary resources (such as thumbnails). + +Since both `Reader` and `Builder` are **read-only** by design (neither has a `remove()` method), to exclude content you must **read what exists, filter to keep what you need, and create a new** `Builder` **with only that information**. This produces a new `Builder` instance—a "rebuild." + +> [!IMPORTANT] +> This process always creates a new `Builder`. The original signed asset and its manifest are never modified, neither is the starting working store. The `Reader` extracts data without side effects, and the `Builder` constructs a new manifest based on extracted data. + +## Core concepts + +```mermaid +flowchart LR + A[Signed Asset] -->|Reader| B[JSON + Resources] + B -->|Filter| C[Filtered Data] + C -->|new Builder| D[New Builder] + D -->|sign| E[New Asset] +``` + +The fundamental workflow is: + +1. **Read** the existing manifest with `Reader` to get JSON and binary resources +2. **Identify and filter** the parts to keep (parse the JSON, select and gather elements) +3. **Create a new `Builder`** with only the selected parts based on the applied filtering rules +4. **Sign** the new `Builder` into the output asset + +## Reading an existing manifest + +Use `Reader` with a `Context` to extract the manifest store JSON and any binary resources (thumbnails, manifest data). The source asset is never modified. The context is used for trust configuration (which certificates are trusted when validating signatures) and verification settings. See [Configuring `Reader`](context-settings.md#with-reader) and [Trust configuration](context-settings.md#trust) for details. + +```py +ctx = Context.from_dict({ + "trust": { + "user_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCA...\n-----END CERTIFICATE-----", + }, + "verify": { + "verify_trust": True + } +}) + +with open("signed_asset.jpg", "rb") as source: + with Reader("image/jpeg", source, context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active_label = manifest_store["active_manifest"] + manifest = manifest_store["manifests"][active_label] +``` + +### Extracting binary resources + +The JSON returned by `reader.json()` contains only string identifiers (JUMBF URIs) for binary data like thumbnails and ingredient manifest stores. Extract the actual binary content by using `resource_to_stream()`: + +```py +# Extract a thumbnail to an in-memory stream +thumb_stream = io.BytesIO() +reader.resource_to_stream(thumbnail_id, thumb_stream) + +# Or extract to a file +with open("thumbnail.jpg", "wb") as f: + reader.resource_to_stream(thumbnail_id, f) +``` + +## Filtering into a new Builder + +> [!NOTE] +> All examples on this page use `Context` with `Reader` and `Builder`. For `Reader`, the context provides trust configuration and verification settings: `Reader(format, source, context=ctx)`. For `Builder`, the context provides custom settings (thumbnails, claim generator, intent): `Builder(manifest_json, context=ctx)`. When a signer is configured in the context, `builder.sign()` is called without a signer instance. See [Context and settings](context-settings.md) for details. + +Each example below creates a **new `Builder`** from filtered data. The original asset and its manifest store are never modified. + +When transferring ingredients from a `Reader` to a new `Builder`, you must transfer both the JSON metadata and the associated binary resources (thumbnails, manifest data). The JSON contains identifiers that reference those resources; the same identifiers must be used when calling `builder.add_resource()`. + +### Transferring binary resources + +Since ingredients reference binary data (thumbnails, manifest stores), you need to copy those resources from the `Reader` to the new `Builder`. This helper function encapsulates the pattern: + +```py +def transfer_ingredient_resources(reader, builder, ingredients): + """Copy binary resources for a list of ingredients from reader to builder.""" + for ingredient in ingredients: + for key in ("thumbnail", "manifest_data"): + if key in ingredient: + uri = ingredient[key]["identifier"] + buf = io.BytesIO() + reader.resource_to_stream(uri, buf) + buf.seek(0) + builder.add_resource(uri, buf) +``` + +This function is used throughout the examples below. + +### Keep only specific ingredients + +```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + +with open("signed_asset.jpg", "rb") as source: + with Reader("image/jpeg", source, context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Filter: keep only ingredients with a specific relationship + kept = [ + ing for ing in active["ingredients"] + if ing["relationship"] == "parentOf" + ] + + # Create a new Builder with only the kept ingredients + with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "ingredients": kept, + }, context=ctx) as new_builder: + transfer_ingredient_resources(reader, new_builder, kept) + + source.seek(0) + with open("output.jpg", "wb") as dest: + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. + new_builder.sign("image/jpeg", source, dest) +``` + +### Keep only specific assertions + +```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + +with open("signed_asset.jpg", "rb") as source: + with Reader("image/jpeg", source, context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Keep training-mining assertions, filter out everything else + kept = [ + a for a in active["assertions"] + if a["label"] == "cawg.training-mining" + ] + + with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": kept, + }, context=ctx) as new_builder: + source.seek(0) + with open("output.jpg", "wb") as dest: + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. + new_builder.sign("image/jpeg", source, dest) +``` + +### Start fresh and preserve provenance + +Sometimes all existing assertions and ingredients may need to be discarded but the provenance chain should be maintained nevertheless. This is done by creating a new `Builder` with a new manifest definition and adding the original signed asset as an ingredient using `add_ingredient()`. + +The function `add_ingredient()` does not copy the original's assertions into the new manifest. Instead, it stores the original's entire manifest store as opaque binary data inside the ingredient record. This means: + +- The new manifest has its own, independent set of assertions. +- The original's full manifest is preserved inside the ingredient, so validators can inspect the full provenance history. +- The provenance chain is unbroken: anyone reading the new asset can follow the ingredient link back to the original. + +```mermaid +flowchart TD + subgraph Original["Original Signed Asset"] + OA["Assertions: A, B, C"] + OI["Ingredients: X, Y"] + end + subgraph NewBuilder["New Builder"] + NA["Assertions: (empty or new)"] + NI["Ingredient: original.jpg (contains full original manifest as binary data)"] + end + Original -->|"add_ingredient()"| NI + NI -.->|"validators can trace back"| Original + + style NA fill:#efe,stroke:#090 + style NI fill:#efe,stroke:#090 +``` + + + +```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + +with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [], +}, context=ctx) as new_builder: + # Add the original as an ingredient to preserve provenance chain. + # add_ingredient() stores the original's manifest as binary data inside + # the ingredient, but does NOT copy the original's assertions. + with open("original_signed.jpg", "rb") as original: + new_builder.add_ingredient( + {"title": "original.jpg", "relationship": "parentOf"}, + "image/jpeg", + original, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a Signer explicitly. + new_builder.sign("image/jpeg", source, dest) +``` + +## Adding actions to a working store + +Actions record what was done to an asset (e.g., color adjustments, cropping, placing content). Use `builder.add_action()` to add them to a working store. + +```py +builder.add_action({ + "action": "c2pa.color_adjustments", + "parameters": {"name": "brightnesscontrast"}, +}) + +builder.add_action({ + "action": "c2pa.filtered", + "parameters": {"name": "A filter"}, + "description": "Filtering applied", +}) +``` + +### Action JSON fields + + +| Field | Required | Description | +| --- | --- | --- | +| `action` | Yes | Action identifier, e.g. `"c2pa.created"`, `"c2pa.opened"`, `"c2pa.placed"`, `"c2pa.color_adjustments"`, `"c2pa.filtered"` | +| `parameters` | No | Free-form object with action-specific data (including `ingredientIds` for linking ingredients, for instance) | +| `description` | No | Human-readable description of what happened | +| `digitalSourceType` | Sometimes, depending on action | URI describing the digital source type (typically for `c2pa.created`) | + + +### Linking actions to ingredients + +When an action involves a specific ingredient, the ingredient is linked to the action using `ingredientIds` (in the action's `parameters`), referencing a matching key in the ingredient. + +#### How `ingredientIds` resolution works + +The SDK matches each value in `ingredientIds` against ingredients using this priority: + +1. `label` on the ingredient (primary): if set and non-empty, this is used as the linking key. +2. `instance_id` on the ingredient (fallback): used when `label` is absent or empty. + +#### Linking with `label` + +The `label` field on an ingredient is the **primary** linking key. Set a `label` on the ingredient and reference it in the action's `ingredientIds`. The label can be any string: it acts as a linking key between the ingredient and the action. + +```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + +manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + }, + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["c2pa.ingredient.v3"] + }, + }, + ] + }, + } + ], +} + +with Builder(manifest_json, context=ctx) as builder: + # The label on the ingredient matches the value in ingredientIds + with open("photo.jpg", "rb") as photo: + builder.add_ingredient( + { + "title": "photo.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3", + }, + "image/jpeg", + photo, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. + builder.sign("image/jpeg", source, dest) +``` + +##### Linking multiple ingredients + +When linking multiple ingredients, each ingredient needs a unique label. + +> [!NOTE] +> The labels used for linking in the working store may not be the exact labels that appear in the signed manifest. They are indicators for the SDK to know which ingredient to link with which action. The SDK assigns final labels during signing. + +```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + +manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "parameters": { + "ingredientIds": ["c2pa.ingredient.v3_1"] + }, + }, + { + "action": "c2pa.placed", + "parameters": { + "ingredientIds": ["c2pa.ingredient.v3_2"] + }, + }, + ] + }, + } + ], +} + +with Builder(manifest_json, context=ctx) as builder: + # parentOf ingredient linked to c2pa.opened + with open("original.jpg", "rb") as original: + builder.add_ingredient( + { + "title": "original.jpg", + "format": "image/jpeg", + "relationship": "parentOf", + "label": "c2pa.ingredient.v3_1", + }, + "image/jpeg", + original, + ) + + # componentOf ingredient linked to c2pa.placed + with open("overlay.jpg", "rb") as overlay: + builder.add_ingredient( + { + "title": "overlay.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3_2", + }, + "image/jpeg", + overlay, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. + builder.sign("image/jpeg", source, dest) +``` + +#### Linking with `instance_id` + +When no `label` is set on an ingredient, the SDK matches `ingredientIds` against `instance_id`. + +```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + +# instance_id is used as the linking identifier and must be unique +instance_id = "xmp:iid:939a4c48-0dff-44ec-8f95-61f52b11618f" + +manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "parameters": { + "ingredientIds": [instance_id] + }, + } + ] + }, + } + ], +} + +with Builder(manifest_json, context=ctx) as builder: + # No label set: instance_id is used as the linking key + with open("source_photo.jpg", "rb") as photo: + builder.add_ingredient( + { + "title": "source_photo.jpg", + "relationship": "parentOf", + "instance_id": instance_id, + }, + "image/jpeg", + photo, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. + builder.sign("image/jpeg", source, dest) +``` + +> [!NOTE] +> The `instance_id` can be read back from the ingredient JSON after signing. + +#### Reading linked ingredients + +After signing, `ingredientIds` is gone. The action's `parameters.ingredients[]` contains hashed JUMBF URIs pointing to ingredient assertions. To match an action to its ingredient, extract the label from the URL: + +```py +ctx = Context.from_dict({"verify": {"verify_trust": True}}) + +with open("signed_asset.jpg", "rb") as signed: + with Reader("image/jpeg", signed, context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active_label = manifest_store["active_manifest"] + manifest = manifest_store["manifests"][active_label] + + # Build a map: label -> ingredient + label_to_ingredient = { + ing["label"]: ing for ing in manifest["ingredients"] + } + + # Match each action to its ingredients by extracting labels from URLs + for assertion in manifest["assertions"]: + if assertion["label"] != "c2pa.actions.v2": + continue + for action in assertion["data"]["actions"]: + for ref in action.get("parameters", {}).get("ingredients", []): + label = ref["url"].rsplit("/", 1)[-1] + matched = label_to_ingredient.get(label) + # matched is the ingredient linked to this action +``` + +#### When to use `label` vs `instance_id` + +| Property | `label` | `instance_id` | +| --- | --- | --- | +| **Who controls it** | Caller (any string) | Caller (any string, or from XMP metadata) | +| **Priority for linking** | Primary: checked first | Fallback: used when label is absent/empty | +| **When to use** | JSON-defined manifests where the caller controls the ingredient definition | Programmatic workflows using XMP-based IDs | +| **Survives signing** | SDK may reassign the actual assertion label | Unchanged | +| **Stable across rebuilds** | The caller controls the build-time value; the post-signing label may change | Yes, always the same set value | + + +**Use `label`** when defining manifests in JSON. +**Use `instance_id`** when working programmatically with ingredients whose identity comes from other sources, or when a stable identifier that persists unchanged across rebuilds is needed. + +## Working with archives + +A `Builder` represents a **working store**: a manifest that is being assembled but has not yet been signed. Archives serialize this working store (definition + resources) to a `.c2pa` binary format, allowing to save, transfer, or resume the work later. For more background on working stores and archives, see [Working stores and archives](working-stores.md). + +There are two distinct types of archives, sharing the same binary format but being conceptually different: builder archives (working store archives) and ingredient archives. + +### Builder archives vs. ingredient archives + +A **builder archive** (also called a working store archive) is a serialized snapshot of a `Builder`. It contains the manifest definition, all resources, and any ingredients that were added. It is created by `builder.to_archive()` and restored with `Builder.from_archive()` to create a new builder instance from an archive, or `builder.with_archive()` to load a working store from a builder archive into an existing builder instance. + +An **ingredient archive** contains the manifest store from an asset that was added as an ingredient. + +The key difference: a builder archive is a work-in-progress (unsigned). An ingredient archive carries the provenance history of a source asset for reuse as an ingredient in other working stores. + +### The ingredients catalog pattern + +An **ingredients catalog** is a collection of archived ingredients that can be selected when constructing a final manifest. Each archive holds ingredients; at build time the caller selects only the ones needed. + +```mermaid +flowchart TD + subgraph Catalog["Ingredients Catalog (archived)"] + A1["Archive: photos.c2pa (ingredients from photo shoot)"] + A2["Archive: graphics.c2pa (ingredients from design assets)"] + A3["Archive: audio.c2pa (ingredients from audio tracks)"] + end + CTX["Context (to propagate settings and configuration)"] + subgraph Build["Final Builder"] + direction TB + SEL["Pick and choose ingredients from any archive in the catalog"] + FB["New Builder with selected ingredients only"] + end + A1 -->|"select photo_1, photo_3"| SEL + A2 -->|"select logo"| SEL + A3 -. "skip (not needed)" .-> X((not used)) + CTX -.->|"settings"| FB + SEL --> FB + FB -->|sign| OUT[Signed Output Asset] + + style A3 fill:#eee,stroke:#999 + style X fill:#f99,stroke:#c00 + style CTX fill:#e8f4fd,stroke:#4a90d9 +``` + + + +```py +ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False}, + "claim_generator_info": {"name": "an-application", "version": "0.1.0"} + }, + "signer": signer, +}) + +archive_stream.seek(0) +with Reader("application/c2pa", archive_stream, context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + + selected = [ + ing for ing in active["ingredients"] + if ing["title"] in {"photo_1.jpg", "logo.png"} + ] + + with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "ingredients": selected, + }, context=ctx) as new_builder: + transfer_ingredient_resources(reader, new_builder, selected) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. + new_builder.sign("image/jpeg", source, dest) +``` + +### Overriding ingredient properties + +When adding an ingredient from an archive or from a file, the JSON passed to `add_ingredient()` can override properties like `title` and `relationship`. This is useful when reusing archived ingredients in a different context: + +```py +with open("signed_asset.jpg", "rb") as signed: + builder.add_ingredient( + { + "title": "my-custom-title.jpg", + "relationship": "parentOf", + "instance_id": "my-tracking-id:asset-example-id", + }, + "image/jpeg", + signed, + ) +``` + +The `title`, `relationship`, and `instance_id` fields in the provided JSON take priority. The library fills in the rest (thumbnail, manifest_data, format) from the source. This works with signed assets, `.c2pa` archives, or unsigned files. + +### Using custom vendor parameters in actions + +The C2PA specification allows **vendor-namespaced parameters** on actions using reverse domain notation. These parameters survive signing and can be read back, useful for tagging actions with IDs that support filtering. + +```py +manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeCapture", + "parameters": { + "com.mycompany.tool": "my-editor", + "com.mycompany.session_id": "session-abc-123", + }, + }, + { + "action": "c2pa.placed", + "description": "Placed an image", + "parameters": { + "com.mycompany.layer_id": "layer-42", + "ingredientIds": ["c2pa.ingredient.v3"], + }, + }, + ] + }, + } + ], +} +``` + +After signing, these custom parameters appear alongside the standard fields: + +```json +{ + "action": "c2pa.placed", + "parameters": { + "com.mycompany.layer_id": "layer-42", + "ingredients": [{"url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"}] + } +} +``` + +Custom vendor parameters can be used to filter actions. For example, to find all actions related to a specific layer: + +```py +layer_actions = [ + action for action in actions + if action.get("parameters", {}).get("com.mycompany.layer_id") == "layer-42" +] +``` + +> [!NOTE] +> Vendor parameters must use reverse domain notation with period-separated components (for example, `com.mycompany.tool`, `net.example.session_id`). Some namespaces (for example, `c2pa` or `cawg`) may be reserved. + +### Extracting ingredients from a working store + +An example workflow is to build up a working store with multiple ingredients, archive it, and then later extract specific ingredients from that archive to use in a new working store. + +```mermaid +flowchart TD + subgraph Step1["Step 1: Build a working store with ingredients"] + IA["add_ingredient(A.jpg)"] --> B1[Builder] + IB["add_ingredient(B.jpg)"] --> B1 + B1 -->|"to_archive()"| AR["archive.c2pa"] + end + subgraph Step2["Step 2: Extract ingredients from archive"] + AR -->|"Reader(application/c2pa)"| RD[JSON + resources] + RD -->|"pick ingredients"| SEL[Selected ingredients] + end + CTX["Context (optional)"] + subgraph Step3["Step 3: Reuse in a new Builder"] + SEL -->|"new Builder + add_resource()"| B2[New Builder] + CTX -.->|"settings"| B2 + B2 -->|sign| OUT[Signed Output] + end + + style CTX fill:#e8f4fd,stroke:#4a90d9 +``` + + + +**Step 1:** Build a working store and archive it: + +```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, +}) + +with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], +}, context=ctx) as builder: + # Add ingredients to the working store + with open("A.jpg", "rb") as ing_a: + builder.add_ingredient( + {"title": "A.jpg", "relationship": "componentOf"}, + "image/jpeg", + ing_a, + ) + + with open("B.jpg", "rb") as ing_b: + builder.add_ingredient( + {"title": "B.jpg", "relationship": "componentOf"}, + "image/jpeg", + ing_b, + ) + + # Save the working store as an archive + archive_stream = io.BytesIO() + builder.to_archive(archive_stream) +``` + +> [!NOTE] +> When restoring from an archive, `with_archive()` preserves context settings while `from_archive()` does not. See [Working with archives](working-stores.md#working-with-archives) for the full comparison. + +**Step 2:** Read the archive and extract ingredients: + +```py +archive_stream.seek(0) +with Reader("application/c2pa", archive_stream, context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + ingredients = active["ingredients"] +``` + +**Step 3:** Create a new Builder with the extracted ingredients: + +```py + sign_ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False}, + "claim_generator_info": {"name": "an-application", "version": "0.1.0"} + }, + "signer": signer, + }) + + selected = [ing for ing in ingredients if ing["title"] == "A.jpg"] + + with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "ingredients": selected, + }, context=sign_ctx) as new_builder: + transfer_ingredient_resources(reader, new_builder, selected) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. + new_builder.sign("image/jpeg", source, dest) +``` + +### Merging multiple working stores + +> [!NOTE] +> The `Builder` construction and signing in the merge workflow also support `Context`. The caller can pass `context=ctx` to `Builder()` and call `sign()` without a signer argument when the context has one. See [Context and settings](context-settings.md) for details. + +In some cases it is necessary to merge ingredients from multiple working stores (builder archives) into a single `Builder`. This should be a **fallback strategy**. The recommended practice is to maintain a single active working store and add ingredients incrementally (archived ingredient catalogs help with this). Merging is available when multiple working stores must be consolidated. + +When merging from multiple sources, resource identifier URIs can collide. Rename identifiers with a unique suffix when needed. Use two passes: (1) collect ingredients with collision handling, build the manifest, create the builder; (2) re-read each archive and transfer resources (use original ID for `resource_to_stream()`, renamed ID for `add_resource()` when collisions occurred). + +```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + +used_ids: set[str] = set() +suffix_counter = 0 +all_ingredients = [] +archive_ingredient_counts = [] + +# Pass 1: Collect ingredients, renaming IDs on collision +for archive_stream in archives: + archive_stream.seek(0) + with Reader("application/c2pa", archive_stream, context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + ingredients = active["ingredients"] + + for ingredient in ingredients: + for key in ("thumbnail", "manifest_data"): + if key not in ingredient: + continue + uri = ingredient[key]["identifier"] + if uri in used_ids: + suffix_counter += 1 + ingredient[key]["identifier"] = f"{uri}__{suffix_counter}" + used_ids.add(ingredient[key]["identifier"]) + all_ingredients.append(ingredient) + + archive_ingredient_counts.append(len(ingredients)) + +with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "ingredients": all_ingredients, +}, context=ctx) as builder: + # Pass 2: Transfer resources (match by ingredient index) + offset = 0 + for archive_stream, count in zip(archives, archive_ingredient_counts): + archive_stream.seek(0) + with Reader("application/c2pa", archive_stream, context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + originals = active["ingredients"] + + for original, merged in zip(originals, all_ingredients[offset:offset + count]): + for key in ("thumbnail", "manifest_data"): + if key not in original: + continue + buf = io.BytesIO() + reader.resource_to_stream(original[key]["identifier"], buf) + buf.seek(0) + builder.add_resource(merged[key]["identifier"], buf) + + offset += count + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. + builder.sign("image/jpeg", source, dest) +``` diff --git a/docs/usage.md b/docs/usage.md index aeec23a4..46f7399a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -4,6 +4,10 @@ This package works with media files in the [supported formats](https://github.co For complete working examples, see the [examples folder](https://github.com/contentauth/c2pa-python/tree/main/examples) in the repository. +Reference material: +- [Class diagram](class-diagram.md) +- [API reference documentation](https://contentauth.github.io/c2pa-python/api/c2pa/index.html) + ## Import Import the objects needed from the API: @@ -12,8 +16,13 @@ Import the objects needed from the API: from c2pa import Builder, Reader, Signer, C2paSigningAlg, C2paSignerInfo ``` -You can use both `Builder`, `Reader` and `Signer` classes with context managers by using a `with` statement. -Doing this is recommended to ensure proper resource and memory cleanup. +If you want to use per-instance configuration with `Context` and `Settings`: + +```py +from c2pa import Settings, Context, ContextBuilder, ContextProvider +``` + +All of `Builder`, `Reader`, `Signer`, `Context`, and `Settings` support context managers (the `with` statement) for automatic resource cleanup. ## Define manifest JSON @@ -41,6 +50,102 @@ manifest_json = json.dumps({ }) ``` +## Settings, Context, and ContextProvider + +The `Settings` and `Context` classes provide per-instance configuration for `Reader` and `Builder` operations, replacing the global `load_settings()` function, which is now deprecated. + +See [Context and settings](context-settings.md) for details. + +### Settings + +`Settings` controls behavior such as thumbnail generation, trust lists, and verification flags. + +```py +from c2pa import Settings + +settings = Settings() +settings.set("builder.thumbnail.enabled", "false") # dot-notation path; value is a string +settings.update({"verify": {"remote_manifest_fetch": True}}) # merge additional config + +settings = Settings.from_json('{"builder": {"thumbnail": {"enabled": false}}}') +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +``` + +For the full Settings API reference, see [Settings API](context-settings.md#settings-api). + +### Context + +A `Context` carries `Settings` and optionally a `Signer`, and is passed to `Reader` or `Builder` to control their behavior. + +```py +from c2pa import Context, Settings + +ctx = Context() # SDK defaults +ctx = Context(settings) +ctx = Context.from_json('{"builder": {"thumbnail": {"enabled": false}}}') +ctx = Context.from_dict({"builder": {"thumbnail": {"enabled": False}}}) + +reader = Reader("path/to/media_file.jpg", context=ctx) +builder = Builder(manifest_json, ctx) +``` + +For full details on configuring `Context` and using it with `Reader` and `Builder`, see [Using Context](context-settings.md#using-context) and the [Settings reference](context-settings.md#settings-reference). + +### Using ContextBuilder + +`ContextBuilder` provides a fluent interface for constructing a `Context`. Use `Context.builder()` to get started; for example: + +```py +from c2pa import Context, ContextBuilder, Settings, Signer + +ctx = ( + Context.builder() + .with_settings(settings) + .with_signer(signer) + .build() +) + +ctx = Context.builder().with_settings(settings).build() +ctx = Context.builder().build() # equivalent to Context() +``` + +You can call `with_settings()` multiple times; each call replaces the previous `Settings` object entirely (last one wins). To merge multiple configurations, use `Settings.update()` on a single `Settings` object before passing it to the context; for example: + +```py +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +settings.update({"verify": {"remote_manifest_fetch": True}}) + +ctx = Context.builder().with_settings(settings).build() +``` + +### Context with a Signer + +When a `Signer` is passed to `Context`, the `Signer` object is consumed and must not be reused directly. The `Context` takes ownership, enabling signing without passing an explicit signer to `Builder.sign()`: + +```py +ctx = Context(settings=settings, signer=signer) +# signer is now invalid and must not be used directly again + +builder = Builder(manifest_json, ctx) +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + manifest_bytes = builder.sign(format="image/jpeg", source=src, dest=dst) +``` + +If both an explicit signer and a context signer are available, the explicit signer takes precedence. For more details, including remote signers, see [Configuring signers](context-settings.md#configuring-signers). + +### Using ContextProvider + +`ContextProvider` is an abstract base class that defines the interface `Reader` and `Builder` use to access a context. It requires two properties: + +- `is_valid` (bool): Whether the provider is in a usable state. +- `execution_context`: The raw native context pointer (`C2paContext` handle). + +The built-in `Context` class is the standard `ContextProvider` implementation. Custom providers must wrap a compatible native resource rather than constructing native pointers independently. `Settings` is not a `ContextProvider` and cannot be passed directly to `Reader` or `Builder`. For more details and a custom implementation example, see [ContextProvider](context-settings.md#contextprovider-abstract-base-class). + +### Migrating from load_settings + +The `load_settings()` function is deprecated. Replace it with `Settings` and `Context`. See [Migrating from load_settings](context-settings.md#migrating-from-load_settings) for details. + ## File-based operation ### Read and validate C2PA data @@ -49,25 +154,23 @@ Use the `Reader` to read C2PA data from the specified asset file. This examines the specified media file for C2PA data and generates a report of any data it finds. If there are validation errors, the report includes a `validation_status` field. -An asset file may contain many manifests in a manifest store. The most recent manifest is identified by the value of the `active_manifest` field in the manifests map. The manifests may contain binary resources such as thumbnails which can be retrieved with `resource_to_stream` using the associated `identifier` field values and a `uri`. +An asset file may contain many manifests in a manifest store. The most recent manifest is identified by the value of the `active_manifest` field in the manifests map. The manifests may contain binary resources such as thumbnails which can be retrieved with `resource_to_stream()` using the associated `identifier` field value as the URI. -NOTE: For a comprehensive reference to the JSON manifest structure, see the [Manifest store reference](https://opensource.contentauthenticity.org/docs/manifest/manifest-ref). +> [!NOTE] +> For a comprehensive reference to the JSON manifest structure, see the [Manifest store reference](https://opensource.contentauthenticity.org/docs/manifest/manifest-ref). + +Pass a `Context` to apply custom settings to the `Reader`, such as trust anchors or verification flags. ```py try: - # Create a reader from a file path - with Reader("path/to/media_file.jpg") as reader: - # Print manifest store as JSON - print("Manifest store:", reader.json()) - - # Get the active manifest. - manifest = json.loads(reader.json()) - active_manifest = manifest["manifests"][manifest["active_manifest"]] - if active_manifest: - # Get the uri to the manifest's thumbnail and write it to a file - uri = active_manifest["thumbnail"]["identifier"] - with open("thumbnail_v2.jpg", "wb") as f: - reader.resource_to_stream(uri, f) + settings = Settings.from_dict({ + "verify": {"verify_cert_anchors": True}, + "trust": {"trust_anchors": anchors_pem} + }) + + with Context(settings) as ctx: + with Reader("path/to/media_file.jpg", context=ctx) as reader: + print("Manifest store:", reader.json()) except Exception as err: print(err) @@ -75,73 +178,58 @@ except Exception as err: ### Add a signed manifest -**WARNING**: This example accesses the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as show in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). +> [!WARNING] +> This example accesses the private key and security certificate directly from the local file system. This is fine during development, but doing so in production is insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as show in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). -Use a `Builder` to add a manifest to an asset: +Pass a `Context` to the `Builder` to apply custom settings during signing. The signer is still passed explicitly to `builder.sign()`. ```py try: - # Create a signer from certificate and key files with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: cert_data = cert_file.read() key_data = key_file.read() - # Create signer info using cert and key info signer_info = C2paSignerInfo( alg=C2paSigningAlg.PS256, - cert=cert_data, - key=key_data, - timestamp_url="http://timestamp.digicert.com" + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" ) - # Create signer using the defined SignerInfo - signer = Signer.from_info(signer_info) - - # Create builder with manifest and add ingredients - with Builder(manifest_json) as builder: - # Add any ingredients if needed - with open("path/to/ingredient.jpg", "rb") as ingredient_file: - ingredient_json = json.dumps({"title": "Ingredient Image"}) - builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) + with Context() as ctx: + with Signer.from_info(signer_info) as signer: + with Builder(manifest_json, ctx) as builder: + with open("path/to/ingredient.jpg", "rb") as ingredient_file: + ingredient_json = json.dumps({"title": "Ingredient Image"}) + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) - # Sign the file - with open("path/to/source.jpg", "rb") as source_file, open("path/to/output.jpg", "wb") as dest_file: - manifest_bytes = builder.sign(signer, "image/jpeg", source_file, dest_file) + # Sign using file paths + builder.sign_file("path/to/source.jpg", "path/to/output.jpg", signer) - # Verify the signed file by reading data from the signed output file - with Reader("path/to/output.jpg") as reader: - manifest_store = json.loads(reader.json()) - active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] - print("Signed manifest:", active_manifest) + # Verify the signed file with the same context + with Reader("path/to/output.jpg", context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + print("Signed manifest:", active_manifest) except Exception as e: print("Failed to sign manifest store: " + str(e)) ``` -## Stream-based operation +## Stream-based operations Instead of working with files, you can read, validate, and add a signed manifest to streamed data. This example is similar to what the file-based example does. -### Read and validate C2PA data using streams +### Read and validate manifest data ```py try: - # Create a reader from a format and stream - with open("path/to/media_file.jpg", "rb") as stream: - # First parameter should be the type of the file (here, we use the mimetype) - # But in any case we need something to identify the file type - with Reader("image/jpeg", stream) as reader: - # Print manifest store as JSON, as extracted by the Reader - print("manifest store:", reader.json()) - - # Get the active manifest - manifest = json.loads(reader.json()) - active_manifest = manifest["manifests"][manifest["active_manifest"]] - if active_manifest: - # get the uri to the manifest's thumbnail and write it to a file - uri = active_manifest["thumbnail"]["identifier"] - with open("thumbnail_v2.jpg", "wb") as f: - reader.resource_to_stream(uri, f) + settings = Settings.from_dict({"verify": {"verify_cert_anchors": True}}) + + with Context(settings) as ctx: + with open("path/to/media_file.jpg", "rb") as stream: + with Reader("image/jpeg", stream, context=ctx) as reader: + print("Manifest store:", reader.json()) except Exception as err: print(err) @@ -149,44 +237,35 @@ except Exception as err: ### Add a signed manifest to a stream -**WARNING**: This example accesses the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as show in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). - -Use a `Builder` to add a manifest to an asset: +> [!WARNING] +> This example accesses the private key and security certificate directly from the local file system. This is fine during development, but doing so in production IS insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as shown in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). ```py try: - # Create a signer from certificate and key files with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: cert_data = cert_file.read() key_data = key_file.read() - # Create signer info using the read certificate and key data signer_info = C2paSignerInfo( alg=C2paSigningAlg.PS256, - cert=cert_data, - key=key_data, - timestamp_url="http://timestamp.digicert.com" + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" ) - # Create a Signer using the SignerInfo defined previously - signer = Signer.from_info(signer_info) - - # Create a Builder with manifest and add ingredients - with Builder(manifest_json) as builder: - # Add any ingredients as needed - with open("path/to/ingredient.jpg", "rb") as ingredient_file: - ingredient_json = json.dumps({"title": "Ingredient Image"}) - # Here the ingredient is added using streams - builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) + with Context() as ctx: + with Signer.from_info(signer_info) as signer: + with Builder(manifest_json, ctx) as builder: + with open("path/to/ingredient.jpg", "rb") as ingredient_file: + ingredient_json = json.dumps({"title": "Ingredient Image"}) + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) - # Sign using streams - with open("path/to/source.jpg", "rb") as source_file, open("path/to/output.jpg", "wb") as dest_file: - manifest_bytes = builder.sign(signer, "image/jpeg", source_file, dest_file) + with open("path/to/source.jpg", "rb") as source, open("path/to/output.jpg", "w+b") as dest: + builder.sign(signer, "image/jpeg", source, dest) - # Verify the signed file + # Verify the signed file with the same context with open("path/to/output.jpg", "rb") as stream: - # Create a Reader to read data - with Reader("image/jpeg", stream) as reader: + with Reader("image/jpeg", stream, context=ctx) as reader: manifest_store = json.loads(reader.json()) active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] print("Signed manifest:", active_manifest) diff --git a/docs/working-stores.md b/docs/working-stores.md new file mode 100644 index 00000000..c357b8ee --- /dev/null +++ b/docs/working-stores.md @@ -0,0 +1,646 @@ +# Manifests, working stores, and archives + +This table summarizes the fundamental entities that you work with when using the CAI SDK. + +| Object | Description | Where it is | Primary API | +|--------|-------------|-------------|-------------| +| [**Manifest store**](#manifest-store) | Final signed provenance data. Contains one or more manifests. | Embedded in asset or remotely in cloud | `Reader` class | +| [**Working store**](#working-store) | Editable in-progress manifest. | `Builder` object | `Builder` class | +| [**Archive**](#archive) | Serialized working store | `.c2pa` file/stream | `Builder.to_archive()` / `Builder.with_archive()` | +| [**Resources**](#working-with-resources) | Binary assets referenced by manifest assertions, such as thumbnails or ingredient thumbnails. | In manifest. | `Builder.add_resource()` / `Reader.resource_to_stream()` | +| [**Ingredients**](#working-with-ingredients) | Source materials used to create an asset. | In manifest. | `Builder.add_ingredient()` | + +This diagram summarizes the relationships among these entities. + +```mermaid +graph TD + subgraph MS["Manifest Store"] + subgraph M1["Manifests"] + R1[Resources] + I1[Ingredients] + end + end + + A[Working Store
Builder object] -->|sign| MS + A -->|to_archive| C[C2PA Archive
.c2pa file] + C -->|with_archive| A +``` + +## Key entities + +### Manifest store + +A _manifest store_ is the data structure that's embedded in (or attached to) a signed asset. It contains one or more manifests that contain provenance data and cryptographic signatures. + +**Characteristics:** + +- Final, immutable signed data embedded in or attached to an asset. +- Contains one or more manifests (identified by URIs). +- Has exactly one `active_manifest` property pointing to the most recent manifest. +- Read it by using a `Reader` object. + +**Example:** When you open a signed JPEG file, the C2PA data embedded in it is the manifest store. + +For more information, see: + +- [Reading manifest stores from assets](#reading-manifest-stores-from-assets) +- [Creating and signing manifests](#creating-and-signing-manifests) +- [Embedded versus external manifests](#embedded-versus-external-manifests) + +### Working store + +A _working store_ is a `Builder` object representing an editable, in-progress manifest that has not yet been signed and bound to an asset. Think of it as a manifest in progress, or a manifest being built. + +**Characteristics:** + +- Editable, mutable state in memory (a `Builder` object). +- Contains claims, ingredients, and assertions that can be modified. +- Can be saved to a C2PA archive (`.c2pa` JUMBF binary format) for later use. + +**Example:** When you create a `Builder` object and add assertions to it, you're dealing with a working store, as it is an "in progress" manifest being built. + +For more information, see [Using working stores](#using-working-stores). + +### Archive + +A _C2PA archive_ (or just _archive_) contains the serialized bytes of a working store saved to a file or stream (typically a `.c2pa` file). It uses the standard JUMBF `application/c2pa` format. + +**Characteristics:** + +- Portable serialization of a working store (`Builder`). +- Save an archive by using `Builder.to_archive()` and restore a full working store from an archive by using `Builder.with_archive()` (with a `Builder` created from a `Context`). +- Useful for separating manifest preparation ("work in progress") from final signing. + +For more information, see [Working with archives](#working-with-archives). + +## Reading manifest stores from assets + +Use the `Reader` class to read manifest stores from signed assets. + +### Reading from a file + +```py +from c2pa import Context, Reader + +ctx = Context.from_dict({ + "verify": { + "verify_after_sign": True + } +}) +reader = Reader("signed_image.jpg", context=ctx) +manifest_store_json = reader.json() +``` + +### Reading from a stream + +```py +with open("signed_image.jpg", "rb") as stream: + reader = Reader("image/jpeg", stream, context=ctx) + manifest_json = reader.json() +``` + +For full details on `Context` and `Settings`, see [Context and settings](context-settings.md). + +### Understanding Reader output + +`Reader.json()` returns a JSON string representing the manifest store. The top-level structure looks like this: + +```json +{ + "active_manifest": "urn:uuid:...", + "manifests": { + "urn:uuid:...": { + "claim_generator": "MyApp/1.0", + "claim_generator_info": [{"name": "MyApp", "version": "0.1.0"}], + "title": "signed_image.jpg", + "assertions": [ + {"label": "c2pa.actions", "data": {"actions": [...]}}, + {"label": "c2pa.hash.data", "data": {...}} + ], + "ingredients": [...], + "signature_info": { + "alg": "Es256", + "issuer": "...", + "time": "2025-01-15T12:00:00Z" + } + } + } +} +``` + +- `active_manifest`: The URI label of the most recent manifest. This is typically the one to inspect first. +- `manifests`: A dictionary of all manifests in the store, keyed by their URI label. Assets that have been re-signed or that contain ingredient history may have multiple manifests. +- Within each manifest: `assertions` contain the provenance statements, `ingredients` list source materials, and `signature_info` provides details about who signed and when. + +The SDK also provides convenience methods to avoid manual JSON parsing: + +```py +reader = Reader("signed_image.jpg", context=ctx) + +# Get the active manifest directly as a dict +active = reader.get_active_manifest() + +# Get a specific manifest by label +manifest = reader.get_manifest("urn:uuid:...") + +# Check validation status +state = reader.get_validation_state() +results = reader.get_validation_results() +``` + +`Reader.detailed_json()` returns a more comprehensive JSON representation with a different structure than `json()`. It is useful when additional details about the manifest internals are needed. + +## Using working stores + +A **working store** is represented by a `Builder` object. It contains "live" manifest data as you add information to it. + +### Creating a working store + +```py +import json +from c2pa import Builder, Context + +manifest_json = json.dumps({ + "claim_generator_info": [{ + "name": "example-app", + "version": "0.1.0" + }], + "title": "Example asset", + "assertions": [] +}) + +ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": True} + } +}) +builder = Builder(manifest_json, context=ctx) +``` + +### Modifying a working store + +Before signing, you can modify the working store (`Builder`): + +```py +import io + +# Add binary resources (like thumbnails) +with open("thumbnail.jpg", "rb") as thumb: + builder.add_resource("thumbnail", thumb) + +# Add ingredients (source files) +ingredient_json = json.dumps({ + "title": "Original asset", + "relationship": "parentOf" +}) +with open("source.jpg", "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) + +# Add actions +action_json = { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" +} +builder.add_action(action_json) + +# Configure embedding behavior +builder.set_no_embed() # Don't embed manifest in asset +builder.set_remote_url("https://example.com/manifests/") +``` + +### From working store to manifest store + +When you sign an asset, the working store (`Builder`) becomes a manifest store embedded in the output: + +```py +from c2pa import Signer, C2paSignerInfo, C2paSigningAlg, Context + +# Create a signer +signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, + sign_cert=certs, + private_key=private_key, + ta_url=b"http://timestamp.digicert.com" +) +signer = Signer.from_info(signer_info) + +ctx = Context.from_dict({ + "builder": {"thumbnail": {"enabled": True}}, + "signer": signer, +}) +builder = Builder(manifest_json, context=ctx) + +# Sign the asset - working store becomes a manifest store +with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign("image/jpeg", src, dst) + +# Now "signed.jpg" contains a manifest store +# You can read it back with Reader +reader = Reader("signed.jpg", context=ctx) +manifest_store_json = reader.json() +``` + +## Creating and signing manifests + +### Creating a Signer + +For testing, create a `Signer` with certificates and private key: + +```py +from c2pa import Signer, C2paSignerInfo, C2paSigningAlg + +# Load credentials +with open("certs.pem", "rb") as f: + certs = f.read() +with open("private_key.pem", "rb") as f: + private_key = f.read() + +# Create signer +signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, # ES256, ES384, ES512, PS256, PS384, PS512, ED25519 + sign_cert=certs, # Certificate chain in PEM format + private_key=private_key, # Private key in PEM format + ta_url=b"http://timestamp.digicert.com" # Optional timestamp authority URL +) +signer = Signer.from_info(signer_info) +``` + +**WARNING**: Never hard-code or directly access private keys in production. Use a Hardware Security Module (HSM) or Key Management Service (KMS). + +### Signing an asset + +The `Builder` must be created with a `Context` that includes a signer. Then call `sign()` without passing a signer argument: + +```py +try: + with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + manifest_bytes = builder.sign("image/jpeg", src, dst) + print("Signed successfully!") +except Exception as e: + print(f"Signing failed: {e}") +``` + +### Signing with file paths + +You can also sign using file paths directly: + +```py +manifest_bytes = builder.sign_file("source.jpg", "signed.jpg") +``` + +### Complete example + +This code combines the above examples to create, sign, and read a manifest. + +```py +import json +from c2pa import Builder, Reader, Context, Signer, C2paSignerInfo, C2paSigningAlg + +try: + # 1. Define manifest + manifest_json = json.dumps({ + "claim_generator_info": [{"name": "demo-app", "version": "0.1.0"}], + "title": "Signed image", + "assertions": [] + }) + + # 2. Load credentials and create signer + with open("certs.pem", "rb") as f: + certs = f.read() + with open("private_key.pem", "rb") as f: + private_key = f.read() + + signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, + sign_cert=certs, + private_key=private_key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + # 3. Create context with settings and signer + ctx = Context.from_dict({ + "builder": {"thumbnail": {"enabled": True}} + }, signer=signer) + + # 4. Create Builder with context and sign + builder = Builder(manifest_json, context=ctx) + with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign("image/jpeg", src, dst) + + print("Asset signed with context settings") + + # 5. Read back the manifest store + reader = Reader("signed.jpg", context=ctx) + print(reader.json()) + +except Exception as e: + print(f"Error: {e}") +``` + +## Working with resources + +_Resources_ are binary assets referenced by manifest assertions, such as thumbnails or ingredient thumbnails. + +C2PA manifest data is not just JSON. A manifest store also contains binary resources (thumbnails, ingredient data, and other embedded files) that are referenced from the JSON metadata by JUMBF URIs. When `reader.json()` is called, the JSON includes URI references (like `"self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg"`) that point to these binary resources. To retrieve the actual binary data, use `reader.resource_to_stream()` with the URI from the JSON. This separation keeps the JSON lightweight while allowing manifests to carry rich binary content alongside the metadata. + +### Understanding resource identifiers + +When you add a resource to a working store (`Builder`), you assign it an identifier string. When the manifest store is created during signing, the SDK automatically converts this to a proper JUMBF URI. + +**Resource identifier workflow:** + +```mermaid +graph LR + A[Simple identifier
'thumbnail'] -->|add_resource| B[Working Store
Builder] + B -->|sign| C[JUMBF URI
'self#jumbf=...'] + C --> D[Manifest Store
in asset] +``` + +1. **During manifest creation**: You use a string identifier (e.g., `"thumbnail"`, `"thumbnail1"`). +2. **During signing**: The SDK converts these to JUMBF URIs (e.g., `"self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg"`). +3. **After signing**: The manifest store contains the full JUMBF URI that you use to extract the resource. + +### Extracting resources from a manifest store + +To extract a resource, you need its JUMBF URI from the manifest store: + +```py +import json + +reader = Reader("signed_image.jpg", context=ctx) +manifest_store = json.loads(reader.json()) + +# Get active manifest +active_uri = manifest_store["active_manifest"] +manifest = manifest_store["manifests"][active_uri] + +# Extract thumbnail if it exists +if "thumbnail" in manifest: + # The identifier is the JUMBF URI + thumbnail_uri = manifest["thumbnail"]["identifier"] + # Example: "self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg" + + # Extract to a stream + with open("thumbnail.jpg", "wb") as f: + reader.resource_to_stream(thumbnail_uri, f) + print("Thumbnail extracted") +``` + +### Adding resources to a working store + +When building a manifest, you add resources using identifiers. The SDK will reference these in your manifest JSON and convert them to JUMBF URIs during signing. + +```py +ctx = Context.from_dict({"builder": {"thumbnail": {"enabled": True}}, "signer": signer}) +builder = Builder(manifest_json, context=ctx) + +# Add resource from a stream +with open("thumbnail.jpg", "rb") as thumb: + builder.add_resource("thumbnail", thumb) + +# Sign: the "thumbnail" identifier becomes a JUMBF URI in the manifest store +with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign("image/jpeg", src, dst) +``` + +## Working with ingredients + +Ingredients represent source materials used to create an asset, preserving the provenance chain. Adding an ingredient to a manifest creates a verifiable link from the current asset back to its source material. + +The `relationship` field describes how the source (ingredient) was used: `"parentOf"` for a direct edit, `"componentOf"` for an element composited into a larger work, or `"inputTo"` for a general input. + +Ingredients themselves can be turned into ingredient archives (`.c2pa`). An ingredient archive is a `Builder` archive containing _exactly one_ ingredient. Ingredient archives can be added directly as an ingredient to another working store using the `application/c2pa` MIME type — no un-archiving step is needed. + +### Adding ingredients to a working store + +When creating a manifest, add ingredients to preserve the provenance chain: + +```py +ctx = Context.from_dict({"builder": {"claim_generator_info": {"name": "my-app", "version": "0.1.0"}}, "signer": signer}) +builder = Builder(manifest_json, context=ctx) + +# Define ingredient metadata +ingredient_json = json.dumps({ + "title": "Original asset", + "relationship": "parentOf" +}) + +# Add ingredient from a stream +with open("source.jpg", "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) + +# Sign: ingredients become part of the manifest store +with open("new_asset.jpg", "rb") as src, open("signed_asset.jpg", "w+b") as dst: + builder.sign("image/jpeg", src, dst) +``` + +### Ingredient relationships + +Specify the relationship between the ingredient and the current asset: + +| Relationship | Meaning | +|--------------|---------| +| `parentOf` | The ingredient is a direct parent of this asset | +| `componentOf` | The ingredient is a component used in this asset | +| `inputTo` | The ingredient was an input to creating this asset | + +Example with explicit relationship: + +```py +ingredient_json = json.dumps({ + "title": "Base layer", + "relationship": "componentOf" +}) + +with open("base_layer.png", "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/png", ingredient) +``` + +## Working with archives + +An _archive_ (C2PA archive) is a serialized working store (`Builder` object) saved to a stream. + +Using archives provides these advantages: + +- **Save work-in-progress**: Persist a working store between sessions. +- **Separate creation from signing**: Prepare manifests on one machine, sign on another. +- **Share manifests**: Transfer working stores between systems. +- **Offline preparation**: Build manifests offline, sign them later. + +The default binary format of an archive is the **C2PA JUMBF binary format** (`application/c2pa`), which is the standard way to save and restore working stores. + +### Saving a working store to archive + +```py +import io + +ctx = Context.from_dict({"builder": {"claim_generator_info": {"name": "my-app", "version": "0.1.0"}}}) +builder = Builder(manifest_json, context=ctx) +with open("thumbnail.jpg", "rb") as thumb: + builder.add_resource("thumbnail", thumb) +with open("source.jpg", "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) + +# Save working store to archive stream +archive = io.BytesIO() +builder.to_archive(archive) + +# Or save to a file +with open("manifest.c2pa", "wb") as f: + archive.seek(0) + f.write(archive.read()) + +print("Working store saved to archive") +``` + +A `Builder` containing **only one ingredient and only the ingredient data** (no other ingredient, no other actions) is an ingredient archive. Ingredient archives can be added directly as ingredient to other working stores too. + +### Restoring a working store from archive + +To restore a `Builder` from a working store, use `with_archive()`. The restored `Builder` will have the settings used when the `Builder` was created with a `Context`. The archive replaces only the manifest definition; the `Context` and `Settings` are preserved. + +```py +# Create context with custom settings and signer +ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False}, + "claim_generator_info": {"name": "My App", "version": "0.1.0"} + }, + "signer": signer, +}) + +# Create builder with context, then load archive into it +with open("manifest.c2pa", "rb") as archive: + builder = Builder({}, context=ctx) + builder.with_archive(archive) + +# The builder has the archived manifest definition +# but keeps the context settings (no thumbnails, custom claim generator) +with open("asset.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign("image/jpeg", src, dst) +``` + +> [!IMPORTANT] +> Calling `with_archive()` replaces the `Builder`'s manifest definition with the one from the archive, discarding any definition passed to `Builder()` when it was created. An empty dict `{}` is idiomatic for the initial definition when you plan to load an archive immediately after. + + +### Two-phase workflow example + +**Phase 1: Prepare manifest** + +This step prepares the manifest on a `Builder`, and archives it into a `Builder` archive for later reuse. + +```py +import io +import json + +ctx = Context.from_dict({"builder": {"claim_generator_info": {"name": "my-app", "version": "0.1.0"}}}) +manifest_json = json.dumps({ + "title": "Artwork draft", + "assertions": [] +}) +builder = Builder(manifest_json, context=ctx) + +with open("thumb.jpg", "rb") as thumb: + builder.add_resource("thumbnail", thumb) +with open("sketch.png", "rb") as sketch: + builder.add_ingredient( + json.dumps({"title": "Sketch"}), "image/png", sketch + ) + +# Save working store as archive +with open("artwork_manifest.c2pa", "wb") as f: + builder.to_archive(f) + +print("Working store saved to artwork_manifest.c2pa") +``` + +**Phase 2: Sign the asset** + +Restore the working store with a `Context` so that settings (e.g. thumbnails on/off) and the signer are applied: + +```py +ctx = Context.from_dict({ + "builder": {"thumbnail": {"enabled": False}}, + "signer": signer, +}) + +with open("artwork_manifest.c2pa", "rb") as archive: + builder = Builder({}, context=ctx) + builder.with_archive(archive) + +# Sign using the context's signer +with open("artwork.jpg", "rb") as src, open("signed_artwork.jpg", "w+b") as dst: + builder.sign("image/jpeg", src, dst) +``` + +## Embedded versus external manifests + +By default, manifest stores are **embedded** directly into the asset file. You can also use **external** or **remote** manifest stores. + +### Default: embedded manifest stores + +By default, the manifest store is embedded in the output asset. + +```py +ctx = Context.from_dict({"builder": {"thumbnail": {"enabled": True}}, "signer": signer}) +builder = Builder(manifest_json, context=ctx) + +# Default behavior: manifest store is embedded in the output +with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign("image/jpeg", src, dst) + +# Read it back — manifest store is embedded +reader = Reader("signed.jpg", context=ctx) +``` + +### External manifest stores (no embed) + +Call `set_no_embed()` on the `Builder` instance to prevent embedding the manifest store in the asset. For example: + +```py +ctx = Context.from_dict({"signer": signer}) +builder = Builder(manifest_json, context=ctx) + +builder.set_no_embed() # Don't embed the manifest store + +# Sign: manifest store is NOT embedded, manifest bytes are returned +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + manifest_bytes = builder.sign("image/jpeg", src, dst) + +# manifest_bytes contains the manifest store +# Save it separately (as a sidecar file or upload to server) +with open("output.c2pa", "wb") as f: + f.write(manifest_bytes) + +print("Manifest store saved externally to output.c2pa") +``` + +### Remote manifest stores + +Call `set_remote_url()` on the `Builder` instance to reference a manifest store stored at a remote URL. For example: + +```py +ctx = Context.from_dict({"signer": signer}) +builder = Builder(manifest_json, context=ctx) + +builder.set_remote_url("https://example.com/manifests/") + +# The asset will contain a reference to the remote manifest store +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign("image/jpeg", src, dst) +``` + +## Best practices + +- **Use `Context` for all configuration.** Pass a `Context` to `Builder` and `Reader` rather than using global state. See [Context and settings](context-settings.md). +- **Use ingredients to build provenance chains.** Add a `parentOf` ingredient whenever editing an existing asset. See [Working with ingredients](#working-with-ingredients). +- **Never hard-code private keys in production.** Use a Hardware Security Module (HSM) or Key Management Service (KMS) to access credentials. + +## Additional resources + +- [Manifest reference](https://opensource.contentauthenticity.org/docs/manifest/manifest-ref) +- [X.509 certificates](https://opensource.contentauthenticity.org/docs/c2patool/x_509) +- [Trust lists](https://opensource.contentauthenticity.org/docs/conformance/trust-lists/) +- [CAWG identity](https://cawg.io/identity/) diff --git a/examples/README.md b/examples/README.md index 983fc6a8..191e88f4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,15 +1,12 @@ -# Python example code +# Python example code -The `examples` directory contains some small examples of using the Python library. +The `examples` directory contains some small examples of using this Python library. The examples use asset files from the `tests/fixtures` directory, save the resulting signed assets to the temporary `output` directory, and display manifest store data and other output to the console. ## Signing and verifying assets The [`examples/sign.py`](https://github.com/contentauth/c2pa-python/blob/main/examples/sign.py) script shows how to sign an asset with a C2PA manifest and verify the asset. - -The `examples/sign.py` script shows how to sign an asset with a C2PA manifest and verify it using a callback signer. Callback signers let you define signing logic, for example where to load keys from. - The `examples/sign_info.py` script shows how to sign an asset with a C2PA manifest and verify it using a "default" signer created with the needed signer information. These statements create a `builder` object with the specified manifest JSON (omitted in the snippet below), call `builder.sign()` to sign and attach the manifest to the source file, `tests/fixtures/C.jpg`, and save the signed asset to the output file, `output/C_signed.jpg`: @@ -102,3 +99,7 @@ In this example, `SignerInfo` creates a `Signer` object that signs the manifest. ```bash python examples/sign_info.py ``` + +## Backend application example + +[c2pa-python-example](https://github.com/contentauth/c2pa-python-example) is an example of a simple application that accepts an uploaded JPEG image file, attaches a C2PA manifest, and signs it using a certificate. The app uses the CAI Python library and the Flask Python framework to implement a back-end REST endpoint; it does not have an HTML front-end, so you have to use something like curl to access it. This example is a development setup and should not be deployed as-is to a production environment. diff --git a/examples/no_thumbnails.py b/examples/no_thumbnails.py new file mode 100644 index 00000000..2886bc43 --- /dev/null +++ b/examples/no_thumbnails.py @@ -0,0 +1,110 @@ +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +# Shows how to use Context+Settings API to control +# thumbnails added to the manifest. +# +# This example uses Settings to explicitly turn off +# thumbnail addition when signing. + +import json +import os +import c2pa +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.backends import default_backend + +fixtures_dir = os.path.join(os.path.dirname(__file__), "../tests/fixtures/") +output_dir = os.path.join(os.path.dirname(__file__), "../output/") + +# Ensure the output directory exists. +if not os.path.exists(output_dir): + os.makedirs(output_dir) + +# Load certificates and private key (here from the unit test fixtures). +with open(fixtures_dir + "es256_certs.pem", "rb") as cert_file: + certs = cert_file.read() +with open(fixtures_dir + "es256_private.key", "rb") as key_file: + key = key_file.read() + +# Define a callback signer function. +def callback_signer_es256(data: bytes) -> bytes: + """Callback function that signs data using ES256 algorithm.""" + private_key = serialization.load_pem_private_key( + key, + password=None, + backend=default_backend() + ) + signature = private_key.sign( + data, + ec.ECDSA(hashes.SHA256()) + ) + return signature + +# Create a manifest definition. +manifest_definition = { + "claim_generator_info": [{ + "name": "python_no_thumbnail_example", + "version": "0.1.0", + }], + "format": "image/jpeg", + "title": "No Thumbnail Example", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] +} + +# Use Settings to disable thumbnail generation, +# Settings are JSON matching the C2PA SDK settings schema +settings = c2pa.Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } +}) + +print("Signing image with thumbnails disabled through settings...") +with c2pa.Context(settings) as context: + with c2pa.Signer.from_callback( + callback_signer_es256, + c2pa.C2paSigningAlg.ES256, + certs.decode('utf-8'), + "http://timestamp.digicert.com" + ) as signer: + with c2pa.Builder(manifest_definition, context) as builder: + builder.sign_file( + fixtures_dir + "A.jpg", + output_dir + "A_no_thumbnail.jpg", + signer + ) + + # Read the signed image and verify no thumbnail is present. + with c2pa.Reader(output_dir + "A_no_thumbnail.jpg", context=context) as reader: + manifest_store = json.loads(reader.json()) + manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + if manifest.get("thumbnail") is None: + print("No thumbnail in the manifest as per settings.") + else: + print("Thumbnail found in the manifest.") + +print("\nExample completed successfully!") diff --git a/examples/read.py b/examples/read.py index ea30126b..e4b718a9 100644 --- a/examples/read.py +++ b/examples/read.py @@ -11,27 +11,32 @@ def load_trust_anchors(): + """Load trust anchors and return a Settings object holding trust configuration.""" try: with urllib.request.urlopen(TRUST_ANCHORS_URL) as response: anchors = response.read().decode('utf-8') - settings = { + return c2pa.Settings.from_dict({ "verify": { "verify_cert_anchors": True }, "trust": { "trust_anchors": anchors } - } - c2pa.load_settings(settings) + }) except Exception as e: print(f"Warning: Could not load trust anchors from {TRUST_ANCHORS_URL}: {e}") + return None def read_c2pa_data(media_path: str): print(f"Reading {media_path}") try: - with c2pa.Reader(media_path) as reader: - print(reader.detailed_json()) + settings = load_trust_anchors() + # Settings are put into the context, to make sure they propagate. + # All objects using this context will have trust configured. + with c2pa.Context(settings) as context: + with c2pa.Reader(media_path, context=context) as reader: + print(reader.detailed_json()) except Exception as e: print(f"Error reading C2PA data from {media_path}: {e}") sys.exit(1) @@ -43,5 +48,4 @@ def read_c2pa_data(media_path: str): else: media_path = sys.argv[1] - load_trust_anchors() read_c2pa_data(media_path) diff --git a/examples/sign.py b/examples/sign.py index 7182f99a..e6c14859 100644 --- a/examples/sign.py +++ b/examples/sign.py @@ -19,14 +19,13 @@ from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.backends import default_backend -# Note: Builder, Reader, and Signer support being used as context managers -# (with 'with' statements), but this example shows manual usage which requires -# explicitly calling the close() function to clean up resources. +# Note: Builder, Reader, Signer, and Context support being used as context managers +# (with 'with' statements) to automatically clean up resources. fixtures_dir = os.path.join(os.path.dirname(__file__), "../tests/fixtures/") output_dir = os.path.join(os.path.dirname(__file__), "../output/") -# Ensure the output directory exists +# Ensure the output directory exists. if not os.path.exists(output_dir): os.makedirs(output_dir) @@ -43,7 +42,7 @@ with open(fixtures_dir + "es256_private.key", "rb") as key_file: key = key_file.read() -# Define a callback signer function +# Define a callback signer function. def callback_signer_es256(data: bytes) -> bytes: """Callback function that signs data using ES256 algorithm.""" private_key = serialization.load_pem_private_key( @@ -60,7 +59,6 @@ def callback_signer_es256(data: bytes) -> bytes: # Create a manifest definition as a dictionary. # This manifest follows the V2 manifest format. manifest_definition = { - "claim_generator": "python_example", "claim_generator_info": [{ "name": "python_example", "version": "0.0.1", @@ -87,27 +85,31 @@ def callback_signer_es256(data: bytes) -> bytes: } # Sign the image with the signer created above, -# which will use the callback signer +# which will use the callback signer. print("\nSigning the image file...") -with c2pa.Signer.from_callback( - callback=callback_signer_es256, - alg=c2pa.C2paSigningAlg.ES256, - certs=certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" -) as signer: - with c2pa.Builder(manifest_definition) as builder: - builder.sign_file( - source_path=fixtures_dir + "A.jpg", - dest_path=output_dir + "A_signed.jpg", - signer=signer - ) +# Use default Context and Settings. +with c2pa.Context() as context: + with c2pa.Signer.from_callback( + callback_signer_es256, + c2pa.C2paSigningAlg.ES256, + certs.decode('utf-8'), + "http://timestamp.digicert.com" + ) as signer: + with c2pa.Builder(manifest_definition, context) as builder: + builder.sign_file( + fixtures_dir + "A.jpg", + output_dir + "A_signed.jpg", + signer + ) -# Re-Read the signed image to verify -print("\nReading signed image metadata:") -with open(output_dir + "A_signed.jpg", "rb") as file: - with c2pa.Reader("image/jpeg", file) as reader: - print(reader.json()) + # Re-Read the signed image to verify + print("\nReading signed image metadata:") + with open(output_dir + "A_signed.jpg", "rb") as file: + with c2pa.Reader("image/jpeg", file, context=context) as reader: + # The validation state will depend on loaded trust settings. + # Without loaded trust settings, + # the manifest validation_state will be "Invalid". + print(reader.json()) print("\nExample completed successfully!") - diff --git a/examples/sign_info.py b/examples/sign_info.py index 0efa68d8..6b256647 100644 --- a/examples/sign_info.py +++ b/examples/sign_info.py @@ -100,6 +100,9 @@ print("\nReading signed image metadata:") with open(output_dir + "C_signed.jpg", "rb") as file: with c2pa.Reader("image/jpeg", file) as reader: + # The validation state will depend on loaded trust settings. + # Without loaded trust settings, + # the manifest validation_state will be "Invalid". print(reader.json()) print("\nExample completed successfully!") diff --git a/examples/training.py b/examples/training.py index b07d47ab..2bb446ce 100644 --- a/examples/training.py +++ b/examples/training.py @@ -90,7 +90,7 @@ def getitem(d, key): } } -# V2 signing API example +# Signing API example (v2 claims) try: # Read the private key and certificate files with open(keyFile, "rb") as key_file: @@ -106,26 +106,29 @@ def getitem(d, key): ta_url=b"http://timestamp.digicert.com" ) - with c2pa.Signer.from_info(signer_info) as signer: - with c2pa.Builder(manifest_json) as builder: - # Add the thumbnail resource using a stream - with open(fixtures_dir + "A_thumbnail.jpg", "rb") as thumbnail_file: - builder.add_resource("thumbnail", thumbnail_file) + with ( + c2pa.Context() as context, + c2pa.Signer.from_info(signer_info) as signer, + c2pa.Builder(manifest_json, context) as builder, + ): + # Add the thumbnail resource using a stream + with open(fixtures_dir + "A_thumbnail.jpg", "rb") as thumbnail_file: + builder.add_resource("thumbnail", thumbnail_file) - # Add the ingredient using the correct method - with open(fixtures_dir + "A_thumbnail.jpg", "rb") as ingredient_file: - builder.add_ingredient(json.dumps(ingredient_json), "image/jpeg", ingredient_file) + # Add the ingredient to the working store (Builder) + with open(fixtures_dir + "A_thumbnail.jpg", "rb") as ingredient_file: + builder.add_ingredient(json.dumps(ingredient_json), "image/jpeg", ingredient_file) - if os.path.exists(testOutputFile): - os.remove(testOutputFile) + if os.path.exists(testOutputFile): + os.remove(testOutputFile) - # Sign the file using the stream-based sign method - with open(testFile, "rb") as source_file: - with open(testOutputFile, "w+b") as dest_file: - result = builder.sign(signer, "image/jpeg", source_file, dest_file) + # Sign the file using the stream-based sign method + with open(testFile, "rb") as source_file: + with open(testOutputFile, "w+b") as dest_file: + result = builder.sign(signer, "image/jpeg", source_file, dest_file) - # As an alternative, you can also use file paths directly during signing: - # builder.sign_file(testFile, testOutputFile, signer) + # As an alternative, you can also use file paths directly during signing: + # builder.sign_file(testFile, testOutputFile, signer) except Exception as err: print(f"Exception during signing: {err}") @@ -136,8 +139,11 @@ def getitem(d, key): allowed = True # opt out model, assume training is ok if the assertion doesn't exist try: - # Create reader using the Reader API - with c2pa.Reader(testOutputFile) as reader: + # Create reader using the Reader API with default Context + with ( + c2pa.Context() as context, + c2pa.Reader(testOutputFile, context=context) as reader, + ): # Retrieve the manifest store manifest_store = json.loads(reader.json()) diff --git a/pyproject.toml b/pyproject.toml index dc370403..b290d612 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.28.0" +version = "0.29.0" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/requirements-dev.txt b/requirements-dev.txt index 26e511c1..e45dda87 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -16,7 +16,7 @@ autopep8==2.0.4 # For automatic code formatting flake8==7.3.0 # Test dependencies (for callback signers) -cryptography==45.0.6 +cryptography==46.0.5 # Documentation Sphinx>=7.3.0 diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index 96925b85..5a5bfe78 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -22,9 +22,15 @@ C2paError, Reader, C2paSigningAlg, + C2paDigitalSourceType, + C2paBuilderIntent, C2paSignerInfo, Signer, Stream, + Settings, + Context, + ContextBuilder, + ContextProvider, sdk_version, read_ingredient_file, load_settings @@ -36,9 +42,15 @@ 'C2paError', 'Reader', 'C2paSigningAlg', + 'C2paDigitalSourceType', + 'C2paBuilderIntent', 'C2paSignerInfo', 'Signer', 'Stream', + 'Settings', + 'Context', + 'ContextBuilder', + 'ContextProvider', 'sdk_version', 'read_ingredient_file', 'load_settings' diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index b0252279..0ba07318 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -18,6 +18,7 @@ import sys import os import warnings +from abc import ABC, abstractmethod from pathlib import Path from typing import Optional, Union, Callable, Any, overload import io @@ -31,49 +32,70 @@ # Define required function names _REQUIRED_FUNCTIONS = [ + # Version 'c2pa_version', + # Error retriever and parser 'c2pa_error', - 'c2pa_string_free', + # Legacy APIs, deprecated 'c2pa_load_settings', 'c2pa_read_file', 'c2pa_read_ingredient_file', + # Stream + 'c2pa_create_stream', + 'c2pa_release_stream', + # Reader bindings 'c2pa_reader_from_stream', 'c2pa_reader_from_manifest_data_and_stream', - 'c2pa_reader_free', 'c2pa_reader_json', 'c2pa_reader_detailed_json', 'c2pa_reader_resource_to_stream', + 'c2pa_reader_from_context', + 'c2pa_reader_with_stream', + 'c2pa_reader_with_fragment', + 'c2pa_reader_with_manifest_data_and_stream', + 'c2pa_reader_is_embedded', + 'c2pa_reader_remote_url', + 'c2pa_reader_supported_mime_types', + # Builder bindings 'c2pa_builder_from_json', 'c2pa_builder_from_archive', - 'c2pa_builder_free', 'c2pa_builder_set_no_embed', 'c2pa_builder_set_remote_url', + 'c2pa_builder_set_intent', 'c2pa_builder_add_resource', 'c2pa_builder_add_ingredient_from_stream', 'c2pa_builder_add_action', 'c2pa_builder_to_archive', 'c2pa_builder_sign', - 'c2pa_manifest_bytes_free', - 'c2pa_builder_data_hashed_placeholder', - 'c2pa_builder_sign_data_hashed_embeddable', + 'c2pa_builder_sign_context', + 'c2pa_builder_from_context', + 'c2pa_builder_with_definition', + 'c2pa_builder_with_archive', + 'c2pa_builder_supported_mime_types', 'c2pa_format_embeddable', + # Signer bindings 'c2pa_signer_create', 'c2pa_signer_from_info', 'c2pa_signer_reserve_size', - 'c2pa_signer_free', 'c2pa_ed25519_sign', 'c2pa_signature_free', + # Settings bindings + 'c2pa_settings_new', + 'c2pa_settings_set_value', + 'c2pa_settings_update_from_string', + # Context bindings + 'c2pa_context_builder_new', + 'c2pa_context_builder_set_settings', + 'c2pa_context_builder_build', + 'c2pa_context_builder_set_signer', + 'c2pa_context_new', + # Free bindings + 'c2pa_string_free', 'c2pa_free_string_array', - 'c2pa_reader_supported_mime_types', - 'c2pa_builder_supported_mime_types', - 'c2pa_reader_is_embedded', - 'c2pa_reader_remote_url', + 'c2pa_manifest_bytes_free', + 'c2pa_free', ] -# TODO Bindings: -# c2pa_reader_is_embedded -# c2pa_reader_remote_url - def _validate_library_exports(lib): """Validate that all required functions are present in the loaded library. @@ -158,6 +180,146 @@ class C2paSigningAlg(enum.IntEnum): ED25519 = 6 +class C2paDigitalSourceType(enum.IntEnum): + """List of possible digital source types.""" + EMPTY = 0 + TRAINED_ALGORITHMIC_DATA = 1 + DIGITAL_CAPTURE = 2 + COMPUTATIONAL_CAPTURE = 3 + NEGATIVE_FILM = 4 + POSITIVE_FILM = 5 + PRINT = 6 + HUMAN_EDITS = 7 + COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA = 8 + ALGORITHMICALLY_ENHANCED = 9 + DIGITAL_CREATION = 10 + DATA_DRIVEN_MEDIA = 11 + TRAINED_ALGORITHMIC_MEDIA = 12 + ALGORITHMIC_MEDIA = 13 + SCREEN_CAPTURE = 14 + VIRTUAL_RECORDING = 15 + COMPOSITE = 16 + COMPOSITE_CAPTURE = 17 + COMPOSITE_SYNTHETIC = 18 + + +class C2paBuilderIntent(enum.IntEnum): + """Builder intent enumeration. + """ + CREATE = 0 # New digital creation with specified digital source type + EDIT = 1 # Edit of a pre-existing parent asset + UPDATE = 2 # Restricted version of Edit for non-editorial changes + + +class LifecycleState(enum.IntEnum): + """Internal state for lifecycle management. + Object transitions: UNINITIALIZED -> ACTIVE -> CLOSED + """ + UNINITIALIZED = 0 + ACTIVE = 1 + CLOSED = 2 + + +class ManagedResource: + """Base class for objects that hold a native (C FFI) resource. + This is an internal base class that provides lifecycle management + for native resources (e.g. pointers). + + Subclasses must: + - Set `self._handle` to the native pointer after creation. + - Set `self._lifecycle_state = LifecycleState.ACTIVE` once initialized. + - Override `_release()` to free class-specific resources + (streams, caches, callbacks, etc.), called before the + native pointer is freed. + + The native pointer is freed automatically via `_free_native_ptr`. + """ + + def __init__(self): + self._lifecycle_state = LifecycleState.UNINITIALIZED + self._handle = None + _clear_error_state() + + @staticmethod + def _free_native_ptr(ptr): + """Free a native pointer by casting it to c_void_p and calling c2pa_free.""" + _lib.c2pa_free(ctypes.cast(ptr, ctypes.c_void_p)) + + def _ensure_valid_state(self): + """Raise if the resource is closed or uninitialized.""" + name = type(self).__name__ + if self._lifecycle_state == LifecycleState.CLOSED: + raise C2paError(f"{name} is closed") + if self._lifecycle_state != LifecycleState.ACTIVE: + raise C2paError(f"{name} is not properly initialized") + if not self._handle: + raise C2paError(f"{name} has an invalid internal state (active but no handle)") + _clear_error_state() + + def _release(self): + """Override to free class-specific resources (streams, caches, etc.). + + Called during cleanup before the native handle is freed. + The default implementation does nothing. + """ + + def _mark_consumed(self): + """Mark as consumed by an FFI call that took ownership + of native resources e.g. pointers. This means we should not + call clean-up here anymore, and leave it to the new owner. + """ + + self._handle = None + self._lifecycle_state = LifecycleState.CLOSED + + def _cleanup_resources(self): + """Release native resources idempotently.""" + try: + if ( + hasattr(self, '_lifecycle_state') + and self._lifecycle_state != LifecycleState.CLOSED + ): + self._lifecycle_state = LifecycleState.CLOSED + self._release() + if hasattr(self, '_handle') and self._handle: + try: + ManagedResource._free_native_ptr(self._handle) + except Exception: + logger.error( + "Failed to free native %s resources", + type(self).__name__, + ) + finally: + self._handle = None + except Exception: + pass + + @property + def is_valid(self) -> bool: + """Check if the resource is in a valid (active) state.""" + return ( + self._lifecycle_state == LifecycleState.ACTIVE + and self._handle is not None + ) + + def close(self) -> None: + """Release the resource (idempotent, never raises).""" + self._cleanup_resources() + + def __enter__(self): + """For classes with context manager (with) pattern""" + self._ensure_valid_state() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """For classes with context manager (with) pattern""" + self.close() + + def __del__(self): + """Free native resources if close() was not called.""" + self._cleanup_resources() + + # Mapping from C2paSigningAlg enum to string representation, # as the enum value currently maps by default to an integer value. _ALG_TO_STRING_BYTES_MAPPING = { @@ -258,6 +420,7 @@ def _clear_error_state(): # Free the error to clear the state _lib.c2pa_string_free(error) + class C2paSignerInfo(ctypes.Structure): """Configuration for a Signer.""" _fields_ = [ @@ -331,6 +494,21 @@ class C2paBuilder(ctypes.Structure): """Opaque structure for builder context.""" _fields_ = [] # Empty as it's opaque in the C API + +class C2paSettings(ctypes.Structure): + """Opaque structure for settings context.""" + _fields_ = [] # Empty as it's opaque in the C API + + +class C2paContextBuilder(ctypes.Structure): + """Opaque structure for context builder.""" + _fields_ = [] # Empty as it's opaque in the C API + + +class C2paContext(ctypes.Structure): + """Opaque structure for context.""" + _fields_ = [] # Empty as it's opaque in the C API + # Helper function to set function prototypes @@ -376,7 +554,6 @@ def _setup_function(func, argtypes, restype=None): _lib.c2pa_reader_from_manifest_data_and_stream, [ ctypes.c_char_p, ctypes.POINTER(C2paStream), ctypes.POINTER( ctypes.c_ubyte), ctypes.c_size_t], ctypes.POINTER(C2paReader)) -_setup_function(_lib.c2pa_reader_free, [ctypes.POINTER(C2paReader)], None) _setup_function( _lib.c2pa_reader_json, [ ctypes.POINTER(C2paReader)], ctypes.c_void_p) @@ -408,12 +585,19 @@ def _setup_function(func, argtypes, restype=None): _setup_function(_lib.c2pa_builder_from_archive, [ctypes.POINTER(C2paStream)], ctypes.POINTER(C2paBuilder)) -_setup_function(_lib.c2pa_builder_free, [ctypes.POINTER(C2paBuilder)], None) +_setup_function( + _lib.c2pa_builder_with_archive, + [ctypes.POINTER(C2paBuilder), ctypes.POINTER(C2paStream)], + ctypes.POINTER(C2paBuilder)) _setup_function(_lib.c2pa_builder_set_no_embed, [ ctypes.POINTER(C2paBuilder)], None) _setup_function( _lib.c2pa_builder_set_remote_url, [ ctypes.POINTER(C2paBuilder), ctypes.c_char_p], ctypes.c_int) +_setup_function( + _lib.c2pa_builder_set_intent, + [ctypes.POINTER(C2paBuilder), ctypes.c_uint, ctypes.c_uint], + ctypes.c_int) _setup_function(_lib.c2pa_builder_add_resource, [ctypes.POINTER( C2paBuilder), ctypes.c_char_p, ctypes.POINTER(C2paStream)], ctypes.c_int) _setup_function(_lib.c2pa_builder_add_ingredient_from_stream, @@ -440,21 +624,6 @@ def _setup_function(func, argtypes, restype=None): _lib.c2pa_manifest_bytes_free, [ ctypes.POINTER( ctypes.c_ubyte)], None) -_setup_function( - _lib.c2pa_builder_data_hashed_placeholder, [ - ctypes.POINTER(C2paBuilder), ctypes.c_size_t, ctypes.c_char_p, - ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)) - ], - ctypes.c_int64, -) -_setup_function(_lib.c2pa_builder_sign_data_hashed_embeddable, - [ctypes.POINTER(C2paBuilder), - ctypes.POINTER(C2paSigner), - ctypes.c_char_p, - ctypes.c_char_p, - ctypes.POINTER(C2paStream), - ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], - ctypes.c_int64) _setup_function( _lib.c2pa_format_embeddable, [ ctypes.c_char_p, ctypes.POINTER( @@ -482,7 +651,6 @@ def _setup_function(func, argtypes, restype=None): _setup_function( _lib.c2pa_signer_reserve_size, [ ctypes.POINTER(C2paSigner)], ctypes.c_int64) -_setup_function(_lib.c2pa_signer_free, [ctypes.POINTER(C2paSigner)], None) _setup_function( _lib.c2pa_ed25519_sign, [ ctypes.POINTER( @@ -498,73 +666,203 @@ def _setup_function(func, argtypes, restype=None): ctypes.POINTER(ctypes.c_char_p) ) +# Set up Settings function prototypes +_setup_function(_lib.c2pa_settings_new, [], ctypes.POINTER(C2paSettings)) +_setup_function( + _lib.c2pa_settings_set_value, + [ctypes.POINTER(C2paSettings), ctypes.c_char_p, ctypes.c_char_p], + ctypes.c_int +) +_setup_function( + _lib.c2pa_settings_update_from_string, + [ctypes.POINTER(C2paSettings), ctypes.c_char_p, ctypes.c_char_p], + ctypes.c_int +) + +# Set up ContextBuilder function prototypes +_setup_function( + _lib.c2pa_context_builder_new, + [], + ctypes.POINTER(C2paContextBuilder) +) +_setup_function( + _lib.c2pa_context_builder_set_settings, + [ctypes.POINTER(C2paContextBuilder), ctypes.POINTER(C2paSettings)], + ctypes.c_int +) +_setup_function( + _lib.c2pa_context_builder_build, + [ctypes.POINTER(C2paContextBuilder)], + ctypes.POINTER(C2paContext) +) + +# Set up Context function prototypes +_setup_function(_lib.c2pa_context_new, [], ctypes.POINTER(C2paContext)) +_setup_function( + _lib.c2pa_reader_from_context, + [ctypes.POINTER(C2paContext)], + ctypes.POINTER(C2paReader) +) +_setup_function( + _lib.c2pa_reader_with_stream, + [ctypes.POINTER(C2paReader), ctypes.c_char_p, + ctypes.POINTER(C2paStream)], + ctypes.POINTER(C2paReader) +) +_setup_function( + _lib.c2pa_reader_with_manifest_data_and_stream, + [ctypes.POINTER(C2paReader), ctypes.c_char_p, + ctypes.POINTER(C2paStream), + ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t], + ctypes.POINTER(C2paReader), +) +_setup_function( + _lib.c2pa_reader_with_fragment, + [ctypes.POINTER(C2paReader), ctypes.c_char_p, + ctypes.POINTER(C2paStream), ctypes.POINTER(C2paStream)], + ctypes.POINTER(C2paReader) +) +_setup_function( + _lib.c2pa_builder_from_context, + [ctypes.POINTER(C2paContext)], + ctypes.POINTER(C2paBuilder) +) +_setup_function( + _lib.c2pa_builder_with_definition, + [ctypes.POINTER(C2paBuilder), ctypes.c_char_p], + ctypes.POINTER(C2paBuilder) +) +_setup_function(_lib.c2pa_free, [ctypes.c_void_p], ctypes.c_int) + +_setup_function( + _lib.c2pa_context_builder_set_signer, + [ctypes.POINTER(C2paContextBuilder), ctypes.POINTER(C2paSigner)], + ctypes.c_int +) +_setup_function( + _lib.c2pa_builder_sign_context, + [ctypes.POINTER(C2paBuilder), + ctypes.c_char_p, + ctypes.POINTER(C2paStream), + ctypes.POINTER(C2paStream), + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64 +) + class C2paError(Exception): - """Exception raised for C2PA errors.""" + """Exception raised for C2PA errors. + + This is the base class for all C2PA exceptions. Catching C2paError will + catch all typed C2PA exceptions (e.g., C2paError.ManifestNotFound). + """ def __init__(self, message: str = ""): self.message = message super().__init__(message) - class Assertion(Exception): - """Exception raised for assertion errors.""" - pass - class AssertionNotFound(Exception): - """Exception raised when an assertion is not found.""" - pass +# Define typed exception subclasses that inherit from C2paError +# These are attached to C2paError as class attributes for backward compatibility +# (eg., C2paError.ManifestNotFound), and also to ensure properly inheritance hierarchy + +class _C2paAssertion(C2paError): + """Exception raised for assertion errors.""" + pass + + +class _C2paAssertionNotFound(C2paError): + """Exception raised when an assertion is not found.""" + pass + + +class _C2paDecoding(C2paError): + """Exception raised for decoding errors.""" + pass + + +class _C2paEncoding(C2paError): + """Exception raised for encoding errors.""" + pass + + +class _C2paFileNotFound(C2paError): + """Exception raised when a file is not found.""" + pass + + +class _C2paIo(C2paError): + """Exception raised for IO errors.""" + pass + - class Decoding(Exception): - """Exception raised for decoding errors.""" - pass +class _C2paJson(C2paError): + """Exception raised for JSON errors.""" + pass - class Encoding(Exception): - """Exception raised for encoding errors.""" - pass - class FileNotFound(Exception): - """Exception raised when a file is not found.""" - pass +class _C2paManifest(C2paError): + """Exception raised for manifest errors.""" + pass - class Io(Exception): - """Exception raised for IO errors.""" - pass - class Json(Exception): - """Exception raised for JSON errors.""" - pass +class _C2paManifestNotFound(C2paError): + """ + Exception raised when a manifest is not found, + aka there is no C2PA metadata to read + aka there is no JUMBF data to read. + """ + pass + + +class _C2paNotSupported(C2paError): + """Exception raised for unsupported operations.""" + pass + + +class _C2paOther(C2paError): + """Exception raised for other errors.""" + pass + + +class _C2paRemoteManifest(C2paError): + """Exception raised for remote manifest errors.""" + pass - class Manifest(Exception): - """Exception raised for manifest errors.""" - pass - class ManifestNotFound(Exception): - """Exception raised when a manifest is not found.""" - pass +class _C2paResourceNotFound(C2paError): + """Exception raised when a resource is not found.""" + pass - class NotSupported(Exception): - """Exception raised for unsupported operations.""" - pass - class Other(Exception): - """Exception raised for other errors.""" - pass +class _C2paSignature(C2paError): + """Exception raised for signature errors.""" + pass - class RemoteManifest(Exception): - """Exception raised for remote manifest errors.""" - pass - class ResourceNotFound(Exception): - """Exception raised when a resource is not found.""" - pass +class _C2paVerify(C2paError): + """Exception raised for verification errors.""" + pass - class Signature(Exception): - """Exception raised for signature errors.""" - pass - class Verify(Exception): - """Exception raised for verification errors.""" - pass +# Attach exception subclasses to C2paError for backward compatibility +# Preserves behavior for exception catching like except C2paError.ManifestNotFound, +# also reduces imports (think of it as an alias of sorts) +C2paError.Assertion = _C2paAssertion +C2paError.AssertionNotFound = _C2paAssertionNotFound +C2paError.Decoding = _C2paDecoding +C2paError.Encoding = _C2paEncoding +C2paError.FileNotFound = _C2paFileNotFound +C2paError.Io = _C2paIo +C2paError.Json = _C2paJson +C2paError.Manifest = _C2paManifest +C2paError.ManifestNotFound = _C2paManifestNotFound +C2paError.NotSupported = _C2paNotSupported +C2paError.Other = _C2paOther +C2paError.RemoteManifest = _C2paRemoteManifest +C2paError.ResourceNotFound = _C2paResourceNotFound +C2paError.Signature = _C2paSignature +C2paError.Verify = _C2paVerify class _StringContainer: @@ -619,10 +917,83 @@ def _convert_to_py_string(value) -> str: return py_string +def _raise_typed_c2pa_error(error_str: str) -> None: + """Parse an error string and raise the appropriate typed C2paError. + + Error strings from the native library have the format "ErrorType: message". + This function parses the error type and raises the corresponding + C2paError subclass with the full original error string as the message. + + Args: + error_str: The error string from the native library + + Raises: + C2paError subclass: The appropriate typed exception based on error_str + """ + # Error format from native library is "ErrorType: message" or "ErrorType message" + # Try splitting on ": " first (colon-space), then fall back to space only + if ': ' in error_str: + parts = error_str.split(': ', 1) + else: + parts = error_str.split(' ', 1) + if len(parts) > 1: + error_type = parts[0] + # Use the full error string as the message for backward compatibility + if error_type == "Assertion": + raise C2paError.Assertion(error_str) + elif error_type == "AssertionNotFound": + raise C2paError.AssertionNotFound(error_str) + elif error_type == "Decoding": + raise C2paError.Decoding(error_str) + elif error_type == "Encoding": + raise C2paError.Encoding(error_str) + elif error_type == "FileNotFound": + raise C2paError.FileNotFound(error_str) + elif error_type == "Io": + raise C2paError.Io(error_str) + elif error_type == "Json": + raise C2paError.Json(error_str) + elif error_type == "Manifest": + raise C2paError.Manifest(error_str) + elif error_type == "ManifestNotFound": + raise C2paError.ManifestNotFound(error_str) + elif error_type == "NotSupported": + raise C2paError.NotSupported(error_str) + elif error_type == "Other": + raise C2paError.Other(error_str) + elif error_type == "RemoteManifest": + raise C2paError.RemoteManifest(error_str) + elif error_type == "ResourceNotFound": + raise C2paError.ResourceNotFound(error_str) + elif error_type == "Signature": + raise C2paError.Signature(error_str) + elif error_type == "Verify": + raise C2paError.Verify(error_str) + # If no recognized error type, raise base C2paError + raise C2paError(error_str) + + def _parse_operation_result_for_error( result: ctypes.c_void_p | None, check_error: bool = True) -> Optional[str]: - """Helper function to handle string results from C2PA functions.""" + """Helper function to handle string results from C2PA functions. + + When result is falsy and check_error is True, this function retrieves the + error from the native library, parses it, and raises a typed C2paError. + + When result is truthy (a pointer to an error string), this function + converts it to a Python string, parses it, and raises a typed C2paError. + + Args: + result: A pointer to a result string, or None/falsy on error + check_error: Whether to check for errors when result is falsy + + Returns: + None if no error occurred + + Raises: + C2paError subclass: The appropriate typed exception if an error occurred + """ if not result: # pragma: no cover if check_error: error = _lib.c2pa_error() @@ -630,49 +1001,78 @@ def _parse_operation_result_for_error( error_str = ctypes.cast( error, ctypes.c_char_p).value.decode('utf-8') _lib.c2pa_string_free(error) - parts = error_str.split(' ', 1) - if len(parts) > 1: - error_type, message = parts - if error_type == "Assertion": - raise C2paError.Assertion(message) - elif error_type == "AssertionNotFound": - raise C2paError.AssertionNotFound(message) - elif error_type == "Decoding": - raise C2paError.Decoding(message) - elif error_type == "Encoding": - raise C2paError.Encoding(message) - elif error_type == "FileNotFound": - raise C2paError.FileNotFound(message) - elif error_type == "Io": - raise C2paError.Io(message) - elif error_type == "Json": - raise C2paError.Json(message) - elif error_type == "Manifest": - raise C2paError.Manifest(message) - elif error_type == "ManifestNotFound": - raise C2paError.ManifestNotFound(message) - elif error_type == "NotSupported": - raise C2paError.NotSupported(message) - elif error_type == "Other": - raise C2paError.Other(message) - elif error_type == "RemoteManifest": - raise C2paError.RemoteManifest(message) - elif error_type == "ResourceNotFound": - raise C2paError.ResourceNotFound(message) - elif error_type == "Signature": - raise C2paError.Signature(message) - elif error_type == "Verify": - raise C2paError.Verify(message) - return error_str + _raise_typed_c2pa_error(error_str) return None # In the case result would be a string already (error message) - return _convert_to_py_string(result) + error_str = _convert_to_py_string(result) + if error_str: + _raise_typed_c2pa_error(error_str) + return None + + +def _check_ffi_operation_result(result, fallback_msg, *, check=lambda r: not r): + """Check an FFI native call result and raise C2paError if it indicates failure. + + Args: + result: The return value from the FFI call + fallback_msg: Error message if the native library has no error details + check: Predicate that returns True when the result indicates failure. + Defaults to `not r` (for pointer-returning calls). + Use `lambda r: r != 0` for status-code-returning calls. + Use `lambda r: r < 0` for signed-result calls. + + Returns: + The result unchanged, if the check passed. + + Raises: + C2paError: If the check indicates failure + """ + if check(result): + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError(fallback_msg) + return result + + +def _to_utf8_bytes(data: Union[str, dict], error_context: str = "input") -> bytes: + """Convert a string or dict to UTF-8 bytes. + + If data is a dict, it is serialized to JSON first. + + Args: + data: String or dict to encode. + error_context: Description for error messages. + + Returns: + UTF-8 encoded bytes. + + Raises: + C2paError.Json: If dict serialization fails. + C2paError.Encoding: If UTF-8 encoding fails or data is not a supported type. + """ + if isinstance(data, dict): + try: + data = json.dumps(data) + except (TypeError, ValueError) as e: + raise C2paError.Json(f"Failed to serialize {error_context}: {e}") + if not isinstance(data, str): + raise C2paError.Encoding( + f"Expected str or dict for {error_context}, got {type(data).__name__}" + ) + try: + return data.encode('utf-8') + except UnicodeError as e: + raise C2paError.Encoding(f"Invalid UTF-8 in {error_context}: {e}") def sdk_version() -> str: """ Returns the underlying c2pa-rs/c2pa-c-ffi version string + c2pa-rs and c2pa-c-ffi versions are in lockstep release, + so the version string is the same for both and we return + the shared semantic version number. """ vstr = version() # Example: "c2pa-c/0.60.1 c2pa-rs/0.60.1" @@ -684,7 +1084,11 @@ def sdk_version() -> str: def version() -> str: - """Get the C2PA library version.""" + """ + Get the C2PA library version with the fully qualified names + of the native core libraries (library names and semantic version + numbers). + """ result = _lib.c2pa_version() return _convert_to_py_string(result) @@ -700,7 +1104,14 @@ def load_settings(settings: dict) -> None: def load_settings(settings: Union[str, dict], format: str = "json") -> None: - """Load C2PA settings from a string or dict. + """Load C2PA settings into thread-local storage from a string or dict. + + .. deprecated:: + Use :class:`Settings` and :class:`Context` for + per-instance configuration instead. Settings and + Context will propagate configurations through object instances, + no thread-local configurations. Avoid mixing Context APIs + and legacy load_settings usage. Args: settings: The settings string or dict to load @@ -710,6 +1121,12 @@ def load_settings(settings: Union[str, dict], format: str = "json") -> None: Raises: C2paError: If there was an error loading the settings """ + warnings.warn( + "load_settings() is deprecated. Use Settings" + " and Context for per-instance configuration.", + DeprecationWarning, + stacklevel=2, + ) _clear_error_state() # Convert to JSON string as necessary @@ -729,11 +1146,7 @@ def load_settings(settings: Union[str, dict], format: str = "json") -> None: raise C2paError(f"Failed to encode settings to UTF-8: {e}") result = _lib.c2pa_load_settings(settings_bytes, format_bytes) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error loading settings") + _check_ffi_operation_result(result, "Error loading settings", check=lambda r: r != 0) return result @@ -806,13 +1219,7 @@ def read_ingredient_file( result = _lib.c2pa_read_ingredient_file( container._path_str, container._data_dir_str) - if result is None: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - "Error reading ingredient file {}".format(path) - ) + _check_ffi_operation_result(result, "Error reading ingredient file {}".format(path)) return _convert_to_py_string(result) @@ -851,13 +1258,7 @@ def read_file(path: Union[str, Path], container._data_dir_str = str(data_dir).encode('utf-8') result = _lib.c2pa_read_file(container._path_str, container._data_dir_str) - if result is None: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error is not None: - raise C2paError(error) - raise C2paError.Other( - "Error during read of manifest from file {}".format(path) - ) + _check_ffi_operation_result(result, "Error during read of manifest from file {}".format(path)) return _convert_to_py_string(result) @@ -977,67 +1378,403 @@ def sign_file( signer.close() -class Stream: - # Class-level somewhat atomic counter for generating - # unique stream IDs (useful for tracing streams usage in debug) - _stream_id_counter = count(start=0, step=1) +class ContextProvider(ABC): + """Abstract base class for types that provide a C2PA context. - # Maximum value for a 32-bit signed integer (2^31 - 1) - _MAX_STREAM_ID = 2**31 - 1 + Subclass to implement a custom context provider. + The built-in Context class is the standard implementation. + """ - # Class-level error messages to avoid multiple creation - _ERROR_MESSAGES = { - 'stream_error': "Error cleaning up stream: {}", - 'callback_error': "Error cleaning up callback {}: {}", - 'cleanup_error': "Error during cleanup: {}", - 'read': "Stream is closed or not initialized during read operation", - 'memory_error': "Memory error during stream operation: {}", - 'read_error': "Error during read operation: {}", - 'seek': "Stream is closed or not initialized during seek operation", - 'seek_error': "Error during seek operation: {}", - 'write': "Stream is closed or not initialized during write operation", - 'write_error': "Error during write operation: {}", - 'flush': "Stream is closed or not initialized during flush operation", - 'flush_error': "Error during flush operation: {}" - } + @property + @abstractmethod + def is_valid(self) -> bool: + """Whether this provider is in a usable state. - def __init__(self, file_like_stream): - """Initialize a new Stream wrapper around a file-like object - (or an in-memory stream). + Return True when the underlying native context is active + and its handle has not been freed or consumed. Return + False after the provider has been closed or invalidated. - Args: - file_like_stream: A file-like stream object or an in-memory stream - that implements read, write, seek, tell, and flush methods + The ManagedResource base class provides a standard + implementation that checks lifecycle state and handle + presence. + """ + ... - Raises: - TypeError: The file stream object doesn't - implement all required methods + @property + @abstractmethod + def execution_context(self): + """Return the raw native C2paContext pointer. + + The returned pointer must be valid for the duration of any + FFI call that uses it. Callers should check is_valid before + accessing this property. """ - # Initialize _closed first to prevent AttributeError - # during garbage collection - self._closed = False - self._initialized = False - self._stream = None + ... - # Generate unique stream ID using object ID and counter - stream_counter = next(Stream._stream_id_counter) - # Handle counter overflow by resetting the counter - if stream_counter >= Stream._MAX_STREAM_ID: # pragma: no cover - # Reset the counter to 0 and get the next value - Stream._stream_id_counter = count(start=0, step=1) - stream_counter = next(Stream._stream_id_counter) +class Settings(ManagedResource): + """Configuration for C2PA operations. - self._stream_id = f"{id(self)}-{stream_counter}" + Settings configure SDK behavior. Use with Context class to + apply settings to Reader/Builder operations. + """ - # Rest of the existing initialization code... - required_methods = ['read', 'write', 'seek', 'tell', 'flush'] - missing_methods = [ - method for method in required_methods if not hasattr( - file_like_stream, method)] - if missing_methods: - raise TypeError( - "Object must be a stream-like object with methods: {}. " + + def __init__(self): + """Create new Settings with default values.""" + super().__init__() + + ptr = _lib.c2pa_settings_new() + _check_ffi_operation_result(ptr, "Failed to create Settings") + + self._handle = ptr + self._lifecycle_state = LifecycleState.ACTIVE + + @classmethod + def from_json(cls, json_str: str) -> 'Settings': + """Create Settings from a serialized JSON configuration string. + + Args: + json_str: JSON string with settings configuration. + + Returns: + A new Settings instance with the given configuration. + """ + settings = cls() + settings.update(json_str) + return settings + + @classmethod + def from_dict(cls, config: dict) -> 'Settings': + """Create Settings from a (JSON-based)dictionary. + + Args: + config: Dictionary with settings configuration. + + Returns: + A new Settings instance with the given configuration. + """ + return cls.from_json(json.dumps(config)) + + def set(self, path: str, value: str) -> 'Settings': + """Set a configuration value by dot-notation path. + + Args: + path: Dot-notation path (e.g. "builder.thumbnail.enabled"). + value: The value to set. + + Returns: + self, for method chaining. + """ + self._ensure_valid_state() + + path_bytes = _to_utf8_bytes(path, "settings path") + value_bytes = _to_utf8_bytes(value, "settings value") + + result = _lib.c2pa_settings_set_value( + self._handle, path_bytes, value_bytes + ) + if result != 0: + _parse_operation_result_for_error(None) + + return self + + def update( + self, data: Union[str, dict], + ) -> 'Settings': + """Update current configuration from a JSON string or dict. + If the updated string overwrite an existing settings value, + the last setting value set for that property wins. + + Args: + data: A JSON string or dict with configuration to merge. + + Returns: + self, for method chaining. + """ + self._ensure_valid_state() + + data_bytes = _to_utf8_bytes(data, "settings data") + + result = _lib.c2pa_settings_update_from_string( + self._handle, data_bytes, b"json" + ) + if result != 0: + _parse_operation_result_for_error(None) + + return self + + @property + def _c_settings(self): + """Expose the raw pointer for Context to consume.""" + self._ensure_valid_state() + return self._handle + + + +class ContextBuilder: + """Fluent builder for Context. + + Use Context.builder() to create an instance. + """ + + def __init__(self): + self._settings = None + self._signer = None + + def with_settings( + self, settings: 'Settings', + ) -> 'ContextBuilder': + """Attach Settings to the Context being built. + + Can be called multiple times, but each call replaces + the previous Settings object entirely (the last one wins). + To merge multiple configurations, use Settings.update() + on a single Settings instance before passing it in. + + Args: + settings: The Settings instance to use. + + Returns: + self, for method chaining. + """ + self._settings = settings + return self + + def with_signer( + self, signer: 'Signer', + ) -> 'ContextBuilder': + """Attach a Signer (will be consumed on build).""" + self._signer = signer + return self + + def build(self) -> 'Context': + """Build and return a configured Context.""" + return Context( + settings=self._settings, + signer=self._signer, + ) + + +class Context(ManagedResource, ContextProvider): + """Per-instance context for C2PA operations. + + A Context may carry Settings and a Signer, + and is passed to Reader or Builder to control their behavior, + thus propagating settings and configurations by passing + the Context object (+settings on it) as parameter. + + When a Signer is provided, the Signer object is consumed, + as it becomes included into the Context, and must not be + used directly again after that. + """ + + + def __init__( + self, + settings: Optional['Settings'] = None, + signer: Optional['Signer'] = None, + ): + """Create a Context. + + Args: + settings: Optional Settings for configuration. + If None, default SDK settings are used. + signer: Optional Signer. If provided it is consumed + and must not be used directly again after that call. + + Raises: + C2paError: If creation fails + """ + super().__init__() + self._has_signer = False + self._signer_callback_cb = None + + if settings is None and signer is None: + # Simple default context + ptr = _lib.c2pa_context_new() + _check_ffi_operation_result( + ptr, "Failed to create Context" + ) + self._handle = ptr + else: + # Use ContextBuilder for settings/signer + builder_ptr = _lib.c2pa_context_builder_new() + _check_ffi_operation_result( + builder_ptr, "Failed to create ContextBuilder" + ) + + try: + if settings is not None: + result = ( + _lib.c2pa_context_builder_set_settings( + builder_ptr, settings._c_settings, + ) + ) + if result != 0: + _parse_operation_result_for_error(None) + + if signer is not None: + signer._ensure_valid_state() + result = ( + _lib.c2pa_context_builder_set_signer( + builder_ptr, signer._handle, + ) + ) + if result != 0: + _parse_operation_result_for_error(None) + + # Build consumes builder_ptr + ptr = ( + _lib.c2pa_context_builder_build(builder_ptr) + ) + builder_ptr = None + self._handle = ptr + + _check_ffi_operation_result( + ptr, "Failed to build Context" + ) + + # Build succeeded, consume the Signer. + # Keep its callback ref alive on this Context, + # then mark it so it won't double-free the + # pointer the Context now owns. + if signer is not None: + self._signer_callback_cb = signer._callback_cb + signer._mark_consumed() + self._has_signer = True + except Exception: + # Free builder if build was not reached + if builder_ptr is not None: + try: + ManagedResource._free_native_ptr(builder_ptr) + except Exception: + pass + raise + + self._lifecycle_state = LifecycleState.ACTIVE + + def _release(self): + """Release Context-specific resources.""" + self._signer_callback_cb = None + + @classmethod + def builder(cls) -> 'ContextBuilder': + """Return a fluent ContextBuilder.""" + return ContextBuilder() + + @classmethod + def from_json( + cls, + json_str: str, + signer: Optional['Signer'] = None, + ) -> 'Context': + """Create Context from a JSON configuration string. + + Args: + json_str: JSON string with settings config. + signer: Optional Signer (consumed if provided). + + Returns: + A new Context instance. + """ + settings = Settings.from_json(json_str) + try: + return cls(settings=settings, signer=signer) + finally: + settings.close() + + @classmethod + def from_dict( + cls, + config: dict, + signer: Optional['Signer'] = None, + ) -> 'Context': + """Create Context from a dictionary. + + Args: + config: Dictionary with settings configuration. + signer: Optional Signer (consumed if provided). + + Returns: + A new Context instance. + """ + return cls.from_json(json.dumps(config), signer=signer) + + @property + def has_signer(self) -> bool: + """Whether this context was created with a signer.""" + return self._has_signer + + @property + def execution_context(self): + """Return the raw C2paContext pointer.""" + self._ensure_valid_state() + return self._handle + + + +class Stream: + # Class-level somewhat atomic counter for generating + # unique stream IDs (useful for tracing streams usage in debug) + _stream_id_counter = count(start=0, step=1) + + # Maximum value for a 32-bit signed integer (2^31 - 1) + _MAX_STREAM_ID = 2**31 - 1 + + # Class-level error messages to avoid multiple creation + _ERROR_MESSAGES = { + 'stream_error': "Error cleaning up stream: {}", + 'callback_error': "Error cleaning up callback {}: {}", + 'cleanup_error': "Error during cleanup: {}", + 'read': "Stream is closed or not initialized during read operation", + 'memory_error': "Memory error during stream operation: {}", + 'read_error': "Error during read operation: {}", + 'seek': "Stream is closed or not initialized during seek operation", + 'seek_error': "Error during seek operation: {}", + 'write': "Stream is closed or not initialized during write operation", + 'write_error': "Error during write operation: {}", + 'flush': "Stream is closed or not initialized during flush operation", + 'flush_error': "Error during flush operation: {}" + } + + def __init__(self, file_like_stream): + """Initialize a new Stream wrapper around a file-like object + (or an in-memory stream). + + Args: + file_like_stream: A file-like stream object or an in-memory stream + that implements read, write, seek, tell, and flush methods + + Raises: + TypeError: The file stream object doesn't + implement all required methods + """ + # Initialize _closed first to prevent AttributeError + # during garbage collection + self._closed = False + self._initialized = False + self._stream = None + + # Generate unique stream ID using object ID and counter + stream_counter = next(Stream._stream_id_counter) + + # Handle counter overflow by resetting the counter + if stream_counter >= Stream._MAX_STREAM_ID: # pragma: no cover + # Reset the counter to 0 and get the next value + Stream._stream_id_counter = count(start=0, step=1) + stream_counter = next(Stream._stream_id_counter) + + self._stream_id = f"{id(self)}-{stream_counter}" + + # Rest of the existing initialization code... + required_methods = ['read', 'write', 'seek', 'tell', 'flush'] + missing_methods = [ + method for method in required_methods if not hasattr( + file_like_stream, method)] + if missing_methods: + raise TypeError( + "Object must be a stream-like object with methods: {}. " "Missing: {}".format( ", ".join(required_methods), ", ".join(missing_methods), @@ -1176,6 +1913,7 @@ def flush_callback(ctx): self._flush_cb = FlushCallback(flush_callback) # Create the stream + _clear_error_state() self._stream = _lib.c2pa_create_stream( None, self._read_cb, @@ -1293,7 +2031,89 @@ def initialized(self) -> bool: return self._initialized -class Reader: +def _get_supported_mime_types(ffi_func, cache): + """Shared helper to retrieve supported MIME types from the native library. + + Args: + ffi_func: The FFI function to call (e.g. _lib.c2pa_reader_supported_mime_types) + cache: The current cache value (frozenset or None) + + Returns: + A tuple of (list of MIME type strings, updated cache value) + """ + if cache is not None: + return list(cache), cache + + _clear_error_state() + count = ctypes.c_size_t() + arr = ffi_func(ctypes.byref(count)) + + if not arr: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(f"Failed to get supported MIME types: {error}") + return [], cache + + if count.value <= 0: + try: + _lib.c2pa_free_string_array(arr, count.value) + except Exception: + pass + return [], cache + + try: + result = [] + for i in range(count.value): + try: + if arr[i] is None: + continue + mime_type = arr[i].decode("utf-8", errors='replace') + if mime_type: + result.append(mime_type) + except Exception: + continue + finally: + try: + _lib.c2pa_free_string_array(arr, count.value) + except Exception: + pass + + if result: + cache = frozenset(result) + + if cache: + return list(cache), cache + return [], cache + + +def _validate_and_encode_format( + format_str: str, supported_types: list[str], class_name: str +) -> bytes: + """Validate a MIME type / format string and encode it to UTF-8 bytes. + + Args: + format_str: The MIME type or format string to validate + supported_types: List of supported MIME types + class_name: Name of the calling class (for error messages) + + Returns: + UTF-8 encoded format bytes + + Raises: + C2paError.NotSupported: If the format is not supported + C2paError.Encoding: If the string contains invalid UTF-8 characters + """ + if format_str.lower() not in supported_types: + raise C2paError.NotSupported( + f"{class_name} does not support {format_str}") + try: + return format_str.encode('utf-8') + except UnicodeError as e: + raise C2paError.Encoding( + f"Invalid UTF-8 characters in input: {e}") + + +class Reader(ManagedResource): """High-level wrapper for C2PA Reader operations. Example: @@ -1304,6 +2124,7 @@ class Reader: Where `output` is either an in-memory stream or an opened file. """ + # Supported mimetypes cache _supported_mime_types_cache = None @@ -1318,7 +2139,8 @@ class Reader: 'file_error': "Error cleaning up file: {}", 'reader_cleanup_error': "Error cleaning up reader: {}", 'encoding_error': "Invalid UTF-8 characters in input: {}", - 'closed_error': "Reader is closed" + 'closed_error': "Reader is closed", + 'fragment_error': "Failed to process fragment: {}" } @classmethod @@ -1332,95 +2154,147 @@ def get_supported_mime_types(cls) -> list[str]: Raises: C2paError: If there was an error retrieving the MIME types """ - if cls._supported_mime_types_cache is not None: - return cls._supported_mime_types_cache + result, cls._supported_mime_types_cache = _get_supported_mime_types( + _lib.c2pa_reader_supported_mime_types, cls._supported_mime_types_cache + ) + return result - count = ctypes.c_size_t() - arr = _lib.c2pa_reader_supported_mime_types(ctypes.byref(count)) + @classmethod + def _is_mime_type_supported(cls, mime_type: str) -> bool: + """Check if a MIME type is supported. - # Validate the returned array pointer - if not arr: - # If no array returned, check for errors - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(f"Failed to get supported MIME types: {error}") - # Return empty list if no error but no array - return [] + Args: + mime_type: The MIME type to check - # Validate count value - if count.value <= 0: - # Free the array even if count is invalid - try: - _lib.c2pa_free_string_array(arr, count.value) - except Exception: - pass - return [] + Returns: + True if the MIME type is supported + """ + if cls._supported_mime_types_cache is None: + cls.get_supported_mime_types() + return mime_type in cls._supported_mime_types_cache - try: - result = [] - for i in range(count.value): - try: - # Validate each array element before accessing - if arr[i] is None: - continue + @classmethod + @overload + def try_create( + cls, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None, + ) -> Optional["Reader"]: ... - mime_type = arr[i].decode("utf-8", errors='replace') - if mime_type: - result.append(mime_type) - except Exception: - # Ignore cleanup errors - continue - finally: - # Always free the native memory, even if string extraction fails - try: - _lib.c2pa_free_string_array(arr, count.value) - except Exception: - # Ignore cleanup errors - pass + @classmethod + @overload + def try_create( + cls, + format_or_path: Union[str, Path], + stream: Optional[Any], + manifest_data: Optional[Any], + context: 'ContextProvider', + ) -> Optional["Reader"]: ... + + @classmethod + def try_create( + cls, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None, + context: Optional['ContextProvider'] = None, + ) -> Optional["Reader"]: + """This is a factory method to create a new Reader, + returning None if no manifest/c2pa data/JUMBF data could be read + (instead of raising a ManifestNotFound: no JUMBF data found exception). + + Returns None instead of raising C2paError.ManifestNotFound if no + C2PA manifest data is found in the asset. This is useful when you + want to check if an asset contains C2PA data without handling + exceptions for the expected case of no manifest. + + Args: + format_or_path: The format or path to read from + stream: Optional stream to read from (Python stream-like object) + manifest_data: Optional manifest data in bytes + context: Optional ContextProvider for settings - # Cache the result - if result: - cls._supported_mime_types_cache = result + Returns: + Reader instance if the asset contains C2PA data, + None if no manifest found (ManifestNotFound: no JUMBF data found) - return cls._supported_mime_types_cache + Raises: + C2paError: If there was an error other than ManifestNotFound + """ + try: + return cls( + format_or_path, stream, manifest_data, + context=context, + ) + except C2paError.ManifestNotFound: + return None - def __init__(self, - format_or_path: Union[str, - Path], - stream: Optional[Any] = None, - manifest_data: Optional[Any] = None): + @overload + def __init__( + self, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None, + ) -> None: ... + + @overload + def __init__( + self, + format_or_path: Union[str, Path], + stream: Optional[Any], + manifest_data: Optional[Any], + context: 'ContextProvider', + ) -> None: ... + + def __init__( + self, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None, + context: Optional['ContextProvider'] = None, + ): """Create a new Reader. Args: format_or_path: The format or path to read from stream: Optional stream to read from (Python stream-like object) manifest_data: Optional manifest data in bytes + context: Optional context implementing ContextProvider with settings Raises: C2paError: If there was an error creating the reader C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters """ - # Native libs plumbing: - # Clear any stale error state from previous operations - _clear_error_state() - - self._closed = False - self._initialized = False + super().__init__() - self._reader = None self._own_stream = None # This is used to keep track of a file # we may have opened ourselves, and that we need to close later self._backing_file = None - # Caches for manifest JSON string and parsed data + # Caches for manifest JSON string and parsed data. + # These are invalidated when with_fragment() is called, because each + # new BMFF fragment can refine or update the manifest content as the + # reader progressively builds its understanding of the fragmented stream. + # They are also cleared on close() to release memory. self._manifest_json_str_cache = None self._manifest_data_cache = None + self._context = context + + if context is not None: + self._init_from_context( + context, format_or_path, stream, + manifest_data, + ) + return + + supported = Reader.get_supported_mime_types() + if stream is None: - # If we don't get a stream as param: # Create a stream from the file path in format_or_path path = str(format_or_path) mime_type = _get_mime_type_from_path(path) @@ -1429,237 +2303,186 @@ def __init__(self, raise C2paError.NotSupported( f"Could not determine MIME type for file: {path}") - if mime_type not in Reader.get_supported_mime_types(): - raise C2paError.NotSupported( - f"Reader does not support {mime_type}") - - try: - mime_type_str = mime_type.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Reader._ERROR_MESSAGES['encoding_error'].format( - str(e))) - - try: - with open(path, 'rb') as file: - self._own_stream = Stream(file) - - self._reader = _lib.c2pa_reader_from_stream( - mime_type_str, - self._own_stream._stream - ) - - if not self._reader: - self._own_stream.close() - error = _parse_operation_result_for_error( - _lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Reader._ERROR_MESSAGES['reader_error'].format( - "Unknown error" - ) - ) - - # Store the file to close it later - self._backing_file = file - self._initialized = True + format_bytes = _validate_and_encode_format( + mime_type, supported, "Reader") + self._init_from_file(path, format_bytes) - except Exception as e: - # File automatically closed by context manager - if self._own_stream: - self._own_stream.close() - if hasattr(self, '_backing_file') and self._backing_file: - self._backing_file.close() - raise C2paError.Io( - Reader._ERROR_MESSAGES['io_error'].format( - str(e))) elif isinstance(stream, str): - # We may have gotten format + a file path - # If stream is a string, treat it as a path and try to open it - - # format_or_path is a format - format_lower = format_or_path.lower() - if format_lower not in Reader.get_supported_mime_types(): - raise C2paError.NotSupported( - f"Reader does not support {format_or_path}") - - try: - with open(stream, 'rb') as file: - self._own_stream = Stream(file) - - format_str = str(format_or_path) - format_bytes = format_str.encode('utf-8') - - if manifest_data is None: - self._reader = _lib.c2pa_reader_from_stream( - format_bytes, self._own_stream._stream) - else: - if not isinstance(manifest_data, bytes): - raise TypeError( - Reader._ERROR_MESSAGES['manifest_error']) - manifest_array = ( - ctypes.c_ubyte * - len(manifest_data))( - * - manifest_data) - self._reader = ( - _lib.c2pa_reader_from_manifest_data_and_stream( - format_bytes, - self._own_stream._stream, - manifest_array, - len(manifest_data), - ) - ) + # stream is a file path, format_or_path is the format + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") + self._init_from_file( + stream, format_bytes, manifest_data) - if not self._reader: - self._own_stream.close() - error = _parse_operation_result_for_error( - _lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Reader._ERROR_MESSAGES['reader_error'].format( - "Unknown error" - ) - ) - - self._backing_file = file - self._initialized = True - except Exception as e: - # File closed by context manager - if self._own_stream: - self._own_stream.close() - if hasattr(self, '_backing_file') and self._backing_file: - self._backing_file.close() - raise C2paError.Io( - Reader._ERROR_MESSAGES['io_error'].format( - str(e))) else: - # format_or_path is a format string - format_str = str(format_or_path) - if format_str.lower() not in Reader.get_supported_mime_types(): - raise C2paError.NotSupported( - f"Reader does not support {format_str}") - - # Use the provided stream - self._format_str = format_str.encode('utf-8') + # format_or_path is a format string, stream is a stream object + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") with Stream(stream) as stream_obj: - if manifest_data is None: - self._reader = _lib.c2pa_reader_from_stream( - self._format_str, stream_obj._stream) - else: - if not isinstance(manifest_data, bytes): - raise TypeError( - Reader._ERROR_MESSAGES['manifest_error']) - manifest_array = ( - ctypes.c_ubyte * - len(manifest_data))( - * - manifest_data) - self._reader = ( - _lib.c2pa_reader_from_manifest_data_and_stream( - self._format_str, - stream_obj._stream, - manifest_array, - len(manifest_data) - ) - ) + self._create_reader( + format_bytes, stream_obj, manifest_data) + self._lifecycle_state = LifecycleState.ACTIVE - if not self._reader: - error = _parse_operation_result_for_error( - _lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Reader._ERROR_MESSAGES['reader_error'].format( - "Unknown error" - ) - ) + def _create_reader(self, format_bytes, stream_obj, + manifest_data=None): + """Create a Reader from a Stream. - self._initialized = True + Args: + format_bytes: UTF-8 encoded format/MIME type + stream_obj: A Stream instance + manifest_data: Optional manifest bytes + """ + if manifest_data is None: + self._handle = _lib.c2pa_reader_from_stream( + format_bytes, stream_obj._stream) + else: + if not isinstance(manifest_data, bytes): + raise TypeError(Reader._ERROR_MESSAGES['manifest_error']) + manifest_array = ( + ctypes.c_ubyte * + len(manifest_data))( + *manifest_data) + self._handle = ( + _lib.c2pa_reader_from_manifest_data_and_stream( + format_bytes, + stream_obj._stream, + manifest_array, + len(manifest_data), + ) + ) - def __enter__(self): - self._ensure_valid_state() - return self + _check_ffi_operation_result(self._handle, + Reader._ERROR_MESSAGES['reader_error'].format("Unknown error") + ) - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() + def _init_from_file(self, path, format_bytes, + manifest_data=None): + """Open a file and create a reader from it. - def __del__(self): - """Ensure resources are cleaned up if close() wasn't called. - - This destructor handles cleanup without causing double frees. - It only cleans up if the object hasn't been explicitly closed. + Args: + path: File path to open + format_bytes: UTF-8 encoded format/MIME type + manifest_data: Optional manifest bytes """ - self._cleanup_resources() - - def _ensure_valid_state(self): - """Ensure the reader is in a valid state for operations. - - Raises: - C2paError: If the reader is closed, not initialized, or invalid + try: + self._backing_file = open(path, 'rb') + self._own_stream = Stream(self._backing_file) + self._create_reader(format_bytes, self._own_stream, manifest_data) + self._lifecycle_state = LifecycleState.ACTIVE + except C2paError: + self._close_streams() + raise + except Exception as e: + self._close_streams() + raise C2paError.Io( + Reader._ERROR_MESSAGES['io_error'].format(str(e))) + + def _init_from_context(self, context, format_or_path, + stream, manifest_data=None): + """Initialize Reader from a Context object implementing + the ContextProvider interface/abstract base class. """ - if self._closed: - raise C2paError(Reader._ERROR_MESSAGES['closed_error']) - if not self._initialized: - raise C2paError("Reader is not properly initialized") - if not self._reader: - raise C2paError(Reader._ERROR_MESSAGES['closed_error']) + if not context.is_valid: + raise C2paError("Context is not valid") - def _cleanup_resources(self): - """Internal cleanup method that releases native resources. + # Determine format and open stream + supported = Reader.get_supported_mime_types() - This method handles the actual cleanup logic and can be called - from both close() and __del__ without causing double frees. - """ - try: - # Only cleanup if not already closed and we have a valid reader - if hasattr(self, '_closed') and not self._closed: - self._closed = True + if stream is None: + path = str(format_or_path) + mime_type = _get_mime_type_from_path(path) + if not mime_type: + raise C2paError.NotSupported( + f"Could not determine MIME type for file: {path}") + format_bytes = _validate_and_encode_format( + mime_type, supported, "Reader") + self._backing_file = open(path, 'rb') + self._own_stream = Stream(self._backing_file) + elif isinstance(stream, str): + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") + self._backing_file = open(stream, 'rb') + self._own_stream = Stream(self._backing_file) + else: + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") + self._own_stream = Stream(stream) - # Clean up reader - if hasattr(self, '_reader') and self._reader: - try: - _lib.c2pa_reader_free(self._reader) - except Exception: - # Cleanup failure doesn't raise exceptions - logger.error( - "Failed to free native Reader resources" - ) - pass - finally: - self._reader = None + try: + # Create reader from context + reader_ptr = _lib.c2pa_reader_from_context( + context.execution_context, + ) + _check_ffi_operation_result(reader_ptr, + Reader._ERROR_MESSAGES[ + 'reader_error' + ].format("Unknown error") + ) - # Clean up stream - if hasattr(self, '_own_stream') and self._own_stream: - try: - self._own_stream.close() - except Exception: - # Cleanup failure doesn't raise exceptions - logger.error("Failed to close Reader stream") - pass - finally: - self._own_stream = None + if manifest_data is not None: + if not isinstance(manifest_data, bytes): + raise TypeError( + Reader._ERROR_MESSAGES[ + 'manifest_error']) + manifest_array = ( + ctypes.c_ubyte * + len(manifest_data))( + *manifest_data) + # Consume current reader, + # with manifest data and stream (C FFI pattern), + # to create a new one (switch out) + new_ptr = ( + _lib.c2pa_reader_with_manifest_data_and_stream( + reader_ptr, + format_bytes, + self._own_stream._stream, + manifest_array, + len(manifest_data), + ) + ) + # reader_ptr has been invalidated(consumed) + else: + # Consume reader with stream + new_ptr = _lib.c2pa_reader_with_stream( + reader_ptr, format_bytes, + self._own_stream._stream, + ) + # reader_ptr has been invalidated(consumed) - # Clean up backing file (if needed) - if self._backing_file: - try: - self._backing_file.close() - except Exception: - # Cleanup failure doesn't raise exceptions - logger.warning("Failed to close Reader backing file") - pass - finally: - self._backing_file = None + self._handle = new_ptr - # Reset initialized state after cleanup - self._initialized = False + _check_ffi_operation_result(new_ptr, + Reader._ERROR_MESSAGES[ + 'reader_error' + ].format("Unknown error") + ) + self._lifecycle_state = LifecycleState.ACTIVE except Exception: - # Ensure we don't raise exceptions during cleanup - pass + self._close_streams() + raise + + def _close_streams(self): + """Close owned stream and backing file if present.""" + if getattr(self, '_own_stream', None): + try: + self._own_stream.close() + except Exception: + logger.error("Failed to close Reader stream") + finally: + self._own_stream = None + if getattr(self, '_backing_file', None): + try: + self._backing_file.close() + except Exception: + logger.warning("Failed to close Reader backing file") + finally: + self._backing_file = None + + def _release(self): + """Release Reader-specific resources (stream, backing file).""" + self._close_streams() def _get_cached_manifest_data(self) -> Optional[dict]: """Get the cached manifest data, fetching and parsing if not cached. @@ -1688,30 +2511,61 @@ def _get_cached_manifest_data(self) -> Optional[dict]: return self._manifest_data_cache - def close(self): - """Release the reader resources. + def with_fragment(self, format: str, stream, + fragment_stream) -> "Reader": + """Process a BMFF fragment stream with this reader. - This method ensures all resources are properly cleaned up, - even if errors occur during cleanup. - Errors during cleanup are logged but not raised to ensure cleanup. - Multiple calls to close() are handled gracefully. + Used for fragmented BMFF media (DASH/HLS streaming) where + content is split into init segments and fragment files. + + Args: + format: MIME type of the media (e.g., "video/mp4") + stream: Stream-like object with the main/init segment data + fragment_stream: Stream-like object with the fragment data + + Returns: + This reader instance, for method chaining. + + Raises: + C2paError: If there was an error processing the fragment """ - if self._closed: - return + self._ensure_valid_state() - try: - # Use the internal cleanup method - self._cleanup_resources() - except Exception as e: - # Log any unexpected errors during close - logger.error( - Reader._ERROR_MESSAGES['cleanup_error'].format( - str(e))) - finally: - # Clear the cache when closing - self._manifest_json_str_cache = None - self._manifest_data_cache = None - self._closed = True + supported = Reader.get_supported_mime_types() + format_bytes = _validate_and_encode_format( + format, supported, "Reader" + ) + + with Stream(stream) as main_obj, Stream(fragment_stream) as frag_obj: + new_ptr = _lib.c2pa_reader_with_fragment( + self._handle, + format_bytes, + main_obj._stream, + frag_obj._stream, + ) + + if not new_ptr: + self._mark_consumed() + _check_ffi_operation_result(new_ptr, + Reader._ERROR_MESSAGES[ + 'fragment_error' + ].format("Unknown error")) + self._handle = new_ptr + + # Invalidate caches: processing a new BMFF fragment updates the native + # reader's state, which can change the manifest data it returns. + # The cached JSON string and parsed dict may now be stale, so clear + # them to force a fresh read from the native layer on next access. + self._manifest_json_str_cache = None + self._manifest_data_cache = None + + return self + + def close(self): + """Release the reader resources.""" + self._manifest_json_str_cache = None + self._manifest_data_cache = None + super().close() def json(self) -> str: """Get the manifest store as a JSON string. @@ -1729,13 +2583,9 @@ def json(self) -> str: if self._manifest_json_str_cache is not None: return self._manifest_json_str_cache - result = _lib.c2pa_reader_json(self._reader) - - if result is None: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error during manifest parsing in Reader") + result = _lib.c2pa_reader_json(self._handle) + _check_ffi_operation_result(result, + "Error during manifest parsing in Reader") # Cache the result and return it self._manifest_json_str_cache = _convert_to_py_string(result) @@ -1759,16 +2609,30 @@ def detailed_json(self) -> str: self._ensure_valid_state() - result = _lib.c2pa_reader_detailed_json(self._reader) - - if result is None: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error during detailed manifest parsing in Reader") + result = _lib.c2pa_reader_detailed_json(self._handle) + _check_ffi_operation_result(result, + "Error during detailed manifest parsing in Reader") return _convert_to_py_string(result) + def _get_manifest_field(self, extractor): + """Extract a field from (cached) manifest data, or None if unavailable. + + Args: + extractor: A callable that takes the parsed manifest dict + and returns the desired field value. + + Returns: + Extracted field value, or None if not available. + """ + try: + data = self._get_cached_manifest_data() + if data is None: + return None + return extractor(data) + except C2paError.ManifestNotFound: + return None + def get_active_manifest(self) -> Optional[dict]: """Get the active manifest from the manifest store. @@ -1783,30 +2647,18 @@ def get_active_manifest(self) -> Optional[dict]: Raises: KeyError: If the active_manifest key is missing from the JSON """ - try: - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() - if manifest_data is None: - # raise C2paError("Failed to parse manifest JSON") - return None - - # Get the active manfiest id/label - if "active_manifest" not in manifest_data: + def _extract(data): + if "active_manifest" not in data: raise KeyError("No 'active_manifest' key found") - - active_manifest_id = manifest_data["active_manifest"] - - # Retrieve the active manifest data using manifest id/label - if "manifests" not in manifest_data: + active_manifest_id = data["active_manifest"] + if "manifests" not in data: raise KeyError("No 'manifests' key found in manifest data") - - manifests = manifest_data["manifests"] + manifests = data["manifests"] if active_manifest_id not in manifests: raise KeyError("Active manifest not found in manifest store") - return manifests[active_manifest_id] - except C2paError.ManifestNotFound: - return None + + return self._get_manifest_field(_extract) def get_manifest(self, label: str) -> Optional[dict]: """Get a specific manifest from the manifest store by its label. @@ -1825,23 +2677,15 @@ def get_manifest(self, label: str) -> Optional[dict]: Raises: KeyError: If the manifests key is missing from the JSON """ - try: - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() - if manifest_data is None: - # raise C2paError("Failed to parse manifest JSON") - return None - - if "manifests" not in manifest_data: + def _extract(data): + if "manifests" not in data: raise KeyError("No 'manifests' key found in manifest data") - - manifests = manifest_data["manifests"] + manifests = data["manifests"] if label not in manifests: raise KeyError(f"Manifest {label} not found in manifest store") - return manifests[label] - except C2paError.ManifestNotFound: - return None + + return self._get_manifest_field(_extract) def get_validation_state(self) -> Optional[str]: """Get the validation state of the manifest store. @@ -1855,15 +2699,7 @@ def get_validation_state(self) -> Optional[str]: or None if the validation_state field is not present or if no manifest is found or if there was an error parsing the JSON. """ - try: - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() - if manifest_data is None: - return None - - return manifest_data.get("validation_state") - except C2paError.ManifestNotFound: - return None + return self._get_manifest_field(lambda d: d.get("validation_state")) def get_validation_results(self) -> Optional[dict]: """Get the validation results of the manifest store. @@ -1878,15 +2714,7 @@ def get_validation_results(self) -> Optional[dict]: field is not present or if no manifest is found or if there was an error parsing the JSON. """ - try: - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() - if manifest_data is None: - return None - - return manifest_data.get("validation_results") - except C2paError.ManifestNotFound: - return None + return self._get_manifest_field(lambda d: d.get("validation_results")) def resource_to_stream(self, uri: str, stream: Any) -> int: """Write a resource to a stream. @@ -1906,15 +2734,11 @@ def resource_to_stream(self, uri: str, stream: Any) -> int: uri_str = uri.encode('utf-8') with Stream(stream) as stream_obj: result = _lib.c2pa_reader_resource_to_stream( - self._reader, uri_str, stream_obj._stream) - - if result < 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError.Other( - "Error during resource {} to stream conversion".format(uri) - ) + self._handle, uri_str, stream_obj._stream) + + _check_ffi_operation_result(result, + "Error during resource {} to stream conversion".format(uri), + check=lambda r: r < 0) return result @@ -1930,7 +2754,7 @@ def is_embedded(self) -> bool: """ self._ensure_valid_state() - result = _lib.c2pa_reader_is_embedded(self._reader) + result = _lib.c2pa_reader_is_embedded(self._handle) return bool(result) @@ -1947,7 +2771,7 @@ def get_remote_url(self) -> Optional[str]: """ self._ensure_valid_state() - result = _lib.c2pa_reader_remote_url(self._reader) + result = _lib.c2pa_reader_remote_url(self._handle) if result is None: # No remote URL set (manifest is embedded) @@ -1958,9 +2782,10 @@ def get_remote_url(self) -> Optional[str]: return url_str -class Signer: +class Signer(ManagedResource): """High-level wrapper for C2PA Signer operations.""" + # Class-level error messages to avoid multiple creation _ERROR_MESSAGES = { 'closed_error': "Signer is closed", @@ -1993,13 +2818,8 @@ def from_info(cls, signer_info: C2paSignerInfo) -> 'Signer': signer_ptr = _lib.c2pa_signer_from_info(ctypes.byref(signer_info)) - if not signer_ptr: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - # More detailed error message when possible - raise C2paError(error) - raise C2paError( - "Failed to create signer from configured signer_info") + _check_ffi_operation_result(signer_ptr, + "Failed to create signer from configured signer_info") return cls(signer_ptr) @@ -2132,130 +2952,43 @@ def wrapped_callback( tsa_url_bytes ) - if not signer_ptr: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to create signer") + _check_ffi_operation_result(signer_ptr, + "Failed to create signer") # Create and return the signer instance with the callback signer_instance = cls(signer_ptr) - # Keep callback alive on the object to prevent gc of the callback - # So the callback will live as long as the signer leaves, - # and there is a 1:1 relationship between signer and callback. - signer_instance._callback_cb = callback_cb - - return signer_instance - - def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): - """Initialize a new Signer instance. - - Note: This constructor is not meant to be called directly. - Use from_info() or from_callback() instead. - - Args: - signer_ptr: Pointer to the native C2PA signer - - Raises: - C2paError: If the signer pointer is invalid - """ - # Native libs plumbing: - # Clear any stale error state from previous operations - _clear_error_state() - - # Validate pointer before assignment - if not signer_ptr: - raise C2paError("Invalid signer pointer: pointer is null") - - self._signer = signer_ptr - self._closed = False - - # Set only for signers which are callback signers - self._callback_cb = None - - def __enter__(self): - """Context manager entry.""" - self._ensure_valid_state() - - if not self._signer: - raise C2paError("Invalid signer pointer: pointer is null") - - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit.""" - self.close() - - def _cleanup_resources(self): - """Internal cleanup method that releases native resources. - - This method handles the actual cleanup logic and can be called - from both close() and __del__ without causing double frees. - """ - try: - if not self._closed and self._signer: - self._closed = True - - try: - _lib.c2pa_signer_free(self._signer) - except Exception: - # Log cleanup errors but don't raise exceptions - logger.error("Failed to free native Signer resources") - finally: - self._signer = None + # Keep callback alive on the object to prevent gc of the callback + # So the callback will live as long as the signer leaves, + # and there is a 1:1 relationship between signer and callback. + signer_instance._callback_cb = callback_cb - # Clean up callback reference - if self._callback_cb: - self._callback_cb = None + return signer_instance - except Exception: - # Ensure we don't raise exceptions during cleanup - pass + def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): + """Initialize a new Signer instance. This constructor is not meant + to be called directly. Use from_info() or from_callback() instead. - def _ensure_valid_state(self): - """Ensure the signer is in a valid state for operations. + Args: + signer_ptr: Pointer to the native C2PA signer Raises: - C2paError: If the signer is closed or invalid + C2paError: If the signer pointer is invalid """ - if self._closed: - raise C2paError(Signer._ERROR_MESSAGES['closed_error']) - if not self._signer: - raise C2paError(Signer._ERROR_MESSAGES['closed_error']) - - def close(self): - """Release the signer resources. + super().__init__() - This method ensures all resources are properly cleaned up, - even if errors occur during cleanup. + self._callback_cb = None - Note: - Multiple calls to close() are handled gracefully. - Errors during cleanup are logged but not raised - to ensure cleanup. - """ - if self._closed: - return + if not signer_ptr: + raise C2paError("Invalid signer pointer: pointer is null") - try: - # Validate pointer before cleanup if it exists - if self._signer and self._signer != 0: - # Use the internal cleanup method - self._cleanup_resources() - else: - # Make sure to release the callback - if self._callback_cb: - self._callback_cb = None + self._handle = signer_ptr + self._lifecycle_state = LifecycleState.ACTIVE - except Exception as e: - # Log any unexpected errors during close - logger.error( - Signer._ERROR_MESSAGES['cleanup_error'].format( - str(e))) - finally: - # Always mark as closed, regardless of cleanup success - self._closed = True + def _release(self): + """Release Signer-specific resources (callback reference).""" + if self._callback_cb: + self._callback_cb = None def reserve_size(self) -> int: """Get the size to reserve for signatures from this signer. @@ -2268,20 +3001,18 @@ def reserve_size(self) -> int: """ self._ensure_valid_state() - result = _lib.c2pa_signer_reserve_size(self._signer) + result = _lib.c2pa_signer_reserve_size(self._handle) - if result < 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to get reserve size") + _check_ffi_operation_result(result, + "Failed to get reserve size", check=lambda r: r < 0) return result -class Builder: +class Builder(ManagedResource): """High-level wrapper for C2PA Builder operations.""" + # Supported mimetypes cache _supported_mime_types_cache = None @@ -2313,64 +3044,37 @@ def get_supported_mime_types(cls) -> list[str]: Raises: C2paError: If there was an error retrieving the MIME types """ - if cls._supported_mime_types_cache is not None: - return cls._supported_mime_types_cache - - count = ctypes.c_size_t() - arr = _lib.c2pa_builder_supported_mime_types(ctypes.byref(count)) - - # Validate the returned array pointer - if not arr: - # If no array returned, check for errors - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(f"Failed to get supported MIME types: {error}") - # Return empty list if no error but no array - return [] - - # Validate count value - if count.value <= 0: - # Free the array even if count is invalid - try: - _lib.c2pa_free_string_array(arr, count.value) - except Exception: - pass - return [] - - try: - result = [] - for i in range(count.value): - try: - # Validate each array element before accessing - if arr[i] is None: - continue - - mime_type = arr[i].decode("utf-8", errors='replace') - if mime_type: - result.append(mime_type) - except Exception: - # Ignore decoding failures - continue - finally: - # Always free the native memory, even if string extraction fails - try: - _lib.c2pa_free_string_array(arr, count.value) - except Exception: - # Ignore cleanup errors - pass + result, cls._supported_mime_types_cache = _get_supported_mime_types( + _lib.c2pa_builder_supported_mime_types, cls._supported_mime_types_cache + ) + return result - # Cache the result - if result: - cls._supported_mime_types_cache = result + @classmethod + @overload + def from_json( + cls, + manifest_json: Any, + ) -> 'Builder': ... - return cls._supported_mime_types_cache + @classmethod + @overload + def from_json( + cls, + manifest_json: Any, + context: 'ContextProvider', + ) -> 'Builder': ... @classmethod - def from_json(cls, manifest_json: Any) -> 'Builder': + def from_json( + cls, + manifest_json: Any, + context: Optional['ContextProvider'] = None, + ) -> 'Builder': """Create a new Builder from a JSON manifest. Args: manifest_json: The JSON manifest definition + context: Optional ContextProvider for settings Returns: A new Builder instance @@ -2378,163 +3082,132 @@ def from_json(cls, manifest_json: Any) -> 'Builder': Raises: C2paError: If there was an error creating the builder """ - return cls(manifest_json) + return cls(manifest_json, context=context) @classmethod - def from_archive(cls, stream: Any) -> 'Builder': + def from_archive( + cls, + stream: Any, + ) -> 'Builder': """Create a new Builder from an archive stream. + This creates builder without a context. To use a context, + create a Builder with a context first, then call with_archive() on it. + Args: stream: The stream containing the archive (any Python stream-like object) Returns: - A new Builder instance + A new Builder instance (without any context) Raises: - C2paError: If there was an error creating the builder from archive + C2paError: If there was an error creating the builder + from archive """ - builder = cls({}) - stream_obj = Stream(stream) + # Handle builder._handle lifecycle somewhat manually + builder = object.__new__(cls) + ManagedResource.__init__(builder) + builder._context = None + builder._has_context_signer = False - builder._builder = _lib.c2pa_builder_from_archive(stream_obj._stream) + stream_obj = Stream(stream) - if not builder._builder: - # Clean up the stream object if builder creation fails - stream_obj.close() + try: + builder._handle = ( + _lib.c2pa_builder_from_archive(stream_obj._stream) + ) - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to create builder from archive") + _check_ffi_operation_result(builder._handle, + "Failed to create builder from archive" + ) - builder._initialized = True - return builder + builder._lifecycle_state = LifecycleState.ACTIVE + return builder + finally: + stream_obj.close() - def __init__(self, manifest_json: Any): + @overload + def __init__( + self, + manifest_json: Any, + ) -> None: ... + + @overload + def __init__( + self, + manifest_json: Any, + context: 'ContextProvider', + ) -> None: ... + + def __init__( + self, + manifest_json: Any, + context: Optional['ContextProvider'] = None, + ): """Initialize a new Builder instance. Args: manifest_json: The manifest JSON definition (string or dict) + context: Optional Context (ContextProvider) for settings Raises: C2paError: If there was an error creating the builder C2paError.Encoding: If manifest JSON contains invalid UTF-8 chars C2paError.Json: If the manifest JSON cannot be serialized """ - # Native libs plumbing: - # Clear any stale error state from previous operations - _clear_error_state() - - self._closed = False - self._initialized = False - self._builder = None + super().__init__() - if not isinstance(manifest_json, str): - try: - manifest_json = json.dumps(manifest_json) - except (TypeError, ValueError) as e: - raise C2paError.Json( - Builder._ERROR_MESSAGES['json_error'].format( - str(e))) + self._context = context + self._has_context_signer = ( + context is not None + and hasattr(context, 'has_signer') + and context.has_signer + ) - try: - json_str = manifest_json.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Builder._ERROR_MESSAGES['encoding_error'].format( - str(e))) + json_str = _to_utf8_bytes(manifest_json, "manifest JSON") - self._builder = _lib.c2pa_builder_from_json(json_str) + if context is not None: + self._init_from_context(context, json_str) + else: + self._handle = _lib.c2pa_builder_from_json(json_str) - if not self._builder: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( + _check_ffi_operation_result(self._handle, Builder._ERROR_MESSAGES['builder_error'].format( "Unknown error" ) ) - self._initialized = True - - def __del__(self): - """Ensure resources are cleaned up if close() wasn't called.""" - self._cleanup_resources() - - def __enter__(self): - self._ensure_valid_state() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() - - def _ensure_valid_state(self): - """Ensure the builder is in a valid state for operations. - - Raises: - C2paError: If the builder is closed, not initialized, or invalid - """ - if self._closed: - raise C2paError(Builder._ERROR_MESSAGES['closed_error']) - if not self._initialized: - raise C2paError("Builder is not properly initialized") - if not self._builder: - raise C2paError(Builder._ERROR_MESSAGES['closed_error']) + self._lifecycle_state = LifecycleState.ACTIVE - def _cleanup_resources(self): - """Internal cleanup method that releases native resources. + def _init_from_context(self, context, json_str): + """Initialize Builder from a ContextProvider. - This method handles the actual cleanup logic and can be called - from both close() and __del__ without causing double frees. + Uses c2pa_builder_from_context + + c2pa_builder_with_definition (consume-and-return). """ - try: - # Only cleanup if not already closed and we have a valid builder - if hasattr(self, '_closed') and not self._closed: - self._closed = True - - if hasattr( - self, - '_builder') and self._builder and self._builder != 0: - try: - _lib.c2pa_builder_free(self._builder) - except Exception: - # Log cleanup errors but don't raise exceptions - logger.error( - "Failed to release native Builder resources" - ) - pass - finally: - self._builder = None + if not context.is_valid: + raise C2paError("Context is not valid") - # Reset initialized state after cleanup - self._initialized = False - except Exception: - # Ensure we don't raise exceptions during cleanup - pass - - def close(self): - """Release the builder resources. + builder_ptr = _lib.c2pa_builder_from_context( + context.execution_context, + ) + _check_ffi_operation_result(builder_ptr, + Builder._ERROR_MESSAGES[ + 'builder_error' + ].format("Unknown error") + ) - This method ensures all resources are properly cleaned up, - even if errors occur during cleanup. - Errors during cleanup are logged but not raised to ensure cleanup. - Multiple calls to close() are handled gracefully. - """ - if self._closed: - return + # Consume-and-return: builder_ptr is consumed, + # new_ptr is the valid pointer going forward + new_ptr = _lib.c2pa_builder_with_definition(builder_ptr, json_str) + self._handle = new_ptr - try: - # Use the internal cleanup method - self._cleanup_resources() - except Exception as e: - # Log any unexpected errors during close - logger.error( - Builder._ERROR_MESSAGES['cleanup_error'].format( - str(e))) - finally: - self._closed = True + _check_ffi_operation_result(new_ptr, + Builder._ERROR_MESSAGES[ + 'builder_error' + ].format("Unknown error") + ) def set_no_embed(self): """Set the no-embed flag. @@ -2544,7 +3217,7 @@ def set_no_embed(self): This is useful when creating cloud or sidecar manifests. """ self._ensure_valid_state() - _lib.c2pa_builder_set_no_embed(self._builder) + _lib.c2pa_builder_set_no_embed(self._handle) def set_remote_url(self, remote_url: str): """Set the remote URL. @@ -2560,15 +3233,50 @@ def set_remote_url(self, remote_url: str): """ self._ensure_valid_state() - url_str = remote_url.encode('utf-8') - result = _lib.c2pa_builder_set_remote_url(self._builder, url_str) + url_bytes = _to_utf8_bytes(remote_url, "remote URL") + result = _lib.c2pa_builder_set_remote_url(self._handle, url_bytes) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Builder._ERROR_MESSAGES['url_error'].format("Unknown error")) + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES['url_error'].format("Unknown error"), + check=lambda r: r != 0) + + def set_intent( + self, + intent: C2paBuilderIntent, + digital_source_type: C2paDigitalSourceType = ( + C2paDigitalSourceType.EMPTY + ) + ): + """Set the intent for the manifest. + + The intent specifies what kind of manifest to create: + - CREATE: New with specified digital source type. + Must not have a parent ingredient. + - EDIT: Edit of a pre-existing parent asset. + Must have a parent ingredient. + - UPDATE: Restricted version of Edit for non-editorial changes. + Must have only one ingredient, as a parent. + + Args: + intent: The builder intent (C2paBuilderIntent enum value) + digital_source_type: The digital source type (required + for CREATE intent). Defaults to C2paDigitalSourceType.EMPTY + (for all other cases). + + Raises: + C2paError: If there was an error setting the intent + """ + self._ensure_valid_state() + + result = _lib.c2pa_builder_set_intent( + self._handle, + ctypes.c_uint(intent), + ctypes.c_uint(digital_source_type), + ) + + _check_ffi_operation_result(result, + "Error setting intent for Builder: Unknown error", + check=lambda r: r != 0) def add_resource(self, uri: str, stream: Any): """Add a resource to the builder. @@ -2583,20 +3291,16 @@ def add_resource(self, uri: str, stream: Any): """ self._ensure_valid_state() - uri_str = uri.encode('utf-8') + uri_bytes = _to_utf8_bytes(uri, "resource URI") with Stream(stream) as stream_obj: result = _lib.c2pa_builder_add_resource( - self._builder, uri_str, stream_obj._stream) + self._handle, uri_bytes, stream_obj._stream) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Builder._ERROR_MESSAGES['resource_error'].format( - "Unknown error" - ) - ) + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES['resource_error'].format( + "Unknown error" + ), + check=lambda r: r != 0) def add_ingredient( self, ingredient_json: Union[str, dict], format: str, source: Any @@ -2647,36 +3351,24 @@ def add_ingredient_from_stream( """ self._ensure_valid_state() - if isinstance(ingredient_json, dict): - ingredient_json = json.dumps(ingredient_json) - - try: - ingredient_str = ingredient_json.encode('utf-8') - format_str = format.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Builder._ERROR_MESSAGES['encoding_error'].format( - str(e))) + ingredient_str = _to_utf8_bytes(ingredient_json, "ingredient JSON") + format_str = _to_utf8_bytes(format, "ingredient format") with Stream(source) as source_stream: result = ( _lib.c2pa_builder_add_ingredient_from_stream( - self._builder, + self._handle, ingredient_str, format_str, source_stream._stream ) ) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Builder._ERROR_MESSAGES['ingredient_error'].format( - "Unknown error" - ) - ) + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES['ingredient_error'].format( + "Unknown error" + ), + check=lambda r: r != 0) def add_ingredient_from_file_path( self, @@ -2738,27 +3430,14 @@ def add_action(self, action_json: Union[str, dict]) -> None: """ self._ensure_valid_state() - if isinstance(action_json, dict): - action_json = json.dumps(action_json) - - try: - action_str = action_json.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Builder._ERROR_MESSAGES['encoding_error'].format(str(e)) - ) + action_str = _to_utf8_bytes(action_json, "action JSON") + result = _lib.c2pa_builder_add_action(self._handle, action_str) - result = _lib.c2pa_builder_add_action(self._builder, action_str) - - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Builder._ERROR_MESSAGES['action_error'].format( - "Unknown error" - ) - ) + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES['action_error'].format( + "Unknown error" + ), + check=lambda r: r != 0) def to_archive(self, stream: Any) -> None: """Write an archive of the builder to a stream. @@ -2774,33 +3453,63 @@ def to_archive(self, stream: Any) -> None: with Stream(stream) as stream_obj: result = _lib.c2pa_builder_to_archive( - self._builder, stream_obj._stream) + self._handle, stream_obj._stream) + + _check_ffi_operation_result(result, + Builder._ERROR_MESSAGES["archive_error"].format( + "Unknown error" + ), + check=lambda r: r != 0) + + def with_archive(self, stream: Any) -> 'Builder': + """Load an archive into this Builder instance, replacing its + manifest definition. The archive carries only the + definition, not settings. Settings come from the context that + was configured to be used with this Builder instance. + + Args: + stream: The stream containing the archive + + Returns: + This builder instance, for method chaining. + + Raises: + C2paError: If there was an error loading the archive + """ + self._ensure_valid_state() - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) + with Stream(stream) as stream_obj: + try: + new_ptr = _lib.c2pa_builder_with_archive(self._handle, stream_obj._stream) + except Exception as e: + self._mark_consumed() raise C2paError( - Builder._ERROR_MESSAGES["archive_error"].format( - "Unknown error" - ) + f"Error loading archive: {e}" ) + # Old handle consumed by FFI + self._handle = new_ptr + _check_ffi_operation_result(new_ptr, "Failed to load archive into builder") + + return self def _sign_internal( self, - signer: Signer, format: str, source_stream: Stream, - dest_stream: Stream) -> bytes: - """Internal signing logic shared between sign() and sign_file() methods - to use same native calls but expose different API surface. + dest_stream: Stream, + signer: Optional[Signer] = None) -> bytes: + """Internal signing implementation. + When `signer` is provided, calls `c2pa_builder_sign` (explicit + signer). When `signer` is `None`, calls + `c2pa_builder_sign_context` (context-based signer). Args: - signer: The signer to use format: The MIME type or extension of the content source_stream: The source stream dest_stream: The destination stream, - opened in w+b (write+read binary) mode. + opened in w+b (write+read binary) mode. + signer: Signer to use. When None the context + signer is used instead. Returns: Manifest bytes @@ -2810,37 +3519,40 @@ def _sign_internal( """ self._ensure_valid_state() - # Validate signer pointer before use - if not signer or not hasattr(signer, '_signer') or not signer._signer: - raise C2paError("Invalid or closed signer") - - format_lower = format.lower() - if format_lower not in Builder.get_supported_mime_types(): - raise C2paError.NotSupported( - f"Builder does not support {format}") + if signer is not None: + if not hasattr(signer, '_handle') or not signer._handle: + raise C2paError("Invalid or closed signer") - format_str = format.encode('utf-8') + format_bytes = _validate_and_encode_format( + format, Builder.get_supported_mime_types(), "Builder") manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() - # c2pa_builder_sign uses streams try: - result = _lib.c2pa_builder_sign( - self._builder, - format_str, - source_stream._stream, - dest_stream._stream, - signer._signer, - ctypes.byref(manifest_bytes_ptr) - ) + if signer is not None: + result = _lib.c2pa_builder_sign( + self._handle, + format_bytes, + source_stream._stream, + dest_stream._stream, + signer._handle, + ctypes.byref(manifest_bytes_ptr) + ) + else: + result = _lib.c2pa_builder_sign_context( + self._handle, + format_bytes, + source_stream._stream, + dest_stream._stream, + ctypes.byref(manifest_bytes_ptr), + ) + # Builder pointer consumed by Rust FFI at this point + self._mark_consumed() except Exception as e: - # Handle errors during the C function call - raise C2paError(f"Error calling c2pa_builder_sign: {str(e)}") + self._mark_consumed() + raise C2paError(f"Error during signing: {e}") - if result < 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error during signing") + _check_ffi_operation_result(result, + "Error during signing", check=lambda r: r < 0) # Capture the manifest bytes if available manifest_bytes = b"" @@ -2861,24 +3573,94 @@ def _sign_internal( logger.error( "Failed to release native manifest bytes memory" ) - pass return manifest_bytes + def _sign_common( + self, + signer: Optional[Signer], + format: str, + source: Any, + dest: Any = None, + ) -> bytes: + """Shared signing logic for sign(). + + Args: + signer: The signer to use, or None for context signer. + format: The MIME type of the content. + source: The source stream. + dest: The destination stream (optional). + + Returns: + Manifest bytes + """ + source_stream = Stream(source) + try: + if dest: + dest_stream = Stream(dest) + else: + mem_buffer = io.BytesIO() + dest_stream = Stream(mem_buffer) + + try: + if signer is not None: + manifest_bytes = self._sign_internal( + format, source_stream, dest_stream, + signer=signer, + ) + elif self._has_context_signer: + manifest_bytes = self._sign_internal(format, source_stream, dest_stream) + else: + raise C2paError( + "No signer provided. Either pass a" + " signer parameter or create the" + " Builder with a Context that has" + " a signer." + ) + finally: + dest_stream.close() + finally: + source_stream.close() + + return manifest_bytes + + @overload def sign( - self, - signer: Signer, - format: str, - source: Any, - dest: Any = None) -> bytes: - """Sign the builder's content and write to a destination stream. + self, + signer: Signer, + format: str, + source: Any, + dest: Any = None, + ) -> bytes: ... + + @overload + def sign( + self, + format: str, + source: Any, + dest: Any = None, + ) -> bytes: ... + + def sign( + self, + signer_or_format: Union[Signer, str], + format_or_source: Any = None, + source_or_dest: Any = None, + dest: Any = None, + ) -> bytes: + """Sign the builder's content. + + Can be called with or without an explicit signer. + If no signer is provided, the context's signer is + used (builder must have been created with a Context + that has a signer). Args: - format: The MIME type or extension of the content - source: The source stream (any Python stream-like object) - dest: The destination stream (any Python stream-like object), - opened in w+b (write+read binary) mode. - signer: The signer to use + signer: The signer to use. If not provided, the + context's signer is used. + format: The MIME type of the content. + source: The source stream. + dest: The destination stream (optional). Returns: Manifest bytes @@ -2886,46 +3668,58 @@ def sign( Raises: C2paError: If there was an error during signing """ - # Convert Python streams to Stream objects - source_stream = Stream(source) - - if dest: - # dest is optional, only if we write back somewhere - dest_stream = Stream(dest) + if isinstance(signer_or_format, Signer): + return self._sign_common( + signer_or_format, + format_or_source, + source_or_dest, + dest, + ) + elif isinstance(signer_or_format, str): + return self._sign_common( + None, + signer_or_format, + format_or_source, + source_or_dest, + ) else: - # no destination? - # we keep things in-memory for validation and processing - mem_buffer = io.BytesIO() - dest_stream = Stream(mem_buffer) - - # Use the internal stream-base signing logic - manifest_bytes = self._sign_internal( - signer, - format, - source_stream, - dest_stream - ) - - if not dest: - # Close temporary in-memory stream since we own it - dest_stream.close() - - return manifest_bytes + raise C2paError( + "First argument must be a Signer or a format string (MIME type)." + ) - def sign_file(self, - source_path: Union[str, - Path], - dest_path: Union[str, - Path], - signer: Signer) -> bytes: - """Sign a file and write the signed data to an output file. + @overload + def sign_file( + self, + source_path: Union[str, Path], + dest_path: Union[str, Path], + signer: Signer, + ) -> bytes: ... + + @overload + def sign_file( + self, + source_path: Union[str, Path], + dest_path: Union[str, Path], + ) -> bytes: ... + + def sign_file( + self, + source_path: Union[str, Path], + dest_path: Union[str, Path], + signer: Optional[Signer] = None, + ) -> bytes: + """Sign a file and write signed data to output. + + Can be called with or without an explicit signer. + If no signer is provided, the context's signer is + used (builder must have been created with a Context + that has a signer). Args: - source_path: Path to the source file. We will attempt - to guess the mimetype of the source file based on - the extension. - dest_path: Path to write the signed file to - signer: The signer to use + source_path: Path to the source file. + dest_path: Path to write the signed file to. + signer: The signer to use. If None, the + context's signer is used. Returns: Manifest bytes @@ -2933,14 +3727,17 @@ def sign_file(self, Raises: C2paError: If there was an error during signing """ - # Get the MIME type from the file extension mime_type = _get_mime_type_from_path(source_path) try: - # Open source file and destination file, then use the sign method - with open(source_path, 'rb') as source_file, \ - open(dest_path, 'w+b') as dest_file: - return self.sign(signer, mime_type, source_file, dest_file) + with ( + open(source_path, 'rb') as source_file, + open(dest_path, 'w+b') as dest_file, + ): + if signer is not None: + return self.sign(signer, mime_type, source_file, dest_file) + # else: + return self.sign(mime_type, source_file, dest_file) except Exception as e: raise C2paError(f"Error signing file: {str(e)}") from e @@ -2971,16 +3768,19 @@ def format_embeddable(format: str, manifest_bytes: bytes) -> tuple[int, bytes]: ctypes.byref(result_bytes_ptr) ) - if result < 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to format embeddable manifest") + _check_ffi_operation_result(result, + "Failed to format embeddable manifest", check=lambda r: r < 0) # Convert the result bytes to a Python bytes object size = result - result_bytes = bytes(result_bytes_ptr[:size]) - _lib.c2pa_manifest_bytes_free(result_bytes_ptr) + try: + result_bytes = bytes(result_bytes_ptr[:size]) + except Exception as e: + raise C2paError( + f"Failed to convert embeddable manifest bytes: {e}" + ) from e + finally: + _lib.c2pa_manifest_bytes_free(result_bytes_ptr) return size, result_bytes @@ -2997,7 +3797,7 @@ def create_signer( This function is deprecated and will be removed in a future version. Please use the Signer class method instead. Example: - ```python + ``` signer = Signer.from_callback(callback, alg, certs, tsa_url) ``` @@ -3032,7 +3832,7 @@ def create_signer_from_info(signer_info: C2paSignerInfo) -> Signer: This function is deprecated and will be removed in a future version. Please use the Signer class method instead. Example: - ```python + ``` signer = Signer.from_info(signer_info) ``` @@ -3100,11 +3900,8 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: key_bytes ) - if not signature_ptr: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Failed to sign data with Ed25519") + _check_ffi_operation_result(signature_ptr, + "Failed to sign data with Ed25519") try: # Ed25519 signatures are always 64 bytes @@ -3124,7 +3921,13 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'C2paError', 'C2paSeekMode', 'C2paSigningAlg', + 'C2paDigitalSourceType', 'C2paSignerInfo', + 'C2paBuilderIntent', + 'ContextBuilder', + 'ContextProvider', + 'Settings', + 'Context', 'Stream', 'Reader', 'Builder', diff --git a/src/c2pa/lib.py b/src/c2pa/lib.py index 82b6148b..e14f3d6b 100644 --- a/src/c2pa/lib.py +++ b/src/c2pa/lib.py @@ -239,7 +239,7 @@ def dynamically_load_library( logger.info(f"Using library name from env var C2PA_LIBRARY_NAME: {env_lib_name}") try: possible_paths = _get_possible_search_paths() - lib = _load_single_library(env_lib_name, possible_paths) + lib, load_errors = _load_single_library(env_lib_name, possible_paths) if lib: return lib else: diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..867e2c84 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Placeholder \ No newline at end of file diff --git a/tests/fixtures/dash1.m4s b/tests/fixtures/dash1.m4s new file mode 100644 index 00000000..1a9a9964 Binary files /dev/null and b/tests/fixtures/dash1.m4s differ diff --git a/tests/fixtures/dashinit.mp4 b/tests/fixtures/dashinit.mp4 new file mode 100644 index 00000000..b1f703a8 Binary files /dev/null and b/tests/fixtures/dashinit.mp4 differ diff --git a/tests/fixtures/settings.toml b/tests/fixtures/settings.toml deleted file mode 100644 index 31eccf31..00000000 --- a/tests/fixtures/settings.toml +++ /dev/null @@ -1,230 +0,0 @@ -# This sample c2pa settings file enables a trust list. -# We use the toml format here because it does a good job of containing the PEM formatted certificates. -# In practice you should update the trust anchors from a remote source as needed. -# Many other settings are available, see the c2pa documentation for more information. -[verify] -trusted = true - -[trust] -trust_config = """ -//id-kp-emailProtection -1.3.6.1.5.5.7.3.4 -//id-kp-documentSigning -1.3.6.1.5.5.7.3.36 -//id-kp-timeStamping -1.3.6.1.5.5.7.3.8 -//id-kp-OCSPSigning -1.3.6.1.5.5.7.3.9 -// MS C2PA Signing -1.3.6.1.4.1.311.76.59.1.9 -""" -trust_anchors = """ ------BEGIN CERTIFICATE----- -MIICEzCCAcWgAwIBAgIUW4fUnS38162x10PCnB8qFsrQuZgwBQYDK2VwMHcxCzAJ -BgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdoZXJlMRowGAYD -VQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRFU1RJTkdfT05M -WTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2NDFaFw0zMjA2MDcxODQ2 -NDFaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdo -ZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRF -U1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAqMAUGAytlcAMhAGPUgK9q1H3D -eKMGqLGjTXJSpsrLpe0kpxkaFMe7KUAuo2MwYTAdBgNVHQ4EFgQUXuZWArP1jiRM -fgye6ZqRyGupTowwHwYDVR0jBBgwFoAUXuZWArP1jiRMfgye6ZqRyGupTowwDwYD -VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwBQYDK2VwA0EA8E79g54u2fUy -dfVLPyqKmtjenOUMvVQD7waNbetLY7kvUJZCd5eaDghk30/Q1RaNjiP/2RfA/it8 -zGxQnM2hCA== ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIIC2jCCAjygAwIBAgIUYm+LFaltpWbS9kED6RRAamOdUHowCgYIKoZIzj0EAwQw -dzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx -GjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO -R19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw -NzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT -b21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG -T1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIGbMBAGByqGSM49AgEG -BSuBBAAjA4GGAAQBaifSYJBkf5fgH3FWPxRdV84qwIsLd7RcIDcRJrRkan0xUYP5 -zco7R4fFGaQ9YJB8dauyqiNg00LVuPajvKmhgEMAT4eSfEhYC25F2ggXQlBIK3Q7 -mkXwJTIJSObnbw4S9Jy3W6OVKq351VpgWUcmhvGRRejW7S/D8L2tzqRW7JPI2uSj -YzBhMB0GA1UdDgQWBBS6OykommTmfYoLJuPN4OU83wjPqjAfBgNVHSMEGDAWgBS6 -OykommTmfYoLJuPN4OU83wjPqjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQE -AwIBhjAKBggqhkjOPQQDBAOBiwAwgYcCQV4B6uKKoCWecEDlzj2xQLFPmnBQIOzD -nyiSEcYyrCKwMV+HYS39oM+T53NvukLKUTznHwdWc9++HNaqc+IjsDl6AkIB2lXd -5+s3xf0ioU91GJ4E13o5rpAULDxVSrN34A7BlsaXYQLnSkLMqva6E7nq2JBYjkqf -iwNQm1DDcQPtPTnddOs= ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIICkTCCAhagAwIBAgIUIngKvNC/BMF3TRIafgweprIbGgAwCgYIKoZIzj0EAwMw -dzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx -GjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO -R19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw -NzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT -b21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG -T1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMHYwEAYHKoZIzj0CAQYF -K4EEACIDYgAEX3FzSTnCcEAP3wteNaiy4GZzZ+ABd2Y7gJpfyZf3kkCuX/I3psFq -QBRvb3/FEBaDT4VbDNlZ0WLwtw5d3PI42Zufgpxemgfjf31d8H51eU3/IfAz5AFX -y/OarhObHgVvo2MwYTAdBgNVHQ4EFgQUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wHwYD -VR0jBBgwFoAUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wDwYDVR0TAQH/BAUwAwEB/zAO -BgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAPOgmJbVdhDh9KlgQXqE -FzHiCt347JG4strk22MXzOgxQ0LnXStIh+viC3S1INzuBgIxAI1jiUBX/V7Gg0y6 -Y/p6a63Xp2w+ia7vlUaUBWsR3ex9NNSTPLNoDkoTCSDOE2O20w== ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIICUzCCAfmgAwIBAgIUdmkq4byvgk2FSnddHqB2yjoD68gwCgYIKoZIzj0EAwIw -dzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx -GjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO -R19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw -NzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT -b21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG -T1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMFkwEwYHKoZIzj0CAQYI -KoZIzj0DAQcDQgAEre/KpcWwGEHt+mD4xso3xotRnRx2IEsMoYwVIKI7iEJrDEye -PcvJuBywA0qiMw2yvAvGOzW/fqUTu1jABrFIk6NjMGEwHQYDVR0OBBYEFF6ZuIbh -eBvZVxVadQBStikOy6iMMB8GA1UdIwQYMBaAFF6ZuIbheBvZVxVadQBStikOy6iM -MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0gA -MEUCIHBC1xLwkCWSGhVXFlSnQBx9cGZivXzCbt8BuwRqPSUoAiEAteZQDk685yh9 -jgOTkp4H8oAmM1As+qlkRK2b+CHAQ3k= ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -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 ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -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 ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -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 ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -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 -xPd7wFhjRZHfuWb2cs63xjAGjQ== ------END CERTIFICATE----- -""" \ No newline at end of file diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 19976d63..d3ad730f 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -23,12 +23,16 @@ import tempfile import shutil import ctypes +import toml +import threading # Suppress deprecation warnings -warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.simplefilter("ignore", category=DeprecationWarning) + +from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType +from c2pa import Settings, Context, ContextBuilder, ContextProvider +from c2pa.c2pa import Stream, LifecycleState, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable -from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version -from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable PROJECT_PATH = os.getcwd() FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") @@ -38,13 +42,40 @@ INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, INGREDIENT_TEST_FILE_NAME) ALTERNATIVE_INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, "cloud.jpg") + +def load_test_settings_json(): + """ + Load default (legacy) trust configuration test settings from a + JSON config file and return its content as JSON-compatible dict. + The return value is used to load settings (thread_local) in tests. + + Returns: + dict: The parsed JSON content as a Python dictionary (JSON-compatible). + + Raises: + FileNotFoundError: If trust_config_test_settings.json is not found. + json.JSONDecodeError: If the JSON file is malformed. + """ + # Locate the file which contains default settings for tests + tests_dir = os.path.dirname(os.path.abspath(__file__)) + settings_path = os.path.join(tests_dir, 'trust_config_test_settings.json') + + # Load the located default test settings + with open(settings_path, 'r') as f: + settings_data = json.load(f) + + return settings_data + + class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): - self.assertIn("0.67.1", sdk_version()) + # This test verifies the native libraries used match the expected version. + self.assertIn("0.78.4", sdk_version()) class TestReader(unittest.TestCase): def setUp(self): + warnings.filterwarnings("ignore", message="load_settings\\(\\) is deprecated") self.data_dir = FIXTURES_DIR self.testPath = DEFAULT_TEST_FILE @@ -58,12 +89,46 @@ def test_can_retrieve_reader_supported_mimetypes(self): 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) @@ -124,6 +189,42 @@ def test_stream_read_get_validation_state(self): 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) @@ -155,21 +256,40 @@ def test_stream_read_detailed_and_parse(self): title = manifest_store["manifests"][manifest_store["active_manifest"]]["claim"]["dc:title"] self.assertEqual(title, DEFAULT_TEST_FILE_NAME) - def test_stream_read_string_stream(self): + def test_stream_read_string_stream_path_only(self): with Reader(self.testPath) as reader: json_data = reader.json() self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + 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_stream_read_string_stream_mimetype_not_supported(self): with self.assertRaises(Error.NotSupported): # xyz is actually an extension that is recognized # as mimetype chemical/x-xyz Reader(os.path.join(FIXTURES_DIR, "C.xyz")) + def test_try_create_raises_mimetype_not_supported(self): + with self.assertRaises(Error.NotSupported): + # xyz is actually an extension that is recognized + # as mimetype chemical/x-xyz, but we don't support it + Reader.try_create(os.path.join(FIXTURES_DIR, "C.xyz")) + def test_stream_read_string_stream_mimetype_not_recognized(self): with self.assertRaises(Error.NotSupported): Reader(os.path.join(FIXTURES_DIR, "C.test")) + def test_try_create_raises_mimetype_not_recognized(self): + with self.assertRaises(Error.NotSupported): + Reader.try_create(os.path.join(FIXTURES_DIR, "C.test")) + def test_stream_read_string_stream(self): with Reader("image/jpeg", self.testPath) as reader: json_data = reader.json() @@ -208,10 +328,10 @@ def test_reader_close_cleanup(self): # Close the reader reader.close() # Verify all resources are cleaned up - self.assertIsNone(reader._reader) + self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) # Verify reader is marked as closed - self.assertTrue(reader._closed) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) def test_resource_to_stream_on_closed_reader(self): """Test that resource_to_stream correctly raises error on closed.""" @@ -304,6 +424,116 @@ def test_read_all_files(self): 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") @@ -463,9 +693,8 @@ def test_reader_context_manager_with_exception(self): try: with Reader(self.testPath) as reader: # Inside context - should be valid - self.assertFalse(reader._closed) - self.assertTrue(reader._initialized) - self.assertIsNotNone(reader._reader) + self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(reader._handle) self.assertIsNotNone(reader._own_stream) self.assertIsNotNone(reader._backing_file) raise ValueError("Test exception") @@ -473,19 +702,17 @@ def test_reader_context_manager_with_exception(self): pass # After exception - should still be closed - self.assertTrue(reader._closed) - self.assertFalse(reader._initialized) - self.assertIsNone(reader._reader) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) def test_reader_partial_initialization_states(self): """Test Reader behavior with partial initialization failures.""" - # Test with _reader = None but _initialized = True + # Test with _reader = None but lifecycle state = ACTIVE reader = Reader.__new__(Reader) - reader._closed = False - reader._initialized = True - reader._reader = None + reader._lifecycle_state = LifecycleState.ACTIVE + reader._handle = None reader._own_stream = None reader._backing_file = None @@ -497,9 +724,8 @@ def test_reader_cleanup_state_transitions(self): reader = Reader(self.testPath) reader._cleanup_resources() - self.assertTrue(reader._closed) - self.assertFalse(reader._initialized) - self.assertIsNone(reader._reader) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) @@ -509,13 +735,12 @@ def test_reader_cleanup_idempotency(self): # First cleanup reader._cleanup_resources() - self.assertTrue(reader._closed) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) # Second cleanup should not change state reader._cleanup_resources() - self.assertTrue(reader._closed) - self.assertFalse(reader._initialized) - self.assertIsNone(reader._reader) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) @@ -524,7 +749,7 @@ def test_reader_state_with_invalid_native_pointer(self): reader = Reader(self.testPath) # Simulate invalid native pointer - reader._reader = 0 + reader._handle = 0 # Operations should fail gracefully with self.assertRaises(Error): @@ -938,7 +1163,6 @@ def test_streams_sign_with_thumbnail_resource(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) output.close() def test_streams_sign_with_es256_alg_v1_manifest(self): @@ -950,7 +1174,7 @@ def test_streams_sign_with_es256_alg_v1_manifest(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + self.assertIn("Valid", json_data) # Write buffer to file # output.seek(0) @@ -975,7 +1199,7 @@ def test_streams_sign_with_es256_alg_v1_manifest_to_existing_empty_file(self): reader = Reader("image/jpeg", target) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + self.assertIn("Valid", json_data) finally: # Clean up... @@ -1001,7 +1225,7 @@ def test_streams_sign_with_es256_alg_v1_manifest_to_new_dest_file(self): reader = Reader("image/jpeg", target) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + self.assertIn("Valid", json_data) finally: # Clean up... @@ -1023,7 +1247,9 @@ def test_streams_sign_with_es256_alg(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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): @@ -1035,9 +1261,262 @@ def test_streams_sign_with_es256_alg_2(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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() @@ -1060,9 +1539,71 @@ def test_sign_with_ed25519_alg(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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() @@ -1085,7 +1626,9 @@ def test_sign_with_ed25519_alg_2(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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): @@ -1110,7 +1653,9 @@ def test_sign_with_ps256_alg(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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): @@ -1135,9 +1680,70 @@ def test_sign_with_ps256_alg_2(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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) @@ -1150,18 +1756,71 @@ def test_archive_sign(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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(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"}' + 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) @@ -1169,10 +1828,66 @@ def test_archive_sign_with_added_ingredient(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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_remote_sign(self): with open(self.testPath, "rb") as file: builder = Builder(self.manifestDefinition) @@ -1200,7 +1915,6 @@ def test_remote_sign_using_returned_bytes(self): with Reader("image/jpeg", read_buffer, manifest_data) as reader: manifest_data = reader.json() self.assertIn("Python Test", manifest_data) - self.assertNotIn("validation_status", manifest_data) def test_remote_sign_using_returned_bytes_V2(self): with open(self.testPath, "rb") as file: @@ -1215,7 +1929,56 @@ def test_remote_sign_using_returned_bytes_V2(self): with Reader("image/jpeg", read_buffer, manifest_data) as reader: manifest_data = reader.json() self.assertIn("Python Test", manifest_data) - self.assertNotIn("validation_status", manifest_data) + + def test_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""" @@ -1274,7 +2037,76 @@ def test_sign_all_files(self): reader = Reader(mime_type, output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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: @@ -1294,7 +2126,7 @@ def test_builder_no_added_ingredient_on_closed_builder(self): def test_builder_add_ingredient(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{"test": "ingredient"}' @@ -1305,7 +2137,7 @@ def test_builder_add_ingredient(self): def test_builder_add_ingredient_dict(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient with a dictionary instead of JSON string ingredient_dict = {"test": "ingredient"} @@ -1316,7 +2148,7 @@ def test_builder_add_ingredient_dict(self): def test_builder_add_multiple_ingredients(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test builder operations builder.set_no_embed() @@ -1336,7 +2168,7 @@ def test_builder_add_multiple_ingredients(self): def test_builder_add_multiple_ingredients_2(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test builder operations builder.set_no_embed() @@ -1356,7 +2188,7 @@ def test_builder_add_multiple_ingredients_2(self): def test_builder_add_multiple_ingredients_and_resources(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test builder operations builder.set_no_embed() @@ -1385,7 +2217,7 @@ def test_builder_add_multiple_ingredients_and_resources(self): def test_builder_add_multiple_ingredients_and_resources_interleaved(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None with open(self.testPath, 'rb') as f: builder.add_resource("test_uri_1", f) @@ -1408,7 +2240,7 @@ def test_builder_add_multiple_ingredients_and_resources_interleaved(self): def test_builder_sign_with_ingredient(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{ "title": "Test Ingredient" }' @@ -1449,13 +2281,115 @@ def test_builder_sign_with_ingredient(self): builder.close() - def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + 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: @@ -1497,12 +2431,12 @@ def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): load_settings('{"builder": { "thumbnail": {"enabled": true}}}') def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): - builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None - # 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: @@ -1545,7 +2479,7 @@ def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): def test_builder_sign_with_duplicate_ingredient(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{"title": "Test Ingredient"}' @@ -1591,7 +2525,7 @@ def test_builder_sign_with_duplicate_ingredient(self): def test_builder_sign_with_ingredient_from_stream(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient using stream ingredient_json = '{"title": "Test Ingredient Stream"}' @@ -1631,7 +2565,7 @@ def test_builder_sign_with_ingredient_from_stream(self): def test_builder_sign_with_ingredient_dict_from_stream(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient using stream with a dictionary ingredient_dict = {"title": "Test Ingredient Stream"} @@ -1671,7 +2605,7 @@ def test_builder_sign_with_ingredient_dict_from_stream(self): def test_builder_sign_with_multiple_ingredient(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Add first ingredient ingredient_json1 = '{"title": "Test Ingredient 1"}' @@ -1716,7 +2650,7 @@ def test_builder_sign_with_multiple_ingredient(self): def test_builder_sign_with_multiple_ingredients_from_stream(self): builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Add first ingredient using stream ingredient_json1 = '{"title": "Test Ingredient Stream 1"}' @@ -1775,8 +2709,11 @@ def test_builder_set_remote_url(self): def test_builder_set_remote_url_no_embed(self): """Test setting the remote url of a builder with no embed flag.""" - builder = Builder.from_json(self.manifestDefinition) + + # 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") @@ -1805,7 +2742,9 @@ def test_sign_single(self): reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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): @@ -1820,7 +2759,9 @@ def test_sign_mp4_video_file_single(self): reader = Reader("video/mp4", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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): @@ -1835,37 +2776,11 @@ def test_sign_mov_video_file_single(self): reader = Reader("mov", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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_file_tmn_wip(self): - temp_dir = tempfile.mkdtemp() - try: - # Create a temporary output file path - output_path = os.path.join(temp_dir, "signed_output.jpg") - - # Use the sign_file method - builder = Builder(self.manifestDefinition) - builder.sign_file( - self.testPath, - 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("image/jpeg", file) - json_data = reader.json() - self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) - - finally: - # Clean up the temporary directory - shutil.rmtree(temp_dir) - def test_sign_file_video(self): temp_dir = tempfile.mkdtemp() try: @@ -1888,7 +2803,9 @@ def test_sign_file_video(self): reader = Reader("video/mp4", file) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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 @@ -1940,7 +2857,9 @@ def test_builder_sign_file_callback_signer_from_callback(self): with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # 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) @@ -1991,7 +2910,9 @@ def test_builder_sign_file_callback_signer_from_callback_V2(self): with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # 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) @@ -2044,7 +2965,9 @@ def ed25519_callback(data: bytes) -> bytes: reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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() @@ -2068,7 +2991,9 @@ def test_signing_manifest_v2(self): # Basic verification of the manifest self.assertIn("Python Test Image V2", json_data) - self.assertNotIn("validation_status", 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() @@ -2101,7 +3026,9 @@ def test_sign_file_mp4_video(self): reader = Reader("video/mp4", file) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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 @@ -2129,13 +3056,17 @@ def test_sign_file_mov_video(self): reader = Reader("mov", file) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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) - self.assertNotIn("validation_status", 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 @@ -2163,13 +3094,17 @@ def test_sign_file_mov_video_V2(self): reader = Reader("mov", file) json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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) - self.assertNotIn("validation_status", 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 @@ -2202,7 +3137,7 @@ def test_builder_with_invalid_signer_object(self): # Use a mock object that looks like a signer but isn't class MockSigner: def __init__(self): - self._signer = None + self._handle = None mock_signer = MockSigner() @@ -2322,56 +3257,49 @@ def test_builder_state_transitions(self): builder = Builder(self.manifestDefinition) # Initial state - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) # After close builder.close() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_context_manager_states(self): """Test Builder state management in context manager.""" with Builder(self.manifestDefinition) as builder: # Inside context - should be valid - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) # Placeholder operation builder.set_no_embed() # After context exit - should be closed - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_context_manager_with_exception(self): """Test Builder state after exception in context manager.""" try: with Builder(self.manifestDefinition) as builder: # Inside context - should be valid - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) raise ValueError("Test exception") except ValueError: pass # After exception - should still be closed - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_partial_initialization_states(self): """Test Builder behavior with partial initialization failures.""" - # Test with _builder = None but _initialized = True + # Test with _builder = None but _state = ACTIVE builder = Builder.__new__(Builder) - builder._closed = False - builder._initialized = True - builder._builder = None + builder._lifecycle_state = LifecycleState.ACTIVE + builder._handle = None with self.assertRaises(Error): builder._ensure_valid_state() @@ -2382,9 +3310,8 @@ def test_builder_cleanup_state_transitions(self): # Test _cleanup_resources method builder._cleanup_resources() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_cleanup_idempotency(self): """Test that cleanup operations are idempotent.""" @@ -2392,13 +3319,12 @@ def test_builder_cleanup_idempotency(self): # First cleanup builder._cleanup_resources() - self.assertTrue(builder._closed) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) # Second cleanup should not change state builder._cleanup_resources() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_state_after_sign_operations(self): """Test Builder state after signing operations.""" @@ -2407,14 +3333,9 @@ def test_builder_state_after_sign_operations(self): with open(self.testPath, "rb") as file: manifest_bytes = builder.sign(self.signer, "image/jpeg", file) - # State should still be valid after signing - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) - - # Should be able to sign again - with open(self.testPath, "rb") as file: - manifest_bytes2 = builder.sign(self.signer, "image/jpeg", file) + # Builder is consumed by sign — pointer ownership transferred to Rust + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_state_after_archive_operations(self): """Test Builder state after archive operations.""" @@ -2425,9 +3346,8 @@ def test_builder_state_after_archive_operations(self): builder.to_archive(archive_stream) # State should still be valid - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) - self.assertIsNotNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) + self.assertIsNotNone(builder._handle) def test_builder_state_after_double_close(self): """Test Builder state after double close operations.""" @@ -2435,22 +3355,20 @@ def test_builder_state_after_double_close(self): # First close builder.close() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) # Second close should not change state builder.close() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) - self.assertIsNone(builder._builder) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_state_with_invalid_native_pointer(self): """Test Builder state handling with invalid native pointer.""" builder = Builder(self.manifestDefinition) # Simulate invalid native pointer - builder._builder = 0 + builder._handle = 0 # Operations should fail gracefully with self.assertRaises(Error): @@ -2534,7 +3452,7 @@ def test_builder_add_action_to_manifest_no_auto_add(self): builder.close() # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + 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 @@ -2615,11 +3533,11 @@ def test_builder_add_action_to_manifest_from_dict_no_auto_add(self): builder.close() # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + 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}}}}') + 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": [{ @@ -2704,7 +3622,7 @@ def test_builder_add_action_to_manifest_with_auto_add(self): 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}}}}') + 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 @@ -2769,11 +3687,11 @@ def test_builder_minimal_manifest_add_actions_and_sign_no_auto_add(self): builder.close() # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + 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}}}}') + 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": [{ @@ -2843,7 +3761,7 @@ def test_builder_minimal_manifest_add_actions_and_sign_with_auto_add(self): builder.close() # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + 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 @@ -2924,7 +3842,7 @@ def test_builder_sign_dicts_no_auto_add(self): builder.close() # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + 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""" @@ -3367,6 +4285,7 @@ def setUp(self): warnings.filterwarnings("ignore", message="The read_ingredient_file function is deprecated") warnings.filterwarnings("ignore", message="The create_signer function is deprecated") warnings.filterwarnings("ignore", message="The create_signer_from_info function is deprecated") + warnings.filterwarnings("ignore", message="load_settings\\(\\) is deprecated") self.data_dir = FIXTURES_DIR self.testPath = DEFAULT_TEST_FILE @@ -3437,11 +4356,6 @@ def tearDown(self): if os.path.exists(self.temp_data_dir): shutil.rmtree(self.temp_data_dir) - def test_invalid_settings_str(self): - """Test loading a malformed settings string.""" - with self.assertRaises(Error): - load_settings(r'{"verify": { "remote_manifest_fetch": false }') - def test_read_ingredient_file(self): """Test reading a C2PA ingredient from a file.""" # Test reading ingredient from file with data_dir @@ -3462,6 +4376,7 @@ def test_read_ingredient_file_who_has_no_manifest(self): temp_data_dir = os.path.join(self.data_dir, "temp_data") os.makedirs(temp_data_dir, exist_ok=True) + # Load settings first, before they need to be used load_settings('{"builder": { "thumbnail": {"enabled": false}}}') ingredient_json_with_dir = read_ingredient_file(self.testPath2, temp_data_dir) @@ -3751,7 +4666,7 @@ def test_builder_add_ingredient_from_file_path(self): builder.close() - def test_builder_add_ingredient_from_file_path(self): + def test_builder_add_ingredient_from_file_path_not_found(self): """Test Builder class add_ingredient_from_file_path method.""" # Suppress the specific deprecation warning for this test, as this is a legacy method @@ -3825,7 +4740,6 @@ def test_sign_file_using_callback_signer_overloads(self): reader = Reader("image/jpeg", file) file_manifest_json = reader.json() self.assertIn("Python Test", file_manifest_json) - self.assertNotIn("validation_status", file_manifest_json) finally: shutil.rmtree(temp_dir) @@ -3996,56 +4910,8 @@ def test_sign_file_callback_signer(self): # Read the signed file and verify the manifest with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: json_data = reader.json() - self.assertNotIn("validation_status", json_data) - - # Parse the JSON and verify the signature algorithm - manifest_data = json.loads(json_data) - active_manifest_id = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_id] - - self.assertIn("signature_info", active_manifest) - signature_info = active_manifest["signature_info"] - self.assertEqual(signature_info["alg"], self.callback_signer_alg) - - finally: - shutil.rmtree(temp_dir) - - def test_sign_file_callback_signer(self): - """Test signing a file using the sign_file method.""" - - temp_dir = tempfile.mkdtemp() - - try: - output_path = os.path.join(temp_dir, "signed_output.jpg") - - # Use the sign_file method - builder = Builder(self.manifestDefinition) - - # Create signer with callback using create_signer function - signer = create_signer( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) - - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) - - # Verify results - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) - - # Read the signed file and verify the manifest - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: - json_data = reader.json() - self.assertNotIn("validation_status", json_data) + # 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) @@ -4091,7 +4957,8 @@ def test_sign_file_callback_signer_managed_single(self): with Reader("image/jpeg", file) as reader: json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", 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) @@ -4133,10 +5000,12 @@ def test_sign_file_callback_signer_managed_multiple_uses(self): self.assertIsInstance(manifest_bytes_1, bytes) self.assertGreater(len(manifest_bytes_1), 0) - # Second signing operation with the same signer - # This is to verify we don't free the signer or the callback too early + # Second signing operation with a new builder but same signer + # Builder is consumed by sign, so we need a fresh one. + # This verifies we don't free the signer or the callback too early. + builder2 = Builder(self.manifestDefinition) output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") - manifest_bytes_2 = builder.sign_file( + manifest_bytes_2 = builder2.sign_file( source_path=self.testPath, dest_path=output_path_2, signer=signer @@ -4152,7 +5021,8 @@ def test_sign_file_callback_signer_managed_multiple_uses(self): with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: json_data = reader.json() self.assertIn("Python Test", json_data) - self.assertNotIn("validation_status", json_data) + # 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) @@ -4173,5 +5043,1196 @@ def test_create_signer_from_info(self): self.assertIsNotNone(signer) +class TestContextAPIs(unittest.TestCase): + """Base for context-related tests; provides test_manifest and signer helpers.""" + + test_manifest = { + "claim_generator": "c2pa_python_sdk_test/context", + "claim_generator_info": [{ + "name": "c2pa_python_sdk_contextual_test", + "version": "0.1.0", + }], + "format": "image/jpeg", + "title": "Test Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + }] + } + } + ] + } + + def _ctx_make_signer(self): + """Create a Signer for context tests.""" + certs_path = os.path.join( + FIXTURES_DIR, "es256_certs.pem" + ) + key_path = os.path.join( + FIXTURES_DIR, "es256_private.key" + ) + with open(certs_path, "rb") as f: + certs = f.read() + with open(key_path, "rb") as f: + key = f.read() + info = C2paSignerInfo( + alg=b"es256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com", + ) + return Signer.from_info(info) + + def _ctx_make_callback_signer(self): + """Create a callback-based Signer for context tests.""" + certs_path = os.path.join( + FIXTURES_DIR, "es256_certs.pem" + ) + key_path = os.path.join( + FIXTURES_DIR, "es256_private.key" + ) + with open(certs_path, "rb") as f: + certs = f.read() + with open(key_path, "rb") as f: + key_data = f.read() + + from cryptography.hazmat.primitives import ( + serialization, + ) + private_key = serialization.load_pem_private_key( + key_data, password=None, + backend=default_backend(), + ) + + def sign_cb(data: bytes) -> bytes: + from cryptography.hazmat.primitives.asymmetric import ( # noqa: E501 + utils as asym_utils, + ) + sig = private_key.sign( + data, ec.ECDSA(hashes.SHA256()), + ) + r, s = asym_utils.decode_dss_signature(sig) + return ( + r.to_bytes(32, byteorder='big') + + s.to_bytes(32, byteorder='big') + ) + + return Signer.from_callback( + sign_cb, + SigningAlg.ES256, + certs.decode('utf-8'), + "http://timestamp.digicert.com", + ) + + def _ctx_make_ed25519_signer(self): + """Create an ED25519 Signer for context tests.""" + with open( + os.path.join(FIXTURES_DIR, "ed25519.pub"), "rb" + ) as f: + certs = f.read() + with open( + os.path.join(FIXTURES_DIR, "ed25519.pem"), "rb" + ) as f: + key = f.read() + info = C2paSignerInfo( + alg=b"ed25519", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com", + ) + return Signer.from_info(info) + + def _ctx_make_ps256_signer(self): + """Create a PS256 Signer for context tests.""" + with open( + os.path.join(FIXTURES_DIR, "ps256.pub"), "rb" + ) as f: + certs = f.read() + with open( + os.path.join(FIXTURES_DIR, "ps256.pem"), "rb" + ) as f: + key = f.read() + info = C2paSignerInfo( + alg=b"ps256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com", + ) + return Signer.from_info(info) + + +class TestSettings(TestContextAPIs): + + def test_settings_default_construction(self): + settings = Settings() + self.assertTrue(settings.is_valid) + settings.close() + + def test_settings_set_chaining(self): + settings = Settings() + result = ( + settings.set( + "builder.thumbnail.enabled", "false" + ).set( + "builder.thumbnail.enabled", "true" + ) + ) + self.assertIs(result, settings) + settings.close() + + def test_settings_from_json(self): + settings = Settings.from_json( + '{"builder":{"thumbnail":' + '{"enabled":false}}}' + ) + self.assertTrue(settings.is_valid) + settings.close() + + def test_settings_from_dict(self): + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + self.assertTrue(settings.is_valid) + settings.close() + + def test_settings_update_json(self): + settings = Settings() + result = settings.update( + '{"builder":{"thumbnail":' + '{"enabled":false}}}' + ) + self.assertIs(result, settings) + settings.close() + + def test_settings_update_dict(self): + settings = Settings() + result = settings.update({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + self.assertIs(result, settings) + settings.close() + + def test_settings_is_valid_after_close(self): + settings = Settings() + settings.close() + self.assertFalse(settings.is_valid) + + def test_settings_raises_after_close(self): + settings = Settings() + settings.close() + with self.assertRaises(Error): + settings.set( + "builder.thumbnail.enabled", "false" + ) + + +class TestContext(TestContextAPIs): + + def test_context_default(self): + context = Context() + self.assertTrue(context.is_valid) + self.assertFalse(context.has_signer) + context.close() + + def test_context_from_settings(self): + settings = Settings() + context = Context(settings) + self.assertTrue(context.is_valid) + context.close() + settings.close() + + def test_context_from_json(self): + context = Context.from_json( + '{"builder":{"thumbnail":' + '{"enabled":false}}}' + ) + self.assertTrue(context.is_valid) + context.close() + + def test_context_from_dict(self): + context = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + self.assertTrue(context.is_valid) + context.close() + + def test_context_context_manager(self): + with Context() as context: + self.assertTrue(context.is_valid) + + def test_context_is_valid_after_close(self): + context = Context() + context.close() + self.assertFalse(context.is_valid) + + +class TestContextBuilder(TestContextAPIs): + + def test_context_builder_default(self): + context = Context.builder().build() + self.assertTrue(context.is_valid) + self.assertFalse(context.has_signer) + context.close() + + def test_context_builder_with_settings(self): + settings = Settings() + context = Context.builder().with_settings(settings).build() + self.assertTrue(context.is_valid) + context.close() + settings.close() + + def test_context_builder_with_signer(self): + signer = self._ctx_make_signer() + context = ( + Context.builder() + .with_signer(signer) + .build() + ) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + + def test_context_builder_with_settings_and_signer(self): + settings = Settings() + signer = self._ctx_make_signer() + context = ( + Context.builder() + .with_settings(settings) + .with_signer(signer) + .build() + ) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + settings.close() + + def test_context_builder_chaining_returns_self(self): + settings = Settings() + context_builder = Context.builder() + result = context_builder.with_settings(settings) + self.assertIs(result, context_builder) + context = context_builder.build() + context.close() + settings.close() + + 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_format_and_path_with_ctx(self): + context = Context() + reader = Reader("image/jpeg", DEFAULT_TEST_FILE, context=context) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + context.close() + + def test_with_fragment_on_closed_reader_raises(self): + context = Context() + reader = Reader(DEFAULT_TEST_FILE, context=context) + reader.close() + with self.assertRaises(Error): + reader.with_fragment( + "video/mp4", + io.BytesIO(b"\x00" * 100), + io.BytesIO(b"\x00" * 100), + ) + context.close() + + def test_with_fragment_unsupported_format_raises(self): + context = Context() + reader = Reader(DEFAULT_TEST_FILE, context=context) + with self.assertRaises(Error): + reader.with_fragment( + "text/plain", + io.BytesIO(b"\x00" * 100), + io.BytesIO(b"\x00" * 100), + ) + reader.close() + context.close() + + def test_with_fragment_with_dash_fixtures(self): + context = Context() + init_path = os.path.join(FIXTURES_DIR, "dashinit.mp4") + with open(init_path, "rb") as init_fragment: + reader = Reader("video/mp4", init_fragment, context=context) + frag_path = os.path.join(FIXTURES_DIR, "dash1.m4s") + with open(init_path, "rb") as init_fragment, \ + open(frag_path, "rb") as next_fragment: + reader.with_fragment("video/mp4", init_fragment, next_fragment) + reader.close() + context.close() + + +class TestBuilderWithContext(TestContextAPIs): + + def test_contextual_builder_with_default_context(self): + context = Context() + builder = Builder(self.test_manifest, context) + self.assertIsNotNone(builder) + builder.close() + context.close() + + def test_contextual_builder_with_settings_context(self): + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + context = Context(settings) + builder = Builder(self.test_manifest, context) + signer = self._ctx_make_signer() + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + builder.sign( + signer, "image/jpeg", source_file, dest_file, + ) + reader = Reader(dest_path) + manifest = reader.get_active_manifest() + self.assertIsNone( + manifest.get("thumbnail") + ) + reader.close() + builder.close() + context.close() + settings.close() + + def test_contextual_builder_from_json_with_context(self): + context = Context() + builder = Builder.from_json(self.test_manifest, context) + self.assertIsNotNone(builder) + builder.close() + context.close() + + def test_contextual_builder_sign_context_signer(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign( + "image/jpeg", + source_file, + dest_file, + ) + self.assertIsNotNone(manifest_bytes) + self.assertGreater(len(manifest_bytes), 0) + reader = Reader(dest_path) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + builder.close() + context.close() + + def test_contextual_builder_sign_signer_ovverride(self): + context_signer = self._ctx_make_signer() + context = Context(signer=context_signer) + builder = Builder( + self.test_manifest, context=context, + ) + explicit_signer = self._ctx_make_signer() + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign( + explicit_signer, + "image/jpeg", source_file, dest_file, + ) + self.assertIsNotNone(manifest_bytes) + self.assertGreater(len(manifest_bytes), 0) + builder.close() + explicit_signer.close() + context.close() + + def test_contextual_builder_sign_no_signer_raises(self): + context = Context() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + with self.assertRaises(Error): + builder.sign( + "image/jpeg", + source_file, + dest_file, + ) + builder.close() + context.close() + + def test_sign_file_with_context_signer_no_explicit_signer(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + manifest_bytes = builder.sign_file( + source_path=DEFAULT_TEST_FILE, + dest_path=dest_path, + ) + self.assertIsNotNone(manifest_bytes) + self.assertGreater(len(manifest_bytes), 0) + reader = Reader(dest_path) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + builder.close() + context.close() + + def test_sign_file_no_signer_raises(self): + context = Context() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with self.assertRaises(Error): + builder.sign_file( + source_path=DEFAULT_TEST_FILE, + dest_path=dest_path, + ) + builder.close() + context.close() + + def test_with_archive_preserves_settings(self): + """with_archive() preserves the builder's context settings. + + Settings live on the builder's context, not in the archive. + The archive only carries the manifest definition. This test + proves that a builder created with no-thumbnail settings + keeps those settings after loading an archive. + """ + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + + # Context provides the no-thumbnail setting; + # with_archive only loads the manifest definition. + builder2 = Builder({}, context) + builder2.with_archive(archive) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder2.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=Context(), + ) + manifest = reader.get_active_manifest() + self.assertIsNone( + manifest.get("thumbnail"), + "with_archive should preserve no-thumbnail setting", + ) + reader.close() + archive.close() + builder2.close() + signer.close() + context.close() + settings.close() + + def test_with_archive_replaces_definition(self): + """with_archive() restores the original builder's + manifest definition, even if something set on new Builder.""" + context = Context() + signer = self._ctx_make_signer() + original_manifest = dict(self.test_manifest) + original_manifest["title"] = "Original Title" + builder = Builder(original_manifest, context) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + + replaced_manifest = dict(self.test_manifest) + replaced_manifest["title"] = "Replaced Title" + builder2 = Builder(replaced_manifest, context) + builder2.with_archive(archive) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder2.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=Context(), + ) + json_data = reader.json() + self.assertIn("Original Title", json_data) + self.assertNotIn("Replaced Title", json_data) + reader.close() + archive.close() + builder2.close() + signer.close() + context.close() + + def test_with_archive_on_closed_builder_raises(self): + """with_archive() on a closed builder raises C2paError.""" + context = Context() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder.close() + with self.assertRaises(Error): + builder.with_archive(archive) + context.close() + + def test_from_archive_roundtrip(self): + """from_archive() can't propagate contexts.""" + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + + # from_archive creates a context-free builder + builder2 = Builder.from_archive(archive) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder2.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=Context(), + ) + manifest = reader.get_active_manifest() + # from_archive can't propagate contexts + self.assertIsNotNone( + manifest.get("thumbnail"), + "from_archive should lose settings and generate thumbnail", + ) + reader.close() + archive.close() + builder2.close() + signer.close() + context.close() + settings.close() + + +class TestContextIntegration(TestContextAPIs): + + def test_sign_no_thumbnail_via_context(self): + settings = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + builder.sign( + signer, "image/jpeg", source_file, dest_file, + ) + reader = Reader(dest_path) + manifest = reader.get_active_manifest() + self.assertIsNone( + manifest.get("thumbnail") + ) + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_sign_read_roundtrip(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + builder.sign( + "image/jpeg", + source_file, + dest_file, + ) + reader = Reader(dest_path) + data = reader.json() + self.assertIsNotNone(data) + self.assertIn("manifests", data) + reader.close() + builder.close() + context.close() + + def test_shared_context_multi_builders(self): + context = Context() + signer1 = self._ctx_make_signer() + signer2 = self._ctx_make_signer() + + builder1 = Builder(self.test_manifest, context) + builder2 = Builder(self.test_manifest, context) + + with tempfile.TemporaryDirectory() as temp_dir: + for index, (builder, signer) in enumerate( + [(builder1, signer1), (builder2, signer2)] + ): + dest_path = os.path.join( + temp_dir, f"out{index}.jpg" + ) + with ( + open( + DEFAULT_TEST_FILE, "rb" + ) as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign( + signer, "image/jpeg", + source_file, dest_file, + ) + self.assertGreater(len(manifest_bytes), 0) + + builder1.close() + builder2.close() + signer1.close() + signer2.close() + context.close() + + def test_trusted_sign_no_thumbnail_via_context(self): + trust_dict = load_test_settings_json() + trust_dict.setdefault("builder", {})["thumbnail"] = { + "enabled": False, + } + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + builder.sign( + signer, "image/jpeg", + source_file, dest_file, + ) + reader = Reader(dest_path, context=context) + manifest = reader.get_active_manifest() + self.assertIsNone(manifest.get("thumbnail")) + validation_state = reader.get_validation_state() + self.assertEqual(validation_state, "Trusted") + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_shared_trusted_context_multi_builders(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer1 = self._ctx_make_signer() + signer2 = self._ctx_make_signer() + + builder1 = Builder( + self.test_manifest, context=context, + ) + builder2 = Builder( + self.test_manifest, context=context, + ) + + with tempfile.TemporaryDirectory() as temp_dir: + for index, (builder, signer) in enumerate( + [(builder1, signer1), (builder2, signer2)] + ): + dest_path = os.path.join( + temp_dir, f"out{index}.jpg" + ) + with ( + open( + DEFAULT_TEST_FILE, "rb" + ) as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign( + signer, "image/jpeg", + source_file, dest_file, + ) + self.assertGreater( + len(manifest_bytes), 0, + ) + reader = Reader( + dest_path, context=context, + ) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + + builder1.close() + builder2.close() + signer1.close() + signer2.close() + context.close() + settings.close() + + def test_read_validation_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + with open(DEFAULT_TEST_FILE, "rb") as f: + reader = Reader("image/jpeg", f, context=context) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + context.close() + settings.close() + + def test_sign_es256_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + open(dest_path, "w+b") as dest, + ): + builder.sign( + signer, "image/jpeg", source, dest, + ) + reader = Reader(dest_path, context=context) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_sign_ed25519_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_ed25519_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + open(dest_path, "w+b") as dest, + ): + builder.sign( + signer, "image/jpeg", source, dest, + ) + reader = Reader(dest_path, context=context) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_sign_ps256_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_ps256_signer() + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + open(dest_path, "w+b") as dest, + ): + builder.sign( + signer, "image/jpeg", source, dest, + ) + reader = Reader(dest_path, context=context) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_archive_sign_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder({}, context=context) + builder.with_archive(archive) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=context, + ) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + archive.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_archive_sign_with_ingredient_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder({}, context=context) + builder.with_archive(archive) + ingredient_json = '{"test": "ingredient"}' + with open(DEFAULT_TEST_FILE, "rb") as f: + builder.add_ingredient( + ingredient_json, "image/jpeg", f, + ) + with ( + open(DEFAULT_TEST_FILE, "rb") as source, + io.BytesIO(bytearray()) as output, + ): + builder.sign( + signer, "image/jpeg", source, output, + ) + output.seek(0) + reader = Reader( + "image/jpeg", output, context=context, + ) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + archive.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_remote_sign_trusted_via_context(self): + trust_dict = load_test_settings_json() + settings = Settings.from_dict(trust_dict) + context = Context(settings=settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + builder.set_no_embed() + with open(DEFAULT_TEST_FILE, "rb") as source: + with io.BytesIO() as output_buffer: + manifest_data = builder.sign( + signer, "image/jpeg", + source, output_buffer, + ) + output_buffer.seek(0) + read_buffer = io.BytesIO( + output_buffer.getvalue() + ) + reader = Reader( + "image/jpeg", read_buffer, + manifest_data, context=context, + ) + validation_state = ( + reader.get_validation_state() + ) + self.assertEqual( + validation_state, "Trusted", + ) + reader.close() + read_buffer.close() + builder.close() + signer.close() + context.close() + settings.close() + + def test_sign_callback_signer_in_ctx(self): + signer = self._ctx_make_callback_signer() + context = Context(signer=signer) + builder = Builder( + self.test_manifest, context=context, + ) + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, + ): + manifest_bytes = builder.sign( + "image/jpeg", + source_file, + dest_file, + ) + self.assertGreater(len(manifest_bytes), 0) + reader = Reader(dest_path) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + builder.close() + context.close() + + if __name__ == '__main__': - unittest.main() + unittest.main(warnings='ignore') diff --git a/tests/test_unit_tests_threaded.py b/tests/test_unit_tests_threaded.py index 9a00dd6a..8eaacf88 100644 --- a/tests/test_unit_tests_threaded.py +++ b/tests/test_unit_tests_threaded.py @@ -21,7 +21,8 @@ import asyncio import random -from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version +from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version # noqa: E501 +from c2pa import Context, Settings from c2pa.c2pa import Stream PROJECT_PATH = os.getcwd() @@ -40,11 +41,11 @@ class TestReaderWithThreads(unittest.TestCase): def setUp(self): # Use the fixtures_dir fixture to set up paths self.data_dir = FIXTURES_FOLDER - self.testPath = DEFAULT_TEST_FILE + self.test_path = DEFAULT_TEST_FILE def test_stream_read(self): def read_metadata(): - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: reader = Reader("image/jpeg", file) json_data = reader.json() self.assertIn("C.jpg", json_data) @@ -64,7 +65,7 @@ def read_metadata(): def test_stream_read_and_parse(self): def read_and_parse(): - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: reader = Reader("image/jpeg", file) manifest_store = json.loads(reader.json()) title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] @@ -316,6 +317,213 @@ def process_file_with_cache(filename): if errors: self.fail("\n".join(errors)) + +class TestContextualReaderWithThreads(unittest.TestCase): + def setUp(self): + self.data_dir = FIXTURES_FOLDER + self.test_path = DEFAULT_TEST_FILE + + def test_stream_read(self): + def read_metadata(): + ctx = Context() + with open(self.test_path, "rb") as file: + reader = Reader("image/jpeg", file, context=ctx) + json_data = reader.json() + self.assertIn("C.jpg", json_data) + return json_data + + thread1 = threading.Thread(target=read_metadata) + thread2 = threading.Thread(target=read_metadata) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + def test_stream_read_and_parse(self): + def read_and_parse(): + ctx = Context() + with open(self.test_path, "rb") as file: + reader = Reader("image/jpeg", file, context=ctx) + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, "C.jpg") + return manifest_store + + thread1 = threading.Thread(target=read_and_parse) + thread2 = threading.Thread(target=read_and_parse) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + def test_read_all_files(self): + """Test reading C2PA metadata from all files using context APIs.""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.pdf': 'application/pdf', + } + skip_files = {'.DS_Store'} + + def process_file(filename): + if filename in skip_files: + return None + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + ctx = Context() + with open(file_path, "rb") as file: + reader = Reader(mime_type, file, context=ctx) + json_data = reader.json() + manifest = json.loads(json_data) + if "manifests" not in manifest or "active_manifest" not in manifest: + return f"Invalid manifest structure in {filename}" + return None + except Exception as e: + return f"Failed to read metadata from {filename}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + future_to_file = { + executor.submit(process_file, filename): filename + for filename in os.listdir(reading_dir) + } + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename}: {str(e)}") + if errors: + self.fail("\n".join(errors)) + + def test_read_cached_all_files(self): + """Test reading C2PA metadata with cache using context APIs.""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.pdf': 'application/pdf', + } + skip_files = {'.DS_Store'} + + def process_file_with_cache(filename): + if filename in skip_files: + return None + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + ctx = Context() + with open(file_path, "rb") as file: + reader = Reader(mime_type, file, context=ctx) + if reader._manifest_json_str_cache is not None: + return f"JSON cache should be None initially for {filename}" + if reader._manifest_data_cache is not None: + return f"Manifest data cache should be None initially for {filename}" + json_data_1 = reader.json() + if reader._manifest_json_str_cache is None: + return f"JSON cache not set after first json() call for {filename}" + if json_data_1 != reader._manifest_json_str_cache: + return f"JSON cache doesn't match return value for {filename}" + json_data_2 = reader.json() + if json_data_1 != json_data_2: + return f"JSON inconsistency for {filename}" + if not isinstance(json_data_1, str): + return f"JSON data is not a string for {filename}" + try: + active_manifest = reader.get_active_manifest() + if not isinstance(active_manifest, dict): + return f"Active manifest not dict for {filename}" + if reader._manifest_json_str_cache is None: + return f"JSON cache not set after get_active_manifest for {filename}" + if reader._manifest_data_cache is None: + return f"Manifest data cache not set after get_active_manifest for {filename}" + active_manifest_2 = reader.get_active_manifest() + if active_manifest != active_manifest_2: + return f"Active manifest cache inconsistency for {filename}" + validation_state = reader.get_validation_state() + validation_results = reader.get_validation_results() + validation_state_2 = reader.get_validation_state() + if validation_state != validation_state_2: + return f"Validation state cache inconsistency for {filename}" + validation_results_2 = reader.get_validation_results() + if validation_results != validation_results_2: + return f"Validation results cache inconsistency for {filename}" + except KeyError: + pass + manifest = json.loads(json_data_1) + if "manifests" not in manifest: + return f"Missing 'manifests' key in {filename}" + if "active_manifest" not in manifest: + return f"Missing 'active_manifest' key in {filename}" + reader.close() + if reader._manifest_json_str_cache is not None: + return f"JSON cache not cleared for {filename}" + if reader._manifest_data_cache is not None: + return f"Manifest data cache not cleared for {filename}" + return None + except Exception as e: + return f"Failed to read cached metadata from {filename}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + future_to_file = { + executor.submit(process_file_with_cache, filename): filename + for filename in os.listdir(reading_dir) + } + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename}: {str(e)}") + if errors: + self.fail("\n".join(errors)) + + class TestBuilderWithThreads(unittest.TestCase): def setUp(self): # Use the fixtures_dir fixture to set up paths @@ -334,10 +542,10 @@ def setUp(self): ) self.signer = Signer.from_info(self.signer_info) - self.testPath = DEFAULT_TEST_FILE - self.testPath2 = INGREDIENT_TEST_FILE - self.testPath3 = OTHER_ALTERNATIVE_INGREDIENT_TEST_FILE - self.testPath4 = ALTERNATIVE_INGREDIENT_TEST_FILE + self.test_path = DEFAULT_TEST_FILE + self.test_path2 = INGREDIENT_TEST_FILE + self.test_path3 = OTHER_ALTERNATIVE_INGREDIENT_TEST_FILE + self.test_path4 = ALTERNATIVE_INGREDIENT_TEST_FILE # For that test manifest, we use a placeholder assertion with content # varying depending on thread/manifest, to check for data scrambling. @@ -659,7 +867,7 @@ def test_parallel_manifest_writing(self): output2 = io.BytesIO(bytearray()) def write_manifest(manifest_def, output_stream, thread_id): - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: builder = Builder(manifest_def) builder.sign(self.signer, "image/jpeg", file, output_stream) output_stream.seek(0) @@ -907,7 +1115,7 @@ def test_concurrent_read_after_write(self): def write_manifest(): try: - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: builder = Builder(self.manifestDefinition_1) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) @@ -980,7 +1188,7 @@ def test_concurrent_read_write_multiple_readers(self): def write_manifest(): try: - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: builder = Builder(self.manifestDefinition_1) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) # Reset stream position after write @@ -1069,7 +1277,7 @@ def test_resource_contention_read(self): stream_lock = threading.Lock() # Lock for stream access # First write some data to read - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: builder = Builder(self.manifestDefinition_1) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) @@ -1150,7 +1358,7 @@ def test_resource_contention_read_parallel(self): start_times_lock = threading.Lock() # First write some data to read - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: builder = Builder(self.manifestDefinition_1) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) @@ -1237,7 +1445,7 @@ def archive_sign( manifest_def, thread_id): try: - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: # Create and save archive builder = Builder(manifest_def) builder.to_archive(archive_stream) @@ -1339,7 +1547,7 @@ def test_sign_all_files_twice(self): def sign_file(output_stream, manifest_def, thread_id): try: - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: # Sign the file builder = Builder(manifest_def) builder.sign( @@ -1429,10 +1637,6 @@ def sign_file(output_stream, manifest_def, thread_id): active_manifest1["title"], active_manifest2["title"]) - # Verify both outputs have valid signatures - self.assertNotIn("validation_status", manifest_store1) - self.assertNotIn("validation_status", manifest_store2) - # Clean up output1.close() output2.close() @@ -1448,7 +1652,7 @@ def test_concurrent_read_after_write_async(self): async def write_manifest(): nonlocal write_success try: - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: builder = Builder(self.manifestDefinition_1) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) @@ -1511,12 +1715,6 @@ async def read_manifest(): self.assertTrue(author_found, "Author assertion not found in manifest") - # Verify no validation errors - self.assertNotIn( - "validation_status", - manifest_store, - "Manifest should not have validation errors") - except Exception as e: read_errors.append(f"Read error: {str(e)}") @@ -1553,7 +1751,7 @@ def test_resource_contention_read_parallel_async(self): start_barrier = asyncio.Barrier(reader_count) # First write some data to read - with open(self.testPath, "rb") as file: + with open(self.test_path, "rb") as file: builder = Builder(self.manifestDefinition_1) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) @@ -1620,257 +1818,60 @@ async def run_async_tests(): # Verify all readers completed self.assertEqual(active_readers, 0, "Not all readers completed") - def test_builder_sign_with_multiple_ingredients_from_stream(self): - """Test Builder class operations with multiple ingredients using streams.""" - # Test creating builder from JSON - builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + def test_builder_sign_with_multiple_ingredient_random_many_threads(self): + """Test Builder class operations with 12 threads, each adding 3 specific ingredients and signing a file.""" + # Number of threads to use in the test + TOTAL_THREADS_USED = 12 + + # Define the specific files to use as ingredients + # Those files should be valid to use as ingredient + ingredient_files = [ + os.path.join(self.data_dir, "A_thumbnail.jpg"), + os.path.join(self.data_dir, "C.jpg"), + os.path.join(self.data_dir, "cloud.jpg") + ] # Thread synchronization - add_errors = [] - add_lock = threading.Lock() + thread_results = {} completed_threads = 0 - completion_lock = threading.Lock() + thread_lock = threading.Lock() # Lock for thread-safe access to shared data - def add_ingredient_from_stream(ingredient_json, file_path, thread_id): + def thread_work(thread_id): nonlocal completed_threads try: - with open(file_path, 'rb') as f: - builder.add_ingredient_from_stream( - ingredient_json, "image/jpeg", f) - with add_lock: - add_errors.append(None) # Success case - except Exception as e: - with add_lock: - add_errors.append(f"Thread {thread_id} error: {str(e)}") - finally: - with completion_lock: - completed_threads += 1 + # Create a new builder for this thread + builder = Builder.from_json(self.manifestDefinition) - # Create and start two threads for parallel ingredient addition - thread1 = threading.Thread( - target=add_ingredient_from_stream, - args=('{"title": "Test Ingredient Stream 1"}', self.testPath3, 1) - ) - thread2 = threading.Thread( - target=add_ingredient_from_stream, - args=('{"title": "Test Ingredient Stream 2"}', self.testPath4, 2) - ) + # Add each ingredient + for i, file_path in enumerate(ingredient_files, 1): + ingredient_json = json.dumps({ + "title": f"Thread {thread_id} Ingredient {i} - {os.path.basename(file_path)}" + }) - # Start both threads - thread1.start() - thread2.start() + with open(file_path, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) - # Wait for both threads to complete - thread1.join() - thread2.join() + # Use A.jpg as the file to sign + sign_file_path = os.path.join(self.data_dir, "A.jpg") - # Check for errors during ingredient addition - if any(error for error in add_errors if error is not None): - self.fail( - "\n".join( - error for error in add_errors if error is not None)) + # Sign the file + with open(sign_file_path, "rb") as file: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", file, output) - # Verify both ingredients were added successfully - self.assertEqual( - completed_threads, - 2, - "Both threads should have completed") - self.assertEqual( - len(add_errors), - 2, - "Both threads should have completed without errors") + # Ensure all data is written + output.flush() - # Now sign the manifest with the added ingredients - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) + # Get the complete data + output_data = output.getvalue() - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] + # Create a new BytesIO with the complete data + input_stream = io.BytesIO(output_data) - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertEqual(len(active_manifest["ingredients"]), 2) - - # Verify both ingredients exist in the array (order doesn't matter) - ingredient_titles = [ing["title"] - for ing in active_manifest["ingredients"]] - self.assertIn("Test Ingredient Stream 1", ingredient_titles) - self.assertIn("Test Ingredient Stream 2", ingredient_titles) - - builder.close() - - def test_builder_sign_with_same_ingredient_multiple_times(self): - """Test Builder class operations with the same ingredient added multiple times from different threads.""" - # Test creating builder from JSON - builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None - - # Thread synchronization - add_errors = [] - add_lock = threading.Lock() - completed_threads = 0 - completion_lock = threading.Lock() - - def add_ingredient(ingredient_json, thread_id): - nonlocal completed_threads - try: - with open(self.testPath3, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - with add_lock: - add_errors.append(None) # Success case - except Exception as e: - with add_lock: - add_errors.append(f"Thread {thread_id} error: {str(e)}") - finally: - with completion_lock: - completed_threads += 1 - - # Create and start 5 threads for parallel ingredient addition - threads = [] - for i in range(1, 6): - # Create unique manifest JSON for each thread - ingredient_json = json.dumps({ - "title": f"Test Ingredient Thread {i}" - }) - - thread = threading.Thread( - target=add_ingredient, - args=(ingredient_json, i) - ) - threads.append(thread) - thread.start() - - # Wait for all threads to complete - for thread in threads: - thread.join() - - # Check for errors during ingredient addition - if any(error for error in add_errors if error is not None): - self.fail( - "\n".join( - error for error in add_errors if error is not None)) - - # Verify all ingredients were added successfully - self.assertEqual( - completed_threads, - 5, - "All 5 threads should have completed") - self.assertEqual( - len(add_errors), - 5, - "All 5 threads should have completed without errors") - - # Now sign the manifest with the added ingredients - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertEqual(len(active_manifest["ingredients"]), 5) - - # Verify all ingredients exist in the array with correct thread IDs - # and unique metadata - ingredient_titles = [ing["title"] - for ing in active_manifest["ingredients"]] - - # Check that we have 5 unique titles - self.assertEqual(len(set(ingredient_titles)), 5, - "Should have 5 unique ingredient titles") - - # Verify each thread's ingredient exists with correct metadata - for i in range(1, 6): - # Find ingredients with this thread ID - thread_ingredients = [ing for ing in active_manifest["ingredients"] - if ing["title"] == f"Test Ingredient Thread {i}"] - self.assertEqual( - len(thread_ingredients), - 1, - f"Should find exactly one ingredient for thread {i}") - - builder.close() - - def test_builder_sign_with_multiple_ingredient_random_many_threads(self): - """Test Builder class operations with 12 threads, each adding 3 specific ingredients and signing a file.""" - # Number of threads to use in the test - TOTAL_THREADS_USED = 12 - - # Define the specific files to use as ingredients - # THose files should be valid to use as ingredient - ingredient_files = [ - os.path.join(self.data_dir, "A_thumbnail.jpg"), - os.path.join(self.data_dir, "C.jpg"), - os.path.join(self.data_dir, "cloud.jpg") - ] - - # Thread synchronization - thread_results = {} - completed_threads = 0 - thread_lock = threading.Lock() # Lock for thread-safe access to shared data - - def thread_work(thread_id): - nonlocal completed_threads - try: - # Create a new builder for this thread - builder = Builder.from_json(self.manifestDefinition) - - # Add each ingredient - for i, file_path in enumerate(ingredient_files, 1): - ingredient_json = json.dumps({ - "title": f"Thread {thread_id} Ingredient {i} - {os.path.basename(file_path)}" - }) - - with open(file_path, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - # Use A.jpg as the file to sign - sign_file_path = os.path.join(self.data_dir, "A.jpg") - - # Sign the file - with open(sign_file_path, "rb") as file: - output = io.BytesIO() - builder.sign(self.signer, "image/jpeg", file, output) - - # Ensure all data is written - output.flush() - - # Get the complete data - output_data = output.getvalue() - - # Create a new BytesIO with the complete data - input_stream = io.BytesIO(output_data) - - # Now read and verify the signed manifest - reader = Reader("image/jpeg", input_stream) - json_data = reader.json() - manifest_data = json.loads(json_data) + # Now read and verify the signed manifest + reader = Reader("image/jpeg", input_stream) + json_data = reader.json() + manifest_data = json.loads(json_data) # Store results for verification with thread_lock: @@ -1987,5 +1988,768 @@ def thread_work(thread_id): other_manifest["active_manifest"], f"Thread {thread_id} and {other_thread_id} share the same active manifest ID") + +class TestContextualBuilderWithThreads(TestBuilderWithThreads): + """Same as TestBuilderWithThreads but using only the context APIs (Context, Builder/Reader with context=ctx).""" + + def test_sign_all_files(self): + """Test signing all files using a thread pool with Context""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + mime_types = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic', + '.heif': 'image/heif', '.avif': 'image/avif', '.tif': 'image/tiff', + '.tiff': 'image/tiff', '.mp4': 'video/mp4', '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav' + } + skip_files = {'sample3.invalid.wav'} + + def sign_file(filename, thread_id): + if filename in skip_files: + return None + file_path = os.path.join(signing_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + with open(file_path, "rb") as file: + manifest_def = self.manifestDefinition_2 if thread_id % 2 == 0 else self.manifestDefinition_1 + expected_author = "Tester Two" if thread_id % 2 == 0 else "Tester One" + ctx = Context() + builder = Builder(manifest_def, ctx) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + read_ctx = Context() + reader = Reader(mime_type, output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + expected_claim_generator = f"python_test_{2 if thread_id % 2 == 0 else 1}/0.0.1" + self.assertEqual(active_manifest["claim_generator"], expected_claim_generator) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + output.close() + return None + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign {filename} in thread {thread_id}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + future_to_file = { + executor.submit(sign_file, filename, i): (filename, i) + for i, filename in enumerate(all_files) + } + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename, thread_id = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename} in thread {thread_id}: {str(e)}") + if errors: + self.fail("\n".join(errors)) + + def test_sign_all_files_async(self): + """Test signing all files using asyncio with Context""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + mime_types = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic', + '.heif': 'image/heif', '.avif': 'image/avif', '.tif': 'image/tiff', + '.tiff': 'image/tiff', '.mp4': 'video/mp4', '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav' + } + skip_files = {'sample3.invalid.wav'} + + async def async_sign_file(filename, thread_id): + if filename in skip_files: + return None + file_path = os.path.join(signing_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + with open(file_path, "rb") as file: + manifest_def = self.manifestDefinition_2 if thread_id % 2 == 0 else self.manifestDefinition_1 + expected_author = "Tester Two" if thread_id % 2 == 0 else "Tester One" + ctx = Context() + builder = Builder(manifest_def, ctx) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + read_ctx = Context() + reader = Reader(mime_type, output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + expected_claim_generator = f"python_test_{2 if thread_id % 2 == 0 else 1}/0.0.1" + self.assertEqual(active_manifest["claim_generator"], expected_claim_generator) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + output.close() + return None + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign {filename} in thread {thread_id}: {str(e)}" + + async def run_async_tests(): + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + tasks = [asyncio.create_task(async_sign_file(f, i)) for i, f in enumerate(all_files)] + results = await asyncio.gather(*tasks, return_exceptions=True) + errors = [] + for result in results: + if isinstance(result, Exception): + errors.append(str(result)) + elif result: + errors.append(result) + if errors: + self.fail("\n".join(errors)) + asyncio.run(run_async_tests()) + + def test_parallel_manifest_writing(self): + """Test writing different manifests in parallel using context APIs""" + output1 = io.BytesIO(bytearray()) + output2 = io.BytesIO(bytearray()) + + def write_manifest(manifest_def, output_stream, thread_id): + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(manifest_def, ctx) + builder.sign(self.signer, "image/jpeg", file, output_stream) + output_stream.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output_stream, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], f"python_test_{thread_id}/0.0.1") + self.assertEqual(active_manifest["title"], f"Python Test Image {thread_id}") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], f"Tester {'One' if thread_id == 1 else 'Two'}") + break + return active_manifest + + thread1 = threading.Thread(target=write_manifest, args=(self.manifestDefinition_1, output1, 1)) + thread2 = threading.Thread(target=write_manifest, args=(self.manifestDefinition_2, output2, 2)) + thread1.start() + thread2.start() + thread2.join() + thread1.join() + output1.seek(0) + output2.seek(0) + read_ctx1 = Context() + read_ctx2 = Context() + reader1 = Reader("image/jpeg", output1, context=read_ctx1) + reader2 = Reader("image/jpeg", output2, context=read_ctx2) + manifest_store1 = json.loads(reader1.json()) + manifest_store2 = json.loads(reader2.json()) + active_manifest1 = manifest_store1["manifests"][manifest_store1["active_manifest"]] + active_manifest2 = manifest_store2["manifests"][manifest_store2["active_manifest"]] + self.assertNotEqual(active_manifest1["claim_generator"], active_manifest2["claim_generator"]) + self.assertNotEqual(active_manifest1["title"], active_manifest2["title"]) + output1.close() + output2.close() + + def test_parallel_sign_all_files_interleaved(self): + """Test signing all files with context APIs, thread pool cycling through manifest definitions""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + mime_types = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic', + '.heif': 'image/heif', '.avif': 'image/avif', '.tif': 'image/tiff', + '.tiff': 'image/tiff', '.mp4': 'video/mp4', '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav' + } + skip_files = {'sample3.invalid.wav'} + thread_counter = 0 + thread_counter_lock = threading.Lock() + thread_execution_order = [] + thread_order_lock = threading.Lock() + + def sign_file(filename, thread_id): + nonlocal thread_counter + if filename in skip_files: + return None + file_path = os.path.join(signing_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + with open(file_path, "rb") as file: + if thread_id % 3 == 0: + manifest_def = self.manifestDefinition + expected_author = "Tester" + expected_thread = "" + elif thread_id % 3 == 1: + manifest_def = self.manifestDefinition_1 + expected_author = "Tester One" + expected_thread = "1" + else: + manifest_def = self.manifestDefinition_2 + expected_author = "Tester Two" + expected_thread = "2" + with thread_counter_lock: + current_count = thread_counter + thread_counter += 1 + with thread_order_lock: + thread_execution_order.append((current_count, thread_id)) + time.sleep(0.01) + ctx = Context() + builder = Builder(manifest_def, ctx) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + read_ctx = Context() + reader = Reader(mime_type, output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + expected_claim_generator = "python_test/0.0.1" if thread_id % 3 == 0 else f"python_test_{expected_thread}/0.0.1" + self.assertEqual(active_manifest["claim_generator"], expected_claim_generator) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + output.close() + return None + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign {filename} in thread {thread_id}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + future_to_file = {executor.submit(sign_file, filename, i): (filename, i) for i, filename in enumerate(all_files)} + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename, thread_id = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename} in thread {thread_id}: {str(e)}") + max_same_thread_sequence = 3 + current_sequence = 1 + current_thread = thread_execution_order[0][1] if thread_execution_order else None + for i in range(1, len(thread_execution_order)): + if thread_execution_order[i][1] == current_thread: + current_sequence += 1 + if current_sequence > max_same_thread_sequence: + self.fail(f"Thread {current_thread} executed {current_sequence} times in sequence") + else: + current_sequence = 1 + current_thread = thread_execution_order[i][1] + if errors: + self.fail("\n".join(errors)) + + def test_concurrent_read_after_write(self): + """Test reading from a file after writing is complete, using context APIs""" + output = io.BytesIO(bytearray()) + write_complete = threading.Event() + write_errors = [] + read_errors = [] + + def write_manifest(): + try: + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + def read_manifest(): + try: + write_complete.wait() + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Read error: {str(e)}") + + read_thread = threading.Thread(target=read_manifest) + write_thread = threading.Thread(target=write_manifest) + read_thread.start() + write_thread.start() + write_thread.join() + read_thread.join() + output.close() + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + + def test_concurrent_read_write_multiple_readers(self): + """Test multiple readers reading after write, using context APIs""" + output = io.BytesIO(bytearray()) + write_complete = threading.Event() + write_errors = [] + read_errors = [] + reader_count = 3 + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() + + def write_manifest(): + try: + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + write_complete.wait() + with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + + write_thread = threading.Thread(target=write_manifest) + write_thread.start() + read_threads = [threading.Thread(target=read_manifest, args=(i,)) for i in range(reader_count)] + for t in read_threads: + t.start() + write_thread.join() + for t in read_threads: + t.join() + output.close() + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + def test_resource_contention_read(self): + """Test multiple threads reading the same file with context APIs""" + output = io.BytesIO(bytearray()) + read_errors = [] + reader_count = 5 + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() + + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + time.sleep(0.01) + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + + read_threads = [threading.Thread(target=read_manifest, args=(i,)) for i in range(reader_count)] + for t in read_threads: + t.start() + for t in read_threads: + t.join() + output.close() + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + def test_resource_contention_read_parallel(self): + """Test multiple threads starting simultaneously to read with context APIs""" + output = io.BytesIO(bytearray()) + read_errors = [] + reader_count = 5 + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() + start_barrier = threading.Barrier(reader_count) + + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + start_barrier.wait() + with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + + read_threads = [threading.Thread(target=read_manifest, args=(i,)) for i in range(reader_count)] + for t in read_threads: + t.start() + for t in read_threads: + t.join() + output.close() + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + def test_sign_all_files_twice(self): + """Test signing the same file twice with different manifests using context APIs""" + output1 = io.BytesIO(bytearray()) + output2 = io.BytesIO(bytearray()) + sign_errors = [] + thread_results = {} + thread_lock = threading.Lock() + + def sign_file(output_stream, manifest_def, thread_id): + try: + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(manifest_def, ctx) + builder.sign(self.signer, "image/jpeg", file, output_stream) + output_stream.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output_stream, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + if thread_id == 1: + expected_claim_generator = "python_test_1/0.0.1" + expected_author = "Tester One" + else: + expected_claim_generator = "python_test_2/0.0.1" + expected_author = "Tester Two" + with thread_lock: + thread_results[thread_id] = {'manifest': active_manifest} + self.assertEqual(active_manifest["claim_generator"], expected_claim_generator) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + return None + except Exception as e: + return f"Thread {thread_id} error: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + future1 = executor.submit(sign_file, output1, self.manifestDefinition_1, 1) + future2 = executor.submit(sign_file, output2, self.manifestDefinition_2, 2) + for future in concurrent.futures.as_completed([future1, future2]): + error = future.result() + if error: + sign_errors.append(error) + if sign_errors: + self.fail("\n".join(sign_errors)) + self.assertEqual(len(thread_results), 2) + output1.seek(0) + output2.seek(0) + read_ctx1 = Context() + read_ctx2 = Context() + reader1 = Reader("image/jpeg", output1, context=read_ctx1) + reader2 = Reader("image/jpeg", output2, context=read_ctx2) + manifest_store1 = json.loads(reader1.json()) + manifest_store2 = json.loads(reader2.json()) + active_manifest1 = manifest_store1["manifests"][manifest_store1["active_manifest"]] + active_manifest2 = manifest_store2["manifests"][manifest_store2["active_manifest"]] + self.assertNotEqual(active_manifest1["claim_generator"], active_manifest2["claim_generator"]) + self.assertNotEqual(active_manifest1["title"], active_manifest2["title"]) + output1.close() + output2.close() + + def test_concurrent_read_after_write_async(self): + """Test read after write using asyncio with context APIs""" + output = io.BytesIO(bytearray()) + write_complete = asyncio.Event() + write_errors = [] + read_errors = [] + write_success = False + + async def write_manifest(): + nonlocal write_success + try: + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + write_success = True + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + async def read_manifest(): + try: + await write_complete.wait() + if not write_success: + raise Exception("Write operation did not complete successfully") + self.assertGreater(len(output.getvalue()), 0) + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + self.assertIn("manifests", manifest_store) + self.assertIn("active_manifest", manifest_store) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + author_found = False + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + author_found = True + break + self.assertTrue(author_found) + except Exception as e: + read_errors.append(f"Read error: {str(e)}") + + async def run_async_tests(): + write_task = asyncio.create_task(write_manifest()) + await write_task + read_task = asyncio.create_task(read_manifest()) + await read_task + asyncio.run(run_async_tests()) + output.close() + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + + def test_resource_contention_read_parallel_async(self): + """Test multiple async tasks reading the same file with context APIs""" + output = io.BytesIO(bytearray()) + read_errors = [] + reader_count = 5 + active_readers = 0 + readers_lock = asyncio.Lock() + stream_lock = asyncio.Lock() + start_barrier = asyncio.Barrier(reader_count) + + ctx = Context() + with open(self.test_path, "rb") as file: + builder = Builder(self.manifestDefinition_1, ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + async def read_manifest(reader_id): + nonlocal active_readers + try: + async with readers_lock: + active_readers += 1 + await start_barrier.wait() + async with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + async with readers_lock: + active_readers -= 1 + + async def run_async_tests(): + tasks = [asyncio.create_task(read_manifest(i)) for i in range(reader_count)] + await asyncio.gather(*tasks) + asyncio.run(run_async_tests()) + output.close() + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + def test_builder_sign_with_multiple_ingredient_random_many_threads(self): + """Test Builder with 12 threads adding ingredients and signing using context APIs""" + TOTAL_THREADS_USED = 12 + ingredient_files = [ + os.path.join(self.data_dir, "A_thumbnail.jpg"), + os.path.join(self.data_dir, "C.jpg"), + os.path.join(self.data_dir, "cloud.jpg") + ] + thread_results = {} + completed_threads = 0 + thread_lock = threading.Lock() + + def thread_work(thread_id): + nonlocal completed_threads + try: + ctx = Context() + builder = Builder.from_json(self.manifestDefinition, context=ctx) + for i, file_path in enumerate(ingredient_files, 1): + ingredient_json = json.dumps({"title": f"Thread {thread_id} Ingredient {i} - {os.path.basename(file_path)}"}) + with open(file_path, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + sign_file_path = os.path.join(self.data_dir, "A.jpg") + with open(sign_file_path, "rb") as file: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", file, output) + output.flush() + output_data = output.getvalue() + input_stream = io.BytesIO(output_data) + read_ctx = Context() + reader = Reader("image/jpeg", input_stream, context=read_ctx) + json_data = reader.json() + manifest_data = json.loads(json_data) + with thread_lock: + thread_results[thread_id] = { + 'manifest': manifest_data, + 'ingredient_files': [os.path.basename(f) for f in ingredient_files], + 'sign_file': os.path.basename(sign_file_path), + 'manifest_hash': hash(json.dumps(manifest_data, sort_keys=True)) + } + output.close() + input_stream.close() + builder.close() + except Exception as e: + with thread_lock: + thread_results[thread_id] = {'error': str(e)} + finally: + with thread_lock: + completed_threads += 1 + + threads = [threading.Thread(target=thread_work, args=(i,)) for i in range(1, TOTAL_THREADS_USED + 1)] + for t in threads: + t.start() + for t in threads: + t.join() + self.assertEqual(completed_threads, TOTAL_THREADS_USED) + self.assertEqual(len(thread_results), TOTAL_THREADS_USED) + manifest_hashes = set() + thread_manifest_data = {} + for thread_id in range(1, TOTAL_THREADS_USED + 1): + result = thread_results[thread_id] + if 'error' in result: + self.fail(f"Thread {thread_id} failed with error: {result['error']}") + manifest_data = result['manifest'] + ingredient_files_basename = result['ingredient_files'] + manifest_hash = result['manifest_hash'] + thread_manifest_data[thread_id] = manifest_data + manifest_hashes.add(manifest_hash) + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + self.assertIn("ingredients", active_manifest) + self.assertEqual(len(active_manifest["ingredients"]), 3) + ingredient_titles = [ing["title"] for ing in active_manifest["ingredients"]] + for i, file_name in enumerate(ingredient_files_basename, 1): + self.assertIn(f"Thread {thread_id} Ingredient {i} - {file_name}", ingredient_titles) + for other_thread_id in range(1, TOTAL_THREADS_USED + 1): + if other_thread_id != thread_id: + for title in ingredient_titles: + self.assertNotIn(f"Thread {other_thread_id} Ingredient", title) + self.assertEqual(len(manifest_hashes), TOTAL_THREADS_USED) + for thread_id in range(1, TOTAL_THREADS_USED + 1): + current_manifest = thread_manifest_data[thread_id] + self.assertIn("active_manifest", current_manifest) + self.assertIn("manifests", current_manifest) + for other_thread_id in range(1, TOTAL_THREADS_USED + 1): + if other_thread_id != thread_id: + self.assertNotEqual(current_manifest["active_manifest"], thread_manifest_data[other_thread_id]["active_manifest"]) + + if __name__ == '__main__': unittest.main() diff --git a/tests/trust_config_test_settings.json b/tests/trust_config_test_settings.json new file mode 100644 index 00000000..82422751 --- /dev/null +++ b/tests/trust_config_test_settings.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "trust": { + "trust_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCAcWgAwIBAgIUW4fUnS38162x10PCnB8qFsrQuZgwBQYDK2VwMHcxCzAJ\nBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdoZXJlMRowGAYD\nVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRFU1RJTkdfT05M\nWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2NDFaFw0zMjA2MDcxODQ2\nNDFaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdo\nZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRF\nU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAqMAUGAytlcAMhAGPUgK9q1H3D\neKMGqLGjTXJSpsrLpe0kpxkaFMe7KUAuo2MwYTAdBgNVHQ4EFgQUXuZWArP1jiRM\nfgye6ZqRyGupTowwHwYDVR0jBBgwFoAUXuZWArP1jiRMfgye6ZqRyGupTowwDwYD\nVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwBQYDK2VwA0EA8E79g54u2fUy\ndfVLPyqKmtjenOUMvVQD7waNbetLY7kvUJZCd5eaDghk30/Q1RaNjiP/2RfA/it8\nzGxQnM2hCA==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIC2jCCAjygAwIBAgIUYm+LFaltpWbS9kED6RRAamOdUHowCgYIKoZIzj0EAwQw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIGbMBAGByqGSM49AgEG\nBSuBBAAjA4GGAAQBaifSYJBkf5fgH3FWPxRdV84qwIsLd7RcIDcRJrRkan0xUYP5\nzco7R4fFGaQ9YJB8dauyqiNg00LVuPajvKmhgEMAT4eSfEhYC25F2ggXQlBIK3Q7\nmkXwJTIJSObnbw4S9Jy3W6OVKq351VpgWUcmhvGRRejW7S/D8L2tzqRW7JPI2uSj\nYzBhMB0GA1UdDgQWBBS6OykommTmfYoLJuPN4OU83wjPqjAfBgNVHSMEGDAWgBS6\nOykommTmfYoLJuPN4OU83wjPqjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQE\nAwIBhjAKBggqhkjOPQQDBAOBiwAwgYcCQV4B6uKKoCWecEDlzj2xQLFPmnBQIOzD\nnyiSEcYyrCKwMV+HYS39oM+T53NvukLKUTznHwdWc9++HNaqc+IjsDl6AkIB2lXd\n5+s3xf0ioU91GJ4E13o5rpAULDxVSrN34A7BlsaXYQLnSkLMqva6E7nq2JBYjkqf\niwNQm1DDcQPtPTnddOs=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICkTCCAhagAwIBAgIUIngKvNC/BMF3TRIafgweprIbGgAwCgYIKoZIzj0EAwMw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEX3FzSTnCcEAP3wteNaiy4GZzZ+ABd2Y7gJpfyZf3kkCuX/I3psFq\nQBRvb3/FEBaDT4VbDNlZ0WLwtw5d3PI42Zufgpxemgfjf31d8H51eU3/IfAz5AFX\ny/OarhObHgVvo2MwYTAdBgNVHQ4EFgQUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wHwYD\nVR0jBBgwFoAUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wDwYDVR0TAQH/BAUwAwEB/zAO\nBgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAPOgmJbVdhDh9KlgQXqE\nFzHiCt347JG4strk22MXzOgxQ0LnXStIh+viC3S1INzuBgIxAI1jiUBX/V7Gg0y6\nY/p6a63Xp2w+ia7vlUaUBWsR3ex9NNSTPLNoDkoTCSDOE2O20w==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICUzCCAfmgAwIBAgIUdmkq4byvgk2FSnddHqB2yjoD68gwCgYIKoZIzj0EAwIw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMFkwEwYHKoZIzj0CAQYI\nKoZIzj0DAQcDQgAEre/KpcWwGEHt+mD4xso3xotRnRx2IEsMoYwVIKI7iEJrDEye\nPcvJuBywA0qiMw2yvAvGOzW/fqUTu1jABrFIk6NjMGEwHQYDVR0OBBYEFF6ZuIbh\neBvZVxVadQBStikOy6iMMB8GA1UdIwQYMBaAFF6ZuIbheBvZVxVadQBStikOy6iM\nMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0gA\nMEUCIHBC1xLwkCWSGhVXFlSnQBx9cGZivXzCbt8BuwRqPSUoAiEAteZQDk685yh9\njgOTkp4H8oAmM1As+qlkRK2b+CHAQ3k=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIGezCCBC+gAwIBAgIUIYAhaM4iRhACFliU3bfLnLDvj3wwQQYJKoZIhvcNAQEK\nMDSgDzANBglghkgBZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgMF\nAKIDAgFAMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t\nZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S\nIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2MzVa\nFw0zMjA2MDcxODQ2MzVaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG\nA1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG\nA1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ\nKoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglg\nhkgBZQMEAgMFAKIDAgFAA4ICDwAwggIKAoICAQCrjxW/KXQdtwOPKxjDFDxJaLvF\nJz8EIG6EZZ1JG+SVo8FJlYjazbJWmyCEtmoKCb4pgeeLSltty+pgKHFqZug19eKk\njb/fobN32iF3F3mKJ4/r9+VR5DSiXVMUGSI8i9s72OJu9iCGRsHftufDDVe+jGix\nBmacQMqYtmysRqo7tcAUPY8W4hrw5UhykjvJRNi9//nAMMm2BQdWyQj7JN4qnuhL\n1qtBZHJbNpo9U7DGHiZ5vE6rsJv68f1gM3RiVJsc71vm6gEDN5Rz3kXd1oMzsXwH\n8915SSx1hdmIwcikG5pZU4l9vBB+jTuev5Nm9u+WsMVYk6SE6fsTV3zKKQS67WKZ\nXvRkJmbkJf2xZgvUfPHuShQn0k810EFwimoA7kJtrzVE40PECHQwoq2kAs5M+6VY\nW2J1s1FQ49GaRH78WARSkV7SSpK+H1/L1oMbavtAoei81oLVrjPdCV4SoixSBzoR\n+64aQuSsBJD5vVjL1o37oizsc00mas+mR98TswAHtU4nVSxgZAPp9UuO64YdJ8e8\nbftwsoBKI+DTS+4xjQJhvYxI0Jya42PmP7mlwf7g8zTde1unI6TkaUnlvXdb3+2v\nEhhIQCKSN6HdXHQba9Q6/D1PhIaXBmp8ejziSXOoLfSKJ6cMsDOjIxyuM98admN6\nxjZJljVHAqZQynA2KQIDAQABo2MwYTAdBgNVHQ4EFgQUoa/88nSjWTf9DrvK0Imo\nkARXMYwwHwYDVR0jBBgwFoAUoa/88nSjWTf9DrvK0ImokARXMYwwDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB\nZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgMFAKIDAgFAA4ICAQAH\nSCSccH59/JvIMh92cvudtZ4tFzk0+xHWtDqsWxAyYWV009Eg3T6ps/bVbWkiLxCW\ncuExWjQ6yLKwJxegSvTRzwJ4H5xkP837UYIWNRoR3rgPrysm1im3Hjo/3WRCfOJp\nPtgkiPbDn2TzsJQcBpfc7RIdx2bqX41Uz9/nfeQn60MUVJUbvCtCBIV30UfR+z3k\n+w4G5doB4nq6jvQHI364L0gSQcdVdvqgjGyarNTdMHpWFYoN9gPBMoVqSNs2U75d\nLrEQkOhjkE/Akw6q+biFmRWymCHjAU9l7qGEvVxLjFGc+DumCJ6gTunMz8GiXgbd\n9oiqTyanY8VPzr98MZpo+Ga4OiwiIAXAJExN2vCZVco2Tg5AYESpWOqoHlZANdlQ\n4bI25LcZUKuXe+NGRgFY0/8iSvy9Cs44uprUcjAMITODqYj8fCjF2P6qqKY2keGW\nmYBtNJqyYGBg6h+90o88XkgemeGX5vhpRLWyBaYpxanFDkXjmGN1QqjAE/x95Q/u\ny9McE9m1mxUQPJ3vnZRB6cCQBI95ZkTiJPEO8/eSD+0VWVJwLS2UrtWzCbJ+JPKF\nYxtj/MRT8epTRPMpNZwUEih7MEby+05kziKmYF13OOu+K3jjM0rb7sVoFBSzpISC\nr9Fa3LCdekoRZAnjQHXUWko7zo6BLLnCgld97Yem1A==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\nxPd7wFhjRZHfuWb2cs63xjAGjQ==\n-----END CERTIFICATE-----\n", + "trust_config": "//id-kp-emailProtection\n1.3.6.1.5.5.7.3.4\n//id-kp-documentSigning\n1.3.6.1.5.5.7.3.36\n//id-kp-timeStamping\n1.3.6.1.5.5.7.3.8\n//id-kp-OCSPSigning\n1.3.6.1.5.5.7.3.9\n// MS C2PA Signing\n1.3.6.1.4.1.311.76.59.1.9\n// c2pa-kp-claimSigning\n1.3.6.1.4.1.62558.2.1\n" + } +} \ No newline at end of file