From d34087f81f73e8430ba93f950df31699da0a1e3b Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 3 Mar 2026 20:53:06 -0800 Subject: [PATCH 01/75] feat: Context and Settings WIP --- README.md | 3 + c2pa-native-version.txt | 2 +- docs/context.md | 513 +++++++++++++ docs/faqs.md | 121 +++ docs/release-notes.md | 16 + docs/settings.md | 486 ++++++++++++ docs/usage.md | 127 ++- src/c2pa/__init__.py | 6 + src/c2pa/c2pa.py | 1187 ++++++++++++++++++++++++++--- tests/test_unit_tests.py | 757 +++++++++++++++++- tests/test_unit_tests_threaded.py | 261 ++++++- 11 files changed, 3374 insertions(+), 105 deletions(-) create mode 100644 docs/context.md create mode 100644 docs/faqs.md create mode 100644 docs/settings.md diff --git a/README.md b/README.md index 4798e55a..0b689975 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ Features: - Create and sign C2PA manifests using various signing algorithms. - Verify C2PA manifests and extract metadata. - Add assertions and ingredients to assets. +- Per-instance configuration via `Settings` and `Context` (replaces global `load_settings`). +- Embed a `Signer` in a `Context` for simplified signing workflows. +- `ContextProvider` protocol for custom third-party context implementations. - Examples and unit tests to demonstrate usage. ## Prerequisites diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 27f9930a..f4c22cb4 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.75.21 +c2pa-v0.76.2 diff --git a/docs/context.md b/docs/context.md new file mode 100644 index 00000000..d4d8ec7f --- /dev/null +++ b/docs/context.md @@ -0,0 +1,513 @@ +# Using Context to configure the SDK + +Use the `Context` class to configure how `Reader`, `Builder`, and other aspects of the SDK operate. + +## What is Context? + +Context encapsulates SDK configuration: + +- **Settings**: Verification options, [`Builder` behavior](#configuring-builder), [`Reader` trust configuration](#configuring-reader), thumbnail configuration, and more. See [Using settings](settings.md) for complete details. +- [**Signer configuration**](#configuring-a-signer): Optional signer credentials and settings that can be stored in the Context for reuse. +- **State isolation**: Each `Context` is independent, allowing different configurations to coexist in the same application. + +### Why use Context? + +`Context` is better than deprecated global/thread-local `Settings` because it: + +- **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 thread-local 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 between them. +- **Improves code clarity**: Reading `Builder(context, manifest)` immediately shows that configuration is being used. + +> [!NOTE] +> The deprecated `c2pa::load_settings(data, format)` still works for backward compatibility but you are encouraged to migrate your code to use `Context`. See [Migrating from thread-local Settings](#migrating-from-thread-local-settings). + +## Creating a Context + +There are several ways to create a `Context`, depending on your needs: + +- [Using SDK default settings](#using-sdk-default-settings) +- [From an inline JSON string](#from-an-inline-json-string) +- [From a Settings object](#from-a-settings-object) +- [Using ContextBuilder](#using-contextbuilder) + +### Using SDK default settings + +The simplest approach is using [SDK default settings](settings.md#default-configuration). + +**When to use:** For quick prototyping, or when you're happy with default behavior (verification enabled, thumbnails enabled at 1024px, and so on). + +```cpp +#include "c2pa.hpp" + +c2pa::Context context; // Uses SDK defaults +``` + +### From an inline JSON string + +You can pass settings to the constructor directly as a JSON string. + +**When to use:** For simple configuration that doesn't need to be shared across the codebase, or when hard-coding settings for a specific purpose (for example, a utility script). + +```cpp +c2pa::Context context(R"({ + "version": 1, + "verify": {"verify_after_sign": true}, + "builder": { + "thumbnail": {"enabled": false}, + "claim_generator_info": {"name": "An app", "version": "0.1.0"} + } +})"); +``` + +### From a Settings object + +You can build a `Settings` object programmatically, then create a `Context` from that. + +**When to use:** For configuration that needs runtime logic (such as conditional settings based on environment), or when you want to build settings incrementally. + +```cpp +c2pa::Settings settings; +settings.set("builder.thumbnail.enabled", "false"); +settings.set("verify.verify_after_sign", "true"); +settings.update(R"({ + "builder": { + "claim_generator_info": {"name": "An app", "version": "0.1.0"} + } +})"); + +c2pa::Context context(settings); +``` + +### Using ContextBuilder + +You can combine multiple configuration sources by using `Context::ContextBuilder`. + +Use **ContextBuilder** when you want to: + +- Load from a file with `with_json_settings_file()`. +- Combine a base `Settings` with environment-specific overrides from a JSON file. +- Apply multiple JSON snippets in a specific order. + +**Don't use ContextBuilder** if you have a single configuration source. In this case, [direct construction from a Settings object](#from-a-settings-object) using `c2pa::Context context(settings)` is simpler and more readable. + +For example: + +```cpp +c2pa::Settings base_settings; +base_settings.set("builder.thumbnail.enabled", "true"); +base_settings.set("builder.thumbnail.long_edge", "1024"); + +auto context = c2pa::Context::ContextBuilder() + .with_settings(base_settings) + .with_json(R"({"verify": {"verify_after_sign": true}})") + .with_json_settings_file("config/overrides.json") + .create_context(); +``` + +> [!IMPORTANT] +> Later configuration overrides earlier configuration. In the example above, if `overrides.json` sets `builder.thumbnail.enabled` to `false`, it will override the `true` value from `base_settings`. + +**ContextBuilder methods** + +| Method | Description | +|--------|-------------| +| `with_settings(settings)` | Apply a `Settings` object. Must be valid (not moved-from). | +| `with_json(json_string)` | Apply settings from a JSON string. Later calls override earlier ones. | +| `with_json_settings_file(path)` | Load and apply settings from a JSON file. Throws `C2paException` if file doesn't exist or is invalid. | +| `create_context()` | Build and return the `Context`. Consumes the builder (it becomes invalid and cannot be reused). | + +## Common configuration patterns + +### Development environment with test certificates + +During development, you often need to trust self-signed or custom CA certificates: + +```cpp +// Load your test root CA +std::string test_ca = read_file("test-ca.pem"); + +c2pa::Context dev_context(R"({ + "version": 1, + "trust": { + "user_anchors": ")" + test_ca + R"(" + }, + "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} + } +})"); +``` + +### Configuration from environment variables + +Adapt configuration based on the runtime environment: + +```cpp +std::string env = std::getenv("ENVIRONMENT") ? std::getenv("ENVIRONMENT") : "dev"; + +c2pa::Settings settings; +if (env == "production") { + settings.update(read_file("config/production.json"), "json"); + settings.set("verify.strict_v1_validation", "true"); +} else { + settings.update(read_file("config/development.json"), "json"); + settings.set("verify.remote_manifest_fetch", "false"); +} + +c2pa::Context context(settings); +``` + +### Layered configuration + +Load base configuration from a file and apply runtime overrides: + +```cpp +auto context = c2pa::Context::ContextBuilder() + .with_json_settings_file("config/base.json") + .with_json_settings_file("config/" + environment + ".json") + .with_json(R"({ + "builder": { + "claim_generator_info": { + "version": ")" + app_version + R"(" + } + } + })") + .create_context(); +``` + +For the full list of settings and defaults, see [Configuring settings](settings.md). + +## Configuring Reader + +Use `Context` to control how `Reader` validates manifests and handles remote resources, including: + +- **Verification behavior**: Whether to verify after reading, check trust, and so on. +- [**Trust configuration**](#trust-configuration): Which certificates to trust when validating signatures. +- [**Network access**](#offline-operation): Whether to fetch remote manifests or OCSP responses. +- **Performance**: Memory thresholds and other core settings. + +> [!IMPORTANT] +> `Context` is used only at construction. `Reader` copies the configuration it needs internally, so the `Context` object does not need to outlive the `Reader`. This means you can safely use temporary point-in-time contexts; for example, as shown below. + +```cpp +c2pa::Reader reader( + c2pa::Context(R"({"verify": {"remote_manifest_fetch": false}})"), + "image.jpg" +); +``` + +### Reading from a file + +```cpp +// Context that disables remote manifest fetch (for offline environments) +c2pa::Context context(R"({ + "version": 1, + "verify": { + "remote_manifest_fetch": false, + "ocsp_fetch": false + } +})"); + +c2pa::Reader reader(context, "image.jpg"); +std::cout << reader.json() << std::endl; +``` + +### Reading from a stream + +```cpp +std::ifstream stream("image.jpg", std::ios::binary); +c2pa::Reader reader(context, "image/jpeg", stream); + +std::cout << reader.json() << std::endl; +``` + +### Trust configuration + +Example of trust configuration in a settings file: + +```json +{ + "version": 1, + "trust": { + "user_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCA...\n-----END CERTIFICATE-----", + "trust_config": "1.3.6.1.4.1.311.76.59.1.9\n1.3.6.1.4.1.62558.2.1" + } +} +``` + +**PEM format requirements:** + +- Use literal `\n` characters (as two-character strings) in JSON for line breaks. +- Include the full certificate chain if needed. +- Concatenate multiple certificates into a single string. + +Then load the file in your application as follows: + +```cpp +auto context = c2pa::Context::ContextBuilder() + .with_json_settings_file("dev_trust_config.json") + .create_context(); + +c2pa::Reader reader(context, "signed_asset.jpg"); +``` + +### Full validation + +To configure full validation, with all verification features enabled: + +```cpp +c2pa::Context full_validation_context(R"({ + "verify": { + "verify_after_reading": true, + "verify_trust": true, + "verify_timestamp_trust": true, + "remote_manifest_fetch": true + } +})"); + +c2pa::Reader online_reader(full_validation_context, "asset.jpg"); +``` + +For more information, see [Settings - Verify](settings.md#verify). + +### Offline operation + +To configure `Reader` to work with no network access: + +```cpp +c2pa::Context offline_context(R"({ + "verify": { + "remote_manifest_fetch": false, + "ocsp_fetch": false + } +})"); + +c2pa::Reader offline_reader(offline_context, "local_asset.jpg"); +``` + +For more information, see [Settings - Offline or air-gapped environments](settings.md#offline-or-air-gapped-environments). + + +## Configuring Builder + +`Builder` uses `Context` to control how to create and sign C2PA manifests. The `Context` affects: + +- **Claim generator information**: Application name, version, and metadata embedded in the manifest. +- **Thumbnail generation**: Whether to create thumbnails, 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 can be stored in settings for reuse. + + +> [!IMPORTANT] +> The `Context` is used only when constructing the `Builder`. The `Builder` copies the configuration it needs internally, so the `Context` object does not need to outlive the `Builder`. + +### Basic use + +```cpp +c2pa::Context context(R"({ + "version": 1, + "builder": { + "claim_generator_info": { + "name": "An app", + "version": "0.1.0" + }, + "intent": {"Create": "digitalCapture"} + } +})"); + +c2pa::Builder builder(context, manifest_json); + +// Pass signer explicitly at signing time +c2pa::Signer signer("es256", certs, private_key); +builder.sign(source_path, output_path, signer); +``` + +### Controlling thumbnail generation + +```cpp +// Disable thumbnails for faster processing +c2pa::Context no_thumbnails(R"({ + "builder": { + "claim_generator_info": {"name": "Batch Processor"}, + "thumbnail": {"enabled": false} + } +})"); + +// Or customize thumbnail size and quality for mobile +c2pa::Context mobile_thumbnails(R"({ + "builder": { + "claim_generator_info": {"name": "Mobile App"}, + "thumbnail": { + "enabled": true, + "long_edge": 512, + "quality": "low", + "prefer_smallest_format": true + } + } +})"); +``` + +## Configuring a signer + +The `signer` field in settings can specify: +- A **local signer** — certificate and key (paths or PEM strings): + - `signer.local.alg` — e.g. `"ps256"`, `"es256"`, `"ed25519"`. + - `signer.local.sign_cert` — certificate file path or PEM string. + - `signer.local.private_key` — key file path or PEM string. + - `signer.local.tsa_url` — optional TSA URL. +- A **remote signer** — A POST endpoint that receives data to sign and returns the signature: + - `signer.remote.url` — signing service URL. + - `signer.remote.alg`, `signer.remote.sign_cert`, `signer.remote.tsa_url`. + +See [SignerSettings object reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signersettings) for the full property reference. + +You can configure a signer: + +- [From JSON Settings](#from-settings) +- [Explicitly in code](#explicit-signer) + +### From Settings + +Put signer configuration in your JSON or `Settings`: + +```json +{ + "signer": { + "local": { + "alg": "ps256", + "sign_cert": "path/to/cert.pem", + "private_key": "path/to/key.pem", + "tsa_url": "http://timestamp.example.com" + } + } +} +``` + +Then create a `Context` and use it with `Builder`; for example: + +```cpp +c2pa::Context context(settings_json_or_path); +c2pa::Builder builder(context, manifest_json); +// When you call sign(), use a Signer created from your cert/key, +// or the SDK may use the signer from context if the C API supports it. +builder.sign(source_path, dest_path, signer); +``` + +In the C++ API you typically create a `c2pa::Signer` explicitly and pass it to `Builder::sign()`. Settings in the `Context` still control verification, thumbnails, and other builder behavior. + +### Explicit signer + +For full programmatic control, create a `Signer` and pass it to `Builder::sign()`: + +```cpp +c2pa::Signer signer("es256", certs_pem, private_key_pem, "http://timestamp.digicert.com"); +c2pa::Builder builder(context, manifest_json); +builder.sign(source_path, dest_path, signer); +``` + +The `Context` continues to control verification and builder options. The signer is used only for the cryptographic signature. + +## Context lifetime and usage + +Understand how `Context` works to use it properly. + +### Context ownership and lifecycle + +- **Non-copyable, moveable**: `Context` can be moved but not copied. After moving, the source `Context` becomes invalid (`is_valid()` returns `false`). +- **Used at construction only**: When you create a `Reader` or `Builder` with a `Context`, the implementation copies the configuration it needs. The `Context` object does not need to outlive the `Reader` or `Builder` objects. +- **Reusable**: You can reuse the same `Context` to create multiple readers and builders. + +```cpp +c2pa::Context context(settings); + +// All three use the same configuration +c2pa::Builder builder1(context, manifest1); +c2pa::Builder builder2(context, manifest2); +c2pa::Reader reader(context, "image.jpg"); + +// Context can go out of scope, readers/builders still work +``` + +### Multiple contexts for different purposes + +Use different `Context` objects when you need different settings; for example, for development vs. production, or different trust configurations: + +```cpp +c2pa::Context dev_context(dev_settings); +c2pa::Context prod_context(prod_settings); + +// Different builders with different configurations +c2pa::Builder dev_builder(dev_context, manifest); +c2pa::Builder prod_builder(prod_context, manifest); +``` + +### Move semantics + +```cpp +c2pa::Context context1(settings); +c2pa::Context context2 = std::move(context1); + +// context1 is now invalid +assert(!context1.is_valid()); + +// context2 is valid and can be used +c2pa::Builder builder(context2, manifest); +``` + +### Temporary contexts + +Since the context is copied at construction, you can use temporary contexts: + +```cpp +c2pa::Builder builder( + c2pa::Context(R"({"builder": {"thumbnail": {"enabled": false}}})"), + manifest_json +); +// Temporary context destroyed, but builder still has the configuration +``` + +## Migrating from thread-local Settings + +The legacy function `c2pa::load_settings(data, format)` sets thread-local Settings. +This function is deprecated; use `Context` instead. + +| Aspect | load_settings (legacy) | Context | +|--------|------------------------|---------| +| Scope | Global / thread-local | Per Reader/Builder, passed explicitly | +| Multiple configs | Awkward (per-thread) | One context per configuration | +| Testing | Shared global state | Isolated contexts per test | + +**Deprecated:** + +```cpp +// Thread-local settings +std::ifstream config_file("settings.json"); +std::string config((std::istreambuf_iterator(config_file)), std::istreambuf_iterator()); +c2pa::load_settings(config, "json"); +c2pa::Reader reader("image/jpeg", stream); // uses thread-local settings +``` + +**Using current APIs:** + +```cpp +c2pa::Context context(settings_json_string); // or Context(Settings(...)) +c2pa::Reader reader(context, "image/jpeg", stream); +``` + +If you still use `load_settings`, construct `Reader` or `Builder` **without** a context to use the thread-local settings (see [usage.md](usage.md)). Prefer passing a context for new code. + +## See also + +- [Configuring settings](settings.md) — schema, property reference, and examples. +- [Usage](usage.md) — reading and signing with Reader and Builder. +- [CAI settings schema](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/): full schema reference. diff --git a/docs/faqs.md b/docs/faqs.md new file mode 100644 index 00000000..926374c8 --- /dev/null +++ b/docs/faqs.md @@ -0,0 +1,121 @@ +# Frequently-asked questions (FAQs) + +## When do I use `Reader` vs. `Builder` + +## Quick reference decision tree + +```mermaid +flowchart TD + Q1{Need to read an existing manifest?} + Q1 -->|No| USE_B["Use Builder alone (new manifest from scratch)"] + Q1 -->|Yes| Q2{Need to create a new/modified manifest?} + Q2 -->|No| USE_R["Use Reader alone (inspect/extract only)"] + Q2 -->|Yes| USE_BR[Use both Reader + Builder] + USE_BR --> Q3{What to keep from the existing manifest?} + Q3 -->|Everything| P1["add_ingredient() with original asset or archive path"] + Q3 -->|Some parts| P2["1. Read: reader.json() + get_resource() 2. Filter: pick ingredients & actions to keep 3. Build: new Builder with filtered JSON 4. Transfer: .add_resource for kept binaries 5. Sign: builder.sign()"] + Q3 -->|Nothing| P3["New Builder alone (fresh manifest, no prior provenance)"] +``` + +### When to use `Reader` + +**Use a `Reader` when the goal is only to inspect or extract data without creating a new manifest.** + +- Validating whether an asset has C2PA credentials +- Displaying provenance information to a user +- Extracting thumbnails for display +- Checking trust status and validation results +- Inspecting ingredient chains + +```cpp +c2pa::Reader reader(context, "image.jpg"); +auto json = reader.json(); // inspect the manifest +reader.get_resource(thumb_id, stream); // extract a thumbnail +``` + +The `Reader` is read-only. It never modifies the source asset. + +### When to use a `Builder` + +**Use a `Builder` when creating a manifest from scratch on an asset that has no existing C2PA data, or when intentionally starting with a clean slate.** + +- Signing a brand-new asset for the first time +- Adding C2PA credentials to an unsigned asset +- Creating a manifest with all content defined from scratch + +```cpp +c2pa::Builder builder(context, manifest_json); +builder.add_ingredient(ingredient_json, source_path); // add source material +builder.sign(source_path, output_path, signer); +``` + +Every call to the `Builder` constructor or `Builder::from_archive()` creates a new `Builder`. There is no way to modify an existing signed manifest directly. + +### When to use both `Reader` and `Builder` together + +**Use both when filtering content from an existing manifest into a new one. The `Reader` extracts data, application code filters it, and a new `Builder` receives only the selected parts.** + +- Filtering specific ingredients from a manifest +- Dropping specific assertions while keeping others +- Filtering actions (keeping some, removing others) +- Merging ingredients from multiple signed assets or archives +- Extracting content from an ingredients catalog +- Re-signing with different settings while keeping some original content + +```cpp +// Read existing (does not modify the asset) +c2pa::Reader reader(context, "signed.jpg"); +auto parsed = json::parse(reader.json()); + +// Filter what to keep +auto kept = filter(parsed); // application-specific filtering logic + +// Create a new Builder with only the filtered content +c2pa::Builder builder(context, kept.dump()); +// ... transfer resources ... +builder.sign(source, output, signer); +``` + +## How should I add ingredients? + +There are two ways: using `add_ingredient()` and injecting ingredient JSON via `with_definition()`. The table below summarizes these options. + +| Approach | What it does | When to use | +| --- | --- | --- | +| `add_ingredient(json, path)` | Reads the source (a signed asset, an unsigned file, or a `.c2pa` archive), extracts its manifest store automatically, generates a thumbnail | Adding an ingredient where the library should handle extraction. Works with ingredient catalog archives too: pass the archive path and the library extracts the manifest data | +| Inject via `with_definition()` + `add_resource()` | Accepts the ingredient JSON and all binary resources provided manually | Reconstructing from a reader or merging from multiple readers, where the data has already been extracted | + + +## When to use archives + +There are two distinct archive concepts: + +- **Builder archives (working store archives)** (`to_archive` / `from_archive` / `with_archive`) serialize the full `Builder` state (manifest definition, resources, ingredients) so it can be resumed or signed later, possibly on a different machine or in a different process. The archive is not yet signed. Use builder archives when: + - Signing must happen on a different machine (e.g., an HSM server) + - Checkpointing work-in-progress before signing + - Transmitting a `Builder` state across a network boundary + +- **Ingredient archives** contain the manifest store data (`.c2pa` binary) from ingredients that were added to a `Builder`. When a signed asset is added as an ingredient via `add_ingredient()`, the library extracts and stores its manifest store as `manifest_data` within the ingredient record. When the `Builder` is then serialized via `to_archive()`, these ingredient manifest stores are included. Use ingredient archives when: + + - Building an ingredients catalog for pick-and-choose workflows + - Preserving provenance history from source assets + - Transferring ingredient data between `Reader` and `Builder` + +See also [Working stores](https://opensource.contentauthenticity.org/docs/rust-sdk/docs/working-stores). + +Key consideration for builder archives: `from_archive()` creates a new `Builder` with **default** context settings. If specific settings are needed (e.g., thumbnails disabled), use `with_archive()` on a `Builder` that already has the desired context: + +```cpp +// Preserves the caller's context settings +c2pa::Builder builder(my_context); +builder.with_archive(archive_stream); +builder.sign(source, output, signer); +``` + +## Can a manifest be modified in place? + +**No.** C2PA manifests are cryptographically signed. Any modification invalidates the signature. The only way to "modify" a manifest is to create a new `Builder` with the desired changes and sign it. This is by design: it ensures the integrity of the provenance chain. + +## What happens to the provenance chain when rebuilding a working store? + +When creating a new manifest, the chain is preserved once the original asset is added as an ingredient. The ingredient carries the original's manifest data, so validators can trace the full history. If the original is not added as an ingredient, the provenance chain is broken: the new manifest has no link to the original. This might be intentional (starting fresh) or a mistake (losing provenance). \ No newline at end of file diff --git a/docs/release-notes.md b/docs/release-notes.md index 24c4048a..a51d63e4 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,21 @@ # Release notes +## Version vNext + +New features: + +- **`Settings` class**: Per-instance configuration for C2PA operations. Supports `set()` with dot-notation paths, `from_json()`, `from_dict()`, `update()`, dict-like `[]` access, and method chaining. Replaces the global `load_settings()` function. +- **`Context` class**: Carries optional `Settings` and an optional `Signer` for `Reader` and `Builder` operations. Supports `from_json()` and `from_dict()` convenience constructors. When a `Signer` is provided, it is consumed (ownership is transferred to the context). +- **`ContextProvider` protocol**: A `runtime_checkable` protocol that allows third-party implementations of custom context providers. Both `Reader` and `Builder` accept `context` as a keyword-only parameter. +- **`Signer._release()` internal method**: Transfers ownership of the native signer pointer without freeing it, enabling the signer-on-context pattern. +- **`Builder.sign()` with optional signer**: The `signer` parameter is now optional. When omitted, the context's signer is used. Explicit signer always takes precedence over context signer. +- **`Builder.sign_file()` with optional signer**: The `signer` parameter is now optional, matching `sign()`. +- **`Reader` and `Builder` context integration**: Both accept `context=` keyword-only parameter. Reader uses `c2pa_reader_from_context` + `c2pa_reader_with_stream`. Builder uses `c2pa_builder_from_context` + `c2pa_builder_with_definition`. + +Deprecations: + +- **`load_settings()`** is deprecated with a `DeprecationWarning`. Use `Settings` and `Context` for per-instance configuration instead. The function remains fully functional for backward compatibility. + ## Version 0.6.0 diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 00000000..c3611f09 --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,486 @@ +# Using settings + +You can configure SDK settings using a JSON format that controls many aspects of the library's behavior. +The settings JSON format is the same across all languages in the SDK (Rust, C/C++, Python, and so on). + +This document describes how to use settings in C++. The Settings schema is the same as the [Rust library](https://github.com/contentauth/c2pa-rs); for the complete JSON schema, see the [Settings reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/). + +## Using settings with Context + +The recommended approach is to pass settings to a `Context` object and then use the `Context` with `Reader` and `Builder`. This gives you explicit, isolated configuration with no global or thread-local state. For details on creating and using contexts, see [Using Context to configure the SDK](context.md). + +**Legacy approach:** The deprecated `c2pa::load_settings(data, format)` sets thread-local settings. Don't use that approach; instead pass a `Context` (with settings) to `Reader` and `Builder`. See [Using Context with Reader](context.md#using-context-with-reader) and [Using Context with Builder](context.md#using-context-with-builder). + +## Settings API + +Create and configure settings: + +| Method | Description | +|--------|-------------| +| `Settings()` | Create default settings with SDK defaults. | +| `Settings(data, format)` | Parse settings from a string. `format` is `"json"` or `"toml"`. Throws `C2paException` on parse error. | +| `set(path, json_value)` | Set a single value by dot-separated path (e.g. `"verify.verify_after_sign"`). Value must be JSON-encoded. Returns `*this` for chaining. Use this for programmatic configuration. | +| `update(data)` | Merge JSON configuration into existing settings (same as `update(data, "json")`). Later keys override earlier ones. Use this to apply configuration files or JSON strings. | +| `update(data, format)` | Merge configuration from a string; `format` is `"json"` or `"toml"`. | +| `is_valid()` | Returns `true` if the object holds a valid handle (e.g. not moved-from). | + +**Important notes:** + +- Settings are **not copyable**; they are **moveable**. After moving, the source's `is_valid()` is `false`. +- The `set()` and `update()` methods can be chained for sequential configuration. +- When using multiple configuration methods, later calls override earlier ones (last wins). + +## Overview of the Settings structure + +The Settings JSON has this top-level structure: + +```json +{ + "version": 1, + "trust": { ... }, + "cawg_trust": { ... }, + "core": { ... }, + "verify": { ... }, + "builder": { ... }, + "signer": { ... }, + "cawg_x509_signer": { ... } +} +``` + +### Settings format + +Settings can be provided in **JSON** or **TOML**. Use `Settings(data, format)` with `"json"` or `"toml"`, or pass JSON to `Context(json_string)` or `ContextBuilder::with_json()`. JSON is preferred for settings in the C++ SDK. + +```cpp +// JSON +c2pa::Settings settings(R"({"verify": {"verify_after_sign": true}})", "json"); + +// TOML +c2pa::Settings settings(R"( + [verify] + verify_after_sign = true +)", "toml"); + +// Context from JSON string +c2pa::Context context(R"({"verify": {"verify_after_sign": true}})"); +``` + +To load from a file, read the file contents into a string and pass to `Settings` or use `Context::ContextBuilder::with_json_settings_file(path)`. + +## Default configuration + +The settings JSON schema—including the complete default configuration with all properties and their default values—is shared with all languages in the SDK: + +```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 + } +} +``` + +## Overview of Settings + +For a complete reference to all the Settings 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](https://contentauth.github.io/c2pa-c/da/db7/classc2pa_1_1Builder.html). | +| [`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](https://contentauth.github.io/c2pa-c/d3/da1/classc2pa_1_1Signer.html). | +| [`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). | + +The top-level `version` property must be `1`. All other properties are optional. + +For Boolean values, use JSON Booleans `true` and `false`, not the strings `"true"` and `"false"`. + +> [!IMPORTANT] +> If you don't specify a value for a property, the SDK uses the default value. If you specify a value of `null`, the property is explicitly set to `null`, not the default. This distinction is important when you want to override a default behavior. + +### Trust configuration + +The [`trust` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#trust) control which certificates are trusted when validating C2PA manifests. + +- Using `user_anchors`: recommended for development +- Using `allowed_list` (bypass chain validation) +- For team development, you can load trust configuration from a file using `ContextBuilder`; see [Using Context to configure the SDK](context.md#using-contextbuilder) for details. + +| Property | Type | Description | Default | +|----------|------|-------------|---------| +| `trust.allowed_list` | string | Explicitly allowed certificates (PEM format). These certificates are trusted regardless of chain validation. Use for development/testing. | — | +| `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 (e.g., document signing: `1.3.6.1.4.1.311.76.59.1.9`). | — | +| `trust.user_anchors` | string | Additional user-provided root certificates (PEM format). Adds custom certificate authorities without replacing the SDK's built-in trust anchors. | — | + +When using self-signed certificates or custom certificate authorities during development, you need to configure trust settings so the SDK can validate your test signatures. + +#### Using `user_anchors` + +For development, you can add your test root CA to the trusted anchors without replacing the SDK's default trust store. +For example: + +```cpp +// Read your test root CA certificate +std::string test_root_ca = R"(-----BEGIN CERTIFICATE----- +MIICEzCCAcWgAwIBAgIUW4fUnS38162x10PCnB8qFsrQuZgwBQYDK2VwMHcxCzAJ +... +-----END CERTIFICATE-----)"; + +c2pa::Context context(R"({ + "version": 1, + "trust": { + "user_anchors": ")" + test_root_ca + R"(" + } +})"); + +c2pa::Reader reader(context, "signed_asset.jpg"); +``` + +#### Using `allowed_list` + +To bypass chain validation, for quick testing, explicitly allow a specific certificate without validating the chain. +For example: + +```cpp +// Read your test signing certificate +std::string test_cert = read_file("test_cert.pem"); + +c2pa::Settings settings; +settings.update(R"({ + "version": 1, + "trust": { + "allowed_list": ")" + test_cert + R"(" + } +})"); + +c2pa::Context context(settings); +c2pa::Reader reader(context, "signed_asset.jpg"); +``` + +### CAWG trust configuration + +The `cawg_trust` properties configure CAWG (Creator Assertions Working Group) validation of identity assertions in C2PA manifests. The `cawg_trust` object 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. + +Use cases: + +- **Performance tuning for large files**: Set `core.backing_store_memory_threshold_in_mb` to `2048` or higher if 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) specify how the SDK validates C2PA manifests. These settings affect both reading existing manifests and verifying newly signed content. + +Common use cases include: + +- [Offline or air-gapped environments](#offline-or-air-gapped-environments). +- [Fast development iteration](#fast-development-iteration) with verification disabled. +- [Strict validation](#strict-validation) for certification or compliance testing. + +By default, the following `verify` properties are `true`, which enables verification: + +- `remote_manifest_fetch` - Fetch remote manifests referenced in the asset. Disable in offline or air-gapped environments. +- `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_timestamp_trust` - Verify timestamp authority (TSA) certificates. WARNING: Disabling this setting makes verification non-compliant. +- `verify_trust` - Verify signing certificates against configured trust anchors. WARNING: Disabling this setting makes verification non-compliant. + +> [!WARNING] +> Disabling verification options (changing `true` to `false`) can make verification non-compliant with the C2PA specification. Only modify these settings in controlled environments or when you have specific requirements. + +#### Offline or air-gapped environments + +Set `remote_manifest_fetch` and `ocsp_fetch` to `false` to disable network-dependent verification features: + +```cpp +c2pa::Context context(R"({ + "version": 1, + "verify": { + "remote_manifest_fetch": false, + "ocsp_fetch": false + } +})"); + +c2pa::Reader reader(context, "signed_asset.jpg"); +``` + +See also [Using Context with Reader](context.md#using-context-with-reader). + +#### Fast development iteration + +During active development, you can disable verification for faster iteration: + +```cpp +// WARNING: Only use during development, not in production! +c2pa::Settings dev_settings; +dev_settings.set("verify.verify_after_reading", "false"); +dev_settings.set("verify.verify_after_sign", "false"); + +c2pa::Context dev_context(dev_settings); +``` + +#### Strict validation + +For certification or compliance testing, enable strict validation: + +```cpp +c2pa::Context context(R"({ + "version": 1, + "verify": { + "strict_v1_validation": true, + "ocsp_fetch": true, + "verify_trust": true, + "verify_timestamp_trust": true + } +})"); + +c2pa::Reader reader(context, "asset_to_validate.jpg"); +auto validation_result = reader.json(); +``` + +### 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. **Recommended fields:** + +- `name` (string, required): Your application name (e.g., `"My Photo Editor"`) +- `version` (string, recommended): Application version (e.g., `"2.1.0"`) +- `icon` (string, optional): Icon in C2PA format +- `operating_system` (string, optional): OS identifier or `"auto"` to auto-detect + +**Example:** + +```cpp +c2pa::Context context(R"({ + "version": 1, + "builder": { + "claim_generator_info": { + "name": "My Photo Editor", + "version": "2.1.0", + "operating_system": "auto" + } + } +})"); +``` + +#### Thumbnail settings + +The [`builder.thumbnail`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#thumbnailsettings) properties control automatic thumbnail generation. + +For examples of configuring thumbnails for mobile bandwidth or disabling them for batch processing, see [Controlling thumbnail generation](context.md#controlling-thumbnail-generation). + +#### Action tracking settings + +| 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` | + +#### Other builder settings + +| Property | Type | Description | Default | +|----------|------|-------------|---------| +| `builder.intent` | object | Claim intent: `{"Create": "digitalCapture"}`, `{"Edit": null}`, or `{"Update": null}`. Describes the purpose of the claim. | `null` | +| `builder.generate_c2pa_archive` | Boolean | Generate content in C2PA archive format. Keep enabled for standard C2PA compliance. | `true` | + +##### Setting Builder intent + +You can use `Context` to set `Builder` intent for different workflows. + +For example, for original digital capture (photos from camera): + +```cpp +c2pa::Context camera_context(R"({ + "version": 1, + "builder": { + "intent": {"Create": "digitalCapture"}, + "claim_generator_info": {"name": "Camera App", "version": "1.0"} + } +})"); +``` + +Or for editing existing content: + +```cpp +c2pa::Context editor_context(R"({ + "version": 1, + "builder": { + "intent": {"Edit": null}, + "claim_generator_info": {"name": "Photo Editor", "version": "2.0"} + } +})"); +``` + +### Signer + +The [`signer` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signersettings) configure the primary C2PA signer configuration. Set it to `null` if you provide the signer at runtime, or configure as either a **local** or **remote** signer in settings. + +> [!NOTE] +> While you can configure the signer in settings, the typical approach is to pass a `Signer` object directly to the `Builder.sign()` method. Use settings-based signing when you need the same signing configuration across multiple operations or when loading configuration from files. + +#### Local signer + +Use a local signer when you have direct access to the private key and certificate. +For information on all `signer.local` properties, see [signer.local](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signerlocal) in the SDK object reference. + +**Example: Local signer with ES256** + +```cpp +std::string config = R"({ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", + "tsa_url": "http://timestamp.digicert.com" + } + } +})"; + +c2pa::Context context(config); +c2pa::Builder builder(context, manifest_json); +// Signer is already configured in context +builder.sign(source_path, dest_path); +``` + +#### Remote signer + +Use a remote signer when the private key is stored on a secure signing service (HSM, cloud KMS, and so on). +For information on all `signer.remote` properties, see [signer.remote](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signerremote) in the SDK object reference. + +The remote signing service receives a POST request with the data to sign and must return the signature in the expected format. + +For example: + +```cpp +c2pa::Context context(R"({ + "version": 1, + "signer": { + "remote": { + "url": "https://signing-service.example.com/sign", + "alg": "ps256", + "sign_cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + "tsa_url": "http://timestamp.digicert.com" + } + } +})"); +``` + +### CAWG X.509 signer configuration + +The `cawg_x509_signer` property specifies configuration for identity assertions. This has the same structure as `signer` (can be local or remote). + +**When to use:** If you need to sign identity assertions separately from the main C2PA claim. 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` + +**Example: Dual signer configuration** + +```cpp +c2pa::Context context(R"({ + "version": 1, + "signer": { + "local": { + "alg": "es256", + "sign_cert": "...", + "private_key": "..." + } + }, + "cawg_x509_signer": { + "local": { + "alg": "ps256", + "sign_cert": "...", + "private_key": "..." + } + } +})"); +``` + +For additional JSON configuration examples (minimal configuration, local/remote signer, development/production configurations), see the [Rust SDK settings examples](https://github.com/contentauth/c2pa-rs/blob/main/docs/settings.md#examples). + +## See also + +- [Using Context to configure the SDK](context.md): how to create and use contexts with settings. +- [Usage](usage.md): reading and signing with `Reader` and `Builder`. +- [Rust SDK settings](https://github.com/contentauth/c2pa-rs/blob/main/docs/settings.md): the shared settings schema, default configuration, and JSON examples (language-independent). +- [CAI settings schema reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/): full schema reference. diff --git a/docs/usage.md b/docs/usage.md index aeec23a4..b9c68fbf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -10,9 +10,10 @@ Import the objects needed from the API: ```py from c2pa import Builder, Reader, Signer, C2paSigningAlg, C2paSignerInfo +from c2pa import Settings, Context, ContextProvider ``` -You can use both `Builder`, `Reader` and `Signer` classes with context managers by using a `with` statement. +You can use `Builder`, `Reader`, `Signer`, `Settings`, and `Context` classes with context managers by using a `with` statement. Doing this is recommended to ensure proper resource and memory cleanup. ## Define manifest JSON @@ -118,6 +119,130 @@ except Exception as e: print("Failed to sign manifest store: " + str(e)) ``` +## Settings, Context, and ContextProvider + +The `Settings` and `Context` classes provide **per-instance configuration** for Reader and Builder operations. This replaces the global `load_settings()` function, which is now deprecated. + +### Settings + +`Settings` controls behavior such as thumbnail generation, trust lists, and verification flags. + +```py +from c2pa import Settings + +# Create with defaults +settings = Settings() + +# Set individual values by dot-notation path +settings.set("builder.thumbnail.enabled", "false") + +# Method chaining +settings.set("builder.thumbnail.enabled", "false").set("verify.remote_manifest_fetch", "true") + +# Dict-like access +settings["builder.thumbnail.enabled"] = "false" + +# Create from JSON string +settings = Settings.from_json('{"builder": {"thumbnail": {"enabled": false}}}') + +# Create from a dictionary +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) + +# Merge additional configuration +settings.update({"verify": {"remote_manifest_fetch": True}}) + +# Use as a context manager for automatic cleanup +with Settings() as settings: + settings.set("builder.thumbnail.enabled", "false") +``` + +### Context + +A `Context` carries optional `Settings` and an optional `Signer`, and is passed to `Reader` or `Builder` to control their behavior. + +```py +from c2pa import Context, Settings, Reader, Builder, Signer + +# Default context (no custom settings) +ctx = Context() + +# Context with settings +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +ctx = Context(settings=settings) + +# Create from JSON or dict directly +ctx = Context.from_json('{"builder": {"thumbnail": {"enabled": false}}}') +ctx = Context.from_dict({"builder": {"thumbnail": {"enabled": False}}}) + +# Use with Reader +reader = Reader("path/to/media_file.jpg", context=ctx) + +# Use with Builder +builder = Builder(manifest_json, context=ctx) +``` + +### Context with a Signer + +When a `Signer` is passed to `Context`, the signer is **consumed** — the `Signer` object becomes invalid after this call and must not be reused. The `Context` takes ownership of the underlying native signer. This allows signing without passing an explicit signer to `Builder.sign()`. + +```py +from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg + +# Create a signer +signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" +) +signer = Signer.from_info(signer_info) + +# Create context with signer (signer is consumed) +ctx = Context(settings=settings, signer=signer) +# signer is now invalid and must not be used again + +# Build and sign — no signer argument needed +builder = Builder(manifest_json, context=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 always takes precedence: + +```py +# Explicit signer wins over context signer +manifest_bytes = builder.sign(explicit_signer, "image/jpeg", source, dest) +``` + +### ContextProvider protocol + +The `ContextProvider` protocol allows third-party implementations of custom context providers. Any class that implements `is_valid` and `_c_context` properties satisfies the protocol and can be passed to `Reader` or `Builder` as `context`. + +```py +from c2pa import ContextProvider, Context + +# The built-in Context satisfies ContextProvider +ctx = Context() +assert isinstance(ctx, ContextProvider) # True +``` + +### Migrating from load_settings + +The `load_settings()` function is deprecated. Replace it with `Settings` and `Context`: + +```py +# Before (deprecated): +from c2pa import load_settings +load_settings({"builder": {"thumbnail": {"enabled": False}}}) +reader = Reader("file.jpg") + +# After (recommended): +from c2pa import Settings, Context, Reader +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +ctx = Context(settings=settings) +reader = Reader("file.jpg", context=ctx) +``` + ## Stream-based operation 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. diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index 8f0c8fe1..8fd1b888 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -27,6 +27,9 @@ C2paSignerInfo, Signer, Stream, + Settings, + Context, + ContextProvider, sdk_version, read_ingredient_file, load_settings @@ -43,6 +46,9 @@ 'C2paSignerInfo', 'Signer', 'Stream', + 'Settings', + 'Context', + 'ContextProvider', 'sdk_version', 'read_ingredient_file', 'load_settings' diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index d4ff669c..b779fc16 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -19,7 +19,10 @@ import os import warnings from pathlib import Path -from typing import Optional, Union, Callable, Any, overload +from typing import ( + Optional, Union, Callable, Any, overload, + Protocol, runtime_checkable, +) import io from .lib import dynamically_load_library import mimetypes @@ -69,6 +72,18 @@ 'c2pa_builder_supported_mime_types', 'c2pa_reader_is_embedded', 'c2pa_reader_remote_url', + 'c2pa_settings_new', + 'c2pa_settings_set_value', + 'c2pa_settings_update_from_string', + 'c2pa_context_builder_new', + 'c2pa_context_builder_set_settings', + 'c2pa_context_builder_build', + 'c2pa_context_new', + 'c2pa_reader_from_context', + 'c2pa_reader_with_stream', + 'c2pa_builder_from_context', + 'c2pa_builder_with_definition', + 'c2pa_free', ] @@ -136,6 +151,17 @@ def _validate_library_exports(lib): _validate_library_exports(_lib) +# Signer-on-context functions may not yet be in the native library. +# Guard with hasattr checks for forward compatibility. +_SIGNER_CONTEXT_FUNCTIONS = [ + 'c2pa_context_builder_set_signer', + 'c2pa_builder_sign_context', +] + +_has_signer_context = all( + hasattr(_lib, fn) for fn in _SIGNER_CONTEXT_FUNCTIONS +) + class C2paSeekMode(enum.IntEnum): """Seek mode for stream operations.""" @@ -360,6 +386,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 @@ -531,6 +572,78 @@ 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_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) + +# Conditionally set up signer-on-context function prototypes +if _has_signer_context: + _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. @@ -832,14 +945,25 @@ 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. + .. deprecated:: + Use :class:`Settings` and :class:`Context` for + per-instance configuration instead. + Args: settings: The settings string or dict to load - format: The format of the settings string (default: "json"). + format: The format of the settings string + (default: "json"). Ignored when settings is a dict. Raises: - C2paError: If there was an error loading the settings + C2paError: If there was an error loading 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 @@ -1107,6 +1231,502 @@ def sign_file( signer.close() +@runtime_checkable +class ContextProvider(Protocol): + """Protocol for types that provide a C2PA context. + + Allows third-party implementations of custom context providers. + The built-in Context class satisfies this protocol. + """ + + @property + def is_valid(self) -> bool: ... + + @property + def _c_context(self): ... + + +class Settings: + """Per-instance configuration for C2PA operations. + + Settings control behavior such as thumbnail generation, + trust lists, and verification flags. Use with Context to + apply settings to Reader/Builder operations. + + Example:: + + settings = Settings() + settings.set("builder.thumbnail.enabled", "false") + + # Or via from_json / from_dict: + settings = Settings.from_json('{"verify": {...}}') + settings = Settings.from_dict({"verify": {...}}) + + # Dict-like access: + settings["builder.thumbnail.enabled"] = "false" + + # Method chaining: + settings.set("a", "1").set("b", "2") + """ + + def __init__(self): + """Create new Settings with default values.""" + _clear_error_state() + self._closed = False + self._initialized = False + self._settings = None + + ptr = _lib.c2pa_settings_new() + if not ptr: + _parse_operation_result_for_error(None) + raise C2paError("Failed to create Settings") + + self._settings = ptr + self._initialized = True + + @classmethod + def from_json(cls, json_str: str) -> 'Settings': + """Create Settings from a 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, format="json") + return settings + + @classmethod + def from_dict(cls, config: dict) -> 'Settings': + """Create Settings from a 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() + _clear_error_state() + + try: + path_bytes = path.encode('utf-8') + value_bytes = value.encode('utf-8') + except (UnicodeEncodeError, AttributeError) as e: + raise C2paError.Encoding( + f"Encoding: {str(e)}" + ) from e + + result = _lib.c2pa_settings_set_value( + self._settings, path_bytes, value_bytes + ) + if result != 0: + _parse_operation_result_for_error(None) + + return self + + def update( + self, data: Union[str, dict], format: str = "json" + ) -> 'Settings': + """Merge configuration from a JSON string or dict. + + Args: + data: A JSON string or dict with configuration + to merge. + format: Format of the data string. Only "json" + is supported. + + Returns: + self, for method chaining. + """ + self._ensure_valid_state() + _clear_error_state() + + if format != "json": + raise C2paError( + "Only JSON format is supported for settings" + ) + + if isinstance(data, dict): + data = json.dumps(data) + + try: + data_bytes = data.encode('utf-8') + format_bytes = format.encode('utf-8') + except (UnicodeEncodeError, AttributeError) as e: + raise C2paError.Encoding( + f"Encoding: {str(e)}" + ) from e + + result = _lib.c2pa_settings_update_from_string( + self._settings, data_bytes, format_bytes + ) + if result != 0: + _parse_operation_result_for_error(None) + + return self + + def __setitem__(self, path: str, value: str) -> None: + """Dict-like setter: settings["path"] = "value".""" + self.set(path, value) + + @property + def _c_settings(self): + """Expose the raw pointer for Context to consume.""" + self._ensure_valid_state() + return self._settings + + @property + def is_valid(self) -> bool: + """Check if the Settings is in a valid state.""" + return ( + not self._closed + and self._initialized + and self._settings is not None + ) + + def _ensure_valid_state(self): + """Ensure the settings are in a valid state. + + Raises: + C2paError: If the settings are closed or invalid. + """ + if self._closed: + raise C2paError("Settings is closed") + if not self._initialized: + raise C2paError( + "Settings is not properly initialized" + ) + if not self._settings: + raise C2paError("Settings is closed") + + def _cleanup_resources(self): + """Release native resources safely.""" + try: + if hasattr(self, '_closed') and not self._closed: + self._closed = True + if ( + hasattr(self, '_settings') + and self._settings + ): + try: + _lib.c2pa_free( + ctypes.cast( + self._settings, + ctypes.c_void_p + ) + ) + except Exception: + logger.error( + "Failed to free native" + " Settings resources" + ) + finally: + self._settings = None + except Exception: + pass + + def close(self) -> None: + """Release the Settings resources.""" + if self._closed: + return + try: + self._cleanup_resources() + except Exception as e: + logger.error( + f"Error during Settings close: {e}" + ) + + def __enter__(self) -> 'Settings': + self._ensure_valid_state() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def __del__(self): + self._cleanup_resources() + + +class Context: + """Per-instance context for C2PA operations. + + A Context carries optional Settings and an optional Signer, + and is passed to Reader or Builder to control their behavior. + + When a Signer is provided the Signer object is **consumed** + and must not be used again. + + Example:: + + # Default context + ctx = Context() + + # With settings + settings = Settings() + settings.set("builder.thumbnail.enabled", "false") + ctx = Context(settings=settings) + + # With settings and signer (signer is consumed) + signer = Signer.from_info(info) + ctx = Context(settings=settings, signer=signer) + """ + + def __init__( + self, + settings: Optional['Settings'] = None, + signer: Optional['Signer'] = None, + ): + """Create a Context. + + Args: + settings: Optional Settings for configuration. + If None, default settings are used. + signer: Optional Signer. If provided it is + CONSUMED and must not be used again. + + Raises: + C2paError: If creation fails or if signer is + provided but the library does not support + signer-on-context. + """ + _clear_error_state() + self._closed = False + self._initialized = False + self._context = None + 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() + if not ptr: + _parse_operation_result_for_error(None) + raise C2paError( + "Failed to create Context" + ) + self._context = ptr + else: + # Use ContextBuilder for settings/signer + builder_ptr = _lib.c2pa_context_builder_new() + if not builder_ptr: + _parse_operation_result_for_error(None) + raise C2paError( + "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: + if not _has_signer_context: + raise C2paError( + "Signer-on-Context requires" + " a newer c2pa-c library" + " version" + ) + signer_ptr, callback_cb = ( + signer._release() + ) + self._signer_callback_cb = ( + callback_cb + ) + result = ( + _lib + .c2pa_context_builder_set_signer( + builder_ptr, signer_ptr, + ) + ) + if result != 0: + _parse_operation_result_for_error( + None + ) + self._has_signer = True + + # Build consumes builder_ptr + ptr = ( + _lib.c2pa_context_builder_build( + builder_ptr + ) + ) + # builder_ptr is now invalid + builder_ptr = None + + if not ptr: + _parse_operation_result_for_error( + None + ) + raise C2paError( + "Failed to build Context" + ) + self._context = ptr + except Exception: + # Free builder if build was not reached + if builder_ptr is not None: + try: + _lib.c2pa_free( + ctypes.cast( + builder_ptr, + ctypes.c_void_p, + ) + ) + except Exception: + pass + raise + + self._initialized = True + + @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 _c_context(self): + """Expose the raw pointer (ContextProvider protocol).""" + self._ensure_valid_state() + return self._context + + @property + def is_valid(self) -> bool: + """Check if the Context is in a valid state.""" + return ( + not self._closed + and self._initialized + and self._context is not None + ) + + def _ensure_valid_state(self): + """Ensure the context is in a valid state. + + Raises: + C2paError: If the context is closed or invalid. + """ + if self._closed: + raise C2paError("Context is closed") + if not self._initialized: + raise C2paError( + "Context is not properly initialized" + ) + if not self._context: + raise C2paError("Context is closed") + + def _cleanup_resources(self): + """Release native resources safely.""" + try: + if ( + hasattr(self, '_closed') + and not self._closed + ): + self._closed = True + if ( + hasattr(self, '_context') + and self._context + ): + try: + _lib.c2pa_free( + ctypes.cast( + self._context, + ctypes.c_void_p, + ) + ) + except Exception: + logger.error( + "Failed to free native" + " Context resources" + ) + finally: + self._context = None + except Exception: + pass + + def close(self) -> None: + """Release the Context resources.""" + if self._closed: + return + try: + self._cleanup_resources() + except Exception as e: + logger.error( + f"Error during Context close: {e}" + ) + + def __enter__(self) -> 'Context': + self._ensure_valid_state() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def __del__(self): + self._cleanup_resources() + + class Stream: # Class-level somewhat atomic counter for generating # unique stream IDs (useful for tracing streams usage in debug) @@ -1515,52 +2135,67 @@ def get_supported_mime_types(cls) -> list[str]: return cls._supported_mime_types_cache @classmethod - def try_create(cls, - format_or_path: Union[str, Path], - stream: Optional[Any] = None, - manifest_data: Optional[Any] = None) -> Optional["Reader"]: + def try_create( + cls, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None, + *, + context: Optional['ContextProvider'] = None, + ) -> Optional["Reader"]: """This is a factory method to create a new Reader, - returning None if no manifest/c2pa data/JUMBF data could be read - (instead of raising a ManifestNotFound: no JUMBF data found exception). + returning None if no manifest/c2pa data/JUMBF data + could be read (instead of raising a ManifestNotFound + 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. + 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) + stream: Optional stream to read from manifest_data: Optional manifest data in bytes + context: Optional ContextProvider for settings Returns: Reader instance if the asset contains C2PA data, - None if no manifest found (ManifestNotFound: no JUMBF data found) + None if no manifest found Raises: - C2paError: If there was an error other than ManifestNotFound + C2paError: If there was an error other than + ManifestNotFound """ try: - # Reader creations checks deferred to the constructor __init__ method - return cls(format_or_path, stream, manifest_data) + return cls( + format_or_path, stream, manifest_data, + context=context, + ) except C2paError.ManifestNotFound: - # Nothing to read, so no Reader returned return None - def __init__(self, - format_or_path: Union[str, - Path], - stream: Optional[Any] = None, - manifest_data: Optional[Any] = None): + 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) + stream: Optional stream to read from manifest_data: Optional manifest data in bytes + context: Optional ContextProvider for settings Raises: - C2paError: If there was an error creating the reader + C2paError: If there was an error creating + the reader C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters """ @@ -1575,13 +2210,23 @@ def __init__(self, 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 + # we may have opened ourselves, + # and that we need to close later self._backing_file = None # Caches for manifest JSON string and parsed data self._manifest_json_str_cache = None self._manifest_data_cache = None + # Keep context reference alive for lifetime + self._context = context + + if context is not None: + self._init_from_context( + context, format_or_path, stream, + ) + return + if stream is None: # If we don't get a stream as param: # Create a stream from the file path in format_or_path @@ -1743,6 +2388,128 @@ def __init__(self, self._initialized = True + def _init_from_context(self, context, format_or_path, + stream): + """Initialize Reader from a ContextProvider. + + Uses c2pa_reader_from_context + c2pa_reader_with_stream. + """ + if not context.is_valid: + raise C2paError("Context is not valid") + + # Determine format and open stream + if stream is None: + path = str(format_or_path) + mime_type = _get_mime_type_from_path(path) + if not mime_type: + raise C2paError.NotSupported( + "Could not determine MIME type" + f" for file: {path}" + ) + if mime_type not in ( + Reader.get_supported_mime_types() + ): + raise C2paError.NotSupported( + "Reader does not support" + f" {mime_type}" + ) + try: + format_bytes = mime_type.encode('utf-8') + except UnicodeError as e: + raise C2paError.Encoding( + Reader._ERROR_MESSAGES[ + 'encoding_error' + ].format(str(e)) + ) + self._backing_file = open(path, 'rb') + self._own_stream = Stream( + self._backing_file + ) + elif isinstance(stream, str): + fmt = format_or_path.lower() + if fmt not in Reader.get_supported_mime_types(): + raise C2paError.NotSupported( + "Reader does not support" + f" {format_or_path}" + ) + try: + format_bytes = str( + format_or_path + ).encode('utf-8') + except UnicodeError as e: + raise C2paError.Encoding( + Reader._ERROR_MESSAGES[ + 'encoding_error' + ].format(str(e)) + ) + self._backing_file = open(stream, 'rb') + self._own_stream = Stream( + self._backing_file + ) + else: + fmt_str = str(format_or_path) + if ( + fmt_str.lower() + not in Reader.get_supported_mime_types() + ): + raise C2paError.NotSupported( + "Reader does not support" + f" {fmt_str}" + ) + try: + format_bytes = fmt_str.encode('utf-8') + except UnicodeError as e: + raise C2paError.Encoding( + Reader._ERROR_MESSAGES[ + 'encoding_error' + ].format(str(e)) + ) + self._own_stream = Stream(stream) + + try: + # Create base reader from context + reader_ptr = _lib.c2pa_reader_from_context( + context._c_context, + ) + if not reader_ptr: + _parse_operation_result_for_error( + _lib.c2pa_error() + ) + raise C2paError( + Reader._ERROR_MESSAGES[ + 'reader_error' + ].format("Unknown error") + ) + + # Consume-and-return: reader_ptr is consumed, + # new_ptr is the valid pointer going forward + new_ptr = _lib.c2pa_reader_with_stream( + reader_ptr, format_bytes, + self._own_stream._stream, + ) + # reader_ptr is NOW INVALID + + if not new_ptr: + _parse_operation_result_for_error( + _lib.c2pa_error() + ) + raise C2paError( + Reader._ERROR_MESSAGES[ + 'reader_error' + ].format("Unknown error") + ) + + self._reader = new_ptr + self._initialized = True + except Exception: + if self._own_stream: + self._own_stream.close() + self._own_stream = None + if self._backing_file: + self._backing_file.close() + self._backing_file = None + raise + def __enter__(self): self._ensure_valid_state() return self @@ -2387,6 +3154,34 @@ def _ensure_valid_state(self): if not self._signer: raise C2paError(Signer._ERROR_MESSAGES['closed_error']) + def _release(self): + """Release ownership of the native signer pointer. + + After this call the Signer is marked closed and must + not be used. The caller takes ownership of the + returned pointer and is responsible for its lifetime. + + Returns: + Tuple of (signer_ptr, callback_cb): + signer_ptr: The native C2paSigner pointer. + callback_cb: The callback reference (if any). + The caller must store this to prevent GC. + + Raises: + C2paError: If the signer is already closed. + """ + self._ensure_valid_state() + + ptr = self._signer + callback_cb = self._callback_cb + + # Detach pointer without freeing — caller now owns it + self._signer = None + self._callback_cb = None + self._closed = True + + return ptr, callback_cb + def close(self): """Release the signer resources. @@ -2529,98 +3324,189 @@ def get_supported_mime_types(cls) -> list[str]: return cls._supported_mime_types_cache @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 Raises: - C2paError: If there was an error creating the builder + 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, + *, + context: Optional['ContextProvider'] = None, + ) -> 'Builder': """Create a new Builder from an archive stream. Args: stream: The stream containing the archive (any Python stream-like object) + context: Optional ContextProvider for settings Returns: A new Builder instance 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({}) + builder = cls({}, context=context) stream_obj = Stream(stream) - builder._builder = _lib.c2pa_builder_from_archive(stream_obj._stream) + builder._builder = ( + _lib.c2pa_builder_from_archive( + stream_obj._stream + ) + ) if not builder._builder: - # Clean up the stream object if builder creation fails stream_obj.close() - - error = _parse_operation_result_for_error(_lib.c2pa_error()) + error = _parse_operation_result_for_error( + _lib.c2pa_error() + ) if error: raise C2paError(error) - raise C2paError("Failed to create builder from archive") + raise C2paError( + "Failed to create builder from archive" + ) builder._initialized = True return builder - def __init__(self, manifest_json: Any): + def __init__( + self, + manifest_json: Any, + *, + context: Optional['ContextProvider'] = None, + ): """Initialize a new Builder instance. Args: - manifest_json: The manifest JSON definition (string or dict) + manifest_json: The manifest JSON definition + (string or dict) + context: Optional 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 + 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 any stale error state from previous ops _clear_error_state() self._closed = False self._initialized = False self._builder = None + # Keep context reference alive for lifetime + self._context = context + self._has_context_signer = ( + context is not None + and hasattr(context, 'has_signer') + and context.has_signer + ) + 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))) + Builder._ERROR_MESSAGES[ + 'json_error' + ].format(str(e)) + ) try: json_str = manifest_json.encode('utf-8') except UnicodeError as e: raise C2paError.Encoding( - Builder._ERROR_MESSAGES['encoding_error'].format( - str(e))) + Builder._ERROR_MESSAGES[ + 'encoding_error' + ].format(str(e)) + ) - self._builder = _lib.c2pa_builder_from_json(json_str) + if context is not None: + self._init_from_context(context, json_str) + else: + self._builder = ( + _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( + Builder._ERROR_MESSAGES[ + 'builder_error' + ].format("Unknown error") + ) - if not self._builder: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) + self._initialized = True + + def _init_from_context(self, context, json_str): + """Initialize Builder from a ContextProvider. + + Uses c2pa_builder_from_context + + c2pa_builder_with_definition (consume-and-return). + """ + if not context.is_valid: + raise C2paError("Context is not valid") + + builder_ptr = _lib.c2pa_builder_from_context( + context._c_context, + ) + if not builder_ptr: + _parse_operation_result_for_error( + _lib.c2pa_error() + ) raise C2paError( - Builder._ERROR_MESSAGES['builder_error'].format( - "Unknown error" - ) + Builder._ERROR_MESSAGES[ + 'builder_error' + ].format("Unknown error") ) - self._initialized = True + # 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, + ) + # builder_ptr is NOW INVALID + + if not new_ptr: + _parse_operation_result_for_error( + _lib.c2pa_error() + ) + raise C2paError( + Builder._ERROR_MESSAGES[ + 'builder_error' + ].format("Unknown error") + ) + + self._builder = new_ptr def __del__(self): """Ensure resources are cleaned up if close() wasn't called.""" @@ -3069,19 +3955,20 @@ def _sign_internal( return manifest_bytes 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=None, + format=None, + source=None, + dest=None, + ) -> bytes: + """Sign the builder's content. 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 None, the + context's signer is used. + format: The MIME type of the content. + source: The source stream. + dest: The destination stream (optional). Returns: Manifest bytes @@ -3089,46 +3976,142 @@ def sign( Raises: C2paError: If there was an error during signing """ - # Convert Python streams to Stream objects + if format is None or source is None: + raise C2paError( + "format and source are required" + " for sign()" + ) + source_stream = Stream(source) if dest: - # dest is optional, only if we write back somewhere dest_stream = Stream(dest) else: - # no destination? - # we keep things in-memory for validation and processing mem_buffer = io.BytesIO() dest_stream = Stream(mem_buffer) - # Use the internal stream-base signing logic - manifest_bytes = self._sign_internal( - signer, - format, - source_stream, - dest_stream - ) + if signer is not None: + # Explicit signer always wins + manifest_bytes = self._sign_internal( + signer, format, + source_stream, dest_stream, + ) + elif self._has_context_signer: + # Context signer as fallback + manifest_bytes = self._sign_context_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." + ) if not dest: - # Close temporary in-memory stream since we own it dest_stream.close() return manifest_bytes - def sign_file(self, - source_path: Union[str, - Path], - dest_path: Union[str, - Path], - signer: Signer) -> bytes: - """Sign a file and write the signed data to an output file. + def _sign_context_internal( + self, + format: str, + source_stream: 'Stream', + dest_stream: 'Stream', + ) -> bytes: + """Sign using the signer stored in the context. + + Uses c2pa_builder_sign_context instead of + c2pa_builder_sign. + """ + self._ensure_valid_state() + + if not _has_signer_context: + raise C2paError( + "Signer-on-Context requires a newer" + " version of the c2pa-c library." + ) + + format_lower = format.lower() + if ( + format_lower + not in Builder.get_supported_mime_types() + ): + raise C2paError.NotSupported( + "Builder does not support" + f" {format}" + ) + + format_str = format.encode('utf-8') + manifest_bytes_ptr = ( + ctypes.POINTER(ctypes.c_ubyte)() + ) + + try: + result = _lib.c2pa_builder_sign_context( + self._builder, + format_str, + source_stream._stream, + dest_stream._stream, + ctypes.byref(manifest_bytes_ptr), + ) + except Exception as e: + raise C2paError( + "Error calling" + f" c2pa_builder_sign_context: {e}" + ) + + if result < 0: + error = _parse_operation_result_for_error( + _lib.c2pa_error() + ) + if error: + raise C2paError(error) + raise C2paError( + "Error during context-based signing" + ) + + manifest_bytes = b"" + if manifest_bytes_ptr and result > 0: + try: + temp_buffer = ( + ctypes.c_ubyte * result + )() + ctypes.memmove( + temp_buffer, + manifest_bytes_ptr, + result, + ) + manifest_bytes = bytes(temp_buffer) + except Exception: + manifest_bytes = b"" + finally: + try: + _lib.c2pa_manifest_bytes_free( + manifest_bytes_ptr + ) + except Exception: + logger.error( + "Failed to release native" + " manifest bytes memory" + ) + + return manifest_bytes + + def sign_file( + self, + source_path: Union[str, Path], + dest_path: Union[str, Path], + signer=None, + ) -> bytes: + """Sign a file and write signed data to output. Args: - source_path: Path to the source file. We will attempt - to guess the mimetype of the source file based on - the extension. - dest_path: Path to write the signed file to - signer: The signer to use + source_path: Path to the source file. + dest_path: Path to write the signed file to. + signer: The signer to use. If None, the + context's signer is used. Returns: Manifest bytes @@ -3136,14 +4119,19 @@ 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) + 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, + ): + return self.sign( + signer, mime_type, + source_file, dest_file, + ) except Exception as e: raise C2paError(f"Error signing file: {str(e)}") from e @@ -3330,6 +4318,9 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'C2paDigitalSourceType', 'C2paSignerInfo', 'C2paBuilderIntent', + 'ContextProvider', + 'Settings', + 'Context', 'Stream', 'Reader', 'Builder', diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 18f6b817..7356ad4c 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -28,9 +28,14 @@ # Suppress deprecation warnings warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings( + "ignore", message="load_settings\\(\\) is deprecated" +) -from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType -from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable +from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType # noqa: E501 +from c2pa import Settings, Context, ContextProvider +from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable # noqa: E501 +from c2pa.c2pa import _has_signer_context PROJECT_PATH = os.getcwd() FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") @@ -68,11 +73,15 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): # This test verifies the native libraries used match the expected version. - self.assertIn("0.75.21", sdk_version()) + self.assertIn("0.76.2", 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 @@ -935,6 +944,10 @@ def test_stream_read_and_parse_cached(self): class TestBuilderWithSigner(unittest.TestCase): def setUp(self): + warnings.filterwarnings( + "ignore", + message="load_settings\\(\\) is deprecated", + ) # Use the fixtures_dir fixture to set up paths self.data_dir = FIXTURES_DIR self.testPath = DEFAULT_TEST_FILE @@ -4304,6 +4317,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 @@ -5109,5 +5123,742 @@ def test_create_signer_from_info(self): self.assertIsNotNone(signer) +# ── Context API manifest definition ────────────── + +_CTX_MANIFEST_DEF = { + "claim_generator": "python_test/context", + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Context Test Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + }] + } + } + ] +} + + +def _ctx_make_signer(): + """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(): + """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", + ) + + +# ── 1. Settings basics ────────────────────────── + + +class TestSettings(unittest.TestCase): + + def test_settings_default_construction(self): + s = Settings() + self.assertTrue(s.is_valid) + s.close() + + def test_settings_set_chaining(self): + s = Settings() + result = ( + s.set( + "builder.thumbnail.enabled", "false" + ).set( + "builder.thumbnail.enabled", "true" + ) + ) + self.assertIs(result, s) + s.close() + + def test_settings_from_json(self): + s = Settings.from_json( + '{"builder":{"thumbnail":' + '{"enabled":false}}}' + ) + self.assertTrue(s.is_valid) + s.close() + + def test_settings_from_dict(self): + s = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + self.assertTrue(s.is_valid) + s.close() + + def test_settings_update_json(self): + s = Settings() + result = s.update( + '{"builder":{"thumbnail":' + '{"enabled":false}}}' + ) + self.assertIs(result, s) + s.close() + + def test_settings_update_dict(self): + s = Settings() + result = s.update({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + self.assertIs(result, s) + s.close() + + def test_settings_setitem(self): + s = Settings() + s["builder.thumbnail.enabled"] = "false" + self.assertTrue(s.is_valid) + s.close() + + def test_settings_context_manager(self): + with Settings() as s: + self.assertTrue(s.is_valid) + + def test_settings_close_idempotency(self): + s = Settings() + s.close() + s.close() + + def test_settings_is_valid_after_close(self): + s = Settings() + s.close() + self.assertFalse(s.is_valid) + + def test_settings_raises_after_close(self): + s = Settings() + s.close() + with self.assertRaises(Error): + s.set( + "builder.thumbnail.enabled", "false" + ) + + def test_settings_update_only_json(self): + s = Settings() + with self.assertRaises(Error): + s.update("data", format="toml") + s.close() + + +# ── 2. Context basics ─────────────────────────── + + +class TestContext(unittest.TestCase): + + def test_context_default(self): + ctx = Context() + self.assertTrue(ctx.is_valid) + self.assertFalse(ctx.has_signer) + ctx.close() + + def test_context_from_settings(self): + s = Settings() + ctx = Context(settings=s) + self.assertTrue(ctx.is_valid) + ctx.close() + s.close() + + def test_context_from_json(self): + ctx = Context.from_json( + '{"builder":{"thumbnail":' + '{"enabled":false}}}' + ) + self.assertTrue(ctx.is_valid) + ctx.close() + + def test_context_from_dict(self): + ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + self.assertTrue(ctx.is_valid) + ctx.close() + + def test_context_context_manager(self): + with Context() as ctx: + self.assertTrue(ctx.is_valid) + + def test_context_close_idempotency(self): + ctx = Context() + ctx.close() + ctx.close() + + def test_context_is_valid_after_close(self): + ctx = Context() + ctx.close() + self.assertFalse(ctx.is_valid) + + def test_context_invalid_settings_raises(self): + s = Settings() + s.close() + with self.assertRaises(Error): + Context(settings=s) + + def test_context_satisfies_protocol(self): + ctx = Context() + self.assertIsInstance(ctx, ContextProvider) + ctx.close() + + +# ── 3. Context with Signer ────────────────────── + + +@unittest.skipUnless( + _has_signer_context, + "Signer-on-context not supported by native lib", +) +class TestContextWithSigner(unittest.TestCase): + + def test_context_with_signer(self): + signer = _ctx_make_signer() + ctx = Context(signer=signer) + self.assertTrue(ctx.is_valid) + self.assertTrue(ctx.has_signer) + ctx.close() + + def test_context_with_settings_and_signer(self): + s = Settings() + signer = _ctx_make_signer() + ctx = Context(settings=s, signer=signer) + self.assertTrue(ctx.is_valid) + self.assertTrue(ctx.has_signer) + ctx.close() + s.close() + + def test_consumed_signer_is_closed(self): + signer = _ctx_make_signer() + ctx = Context(signer=signer) + self.assertTrue(signer._closed) + ctx.close() + + def test_consumed_signer_raises_on_use(self): + signer = _ctx_make_signer() + ctx = Context(signer=signer) + with self.assertRaises(Error): + signer._ensure_valid_state() + ctx.close() + + def test_context_has_signer_flag(self): + signer = _ctx_make_signer() + ctx = Context(signer=signer) + self.assertTrue(ctx.has_signer) + ctx.close() + + def test_context_no_signer_flag(self): + ctx = Context() + self.assertFalse(ctx.has_signer) + ctx.close() + + def test_context_from_json_with_signer(self): + signer = _ctx_make_signer() + ctx = Context.from_json( + '{"builder":{"thumbnail":' + '{"enabled":false}}}', + signer=signer, + ) + self.assertTrue(ctx.has_signer) + self.assertTrue(signer._closed) + ctx.close() + + +# ── 4. Reader with Context ────────────────────── + + +class TestReaderWithContext(unittest.TestCase): + + def test_reader_with_default_context(self): + ctx = Context() + with open(DEFAULT_TEST_FILE, "rb") as f: + reader = Reader( + "image/jpeg", f, context=ctx, + ) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + ctx.close() + + def test_reader_with_settings_context(self): + s = Settings() + ctx = Context(settings=s) + with open(DEFAULT_TEST_FILE, "rb") as f: + reader = Reader( + "image/jpeg", f, context=ctx, + ) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + ctx.close() + s.close() + + def test_reader_without_context(self): + with open(DEFAULT_TEST_FILE, "rb") as f: + reader = Reader("image/jpeg", f) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + + def test_reader_try_create_with_context(self): + ctx = Context() + reader = Reader.try_create( + DEFAULT_TEST_FILE, context=ctx, + ) + self.assertIsNotNone(reader) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + ctx.close() + + def test_reader_try_create_no_manifest(self): + ctx = Context() + reader = Reader.try_create( + INGREDIENT_TEST_FILE, context=ctx, + ) + self.assertIsNone(reader) + ctx.close() + + def test_reader_file_path_with_context(self): + ctx = Context() + reader = Reader( + DEFAULT_TEST_FILE, context=ctx, + ) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + ctx.close() + + def test_reader_format_and_path_with_ctx(self): + ctx = Context() + reader = Reader( + "image/jpeg", DEFAULT_TEST_FILE, + context=ctx, + ) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + ctx.close() + + +# ── 5. Builder with Context ───────────────────── + + +class TestBuilderWithContext(unittest.TestCase): + + def test_builder_with_default_context(self): + ctx = Context() + builder = Builder( + _CTX_MANIFEST_DEF, context=ctx, + ) + self.assertIsNotNone(builder) + builder.close() + ctx.close() + + def test_builder_with_settings_context(self): + s = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + ctx = Context(settings=s) + builder = Builder( + _CTX_MANIFEST_DEF, context=ctx, + ) + signer = _ctx_make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as src, + open(dest, "w+b") as dst, + ): + builder.sign( + signer, "image/jpeg", src, dst, + ) + reader = Reader(dest) + manifest = reader.get_active_manifest() + self.assertIsNone( + manifest.get("thumbnail") + ) + reader.close() + builder.close() + ctx.close() + s.close() + + def test_builder_without_context(self): + builder = Builder(_CTX_MANIFEST_DEF) + self.assertIsNotNone(builder) + builder.close() + + def test_builder_from_json_with_context(self): + ctx = Context() + builder = Builder.from_json( + _CTX_MANIFEST_DEF, context=ctx, + ) + self.assertIsNotNone(builder) + builder.close() + ctx.close() + + @unittest.skipUnless( + _has_signer_context, + "Signer-on-context not supported", + ) + def test_builder_sign_context_signer(self): + signer = _ctx_make_signer() + ctx = Context(signer=signer) + builder = Builder( + _CTX_MANIFEST_DEF, context=ctx, + ) + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as src, + open(dest, "w+b") as dst, + ): + mb = builder.sign( + format="image/jpeg", + source=src, + dest=dst, + ) + self.assertIsNotNone(mb) + self.assertGreater(len(mb), 0) + reader = Reader(dest) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + builder.close() + ctx.close() + + @unittest.skipUnless( + _has_signer_context, + "Signer-on-context not supported", + ) + def test_builder_sign_explicit_overrides(self): + ctx_signer = _ctx_make_signer() + ctx = Context(signer=ctx_signer) + builder = Builder( + _CTX_MANIFEST_DEF, context=ctx, + ) + explicit_signer = _ctx_make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as src, + open(dest, "w+b") as dst, + ): + mb = builder.sign( + explicit_signer, + "image/jpeg", src, dst, + ) + self.assertIsNotNone(mb) + self.assertGreater(len(mb), 0) + builder.close() + explicit_signer.close() + ctx.close() + + def test_builder_sign_no_signer_raises(self): + ctx = Context() + builder = Builder( + _CTX_MANIFEST_DEF, context=ctx, + ) + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as src, + open(dest, "w+b") as dst, + ): + with self.assertRaises(Error): + builder.sign( + format="image/jpeg", + source=src, + dest=dst, + ) + builder.close() + ctx.close() + + +# ── 6. ContextProvider protocol ────────────────── + + +class TestContextProvider(unittest.TestCase): + + def test_isinstance_check(self): + ctx = Context() + self.assertIsInstance(ctx, ContextProvider) + ctx.close() + + def test_custom_context_provider(self): + real_ctx = Context() + + class MyProvider: + @property + def is_valid(self) -> bool: + return True + + @property + def _c_context(self): + return real_ctx._c_context + + provider = MyProvider() + self.assertIsInstance( + provider, ContextProvider + ) + reader = Reader( + DEFAULT_TEST_FILE, context=provider, + ) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + real_ctx.close() + + def test_invalid_provider_rejected(self): + + class BadProvider: + @property + def is_valid(self) -> bool: + return False + + @property + def _c_context(self): + return None + + with self.assertRaises(Error): + Reader( + DEFAULT_TEST_FILE, + context=BadProvider(), + ) + + +# ── 7. Integration tests ──────────────────────── + + +class TestContextIntegration(unittest.TestCase): + + def test_sign_no_thumbnail_via_context(self): + s = Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + ctx = Context(settings=s) + signer = _ctx_make_signer() + builder = Builder( + _CTX_MANIFEST_DEF, context=ctx, + ) + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as src, + open(dest, "w+b") as dst, + ): + builder.sign( + signer, "image/jpeg", src, dst, + ) + reader = Reader(dest) + manifest = reader.get_active_manifest() + self.assertIsNone( + manifest.get("thumbnail") + ) + reader.close() + builder.close() + signer.close() + ctx.close() + s.close() + + @unittest.skipUnless( + _has_signer_context, + "Signer-on-context not supported", + ) + def test_sign_read_roundtrip(self): + signer = _ctx_make_signer() + ctx = Context(signer=signer) + builder = Builder( + _CTX_MANIFEST_DEF, context=ctx, + ) + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as src, + open(dest, "w+b") as dst, + ): + builder.sign( + format="image/jpeg", + source=src, + dest=dst, + ) + reader = Reader(dest) + data = reader.json() + self.assertIsNotNone(data) + self.assertIn("manifests", data) + reader.close() + builder.close() + ctx.close() + + def test_shared_context_multi_builders(self): + ctx = Context() + signer1 = _ctx_make_signer() + signer2 = _ctx_make_signer() + + b1 = Builder(_CTX_MANIFEST_DEF, context=ctx) + b2 = Builder(_CTX_MANIFEST_DEF, context=ctx) + + with tempfile.TemporaryDirectory() as td: + for i, (builder, signer) in enumerate( + [(b1, signer1), (b2, signer2)] + ): + dest = os.path.join( + td, f"out{i}.jpg" + ) + with ( + open( + DEFAULT_TEST_FILE, "rb" + ) as src, + open(dest, "w+b") as dst, + ): + mb = builder.sign( + signer, "image/jpeg", + src, dst, + ) + self.assertGreater(len(mb), 0) + + b1.close() + b2.close() + signer1.close() + signer2.close() + ctx.close() + + @unittest.skipUnless( + _has_signer_context, + "Signer-on-context not supported", + ) + def test_sign_callback_signer_in_ctx(self): + signer = _ctx_make_callback_signer() + ctx = Context(signer=signer) + builder = Builder( + _CTX_MANIFEST_DEF, context=ctx, + ) + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as src, + open(dest, "w+b") as dst, + ): + mb = builder.sign( + format="image/jpeg", + source=src, + dest=dst, + ) + self.assertGreater(len(mb), 0) + reader = Reader(dest) + data = reader.json() + self.assertIsNotNone(data) + reader.close() + builder.close() + ctx.close() + + +# ── 8. Backward compatibility ─────────────────── + + +class TestBackwardCompat(unittest.TestCase): + + def test_existing_sign_api_positional(self): + signer = _ctx_make_signer() + builder = Builder(_CTX_MANIFEST_DEF) + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with ( + open(DEFAULT_TEST_FILE, "rb") as src, + open(dest, "w+b") as dst, + ): + mb = builder.sign( + signer, "image/jpeg", src, dst, + ) + self.assertGreater(len(mb), 0) + builder.close() + signer.close() + + def test_existing_sign_file_positional(self): + signer = _ctx_make_signer() + builder = Builder(_CTX_MANIFEST_DEF) + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + mb = builder.sign_file( + DEFAULT_TEST_FILE, dest, signer, + ) + self.assertGreater(len(mb), 0) + builder.close() + signer.close() + + def test_sign_format_source_required(self): + builder = Builder(_CTX_MANIFEST_DEF) + signer = _ctx_make_signer() + with self.assertRaises(Error): + builder.sign(signer) + builder.close() + signer.close() + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_unit_tests_threaded.py b/tests/test_unit_tests_threaded.py index 14ef48fe..bd3d2da8 100644 --- a/tests/test_unit_tests_threaded.py +++ b/tests/test_unit_tests_threaded.py @@ -21,8 +21,9 @@ import asyncio import random -from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version -from c2pa.c2pa import Stream +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, _has_signer_context PROJECT_PATH = os.getcwd() FIXTURES_FOLDER = os.path.join(os.path.dirname(__file__), "fixtures") @@ -83,6 +84,28 @@ def read_and_parse(): thread1.join() thread2.join() + def test_stream_read_and_parse_with_context(self): + def read_and_parse(): + ctx = Context() + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file, context=ctx) + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, "C.jpg") + return manifest_store + + # Create two threads + thread1 = threading.Thread(target=read_and_parse) + thread2 = threading.Thread(target=read_and_parse) + + # Start both threads + thread1.start() + thread2.start() + + # Wait for both threads to complete + thread1.join() + thread2.join() + def test_read_all_files(self): """Test reading C2PA metadata from all files in the fixtures/files-for-reading-tests directory""" reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") @@ -537,6 +560,122 @@ def sign_file(filename, thread_id): if errors: self.fail("\n".join(errors)) + def test_sign_all_files_with_context(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") + + # 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 + } + + 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 + + # Get file extension and corresponding MIME type + _, 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: + # Choose manifest based on thread number + 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, context=ctx) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + + # Verify the signed file using context + 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"]] + + # Verify the correct manifest was used + 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) + + # Verify the author is correct + assertions = active_manifest["assertions"] + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual(author_name, expected_author) + break + + output.close() + return None # Success case + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign { + filename} in thread {thread_id}: {str(e)}" + + # Create a thread pool with 6 workers + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + # Get all files from both directories + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + + # Submit all files to the thread pool with thread IDs + future_to_file = { + executor.submit(sign_file, filename, i): (filename, i) + for i, filename in enumerate(all_files) + } + + # Collect results as they complete + 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 any errors occurred, fail the test with all error messages + if errors: + self.fail("\n".join(errors)) + def test_sign_all_files_async(self): """Test signing all files using asyncio with a pool of workers""" signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") @@ -653,6 +792,124 @@ async def run_async_tests(): # Run the async tests asyncio.run(run_async_tests()) + def test_sign_all_files_async_with_context(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") + + # 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 + } + + async def async_sign_file(filename, thread_id): + """Async version of file signing operation with Context""" + if filename in skip_files: + return None + + file_path = os.path.join(signing_dir, filename) + if not os.path.isfile(file_path): + return None + + # Get file extension and corresponding MIME type + _, 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: + # Choose manifest based on thread number + 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, context=ctx) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + + # Verify the signed file using context + 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"]] + + # Verify the correct manifest was used + 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) + + # Verify the author is correct + assertions = active_manifest["assertions"] + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual(author_name, expected_author) + break + + output.close() + return None # Success case + 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(): + # Get all files from both directories + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + + # Create tasks for all files + tasks = [] + for i, filename in enumerate(all_files): + task = asyncio.create_task(async_sign_file(filename, i)) + tasks.append(task) + + # Wait for all tasks to complete and collect results + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Process results + errors = [] + for result in results: + if isinstance(result, Exception): + errors.append(str(result)) + elif result: # Non-None result indicates an error + errors.append(result) + + # If any errors occurred, fail the test with all error messages + if errors: + self.fail("\n".join(errors)) + + # Run the async tests + asyncio.run(run_async_tests()) + def test_parallel_manifest_writing(self): """Test writing different manifests to two files in parallel and verify no data mixing occurs""" output1 = io.BytesIO(bytearray()) From ee5d0475578fed208d1215814b494aaf1a45c352 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 3 Mar 2026 20:58:10 -0800 Subject: [PATCH 02/75] fix: Docs 1 --- docs/context.md | 557 +++++++++++++++++++++-------------------------- docs/faqs.md | 91 ++++---- docs/settings.md | 265 ++++++++++------------ 3 files changed, 414 insertions(+), 499 deletions(-) diff --git a/docs/context.md b/docs/context.md index d4d8ec7f..6dce933f 100644 --- a/docs/context.md +++ b/docs/context.md @@ -6,31 +6,31 @@ Use the `Context` class to configure how `Reader`, `Builder`, and other aspects Context encapsulates SDK configuration: -- **Settings**: Verification options, [`Builder` behavior](#configuring-builder), [`Reader` trust configuration](#configuring-reader), thumbnail configuration, and more. See [Using settings](settings.md) for complete details. -- [**Signer configuration**](#configuring-a-signer): Optional signer credentials and settings that can be stored in the Context for reuse. +- **Settings**: Verification options, [Builder behavior](#configuring-builder), [Reader trust configuration](#configuring-reader), thumbnail configuration, and more. See [Using settings](settings.md) for complete details. +- [**Signer configuration**](#configuring-a-signer): Optional signer credentials that can be stored in the Context for reuse. - **State isolation**: Each `Context` is independent, allowing different configurations to coexist in the same application. ### Why use Context? -`Context` is better than deprecated global/thread-local `Settings` because it: +`Context` is better than the deprecated global `load_settings()` function because it: - **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 thread-local state**: Each `Reader` and `Builder` gets its configuration from the `Context` you pass, avoiding subtle bugs from shared state. +- **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 between them. -- **Improves code clarity**: Reading `Builder(context, manifest)` immediately shows that configuration is being used. +- **Improves code clarity**: Reading `Builder(manifest_json, context=ctx)` immediately shows that configuration is being used. > [!NOTE] -> The deprecated `c2pa::load_settings(data, format)` still works for backward compatibility but you are encouraged to migrate your code to use `Context`. See [Migrating from thread-local Settings](#migrating-from-thread-local-settings). +> 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). ## Creating a Context There are several ways to create a `Context`, depending on your needs: - [Using SDK default settings](#using-sdk-default-settings) -- [From an inline JSON string](#from-an-inline-json-string) +- [From a JSON string](#from-a-json-string) +- [From a dictionary](#from-a-dictionary) - [From a Settings object](#from-a-settings-object) -- [Using ContextBuilder](#using-contextbuilder) ### Using SDK default settings @@ -38,152 +38,131 @@ The simplest approach is using [SDK default settings](settings.md#default-config **When to use:** For quick prototyping, or when you're happy with default behavior (verification enabled, thumbnails enabled at 1024px, and so on). -```cpp -#include "c2pa.hpp" +```py +from c2pa import Context -c2pa::Context context; // Uses SDK defaults +ctx = Context() # Uses SDK defaults ``` -### From an inline JSON string +### From a JSON string -You can pass settings to the constructor directly as a JSON string. +You can create a `Context` directly from a JSON configuration string. **When to use:** For simple configuration that doesn't need to be shared across the codebase, or when hard-coding settings for a specific purpose (for example, a utility script). -```cpp -c2pa::Context context(R"({ - "version": 1, +```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 Settings object +### From a dictionary -You can build a `Settings` object programmatically, then create a `Context` from that. +You can create a `Context` from a Python dictionary. -**When to use:** For configuration that needs runtime logic (such as conditional settings based on environment), or when you want to build settings incrementally. - -```cpp -c2pa::Settings settings; -settings.set("builder.thumbnail.enabled", "false"); -settings.set("verify.verify_after_sign", "true"); -settings.update(R"({ - "builder": { - "claim_generator_info": {"name": "An app", "version": "0.1.0"} - } -})"); +**When to use:** When you want to build configuration programmatically using native Python data structures. -c2pa::Context context(settings); +```py +ctx = Context.from_dict({ + "verify": {"verify_after_sign": True}, + "builder": { + "thumbnail": {"enabled": False}, + "claim_generator_info": {"name": "An app", "version": "0.1.0"} + } +}) ``` -### Using ContextBuilder - -You can combine multiple configuration sources by using `Context::ContextBuilder`. - -Use **ContextBuilder** when you want to: +### From a Settings object -- Load from a file with `with_json_settings_file()`. -- Combine a base `Settings` with environment-specific overrides from a JSON file. -- Apply multiple JSON snippets in a specific order. +You can build a `Settings` object programmatically, then create a `Context` from that. -**Don't use ContextBuilder** if you have a single configuration source. In this case, [direct construction from a Settings object](#from-a-settings-object) using `c2pa::Context context(settings)` is simpler and more readable. +**When to use:** For configuration that needs runtime logic (such as conditional settings based on environment), or when you want to build settings incrementally. -For example: +```py +from c2pa import Settings, Context -```cpp -c2pa::Settings base_settings; -base_settings.set("builder.thumbnail.enabled", "true"); -base_settings.set("builder.thumbnail.long_edge", "1024"); +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"} + } +}) -auto context = c2pa::Context::ContextBuilder() - .with_settings(base_settings) - .with_json(R"({"verify": {"verify_after_sign": true}})") - .with_json_settings_file("config/overrides.json") - .create_context(); +ctx = Context(settings=settings) ``` -> [!IMPORTANT] -> Later configuration overrides earlier configuration. In the example above, if `overrides.json` sets `builder.thumbnail.enabled` to `false`, it will override the `true` value from `base_settings`. - -**ContextBuilder methods** - -| Method | Description | -|--------|-------------| -| `with_settings(settings)` | Apply a `Settings` object. Must be valid (not moved-from). | -| `with_json(json_string)` | Apply settings from a JSON string. Later calls override earlier ones. | -| `with_json_settings_file(path)` | Load and apply settings from a JSON file. Throws `C2paException` if file doesn't exist or is invalid. | -| `create_context()` | Build and return the `Context`. Consumes the builder (it becomes invalid and cannot be reused). | - ## Common configuration patterns ### Development environment with test certificates During development, you often need to trust self-signed or custom CA certificates: -```cpp -// Load your test root CA -std::string test_ca = read_file("test-ca.pem"); - -c2pa::Context dev_context(R"({ - "version": 1, - "trust": { - "user_anchors": ")" + test_ca + R"(" - }, - "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} - } -})"); +```py +# Load your test root CA +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} + } +}) ``` ### Configuration from environment variables Adapt configuration based on the runtime environment: -```cpp -std::string env = std::getenv("ENVIRONMENT") ? std::getenv("ENVIRONMENT") : "dev"; +```py +import os + +env = os.environ.get("ENVIRONMENT", "dev") -c2pa::Settings settings; -if (env == "production") { - settings.update(read_file("config/production.json"), "json"); - settings.set("verify.strict_v1_validation", "true"); -} else { - settings.update(read_file("config/development.json"), "json"); - settings.set("verify.remote_manifest_fetch", "false"); -} +settings = Settings() +if env == "production": + settings.update({"verify": {"strict_v1_validation": True}}) +else: + settings.update({"verify": {"remote_manifest_fetch": False}}) -c2pa::Context context(settings); +ctx = Context(settings=settings) ``` ### Layered configuration -Load base configuration from a file and apply runtime overrides: +Load base configuration and apply runtime overrides: -```cpp -auto context = c2pa::Context::ContextBuilder() - .with_json_settings_file("config/base.json") - .with_json_settings_file("config/" + environment + ".json") - .with_json(R"({ - "builder": { - "claim_generator_info": { - "version": ")" + app_version + R"(" - } - } - })") - .create_context(); +```py +import json + +# Load base configuration from a file +with open("config/base.json", "r") as f: + base_config = json.load(f) + +settings = Settings.from_dict(base_config) + +# Apply environment-specific overrides +settings.update({"builder": {"claim_generator_info": {"version": app_version}}}) + +ctx = Context(settings=settings) ``` -For the full list of settings and defaults, see [Configuring settings](settings.md). +For the full list of settings and defaults, see [Using settings](settings.md). ## Configuring Reader @@ -192,88 +171,67 @@ Use `Context` to control how `Reader` validates manifests and handles remote res - **Verification behavior**: Whether to verify after reading, check trust, and so on. - [**Trust configuration**](#trust-configuration): Which certificates to trust when validating signatures. - [**Network access**](#offline-operation): Whether to fetch remote manifests or OCSP responses. -- **Performance**: Memory thresholds and other core settings. > [!IMPORTANT] -> `Context` is used only at construction. `Reader` copies the configuration it needs internally, so the `Context` object does not need to outlive the `Reader`. This means you can safely use temporary point-in-time contexts; for example, as shown below. +> `Context` is used only at construction. `Reader` copies the configuration it needs internally, so the `Context` object does not need to outlive the `Reader`. -```cpp -c2pa::Reader reader( - c2pa::Context(R"({"verify": {"remote_manifest_fetch": false}})"), - "image.jpg" -); +```py +ctx = Context.from_dict({"verify": {"remote_manifest_fetch": False}}) +reader = Reader("image.jpg", context=ctx) ``` ### Reading from a file -```cpp -// Context that disables remote manifest fetch (for offline environments) -c2pa::Context context(R"({ - "version": 1, - "verify": { - "remote_manifest_fetch": false, - "ocsp_fetch": false - } -})"); +```py +ctx = Context.from_dict({ + "verify": { + "remote_manifest_fetch": False, + "ocsp_fetch": False + } +}) -c2pa::Reader reader(context, "image.jpg"); -std::cout << reader.json() << std::endl; +reader = Reader("image.jpg", context=ctx) +print(reader.json()) ``` ### Reading from a stream -```cpp -std::ifstream stream("image.jpg", std::ios::binary); -c2pa::Reader reader(context, "image/jpeg", stream); - -std::cout << reader.json() << std::endl; +```py +with open("image.jpg", "rb") as stream: + reader = Reader("image/jpeg", stream, context=ctx) + print(reader.json()) ``` ### Trust configuration -Example of trust configuration in a settings file: - -```json -{ - "version": 1, - "trust": { - "user_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCA...\n-----END CERTIFICATE-----", - "trust_config": "1.3.6.1.4.1.311.76.59.1.9\n1.3.6.1.4.1.62558.2.1" - } -} -``` - -**PEM format requirements:** - -- Use literal `\n` characters (as two-character strings) in JSON for line breaks. -- Include the full certificate chain if needed. -- Concatenate multiple certificates into a single string. - -Then load the file in your application as follows: +Example of trust configuration in a settings dictionary: -```cpp -auto context = c2pa::Context::ContextBuilder() - .with_json_settings_file("dev_trust_config.json") - .create_context(); +```py +ctx = Context.from_dict({ + "trust": { + "user_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCA...\n-----END CERTIFICATE-----", + "trust_config": "1.3.6.1.4.1.311.76.59.1.9\n1.3.6.1.4.1.62558.2.1" + } +}) -c2pa::Reader reader(context, "signed_asset.jpg"); +reader = Reader("signed_asset.jpg", context=ctx) ``` ### Full validation To configure full validation, with all verification features enabled: -```cpp -c2pa::Context full_validation_context(R"({ - "verify": { - "verify_after_reading": true, - "verify_trust": true, - "verify_timestamp_trust": true, - "remote_manifest_fetch": true - } -})"); +```py +ctx = Context.from_dict({ + "verify": { + "verify_after_reading": True, + "verify_trust": True, + "verify_timestamp_trust": True, + "remote_manifest_fetch": True + } +}) -c2pa::Reader online_reader(full_validation_context, "asset.jpg"); +reader = Reader("asset.jpg", context=ctx) ``` For more information, see [Settings - Verify](settings.md#verify). @@ -282,20 +240,19 @@ For more information, see [Settings - Verify](settings.md#verify). To configure `Reader` to work with no network access: -```cpp -c2pa::Context offline_context(R"({ - "verify": { - "remote_manifest_fetch": false, - "ocsp_fetch": false - } -})"); +```py +ctx = Context.from_dict({ + "verify": { + "remote_manifest_fetch": False, + "ocsp_fetch": False + } +}) -c2pa::Reader offline_reader(offline_context, "local_asset.jpg"); +reader = Reader("local_asset.jpg", context=ctx) ``` For more information, see [Settings - Offline or air-gapped environments](settings.md#offline-or-air-gapped-environments). - ## Configuring Builder `Builder` uses `Context` to control how to create and sign C2PA manifests. The `Context` affects: @@ -305,209 +262,195 @@ For more information, see [Settings - Offline or air-gapped environments](settin - **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 can be stored in settings for reuse. - +- **Signer configuration** (optional): Credentials can be stored in the context for reuse. > [!IMPORTANT] > The `Context` is used only when constructing the `Builder`. The `Builder` copies the configuration it needs internally, so the `Context` object does not need to outlive the `Builder`. ### Basic use -```cpp -c2pa::Context context(R"({ - "version": 1, - "builder": { - "claim_generator_info": { - "name": "An app", - "version": "0.1.0" - }, - "intent": {"Create": "digitalCapture"} - } -})"); +```py +ctx = Context.from_dict({ + "builder": { + "claim_generator_info": { + "name": "An app", + "version": "0.1.0" + }, + "intent": {"Create": "digitalCapture"} + } +}) -c2pa::Builder builder(context, manifest_json); +builder = Builder(manifest_json, context=ctx) -// Pass signer explicitly at signing time -c2pa::Signer signer("es256", certs, private_key); -builder.sign(source_path, output_path, signer); +# Pass signer explicitly at signing time +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) ``` ### Controlling thumbnail generation -```cpp -// Disable thumbnails for faster processing -c2pa::Context no_thumbnails(R"({ - "builder": { - "claim_generator_info": {"name": "Batch Processor"}, - "thumbnail": {"enabled": false} - } -})"); - -// Or customize thumbnail size and quality for mobile -c2pa::Context mobile_thumbnails(R"({ - "builder": { - "claim_generator_info": {"name": "Mobile App"}, - "thumbnail": { - "enabled": true, - "long_edge": 512, - "quality": "low", - "prefer_smallest_format": true +```py +# Disable thumbnails for faster processing +no_thumbnails_ctx = Context.from_dict({ + "builder": { + "claim_generator_info": {"name": "Batch Processor"}, + "thumbnail": {"enabled": False} } - } -})"); +}) + +# Or customize thumbnail size and quality for mobile +mobile_ctx = Context.from_dict({ + "builder": { + "claim_generator_info": {"name": "Mobile App"}, + "thumbnail": { + "enabled": True, + "long_edge": 512, + "quality": "low", + "prefer_smallest_format": True + } + } +}) ``` ## Configuring a signer -The `signer` field in settings can specify: -- A **local signer** — certificate and key (paths or PEM strings): - - `signer.local.alg` — e.g. `"ps256"`, `"es256"`, `"ed25519"`. - - `signer.local.sign_cert` — certificate file path or PEM string. - - `signer.local.private_key` — key file path or PEM string. - - `signer.local.tsa_url` — optional TSA URL. -- A **remote signer** — A POST endpoint that receives data to sign and returns the signature: - - `signer.remote.url` — signing service URL. - - `signer.remote.alg`, `signer.remote.sign_cert`, `signer.remote.tsa_url`. - -See [SignerSettings object reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signersettings) for the full property reference. +You can configure a signer in two ways: -You can configure a signer: - -- [From JSON Settings](#from-settings) -- [Explicitly in code](#explicit-signer) +- [From Settings (signer-on-context)](#from-settings) +- [Explicit signer passed to sign()](#explicit-signer) ### From Settings -Put signer configuration in your JSON or `Settings`: +Create a `Signer` and pass it to the `Context`. The signer is **consumed** — the `Signer` object becomes invalid after this call and must not be reused. The `Context` takes ownership of the underlying native signer. -```json -{ - "signer": { - "local": { - "alg": "ps256", - "sign_cert": "path/to/cert.pem", - "private_key": "path/to/key.pem", - "tsa_url": "http://timestamp.example.com" - } - } -} -``` +```py +from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg + +# Create a signer +signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" +) +signer = Signer.from_info(signer_info) -Then create a `Context` and use it with `Builder`; for example: +# Create context with signer (signer is consumed) +ctx = Context(settings=settings, signer=signer) +# signer is now invalid and must not be used again -```cpp -c2pa::Context context(settings_json_or_path); -c2pa::Builder builder(context, manifest_json); -// When you call sign(), use a Signer created from your cert/key, -// or the SDK may use the signer from context if the C API supports it. -builder.sign(source_path, dest_path, signer); +# Build and sign — no signer argument needed +builder = Builder(manifest_json, context=ctx) +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign(format="image/jpeg", source=src, dest=dst) ``` -In the C++ API you typically create a `c2pa::Signer` explicitly and pass it to `Builder::sign()`. Settings in the `Context` still control verification, thumbnails, and other builder behavior. +> [!NOTE] +> Signer-on-context requires a compatible version of the native c2pa-c library. If the library does not support this feature, a `C2paError` is raised when passing a `Signer` to `Context`. ### Explicit signer -For full programmatic control, create a `Signer` and pass it to `Builder::sign()`: +For full programmatic control, create a `Signer` and pass it directly to `Builder.sign()`: + +```py +signer = Signer.from_info(signer_info) +builder = Builder(manifest_json, context=ctx) -```cpp -c2pa::Signer signer("es256", certs_pem, private_key_pem, "http://timestamp.digicert.com"); -c2pa::Builder builder(context, manifest_json); -builder.sign(source_path, dest_path, signer); +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) ``` -The `Context` continues to control verification and builder options. The signer is used only for the cryptographic signature. +If both an explicit signer and a context signer are available, the explicit signer always takes precedence: + +```py +# Explicit signer wins over context signer +builder.sign(explicit_signer, "image/jpeg", source, dest) +``` ## Context lifetime and usage -Understand how `Context` works to use it properly. +### Context as a context manager -### Context ownership and lifecycle +`Context` supports the `with` statement for automatic resource cleanup: -- **Non-copyable, moveable**: `Context` can be moved but not copied. After moving, the source `Context` becomes invalid (`is_valid()` returns `false`). -- **Used at construction only**: When you create a `Reader` or `Builder` with a `Context`, the implementation copies the configuration it needs. The `Context` object does not need to outlive the `Reader` or `Builder` objects. -- **Reusable**: You can reuse the same `Context` to create multiple readers and builders. +```py +with Context() as ctx: + reader = Reader("image.jpg", context=ctx) + print(reader.json()) +# Resources are automatically released +``` -```cpp -c2pa::Context context(settings); +### Reusable contexts -// All three use the same configuration -c2pa::Builder builder1(context, manifest1); -c2pa::Builder builder2(context, manifest2); -c2pa::Reader reader(context, "image.jpg"); +You can reuse the same `Context` to create multiple readers and builders: -// Context can go out of scope, readers/builders still work +```py +ctx = Context(settings=settings) + +# All three use the same configuration +builder1 = Builder(manifest1, context=ctx) +builder2 = Builder(manifest2, context=ctx) +reader = Reader("image.jpg", context=ctx) + +# Context can be closed after construction; readers/builders still work ``` ### Multiple contexts for different purposes Use different `Context` objects when you need different settings; for example, for development vs. production, or different trust configurations: -```cpp -c2pa::Context dev_context(dev_settings); -c2pa::Context prod_context(prod_settings); +```py +dev_ctx = Context(settings=dev_settings) +prod_ctx = Context(settings=prod_settings) -// Different builders with different configurations -c2pa::Builder dev_builder(dev_context, manifest); -c2pa::Builder prod_builder(prod_context, manifest); +# Different builders with different configurations +dev_builder = Builder(manifest, context=dev_ctx) +prod_builder = Builder(manifest, context=prod_ctx) ``` -### Move semantics +### ContextProvider protocol -```cpp -c2pa::Context context1(settings); -c2pa::Context context2 = std::move(context1); +The `ContextProvider` protocol allows third-party implementations of custom context providers. Any class that implements `is_valid` and `_c_context` properties satisfies the protocol and can be passed to `Reader` or `Builder` as `context`. -// context1 is now invalid -assert(!context1.is_valid()); +```py +from c2pa import ContextProvider, Context -// context2 is valid and can be used -c2pa::Builder builder(context2, manifest); +# The built-in Context satisfies ContextProvider +ctx = Context() +assert isinstance(ctx, ContextProvider) # True ``` -### Temporary contexts - -Since the context is copied at construction, you can use temporary contexts: - -```cpp -c2pa::Builder builder( - c2pa::Context(R"({"builder": {"thumbnail": {"enabled": false}}})"), - manifest_json -); -// Temporary context destroyed, but builder still has the configuration -``` +## Migrating from load_settings -## Migrating from thread-local Settings - -The legacy function `c2pa::load_settings(data, format)` sets thread-local Settings. -This function is deprecated; use `Context` instead. +The `load_settings()` function is deprecated. Replace it with `Settings` and `Context`: | Aspect | load_settings (legacy) | Context | |--------|------------------------|---------| -| Scope | Global / thread-local | Per Reader/Builder, passed explicitly | -| Multiple configs | Awkward (per-thread) | One context per configuration | +| 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:** -```cpp -// Thread-local settings -std::ifstream config_file("settings.json"); -std::string config((std::istreambuf_iterator(config_file)), std::istreambuf_iterator()); -c2pa::load_settings(config, "json"); -c2pa::Reader reader("image/jpeg", stream); // uses thread-local settings +```py +from c2pa import load_settings, Reader + +load_settings({"builder": {"thumbnail": {"enabled": False}}}) +reader = Reader("image.jpg") # uses global settings ``` **Using current APIs:** -```cpp -c2pa::Context context(settings_json_string); // or Context(Settings(...)) -c2pa::Reader reader(context, "image/jpeg", stream); -``` +```py +from c2pa import Settings, Context, Reader -If you still use `load_settings`, construct `Reader` or `Builder` **without** a context to use the thread-local settings (see [usage.md](usage.md)). Prefer passing a context for new code. +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +ctx = Context(settings=settings) +reader = Reader("image.jpg", context=ctx) +``` ## See also -- [Configuring settings](settings.md) — schema, property reference, and examples. +- [Using settings](settings.md) — schema, property reference, and examples. - [Usage](usage.md) — reading and signing with Reader and Builder. - [CAI settings schema](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/): full schema reference. diff --git a/docs/faqs.md b/docs/faqs.md index 926374c8..88bfc78e 100644 --- a/docs/faqs.md +++ b/docs/faqs.md @@ -1,8 +1,8 @@ # Frequently-asked questions (FAQs) -## When do I use `Reader` vs. `Builder` +## When do I use `Reader` vs. `Builder`? -## Quick reference decision tree +### Quick reference decision tree ```mermaid flowchart TD @@ -12,8 +12,8 @@ flowchart TD Q2 -->|No| USE_R["Use Reader alone (inspect/extract only)"] Q2 -->|Yes| USE_BR[Use both Reader + Builder] USE_BR --> Q3{What to keep from the existing manifest?} - Q3 -->|Everything| P1["add_ingredient() with original asset or archive path"] - Q3 -->|Some parts| P2["1. Read: reader.json() + get_resource() 2. Filter: pick ingredients & actions to keep 3. Build: new Builder with filtered JSON 4. Transfer: .add_resource for kept binaries 5. Sign: builder.sign()"] + Q3 -->|Everything| P1["add_ingredient() with original asset"] + Q3 -->|Some parts| P2["1. Read: reader.json() + resource_to_stream() 2. Filter: pick ingredients & actions to keep 3. Build: new Builder with filtered JSON 4. Transfer: add_resource() for kept binaries 5. Sign: builder.sign()"] Q3 -->|Nothing| P3["New Builder alone (fresh manifest, no prior provenance)"] ``` @@ -27,10 +27,10 @@ flowchart TD - Checking trust status and validation results - Inspecting ingredient chains -```cpp -c2pa::Reader reader(context, "image.jpg"); -auto json = reader.json(); // inspect the manifest -reader.get_resource(thumb_id, stream); // extract a thumbnail +```py +reader = Reader("image.jpg", context=ctx) +json_data = reader.json() # inspect the manifest +reader.resource_to_stream(thumb_uri, stream) # extract a thumbnail ``` The `Reader` is read-only. It never modifies the source asset. @@ -43,13 +43,15 @@ The `Reader` is read-only. It never modifies the source asset. - Adding C2PA credentials to an unsigned asset - Creating a manifest with all content defined from scratch -```cpp -c2pa::Builder builder(context, manifest_json); -builder.add_ingredient(ingredient_json, source_path); // add source material -builder.sign(source_path, output_path, signer); +```py +builder = Builder(manifest_json, context=ctx) +with open("ingredient.jpg", "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) ``` -Every call to the `Builder` constructor or `Builder::from_archive()` creates a new `Builder`. There is no way to modify an existing signed manifest directly. +Every call to the `Builder` constructor or `Builder.from_archive()` creates a new `Builder`. There is no way to modify an existing signed manifest directly. ### When to use both `Reader` and `Builder` together @@ -59,57 +61,60 @@ Every call to the `Builder` constructor or `Builder::from_archive()` creates a n - Dropping specific assertions while keeping others - Filtering actions (keeping some, removing others) - Merging ingredients from multiple signed assets or archives -- Extracting content from an ingredients catalog - Re-signing with different settings while keeping some original content -```cpp -// Read existing (does not modify the asset) -c2pa::Reader reader(context, "signed.jpg"); -auto parsed = json::parse(reader.json()); +```py +import json -// Filter what to keep -auto kept = filter(parsed); // application-specific filtering logic +# Read existing (does not modify the asset) +reader = Reader("signed.jpg", context=ctx) +parsed = json.loads(reader.json()) -// Create a new Builder with only the filtered content -c2pa::Builder builder(context, kept.dump()); -// ... transfer resources ... -builder.sign(source, output, signer); +# Filter what to keep (application-specific logic) +kept = filter_manifest(parsed) + +# Create a new Builder with only the filtered content +builder = Builder(json.dumps(kept), context=ctx) +# ... transfer resources ... +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) ``` ## How should I add ingredients? -There are two ways: using `add_ingredient()` and injecting ingredient JSON via `with_definition()`. The table below summarizes these options. +There are two ways: using `add_ingredient()` (or `add_ingredient_file()`) and injecting ingredient JSON directly into the manifest definition. | Approach | What it does | When to use | | --- | --- | --- | -| `add_ingredient(json, path)` | Reads the source (a signed asset, an unsigned file, or a `.c2pa` archive), extracts its manifest store automatically, generates a thumbnail | Adding an ingredient where the library should handle extraction. Works with ingredient catalog archives too: pass the archive path and the library extracts the manifest data | -| Inject via `with_definition()` + `add_resource()` | Accepts the ingredient JSON and all binary resources provided manually | Reconstructing from a reader or merging from multiple readers, where the data has already been extracted | - +| `add_ingredient(json, format, stream)` or `add_ingredient_file(json, path)` | Reads the source (a signed asset, an unsigned file, or a `.c2pa` archive), extracts its manifest store automatically, generates a thumbnail | Adding an ingredient where the library should handle extraction | +| Inject via manifest JSON + `add_resource()` | Accepts the ingredient JSON and all binary resources provided manually | Reconstructing from a reader or merging from multiple readers, where the data has already been extracted | ## When to use archives There are two distinct archive concepts: -- **Builder archives (working store archives)** (`to_archive` / `from_archive` / `with_archive`) serialize the full `Builder` state (manifest definition, resources, ingredients) so it can be resumed or signed later, possibly on a different machine or in a different process. The archive is not yet signed. Use builder archives when: - - Signing must happen on a different machine (e.g., an HSM server) - - Checkpointing work-in-progress before signing - - Transmitting a `Builder` state across a network boundary +- **Builder archives (working store archives)** (`to_archive()` / `from_archive()`) serialize the full `Builder` state (manifest definition, resources, ingredients) so it can be resumed or signed later, possibly on a different machine or in a different process. The archive is not yet signed. Use builder archives when: + - Signing must happen on a different machine (e.g., an HSM server) + - Checkpointing work-in-progress before signing + - Transmitting a `Builder` state across a network boundary - **Ingredient archives** contain the manifest store data (`.c2pa` binary) from ingredients that were added to a `Builder`. When a signed asset is added as an ingredient via `add_ingredient()`, the library extracts and stores its manifest store as `manifest_data` within the ingredient record. When the `Builder` is then serialized via `to_archive()`, these ingredient manifest stores are included. Use ingredient archives when: - - - Building an ingredients catalog for pick-and-choose workflows - - Preserving provenance history from source assets - - Transferring ingredient data between `Reader` and `Builder` + - Building an ingredients catalog for pick-and-choose workflows + - Preserving provenance history from source assets + - Transferring ingredient data between `Reader` and `Builder` See also [Working stores](https://opensource.contentauthenticity.org/docs/rust-sdk/docs/working-stores). -Key consideration for builder archives: `from_archive()` creates a new `Builder` with **default** context settings. If specific settings are needed (e.g., thumbnails disabled), use `with_archive()` on a `Builder` that already has the desired context: +Key consideration for builder archives: `from_archive()` creates a new `Builder` with **default** context settings. If specific settings are needed (e.g., thumbnails disabled), pass a `context` to `from_archive()`: + +```py +import io -```cpp -// Preserves the caller's context settings -c2pa::Builder builder(my_context); -builder.with_archive(archive_stream); -builder.sign(source, output, signer); +# Preserves the caller's context settings +archive_stream = io.BytesIO(archive_data) +builder = Builder.from_archive(archive_stream, context=ctx) +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) ``` ## Can a manifest be modified in place? @@ -118,4 +123,4 @@ builder.sign(source, output, signer); ## What happens to the provenance chain when rebuilding a working store? -When creating a new manifest, the chain is preserved once the original asset is added as an ingredient. The ingredient carries the original's manifest data, so validators can trace the full history. If the original is not added as an ingredient, the provenance chain is broken: the new manifest has no link to the original. This might be intentional (starting fresh) or a mistake (losing provenance). \ No newline at end of file +When creating a new manifest, the chain is preserved once the original asset is added as an ingredient. The ingredient carries the original's manifest data, so validators can trace the full history. If the original is not added as an ingredient, the provenance chain is broken: the new manifest has no link to the original. This might be intentional (starting fresh) or a mistake (losing provenance). diff --git a/docs/settings.md b/docs/settings.md index c3611f09..715ca483 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -3,13 +3,13 @@ You can configure SDK settings using a JSON format that controls many aspects of the library's behavior. The settings JSON format is the same across all languages in the SDK (Rust, C/C++, Python, and so on). -This document describes how to use settings in C++. The Settings schema is the same as the [Rust library](https://github.com/contentauth/c2pa-rs); for the complete JSON schema, see the [Settings reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/). +This document describes how to use settings in Python. The Settings schema is the same as the [Rust library](https://github.com/contentauth/c2pa-rs); for the complete JSON schema, see the [Settings reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/). ## Using settings with Context -The recommended approach is to pass settings to a `Context` object and then use the `Context` with `Reader` and `Builder`. This gives you explicit, isolated configuration with no global or thread-local state. For details on creating and using contexts, see [Using Context to configure the SDK](context.md). +The recommended approach is to pass settings to a `Context` object and then use the `Context` with `Reader` and `Builder`. This gives you explicit, isolated configuration with no global state. For details on creating and using contexts, see [Using Context to configure the SDK](context.md). -**Legacy approach:** The deprecated `c2pa::load_settings(data, format)` sets thread-local settings. Don't use that approach; instead pass a `Context` (with settings) to `Reader` and `Builder`. See [Using Context with Reader](context.md#using-context-with-reader) and [Using Context with Builder](context.md#using-context-with-builder). +**Legacy approach:** The deprecated `load_settings()` function sets global settings. Don't use that approach; instead pass a `Context` (with settings) to `Reader` and `Builder`. See [Using Context with Reader](context.md#configuring-reader) and [Using Context with Builder](context.md#configuring-builder). ## Settings API @@ -18,17 +18,49 @@ Create and configure settings: | Method | Description | |--------|-------------| | `Settings()` | Create default settings with SDK defaults. | -| `Settings(data, format)` | Parse settings from a string. `format` is `"json"` or `"toml"`. Throws `C2paException` on parse error. | -| `set(path, json_value)` | Set a single value by dot-separated path (e.g. `"verify.verify_after_sign"`). Value must be JSON-encoded. Returns `*this` for chaining. Use this for programmatic configuration. | -| `update(data)` | Merge JSON configuration into existing settings (same as `update(data, "json")`). Later keys override earlier ones. Use this to apply configuration files or JSON strings. | -| `update(data, format)` | Merge configuration from a string; `format` is `"json"` or `"toml"`. | -| `is_valid()` | Returns `true` if the object holds a valid handle (e.g. not moved-from). | +| `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 (e.g. `"verify.verify_after_sign"`). Value must be a string. Returns `self` for chaining. Use this for programmatic configuration. | +| `update(data, format="json")` | Merge JSON configuration into existing settings. `data` can be a JSON string or a dict. Later keys override earlier ones. Use this to apply configuration files or JSON strings. Only `"json"` format is supported. | +| `settings["path"] = "value"` | Dict-like setter. Equivalent to `set(path, value)`. | +| `is_valid` | Property that returns `True` if the object holds valid resources (not closed). | +| `close()` | Release native resources. Called automatically when used as a context manager. | **Important notes:** -- Settings are **not copyable**; they are **moveable**. After moving, the source's `is_valid()` is `false`. - The `set()` and `update()` methods can be chained for sequential configuration. - When using multiple configuration methods, later calls override earlier ones (last wins). +- Use the `with` statement for automatic resource cleanup. +- Only JSON format is supported for settings in the Python SDK. + +```py +from c2pa import Settings + +# Create with defaults +settings = Settings() + +# Set individual values by dot-notation path +settings.set("builder.thumbnail.enabled", "false") + +# Method chaining +settings.set("builder.thumbnail.enabled", "false").set("verify.verify_after_sign", "true") + +# Dict-like access +settings["builder.thumbnail.enabled"] = "false" + +# Create from JSON string +settings = Settings.from_json('{"builder": {"thumbnail": {"enabled": false}}}') + +# Create from a dictionary +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) + +# Merge additional configuration +settings.update({"verify": {"remote_manifest_fetch": True}}) + +# Use as a context manager for automatic cleanup +with Settings() as settings: + settings.set("builder.thumbnail.enabled", "false") +``` ## Overview of the Settings structure @@ -49,34 +81,41 @@ The Settings JSON has this top-level structure: ### Settings format -Settings can be provided in **JSON** or **TOML**. Use `Settings(data, format)` with `"json"` or `"toml"`, or pass JSON to `Context(json_string)` or `ContextBuilder::with_json()`. JSON is preferred for settings in the C++ SDK. +Settings are provided in **JSON** only. Pass JSON strings to `Settings.from_json()` or dictionaries to `Settings.from_dict()`. + +```py +# From JSON string +settings = Settings.from_json('{"verify": {"verify_after_sign": true}}') -```cpp -// JSON -c2pa::Settings settings(R"({"verify": {"verify_after_sign": true}})", "json"); +# From dict +settings = Settings.from_dict({"verify": {"verify_after_sign": True}}) -// TOML -c2pa::Settings settings(R"( - [verify] - verify_after_sign = true -)", "toml"); +# Context from JSON string +ctx = Context.from_json('{"verify": {"verify_after_sign": true}}') -// Context from JSON string -c2pa::Context context(R"({"verify": {"verify_after_sign": true}})"); +# Context from dict +ctx = Context.from_dict({"verify": {"verify_after_sign": True}}) ``` -To load from a file, read the file contents into a string and pass to `Settings` or use `Context::ContextBuilder::with_json_settings_file(path)`. +To load from a file, read the file contents and pass them to `Settings.from_json()`: + +```py +import json + +with open("config/settings.json", "r") as f: + settings = Settings.from_json(f.read()) +``` ## Default configuration -The settings JSON schema—including the complete default configuration with all properties and their default values—is shared with all languages in the SDK: +The settings JSON schema — including the complete default configuration with all properties and their default values — is shared with all languages in the SDK: ```json { "version": 1, "builder": { "claim_generator_info": null, - "created_assertion_labels": null, + "created_assertion_labels": null, "certificate_status_fetch": null, "certificate_status_should_override": null, "generate_c2pa_archive": true, @@ -105,7 +144,7 @@ The settings JSON schema—including the complete default configuration with "format": null, "prefer_smallest_format": true, "quality": "medium" - }, + } }, "cawg_trust": { "verify_trust_list": true, @@ -149,20 +188,20 @@ For a complete reference to all the Settings properties, see the [SDK object ref | 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](https://contentauth.github.io/c2pa-c/da/db7/classc2pa_1_1Builder.html). | +| [`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](https://contentauth.github.io/c2pa-c/d3/da1/classc2pa_1_1Signer.html). | +| [`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). | The top-level `version` property must be `1`. All other properties are optional. -For Boolean values, use JSON Booleans `true` and `false`, not the strings `"true"` and `"false"`. +For Boolean values, use JSON Booleans `true` and `false` in JSON strings, or Python `True` and `False` when using `from_dict()` or `update()` with a dict. > [!IMPORTANT] -> If you don't specify a value for a property, the SDK uses the default value. If you specify a value of `null`, the property is explicitly set to `null`, not the default. This distinction is important when you want to override a default behavior. +> If you don't specify a value for a property, 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. ### Trust configuration @@ -170,7 +209,6 @@ The [`trust` properties](https://opensource.contentauthenticity.org/docs/manifes - Using `user_anchors`: recommended for development - Using `allowed_list` (bypass chain validation) -- For team development, you can load trust configuration from a file using `ContextBuilder`; see [Using Context to configure the SDK](context.md#using-contextbuilder) for details. | Property | Type | Description | Default | |----------|------|-------------|---------| @@ -186,21 +224,17 @@ When using self-signed certificates or custom certificate authorities during dev For development, you can add your test root CA to the trusted anchors without replacing the SDK's default trust store. For example: -```cpp -// Read your test root CA certificate -std::string test_root_ca = R"(-----BEGIN CERTIFICATE----- -MIICEzCCAcWgAwIBAgIUW4fUnS38162x10PCnB8qFsrQuZgwBQYDK2VwMHcxCzAJ -... ------END CERTIFICATE-----)"; +```py +with open("test-ca.pem", "r") as f: + test_root_ca = f.read() -c2pa::Context context(R"({ - "version": 1, +ctx = Context.from_dict({ "trust": { - "user_anchors": ")" + test_root_ca + R"(" + "user_anchors": test_root_ca } -})"); +}) -c2pa::Reader reader(context, "signed_asset.jpg"); +reader = Reader("signed_asset.jpg", context=ctx) ``` #### Using `allowed_list` @@ -208,20 +242,19 @@ c2pa::Reader reader(context, "signed_asset.jpg"); To bypass chain validation, for quick testing, explicitly allow a specific certificate without validating the chain. For example: -```cpp -// Read your test signing certificate -std::string test_cert = read_file("test_cert.pem"); +```py +with open("test_cert.pem", "r") as f: + test_cert = f.read() -c2pa::Settings settings; -settings.update(R"({ - "version": 1, +settings = Settings() +settings.update({ "trust": { - "allowed_list": ")" + test_cert + R"(" + "allowed_list": test_cert } -})"); +}) -c2pa::Context context(settings); -c2pa::Reader reader(context, "signed_asset.jpg"); +ctx = Context(settings=settings) +reader = Reader("signed_asset.jpg", context=ctx) ``` ### CAWG trust configuration @@ -265,50 +298,48 @@ By default, the following `verify` properties are `true`, which enables verifica Set `remote_manifest_fetch` and `ocsp_fetch` to `false` to disable network-dependent verification features: -```cpp -c2pa::Context context(R"({ - "version": 1, +```py +ctx = Context.from_dict({ "verify": { - "remote_manifest_fetch": false, - "ocsp_fetch": false + "remote_manifest_fetch": False, + "ocsp_fetch": False } -})"); +}) -c2pa::Reader reader(context, "signed_asset.jpg"); +reader = Reader("signed_asset.jpg", context=ctx) ``` -See also [Using Context with Reader](context.md#using-context-with-reader). +See also [Using Context with Reader](context.md#configuring-reader). #### Fast development iteration During active development, you can disable verification for faster iteration: -```cpp -// WARNING: Only use during development, not in production! -c2pa::Settings dev_settings; -dev_settings.set("verify.verify_after_reading", "false"); -dev_settings.set("verify.verify_after_sign", "false"); +```py +# WARNING: Only use during development, not in production! +settings = Settings() +settings.set("verify.verify_after_reading", "false") +settings.set("verify.verify_after_sign", "false") -c2pa::Context dev_context(dev_settings); +dev_ctx = Context(settings=settings) ``` #### Strict validation For certification or compliance testing, enable strict validation: -```cpp -c2pa::Context context(R"({ - "version": 1, +```py +ctx = Context.from_dict({ "verify": { - "strict_v1_validation": true, - "ocsp_fetch": true, - "verify_trust": true, - "verify_timestamp_trust": true + "strict_v1_validation": True, + "ocsp_fetch": True, + "verify_trust": True, + "verify_timestamp_trust": True } -})"); +}) -c2pa::Reader reader(context, "asset_to_validate.jpg"); -auto validation_result = reader.json(); +reader = Reader("asset_to_validate.jpg", context=ctx) +validation_result = reader.json() ``` ### Builder @@ -326,9 +357,8 @@ The `claim_generator_info` object identifies your application in the C2PA manife **Example:** -```cpp -c2pa::Context context(R"({ - "version": 1, +```py +ctx = Context.from_dict({ "builder": { "claim_generator_info": { "name": "My Photo Editor", @@ -336,7 +366,7 @@ c2pa::Context context(R"({ "operating_system": "auto" } } -})"); +}) ``` #### Thumbnail settings @@ -367,26 +397,24 @@ You can use `Context` to set `Builder` intent for different workflows. For example, for original digital capture (photos from camera): -```cpp -c2pa::Context camera_context(R"({ - "version": 1, +```py +camera_ctx = Context.from_dict({ "builder": { "intent": {"Create": "digitalCapture"}, "claim_generator_info": {"name": "Camera App", "version": "1.0"} } -})"); +}) ``` Or for editing existing content: -```cpp -c2pa::Context editor_context(R"({ - "version": 1, +```py +editor_ctx = Context.from_dict({ "builder": { - "intent": {"Edit": null}, + "intent": {"Edit": None}, "claim_generator_info": {"name": "Photo Editor", "version": "2.0"} } -})"); +}) ``` ### Signer @@ -394,57 +422,18 @@ c2pa::Context editor_context(R"({ The [`signer` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signersettings) configure the primary C2PA signer configuration. Set it to `null` if you provide the signer at runtime, or configure as either a **local** or **remote** signer in settings. > [!NOTE] -> While you can configure the signer in settings, the typical approach is to pass a `Signer` object directly to the `Builder.sign()` method. Use settings-based signing when you need the same signing configuration across multiple operations or when loading configuration from files. +> The typical approach in Python is to create a `Signer` object with `Signer.from_info()` and pass it directly to `Builder.sign()`. Alternatively, pass a `Signer` to `Context` for the signer-on-context pattern. See [Configuring a signer](context.md#configuring-a-signer) for details. #### Local signer Use a local signer when you have direct access to the private key and certificate. For information on all `signer.local` properties, see [signer.local](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signerlocal) in the SDK object reference. -**Example: Local signer with ES256** - -```cpp -std::string config = R"({ - "version": 1, - "signer": { - "local": { - "alg": "es256", - "sign_cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", - "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", - "tsa_url": "http://timestamp.digicert.com" - } - } -})"; - -c2pa::Context context(config); -c2pa::Builder builder(context, manifest_json); -// Signer is already configured in context -builder.sign(source_path, dest_path); -``` - #### Remote signer Use a remote signer when the private key is stored on a secure signing service (HSM, cloud KMS, and so on). For information on all `signer.remote` properties, see [signer.remote](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signerremote) in the SDK object reference. -The remote signing service receives a POST request with the data to sign and must return the signature in the expected format. - -For example: - -```cpp -c2pa::Context context(R"({ - "version": 1, - "signer": { - "remote": { - "url": "https://signing-service.example.com/sign", - "alg": "ps256", - "sign_cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", - "tsa_url": "http://timestamp.digicert.com" - } - } -})"); -``` - ### CAWG X.509 signer configuration The `cawg_x509_signer` property specifies configuration for identity assertions. This has the same structure as `signer` (can be local or remote). @@ -454,28 +443,6 @@ The `cawg_x509_signer` property specifies configuration for identity assertions. - Main claim signature comes from `signer` - Identity assertions are signed with `cawg_x509_signer` -**Example: Dual signer configuration** - -```cpp -c2pa::Context context(R"({ - "version": 1, - "signer": { - "local": { - "alg": "es256", - "sign_cert": "...", - "private_key": "..." - } - }, - "cawg_x509_signer": { - "local": { - "alg": "ps256", - "sign_cert": "...", - "private_key": "..." - } - } -})"); -``` - For additional JSON configuration examples (minimal configuration, local/remote signer, development/production configurations), see the [Rust SDK settings examples](https://github.com/contentauth/c2pa-rs/blob/main/docs/settings.md#examples). ## See also From 7d9f472b5cb98900ba26219739809b80581d372c Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 3 Mar 2026 21:18:10 -0800 Subject: [PATCH 03/75] fix: WIP --- docs/context.md | 132 ++++ docs/faqs.md | 4 +- docs/tbd_selective-manifests.md | 1098 ++++++++++++++++++++++++++++ docs/working-stores.md | 648 +++++++++++++++++ tests/test_docs.py | 1198 +++++++++++++++++++++++++++++++ 5 files changed, 3078 insertions(+), 2 deletions(-) create mode 100644 docs/tbd_selective-manifests.md create mode 100644 docs/working-stores.md create mode 100644 tests/test_docs.py diff --git a/docs/context.md b/docs/context.md index 6dce933f..ccc84145 100644 --- a/docs/context.md +++ b/docs/context.md @@ -20,6 +20,138 @@ Context encapsulates SDK configuration: - **Simplifies testing**: Create isolated configurations for tests without worrying about cleanup or interference between them. - **Improves code clarity**: Reading `Builder(manifest_json, context=ctx)` immediately shows that configuration is being used. +### Class diagram + +This diagram shows the public classes in the SDK and their relationships. + +```mermaid +classDiagram + direction LR + + class Settings { + +from_json(json_str) Settings$ + +from_dict(config) Settings$ + +set(path, value) Settings + +update(data, format) Settings + +close() + +is_valid bool + } + + class ContextProvider { + <> + +is_valid bool + } + + 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, context) 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) + +sign(signer, 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 : satisfies + 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 : optional context= + Context --> Builder : optional context= + Signer --> Builder : sign(signer) + C2paBuilderIntent --> Builder : set_intent + C2paDigitalSourceType --> Builder : set_intent + C2paError --> C2paError_Subtypes : subclasses +``` + > [!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). diff --git a/docs/faqs.md b/docs/faqs.md index 88bfc78e..6ec055c0 100644 --- a/docs/faqs.md +++ b/docs/faqs.md @@ -82,11 +82,11 @@ with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: ## How should I add ingredients? -There are two ways: using `add_ingredient()` (or `add_ingredient_file()`) and injecting ingredient JSON directly into the manifest definition. +There are two ways: using `add_ingredient()` (or `add_ingredient_from_file_path()`) and injecting ingredient JSON directly into the manifest definition. | Approach | What it does | When to use | | --- | --- | --- | -| `add_ingredient(json, format, stream)` or `add_ingredient_file(json, path)` | Reads the source (a signed asset, an unsigned file, or a `.c2pa` archive), extracts its manifest store automatically, generates a thumbnail | Adding an ingredient where the library should handle extraction | +| `add_ingredient(json, format, stream)` or `add_ingredient_from_file_path(json, format, path)` | Reads the source (a signed asset, an unsigned file, or a `.c2pa` archive), extracts its manifest store automatically, generates a thumbnail | Adding an ingredient where the library should handle extraction | | Inject via manifest JSON + `add_resource()` | Accepts the ingredient JSON and all binary resources provided manually | Reconstructing from a reader or merging from multiple readers, where the data has already been extracted | ## When to use archives diff --git a/docs/tbd_selective-manifests.md b/docs/tbd_selective-manifests.md new file mode 100644 index 00000000..b22f4d0a --- /dev/null +++ b/docs/tbd_selective-manifests.md @@ -0,0 +1,1098 @@ +# 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` to extract the manifest store JSON and any binary resources (thumbnails, manifest data). The source asset is never modified. + +```cpp +c2pa::Context context; +c2pa::Reader reader(context, "image/jpeg", source_stream); + +// Get the full manifest store as JSON +std::string store_json = reader.json(); +auto parsed = json::parse(store_json); + +// Identify the active manifest, which is the current/latest manifest +std::string active = parsed["active_manifest"]; +auto manifest = parsed["manifests"][active]; + +// Access specific parts +auto ingredients = manifest["ingredients"]; +auto assertions = manifest["assertions"]; +auto thumbnail_id = manifest["thumbnail"]["identifier"]; +``` + +### Extracting binary resources + +The JSON returned by `reader.json()` only contains string identifiers (JUMBF URIs) for binary data like thumbnails and ingredient manifest stores. Extract the actual binary content by using `get_resource()`: + +```cpp +// Extract a thumbnail to a stream +std::stringstream thumb_stream(std::ios::in | std::ios::out | std::ios::binary); +reader.get_resource(thumbnail_id, thumb_stream); + +// Or extract to a file +reader.get_resource(thumbnail_id, fs::path("thumbnail.jpg")); +``` + +## Filtering into a new Builder + +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:** For each kept ingredient, call `reader.get_resource(id, stream)` for any `thumbnail` or `manifest_data` it contains, then `builder.add_resource(id, stream)` with the same identifier. + +### Keep only specific ingredients + +```cpp +c2pa::Context context; +c2pa::Reader reader(context, "image/jpeg", source_stream); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto ingredients = parsed["manifests"][active]["ingredients"]; + +// Filter: keep only ingredients with a specific relationship +json kept_ingredients = json::array(); +for (auto& ingredient : ingredients) { + if (ingredient["relationship"] == "parentOf") { + kept_ingredients.push_back(ingredient); + } +} + +// Create a new Builder with only the kept ingredients +json new_manifest = json::parse(base_manifest_json); +new_manifest["ingredients"] = kept_ingredients; + +c2pa::Builder builder(context, new_manifest.dump()); + +// Transfer binary resources for kept ingredients (see note above) +for (auto& ingredient : kept_ingredients) { + if (ingredient.contains("thumbnail")) { + std::string id = ingredient["thumbnail"]["identifier"]; + std::stringstream s(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, s); + s.seekg(0); + builder.add_resource(id, s); + } + if (ingredient.contains("manifest_data")) { + std::string id = ingredient["manifest_data"]["identifier"]; + std::stringstream s(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, s); + s.seekg(0); + builder.add_resource(id, s); + } +} + +// Sign the new Builder into an output asset +builder.sign(source_path, output_path, signer); +``` + +### Keep only specific assertions + +```cpp +auto assertions = parsed["manifests"][active]["assertions"]; + +json kept_assertions = json::array(); +for (auto& assertion : assertions) { + // Keep training-mining assertions, filter out everything else + if (assertion["label"] == "c2pa.training-mining") { + kept_assertions.push_back(assertion); + } +} + +json new_manifest = json::parse(R"({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}] +})"); +new_manifest["assertions"] = kept_assertions; + +// Create a new Builder with only the filtered assertions +c2pa::Builder builder(context, new_manifest.dump()); +builder.sign(source_path, output_path, signer); +``` + +### 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 +``` + + + +```cpp +// Create a new Builder with a new definition +c2pa::Builder builder(context); +builder.with_definition(R"({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [] +})"); + +// 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 into this new manifest. +builder.add_ingredient(R"({"title": "original.jpg", "relationship": "parentOf"})", + original_signed_path); +builder.sign(source_path, output_path, signer); +``` + +## 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. + +```cpp +builder.add_action(R"({ + "action": "c2pa.color_adjustments", + "parameters": { "name": "brightnesscontrast" } +})"); + +builder.add_action(R"({ + "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. + +```cpp +c2pa::Context context; + +auto manifest_json = R"( +{ + "claim_generator_info": [{ "name": "an-application", "version": "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"] + } + } + ] + } + } + ] +} +)"; + +c2pa::Builder builder(context, manifest_json); + +// The label on the ingredient matches the value in ingredientIds +auto ingredient_json = R"( +{ + "title": "photo.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3" +} +)"; +builder.add_ingredient(ingredient_json, photo_path); + +builder.sign(source_path, output_path, signer); +``` + +##### 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. + +```cpp +auto manifest_json = R"( +{ + "claim_generator_info": [{ "name": "an-application", "version": "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"] + } + } + ] + } + } + ] +} +)"; + +c2pa::Builder builder(context, manifest_json); + +// parentOf ingredient linked to c2pa.opened +builder.add_ingredient(R"({ + "title": "original.jpg", + "format": "image/jpeg", + "relationship": "parentOf", + "label": "c2pa.ingredient.v3_1" +})", original_path); + +// componentOf ingredient linked to c2pa.placed +builder.add_ingredient(R"({ + "title": "overlay.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3_2" +})", overlay_path); + +builder.sign(source_path, output_path, signer); +``` + +#### Linking with `instance_id` + +When no `label` is set on an ingredient, the SDK matches `ingredientIds` against `instance_id`. + +```cpp +c2pa::Context context; + +// instance_id is used as the linking identifier and must be unique +std::string instance_id = "xmp:iid:939a4c48-0dff-44ec-8f95-61f52b11618f"; + +json manifest_json = { + {"claim_generator_info", json::array({{{"name", "an-application"}, {"version", "1.0"}}})}, + {"assertions", json::array({ + { + {"label", "c2pa.actions"}, + {"data", { + {"actions", json::array({ + { + {"action", "c2pa.opened"}, + {"parameters", { + {"ingredientIds", json::array({instance_id})} + }} + } + })} + }} + } + })} +}; + +c2pa::Builder builder(context, manifest_json.dump()); + +// No label set: instance_id is used as the linking key +json ingredient = { + {"title", "source_photo.jpg"}, + {"relationship", "parentOf"}, + {"instance_id", instance_id} +}; +builder.add_ingredient(ingredient.dump(), source_photo_path); + +builder.sign(source_path, output_path, signer); +``` + +> [!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: + +```cpp +auto reader = c2pa::Reader(context, signed_path); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto manifest = parsed["manifests"][active]; + +// Build a map: label -> ingredient +std::map label_to_ingredient; +for (auto& ing : manifest["ingredients"]) { + label_to_ingredient[ing["label"]] = ing; +} + +// Match each action to its ingredients by extracting labels from URLs +for (auto& assertion : manifest["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + for (auto& action : assertion["data"]["actions"]) { + if (action.contains("parameters") && + action["parameters"].contains("ingredients")) { + for (auto& ref : action["parameters"]["ingredients"]) { + std::string url = ref["url"]; + std::string label = url.substr(url.rfind('/') + 1); + auto& matched = label_to_ingredient[label]; + // Now the ingredient is available + } + } + } + } +} +``` + +#### 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 `read_ingredient_file()` or 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](https://opensource.contentauthenticity.org/docs/rust-sdk/docs/working-stores). + +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()` or `builder.with_archive()`. + +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 + 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)) + SEL --> FB + FB -->|sign| OUT[Signed Output Asset] + + style A3 fill:#eee,stroke:#999 + style X fill:#f99,stroke:#c00 +``` + + + +```cpp +// Read from a catalog of archived ingredients +c2pa::Context archive_ctx; // Add settings if needed, e.g. verify options + +// Open one archive from the catalog +archive_stream.seekg(0); +c2pa::Reader reader(archive_ctx, "application/c2pa", archive_stream); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto available_ingredients = parsed["manifests"][active]["ingredients"]; + +// Pick only the needed ingredients +json selected = json::array(); +for (auto& ingredient : available_ingredients) { + if (ingredient["title"] == "photo_1.jpg" || ingredient["title"] == "logo.png") { + selected.push_back(ingredient); + } +} + +// Create a new Builder with selected ingredients +json manifest = json::parse(R"({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}] +})"); +manifest["ingredients"] = selected; +c2pa::Builder builder(context, manifest.dump()); + +// Transfer binary resources for selected ingredients +for (auto& ingredient : selected) { + if (ingredient.contains("thumbnail")) { + std::string id = ingredient["thumbnail"]["identifier"]; + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, stream); + stream.seekg(0); + builder.add_resource(id, stream); + } + if (ingredient.contains("manifest_data")) { + std::string id = ingredient["manifest_data"]["identifier"]; + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, stream); + stream.seekg(0); + builder.add_resource(id, stream); + } +} + +builder.sign(source_path, output_path, signer); +``` + +### 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: + +```cpp +// Override title, relationship, and set a custom instance_id for tracking +json ingredient_override = { + {"title", "my-custom-title.jpg"}, + {"relationship", "parentOf"}, + {"instance_id", "my-tracking-id:asset-example-id"} +}; +builder.add_ingredient(ingredient_override.dump(), signed_asset_path); +``` + +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. + +```cpp +auto manifest_json = R"( +{ + "claim_generator_info": [{ "name": "an-application", "version": "1.0" }], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/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: + +```cpp +for (auto& action : actions) { + if (action.contains("parameters") && + action["parameters"].contains("com.mycompany.layer_id") && + action["parameters"]["com.mycompany.layer_id"] == "layer-42") { + // This action is related to layer-42 + } +} +``` + +> **Naming convention:** Vendor parameters must use reverse domain notation with period-separated components (e.g., `com.mycompany.tool`, `net.example.session_id`). Some namespaces (e.g., `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 + subgraph Step3["Step 3: Reuse in a new Builder"] + SEL -->|"new Builder + add_resource()"| B2[New Builder] + B2 -->|sign| OUT[Signed Output] + end +``` + + + +**Step 1:** Build a working store and archive it: + +```cpp +c2pa::Context context; +c2pa::Builder builder(context, manifest_json); + +// Add ingredients to the working store +builder.add_ingredient(R"({"title": "A.jpg", "relationship": "componentOf"})", + path_to_A); +builder.add_ingredient(R"({"title": "B.jpg", "relationship": "componentOf"})", + path_to_B); + +// Save the working store as an archive +std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); +builder.to_archive(archive_stream); +``` + +**Step 2:** Read the archive and extract ingredients: + +```cpp +// Read the archive (does not modify it) +archive_stream.seekg(0); +c2pa::Reader reader(context, "application/c2pa", archive_stream); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto ingredients = parsed["manifests"][active]["ingredients"]; +``` + +**Step 3:** Create a new Builder with the extracted ingredients: + +```cpp +// Pick the desired ingredients +json selected = json::array(); +for (auto& ingredient : ingredients) { + if (ingredient["title"] == "A.jpg") { + selected.push_back(ingredient); + } +} + +// Create a new Builder with only the selected ingredients +json new_manifest = json::parse(base_manifest_json); +new_manifest["ingredients"] = selected; +c2pa::Builder new_builder(context, new_manifest.dump()); + +// Transfer binary resources for the selected ingredients +for (auto& ingredient : selected) { + if (ingredient.contains("thumbnail")) { + std::string id = ingredient["thumbnail"]["identifier"]; + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, stream); + stream.seekg(0); + new_builder.add_resource(id, stream); + } + if (ingredient.contains("manifest_data")) { + std::string id = ingredient["manifest_data"]["identifier"]; + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, stream); + stream.seekg(0); + new_builder.add_resource(id, stream); + } +} + +new_builder.sign(source_path, output_path, signer); +``` + +### Merging multiple working stores + +In some cases you may need 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 `get_resource()`, renamed ID for `add_resource()` when collisions occurred). + +```cpp +std::set used_ids; +int suffix_counter = 0; +json all_ingredients = json::array(); +std::vector> archive_info; // (stream, ingredient count) + +// Pass 1: Collect ingredients, renaming IDs on collision +for (auto& archive_stream : archives) { + archive_stream.seekg(0); + c2pa::Reader reader(archive_ctx, "application/c2pa", archive_stream); + auto parsed = json::parse(reader.json()); + auto ingredients = parsed["manifests"][parsed["active_manifest"]]["ingredients"]; + + for (auto& ingredient : ingredients) { + for (const char* key : {"thumbnail", "manifest_data"}) { + if (!ingredient.contains(key)) continue; + std::string id = ingredient[key]["identifier"]; + if (used_ids.count(id)) { + ingredient[key]["identifier"] = id + "__" + std::to_string(++suffix_counter); + } + used_ids.insert(ingredient[key]["identifier"].get()); + } + all_ingredients.push_back(ingredient); + } + archive_info.emplace_back(&archive_stream, ingredients.size()); +} + +json manifest = json::parse(R"({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}] +})"); +manifest["ingredients"] = all_ingredients; +c2pa::Builder builder(context, manifest.dump()); + +// Pass 2: Transfer resources (match by ingredient index) +size_t idx = 0; +for (auto& [stream, count] : archive_info) { + stream->seekg(0); + c2pa::Reader reader(archive_ctx, "application/c2pa", *stream); + auto parsed = json::parse(reader.json()); + auto orig = parsed["manifests"][parsed["active_manifest"]]["ingredients"]; + + for (size_t i = 0; i < count; ++i) { + auto& o = orig[i]; + auto& m = all_ingredients[idx++]; + for (const char* key : {"thumbnail", "manifest_data"}) { + if (!o.contains(key)) continue; + std::stringstream s(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(o[key]["identifier"].get(), s); + s.seekg(0); + builder.add_resource(m[key]["identifier"].get(), s); + } + } +} + +builder.sign(source_path, output_path, signer); +``` + +## Retrieving actions from a working store + +Actions are stored in the `c2pa.actions.v2` assertion. Use `Reader` to extract them from a signed asset or an archived Builder. + +### Reading actions + +```cpp +c2pa::Context context; +c2pa::Reader reader(context, "image/jpeg", source_stream); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto assertions = parsed["manifests"][active]["assertions"]; + +// Find the actions assertion +for (auto& assertion : assertions) { + if (assertion["label"] == "c2pa.actions.v2") { + auto actions = assertion["data"]["actions"]; + for (auto& action : actions) { + std::cout << "Action: " << action["action"] << std::endl; + if (action.contains("description")) { + std::cout << " Description: " << action["description"] << std::endl; + } + } + } +} +``` + +### Reading actions from an archive + +Use the same approach with format `"application/c2pa"` and an archive stream: + +```cpp +std::ifstream archive_file("builder_archive.c2pa", std::ios::binary); +c2pa::Reader reader(context, "application/c2pa", archive_file); +// Then parse and iterate assertions as in the example above +``` + +### Understanding the manifest tree + +The `Reader` returns a manifest store—a dictionary of manifests keyed by label (a URN like `contentauth:urn:uuid:...`). Conceptually it forms a tree: each manifest has assertions and ingredients; ingredients with `manifest_data` carry their own manifest store, which can have its own ingredients and assertions recursively. The `active_manifest` key indicates the root. + +```mermaid +flowchart TD + subgraph Store["Manifest Store"] + M1["Active Manifest\n- assertions (including c2pa.actions.v2)\n- ingredients"] + M2["Ingredient A's manifest\n- its own c2pa.actions.v2\n- its own ingredients"] + M3["Ingredient B's manifest\n- its own c2pa.actions.v2"] + end + M1 -->|"ingredient A has manifest_data"| M2 + M1 -->|"ingredient B has manifest_data"| M3 + M1 -.-|"ingredient C has no manifest_data"| M5["Ingredient C\n(unsigned asset, no provenance)"] + M2 -->|"may have its own ingredients..."| M4["...deeper in the tree"] + + style M5 fill:#eee,stroke:#999,stroke-dasharray: 5 5 +``` + + + +Not every ingredient has provenance. An unsigned asset added as an ingredient has `title`, `format`, and `relationship`, but no `manifest_data` and no entry in the `"manifests"` dictionary. Walking the tree reveals the full provenance chain: what each actor did at each step, including actions performed and ingredients used. + +**To walk the tree and find actions at each level:** + +```cpp +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto active_manifest = parsed["manifests"][active]; + +// Read the active manifest's actions +for (auto& assertion : active_manifest["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + std::cout << "Active manifest actions:" << std::endl; + for (auto& action : assertion["data"]["actions"]) { + std::cout << " " << action["action"].get() << std::endl; + } + } +} + +// Walk into each ingredient's manifest +for (auto& ingredient : active_manifest["ingredients"]) { + std::cout << "Ingredient: " << ingredient["title"].get() << std::endl; + + // If this ingredient has its own manifest (it was a signed asset), + // its manifest label is in "active_manifest" + if (ingredient.contains("active_manifest")) { + std::string ing_manifest_label = ingredient["active_manifest"]; + if (parsed["manifests"].contains(ing_manifest_label)) { + auto ing_manifest = parsed["manifests"][ing_manifest_label]; + + // This ingredient's manifest has its own actions + for (auto& assertion : ing_manifest["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + std::cout << " Ingredient's actions:" << std::endl; + for (auto& action : assertion["data"]["actions"]) { + std::cout << " " << action["action"].get() << std::endl; + } + } + } + + // And its own ingredients (deeper in the tree)... + } + } else { + // This ingredient has no manifest of its own (it was an unsigned asset). + // It still has a title, format, and relationship, but no manifest_data, + // no actions, and no deeper provenance chain. + std::cout << " (no content credentials)" << std::endl; + } +} +``` + +## Filtering actions + +To remove actions, use the same read–filter–rebuild pattern: **read, pick the ones to keep, create a new Builder**. + +```mermaid +flowchart TD + SA["Signed Asset with 3 actions: opened, placed, filtered"] -->|Reader| JSON[Parse JSON] + JSON -->|"Keep only opened + placed"| FILT[Filtered actions] + FILT -->|"New Builder with 2 actions"| NB[New Builder] + NB -->|sign| OUT["New with 2 actions only: opened, placed"] +``` + + + +### Basic action filtering + +When filtering, remember that the first action must remain `c2pa.created` or `c2pa.opened` for the manifest to be valid. If the first action is removed, a new one must be added. + +```cpp +c2pa::Context context; +c2pa::Reader reader(context, "image/jpeg", source_stream); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto manifest = parsed["manifests"][active]; + +// Filter actions: keep c2pa.created/c2pa.opened (mandatory) and c2pa.placed, drop the rest +json kept_actions = json::array(); +for (auto& assertion : manifest["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + for (auto& action : assertion["data"]["actions"]) { + std::string action_type = action["action"]; + if (action_type == "c2pa.created" || action_type == "c2pa.opened" || + action_type == "c2pa.placed") { + kept_actions.push_back(action); + } + // Skip c2pa.filtered, c2pa.color_adjustments, etc. + } + } +} + +// Build a new manifest with only the kept actions +json new_manifest = json::parse(R"({ + "claim_generator_info": [{"name": "an-application", "version": "1.0"}] +})"); + +if (!kept_actions.empty()) { + new_manifest["assertions"] = json::array({ + { + {"label", "c2pa.actions"}, + {"data", {{"actions", kept_actions}}} + } + }); +} + +c2pa::Builder builder(context, new_manifest.dump()); +builder.sign(source_path, output_path, signer); +``` + +### Filtering actions that reference ingredients + +Some actions reference ingredients (via `parameters.ingredients[].url` after signing). If keeping an action that references an ingredient, **the corresponding ingredient and its binary resources must also be kept**. If an ingredient is dropped, any actions that reference it must also be dropped (or updated). + +#### `c2pa.opened` action + +The `c2pa.opened` action is special because it must be the first action and it references the asset that was opened (the `parentOf` ingredient). When filtering: + +- **Always keep `c2pa.opened` or `c2pa.created`**: it is required for a valid manifest +- **Keep the ingredient it references**: the `parentOf` ingredient linked via its `parameters.ingredients[].url` +- Removing the ingredient that `c2pa.opened` points to will make the manifest invalid + +#### `c2pa.placed` action + +The `c2pa.placed` action references a `componentOf` ingredient that was composited into the asset. When filtering: + +- If keeping `c2pa.placed`, keep the ingredient it references +- If the ingredient is dropped, also drop the `c2pa.placed` action +- If `c2pa.placed` is not required: it can safely be removed (and the ingredient it references, if it is the only reference) + +#### Example + +The code below provides an example of filtering with linked ingredients. + +```cpp +c2pa::Context context; +c2pa::Reader reader(context, "image/jpeg", source_stream); +auto parsed = json::parse(reader.json()); +std::string active = parsed["active_manifest"]; +auto manifest = parsed["manifests"][active]; + +// Filter actions and track which ingredients are needed +json kept_actions = json::array(); +std::set needed_ingredient_labels; + +for (auto& assertion : manifest["assertions"]) { + if (assertion["label"] == "c2pa.actions.v2") { + for (auto& action : assertion["data"]["actions"]) { + std::string action_type = action["action"]; + + // Always keep c2pa.opened/c2pa.created (required for valid manifest) + // Keep c2pa.placed (optional -- kept here as an example) + // Drop everything else + bool keep = (action_type == "c2pa.opened" || + action_type == "c2pa.created" || + action_type == "c2pa.placed"); + + if (keep) { + kept_actions.push_back(action); + + // Track which ingredients this action needs + if (action.contains("parameters") && + action["parameters"].contains("ingredients")) { + for (auto& ing_ref : action["parameters"]["ingredients"]) { + std::string url = ing_ref["url"]; + std::string label = url.substr(url.rfind('/') + 1); + needed_ingredient_labels.insert(label); + } + } + } + } + } +} + +// Keep only the ingredients that are referenced by kept actions +json kept_ingredients = json::array(); +for (auto& ingredient : manifest["ingredients"]) { + if (ingredient.contains("label") && + needed_ingredient_labels.count(ingredient["label"])) { + kept_ingredients.push_back(ingredient); + } +} + +// Build the new manifest with filtered actions and matching ingredients +json new_manifest = json::parse(R"({ + "claim_generator_info": [{"name": "an-application", "version": "1.0"}] +})"); +new_manifest["ingredients"] = kept_ingredients; +if (!kept_actions.empty()) { + new_manifest["assertions"] = json::array({ + { + {"label", "c2pa.actions"}, + {"data", {{"actions", kept_actions}}} + } + }); +} + +c2pa::Builder builder(context, new_manifest.dump()); + +// Transfer binary resources for kept ingredients +for (auto& ingredient : kept_ingredients) { + if (ingredient.contains("thumbnail")) { + std::string id = ingredient["thumbnail"]["identifier"]; + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, stream); + stream.seekg(0); + builder.add_resource(id, stream); + } + if (ingredient.contains("manifest_data")) { + std::string id = ingredient["manifest_data"]["identifier"]; + std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(id, stream); + stream.seekg(0); + builder.add_resource(id, stream); + } +} + +builder.sign(source_path, output_path, signer); +``` + +> [!NOTE] +> When copying ingredient JSON objects from a reader, they keep their `label` field. Since the action URLs reference ingredients by label, the links resolve correctly as long as ingredients are not renamed or reindexed. If ingredients are re-added via `add_ingredient()` (which generates new labels), the action URLs will also need to be updated. + +## Controlling manifest embedding + +By default, `sign()` embeds the manifest directly inside the output asset file. + +### Remove the manifest from the asset entirely + +Use `set_no_embed()` so the signed asset contains no embedded manifest. The manifest bytes are returned from `sign()` and can be stored separately (as a sidecar file, on a server, etc.): + +```mermaid +flowchart LR + subgraph Default["Default (embedded)"] + A1[Output Asset] --- A2[Image data + C2PA manifest] + end + + subgraph NoEmbed["With set_no_embed()"] + B1[Output Asset] ~~~ B2[Manifest bytes with store as sidecar or uploaded to server] + end +``` + + + +```cpp +c2pa::Builder builder(context, manifest_json); +builder.set_no_embed(); +builder.set_remote_url("<>"); + +auto manifest_bytes = builder.sign("image/jpeg", source, dest, signer); +// manifest_bytes contains the full manifest store +// Upload manifest_bytes to the remote URL +// The output asset has no embedded manifest +``` + +Reading back: + +```cpp +c2pa::Reader reader(context, "image/jpeg", dest); +reader.is_embedded(); // false +reader.remote_url(); // "<>" +``` + +## Complete workflow diagram + +```mermaid +flowchart TD + subgraph Step1["Step 1: READ"] + SA[Signed Asset] -->|Reader| RD["reader.json() -- full manifest JSON\nreader.get_resource(id, stream) -- binary"] + end + + subgraph Step2["Step 2: FILTER"] + RD --> FI[Parse JSON] + FI --> F1[Pick ingredients to keep] + FI --> F3[Pick actions to keep] + FI --> F4[Ensure kept actions' ingredients are also kept] + FI --> F5["Ensure c2pa.created/opened is still the first action"] + F1 & F3 & F4 & F5 --> FM[Build new manifest JSON with only filtered items] + end + + subgraph Step3["Step 3: BUILD new Builder"] + FM --> BLD["new Builder with context and filtered_json"] + BLD --> AR[".add_resource for each kept binary resource"] + AR --> AI[".add_ingredient to add original as parent (optional)"] + AI --> AA[".add_action to record new actions (optional)"] + end + + subgraph Step4["Step 4: SIGN"] + AA --> SIGN["builder.sign(source, output, signer)"] + SIGN --> OUT[Output asset with new manifest containing only filtered content] + end +``` + diff --git a/docs/working-stores.md b/docs/working-stores.md new file mode 100644 index 00000000..801816dd --- /dev/null +++ b/docs/working-stores.md @@ -0,0 +1,648 @@ +# 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.from_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 -->|from_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 vs external manifests](#embedded-vs-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.from_archive()`. +- 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 Reader + +try: + # Create a Reader from a signed asset file + reader = Reader("signed_image.jpg") + + # Get the manifest store as JSON + manifest_store_json = reader.json() +except Exception as e: + print(f"C2PA Error: {e}") +``` + +### Reading from a stream + +```py +with open("signed_image.jpg", "rb") as stream: + # Create Reader from stream with MIME type + reader = Reader("image/jpeg", stream) + manifest_json = reader.json() +``` + +### Using Context for configuration + +For more control over validation and trust settings, use a `Context`: + +```py +from c2pa import Context, Reader + +# Create context with custom validation settings +ctx = Context.from_dict({ + "verify": { + "verify_after_sign": True + } +}) + +# Use context when creating Reader +reader = Reader("signed_image.jpg", context=ctx) +manifest_json = reader.json() +``` + +## 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 + +# Create a working store with a manifest definition +manifest_json = json.dumps({ + "claim_generator_info": [{ + "name": "example-app", + "version": "0.1.0" + }], + "title": "Example asset", + "assertions": [] +}) + +builder = Builder(manifest_json) + +# Or with custom context +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 + +# 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) + +# 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(signer, "image/jpeg", src, dst) + +# Now "signed.jpg" contains a manifest store +# You can read it back with Reader +reader = Reader("signed.jpg") +manifest_store_json = reader.json() +``` + +## Creating and signing manifests + +### Creating a Builder (working store) + +```py +# Create with manifest definition +builder = Builder(manifest_json) + +# Or with custom context +ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": True} + } +}) +builder = Builder(manifest_json, context=ctx) +``` + +### 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 + +```py +try: + # Sign using streams + with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + manifest_bytes = builder.sign(signer, "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 +# Sign using file paths (uses native Rust file I/O for better performance) +manifest_bytes = builder.sign_file( + "source.jpg", "signed.jpg", signer +) +``` + +### Complete example + +This code combines the above examples to create, sign, and read a manifest. + +```py +import json +from c2pa import Builder, Reader, Signer, C2paSignerInfo, C2paSigningAlg + +try: + # 1. Define manifest for working store + manifest_json = json.dumps({ + "claim_generator_info": [{"name": "demo-app", "version": "0.1.0"}], + "title": "Signed image", + "assertions": [] + }) + + # 2. Load credentials + with open("certs.pem", "rb") as f: + certs = f.read() + with open("private_key.pem", "rb") as f: + private_key = f.read() + + # 3. Create 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) + + # 4. Create working store (Builder) and sign + builder = Builder(manifest_json) + with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + + print("Asset signed - working store is now a manifest store") + + # 5. Read back the manifest store + reader = Reader("signed.jpg") + 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. + +### 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") +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 +builder = Builder(manifest_json) + +# 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(signer, "image/jpeg", src, dst) +``` + +## Working with ingredients + +Ingredients represent source materials used to create an asset, preserving the provenance chain. Ingredients themselves can be turned into ingredient archives (`.c2pa`). + +An ingredient archive is a serialized `Builder` with _exactly one_ ingredient. Once archived with only one ingredient, the Builder archive is an ingredient archive. Such ingredient archives can be used as ingredient in other working stores. + +### Adding ingredients to a working store + +When creating a manifest, add ingredients to preserve the provenance chain: + +```py +builder = Builder(manifest_json) + +# 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) + +# Or add ingredient from a file path +builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", "source.jpg") + +# 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(signer, "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 + +# Create and configure a working store +builder = Builder(manifest_json) +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 + +Create a new `Builder` (working store) from an archive: + +```py +# Restore from stream +with open("manifest.c2pa", "rb") as archive: + builder = Builder.from_archive(archive) + +# Now you can sign with the restored working store +with open("asset.jpg", "rb") as src, open("signed_asset.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +### Restoring with context preservation + +Pass a `context` to `from_archive()` to preserve custom settings: + +```py +# Create context with custom settings +ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } +}) + +# Load archive with context +with open("manifest.c2pa", "rb") as archive: + builder = Builder.from_archive(archive, context=ctx) + +# The builder has the archived manifest but keeps the custom context +with open("asset.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +### Two-phase workflow example + +#### Phase 1: Prepare manifest + +```py +import io +import json + +manifest_json = json.dumps({ + "title": "Artwork draft", + "assertions": [] +}) + +builder = Builder(manifest_json) +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 + +```py +# Restore the working store +with open("artwork_manifest.c2pa", "rb") as archive: + builder = Builder.from_archive(archive) + +# Sign +with open("artwork.jpg", "rb") as src, open("signed_artwork.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + +print("Asset signed with manifest store") +``` + +## Embedded vs 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 + +```py +builder = Builder(manifest_json) + +# 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(signer, "image/jpeg", src, dst) + +# Read it back — manifest store is embedded +reader = Reader("signed.jpg") +``` + +### External manifest stores (no embed) + +Prevent embedding the manifest store in the asset: + +```py +builder = Builder(manifest_json) +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(signer, "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 + +Reference a manifest store stored at a remote URL: + +```py +builder = Builder(manifest_json) +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(signer, "image/jpeg", src, dst) +``` + +## Best practices + +### Use Context for configuration + +Always use `Context` objects for SDK configuration: + +```py +ctx = Context.from_dict({ + "verify": { + "verify_after_sign": True + }, + "trust": { + "user_anchors": trust_anchors_pem + } +}) + +builder = Builder(manifest_json, context=ctx) +reader = Reader("asset.jpg", context=ctx) +``` + +### Use ingredients to build provenance chains + +Add ingredients to your manifests to maintain a clear provenance chain: + +```py +ingredient_json = json.dumps({ + "title": "Original source", + "relationship": "parentOf" +}) + +with open("original.jpg", "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) + +with open("edited.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +## 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/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 00000000..721fe733 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,1198 @@ +# Copyright 2024 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. + +""" +Tests that verify code examples from the documentation actually work. + +Each test corresponds to one or more code snippets from the docs/ folder. +The doc file and section are noted in each test's docstring. +""" + +import os +import io +import json +import unittest +import tempfile +import warnings + +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", message="load_settings\\(\\) is deprecated") + +from c2pa import ( # noqa: E402 + Builder, + C2paError as Error, + Reader, + C2paSigningAlg as SigningAlg, + C2paSignerInfo, + Signer, + Settings, + Context, + ContextProvider, + load_settings, +) +from c2pa.c2pa import _has_signer_context # noqa: E402 + +# ── Paths ──────────────────────────────────────────────────── + +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") +SIGNED_IMAGE = os.path.join(FIXTURES_DIR, "C.jpg") # has C2PA manifest +UNSIGNED_IMAGE = os.path.join(FIXTURES_DIR, "A.jpg") # no manifest +CERTS_FILE = os.path.join(FIXTURES_DIR, "es256_certs.pem") +KEY_FILE = os.path.join(FIXTURES_DIR, "es256_private.key") +THUMBNAIL_FILE = os.path.join(FIXTURES_DIR, "A_thumbnail.jpg") + + +def _load_creds(): + """Load test signing credentials.""" + with open(CERTS_FILE, "rb") as f: + certs = f.read() + with open(KEY_FILE, "rb") as f: + key = f.read() + return certs, key + + +def _make_signer(): + """Create a fresh Signer for tests.""" + certs, key = _load_creds() + info = C2paSignerInfo( + alg=b"es256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com", + ) + return Signer.from_info(info) + + +def _manifest_def(): + """Return a basic manifest definition dict.""" + return { + "claim_generator_info": [{"name": "doc-tests", "version": "0.1.0"}], + "title": "Doc Test Image", + "assertions": [], + } + + +def _manifest_def_json(): + """Return a basic manifest definition as JSON string.""" + return json.dumps(_manifest_def()) + + +# ═══════════════════════════════════════════════════════════════ +# context.md examples +# ═══════════════════════════════════════════════════════════════ + + +class TestContextDocs(unittest.TestCase): + """Tests for docs/context.md code examples.""" + + # -- Creating a Context ------------------------------------------- + + def test_context_default(self): + """context.md § Using SDK default settings""" + from c2pa import Context + + ctx = Context() # Uses SDK defaults + self.assertTrue(ctx.is_valid) + ctx.close() + + def test_context_from_json(self): + """context.md § From a JSON string""" + ctx = Context.from_json('''{ + "verify": {"verify_after_sign": true}, + "builder": { + "thumbnail": {"enabled": false}, + "claim_generator_info": {"name": "An app", "version": "0.1.0"} + } + }''') + self.assertTrue(ctx.is_valid) + ctx.close() + + def test_context_from_dict(self): + """context.md § From a dictionary""" + ctx = Context.from_dict({ + "verify": {"verify_after_sign": True}, + "builder": { + "thumbnail": {"enabled": False}, + "claim_generator_info": {"name": "An app", "version": "0.1.0"} + } + }) + self.assertTrue(ctx.is_valid) + ctx.close() + + def test_context_from_settings_object(self): + """context.md § From a Settings object""" + 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=settings) + self.assertTrue(ctx.is_valid) + ctx.close() + settings.close() + + # -- Common configuration patterns -------------------------------- + + def test_env_var_config(self): + """context.md § Configuration from environment variables""" + 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=settings) + self.assertTrue(ctx.is_valid) + ctx.close() + settings.close() + + # -- Configuring Reader ------------------------------------------- + + def test_reader_with_context_from_file(self): + """context.md § Reading from a file""" + ctx = Context.from_dict({ + "verify": { + "remote_manifest_fetch": False, + "ocsp_fetch": False + } + }) + + reader = Reader(SIGNED_IMAGE, context=ctx) + json_data = reader.json() + self.assertIsNotNone(json_data) + reader.close() + ctx.close() + + def test_reader_with_context_from_stream(self): + """context.md § Reading from a stream""" + ctx = Context.from_dict({ + "verify": { + "remote_manifest_fetch": False, + "ocsp_fetch": False + } + }) + + with open(SIGNED_IMAGE, "rb") as stream: + reader = Reader("image/jpeg", stream, context=ctx) + json_data = reader.json() + self.assertIsNotNone(json_data) + reader.close() + ctx.close() + + def test_reader_full_validation(self): + """context.md § Full validation""" + ctx = Context.from_dict({ + "verify": { + "verify_after_reading": True, + "verify_trust": True, + "verify_timestamp_trust": True, + "remote_manifest_fetch": True + } + }) + + reader = Reader(SIGNED_IMAGE, context=ctx) + self.assertIsNotNone(reader.json()) + reader.close() + ctx.close() + + def test_reader_offline(self): + """context.md § Offline operation""" + ctx = Context.from_dict({ + "verify": { + "remote_manifest_fetch": False, + "ocsp_fetch": False + } + }) + + reader = Reader(SIGNED_IMAGE, context=ctx) + self.assertIsNotNone(reader.json()) + reader.close() + ctx.close() + + # -- Configuring Builder ------------------------------------------ + + def test_builder_with_context(self): + """context.md § Basic use""" + ctx = Context.from_dict({ + "builder": { + "claim_generator_info": { + "name": "An app", + "version": "0.1.0" + }, + } + }) + + manifest_json = _manifest_def() + builder = Builder(manifest_json, context=ctx) + + signer = _make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + # Verify output is valid + reader = Reader(dest) + self.assertIsNotNone(reader.json()) + reader.close() + builder.close() + ctx.close() + + def test_builder_no_thumbnails_context(self): + """context.md § Controlling thumbnail generation""" + no_thumbnails_ctx = Context.from_dict({ + "builder": { + "claim_generator_info": {"name": "Batch Processor"}, + "thumbnail": {"enabled": False} + } + }) + self.assertTrue(no_thumbnails_ctx.is_valid) + + mobile_ctx = Context.from_dict({ + "builder": { + "claim_generator_info": {"name": "Mobile App"}, + "thumbnail": { + "enabled": True, + "long_edge": 512, + "quality": "low", + "prefer_smallest_format": True + } + } + }) + self.assertTrue(mobile_ctx.is_valid) + + # Verify no thumbnails + builder = Builder(_manifest_def(), context=no_thumbnails_ctx) + signer = _make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + reader = Reader(dest) + manifest = reader.get_active_manifest() + self.assertIsNone(manifest.get("thumbnail")) + reader.close() + builder.close() + no_thumbnails_ctx.close() + mobile_ctx.close() + + # -- Configuring a signer ----------------------------------------- + + @unittest.skipUnless( + _has_signer_context, + "Signer-on-context not supported by native lib", + ) + def test_signer_on_context(self): + """context.md § From Settings (signer-on-context)""" + from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg + + certs, key = _load_creds() + + signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + settings = Settings() + ctx = Context(settings=settings, signer=signer) + # signer is now consumed + self.assertTrue(signer._closed) + + manifest_json = _manifest_def() + builder = Builder(manifest_json, context=ctx) + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(format="image/jpeg", source=src, dest=dst) + reader = Reader(dest) + self.assertIsNotNone(reader.json()) + reader.close() + builder.close() + ctx.close() + settings.close() + + def test_explicit_signer(self): + """context.md § Explicit signer""" + signer = _make_signer() + ctx = Context() + manifest_json = _manifest_def() + builder = Builder(manifest_json, context=ctx) + + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + builder.close() + signer.close() + ctx.close() + + # -- Context lifetime and usage ----------------------------------- + + def test_context_as_context_manager(self): + """context.md § Context as a context manager""" + with Context() as ctx: + reader = Reader(SIGNED_IMAGE, context=ctx) + json_data = reader.json() + self.assertIsNotNone(json_data) + reader.close() + + def test_reusable_contexts(self): + """context.md § Reusable contexts""" + settings = Settings() + ctx = Context(settings=settings) + + manifest1 = _manifest_def() + manifest2 = _manifest_def() + manifest2["title"] = "Second Image" + + builder1 = Builder(manifest1, context=ctx) + builder2 = Builder(manifest2, context=ctx) + reader = Reader(SIGNED_IMAGE, context=ctx) + + self.assertIsNotNone(reader.json()) + builder1.close() + builder2.close() + reader.close() + ctx.close() + settings.close() + + def test_multiple_contexts(self): + """context.md § Multiple contexts for different purposes""" + dev_settings = Settings.from_dict({ + "builder": {"thumbnail": {"enabled": False}} + }) + prod_settings = Settings.from_dict({ + "builder": {"thumbnail": {"enabled": True}} + }) + dev_ctx = Context(settings=dev_settings) + prod_ctx = Context(settings=prod_settings) + + manifest = _manifest_def() + dev_builder = Builder(manifest, context=dev_ctx) + prod_builder = Builder(manifest, context=prod_ctx) + + self.assertIsNotNone(dev_builder) + self.assertIsNotNone(prod_builder) + + dev_builder.close() + prod_builder.close() + dev_ctx.close() + prod_ctx.close() + dev_settings.close() + prod_settings.close() + + def test_context_provider_protocol(self): + """context.md § ContextProvider protocol""" + from c2pa import ContextProvider, Context + + ctx = Context() + self.assertIsInstance(ctx, ContextProvider) # True + ctx.close() + + # -- Migrating from load_settings --------------------------------- + + def test_migration_from_load_settings(self): + """context.md § Migrating from load_settings - new API""" + from c2pa import Settings, Context, Reader + + settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) + ctx = Context(settings=settings) + reader = Reader(SIGNED_IMAGE, context=ctx) + self.assertIsNotNone(reader.json()) + reader.close() + ctx.close() + settings.close() + + +# ═══════════════════════════════════════════════════════════════ +# settings.md examples +# ═══════════════════════════════════════════════════════════════ + + +class TestSettingsDocs(unittest.TestCase): + """Tests for docs/settings.md code examples.""" + + def test_settings_api(self): + """settings.md § Settings API""" + from c2pa import Settings + + # Create with defaults + settings = Settings() + + # Set individual values by dot-notation path + settings.set("builder.thumbnail.enabled", "false") + + # Method chaining + settings.set("builder.thumbnail.enabled", "false").set( + "verify.verify_after_sign", "true" + ) + + # Dict-like access + settings["builder.thumbnail.enabled"] = "false" + + settings.close() + + # Create from JSON string + settings = Settings.from_json('{"builder": {"thumbnail": {"enabled": false}}}') + settings.close() + + # Create from a dictionary + settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) + + # Merge additional configuration + settings.update({"verify": {"remote_manifest_fetch": True}}) + settings.close() + + # Use as a context manager for automatic cleanup + with Settings() as settings: + settings.set("builder.thumbnail.enabled", "false") + + def test_settings_from_json_string(self): + """settings.md § Settings format - From JSON string""" + settings = Settings.from_json('{"verify": {"verify_after_sign": true}}') + self.assertTrue(settings.is_valid) + settings.close() + + def test_settings_from_dict(self): + """settings.md § Settings format - From dict""" + settings = Settings.from_dict({"verify": {"verify_after_sign": True}}) + self.assertTrue(settings.is_valid) + settings.close() + + def test_context_from_json_string(self): + """settings.md § Settings format - Context from JSON""" + ctx = Context.from_json('{"verify": {"verify_after_sign": true}}') + self.assertTrue(ctx.is_valid) + ctx.close() + + def test_context_from_dict(self): + """settings.md § Settings format - Context from dict""" + ctx = Context.from_dict({"verify": {"verify_after_sign": True}}) + self.assertTrue(ctx.is_valid) + ctx.close() + + def test_offline_settings(self): + """settings.md § Offline or air-gapped environments""" + ctx = Context.from_dict({ + "verify": { + "remote_manifest_fetch": False, + "ocsp_fetch": False + } + }) + + reader = Reader(SIGNED_IMAGE, context=ctx) + self.assertIsNotNone(reader.json()) + reader.close() + ctx.close() + + def test_fast_dev_iteration_settings(self): + """settings.md § Fast development iteration""" + settings = Settings() + settings.set("verify.verify_after_reading", "false") + settings.set("verify.verify_after_sign", "false") + + dev_ctx = Context(settings=settings) + self.assertTrue(dev_ctx.is_valid) + dev_ctx.close() + settings.close() + + def test_strict_validation_settings(self): + """settings.md § Strict validation""" + ctx = Context.from_dict({ + "verify": { + "strict_v1_validation": True, + "ocsp_fetch": True, + "verify_trust": True, + "verify_timestamp_trust": True + } + }) + + reader = Reader(SIGNED_IMAGE, context=ctx) + validation_result = reader.json() + self.assertIsNotNone(validation_result) + reader.close() + ctx.close() + + def test_claim_generator_info(self): + """settings.md § Claim generator information""" + ctx = Context.from_dict({ + "builder": { + "claim_generator_info": { + "name": "My Photo Editor", + "version": "2.1.0", + "operating_system": "auto" + } + } + }) + self.assertTrue(ctx.is_valid) + ctx.close() + + def test_builder_intent_create(self): + """settings.md § Setting Builder intent - Create""" + camera_ctx = Context.from_dict({ + "builder": { + "intent": {"Create": "digitalCapture"}, + "claim_generator_info": {"name": "Camera App", "version": "1.0"} + } + }) + self.assertTrue(camera_ctx.is_valid) + camera_ctx.close() + + def test_builder_intent_edit(self): + """settings.md § Setting Builder intent - Edit""" + editor_ctx = Context.from_dict({ + "builder": { + "intent": {"Edit": None}, + "claim_generator_info": {"name": "Photo Editor", "version": "2.0"} + } + }) + self.assertTrue(editor_ctx.is_valid) + editor_ctx.close() + + def test_update_only_json(self): + """settings.md - Only JSON format is supported""" + s = Settings() + with self.assertRaises(Error): + s.update("data", format="toml") + s.close() + + +# ═══════════════════════════════════════════════════════════════ +# faqs.md examples +# ═══════════════════════════════════════════════════════════════ + + +class TestFaqsDocs(unittest.TestCase): + """Tests for docs/faqs.md code examples.""" + + def test_reader_only(self): + """faqs.md § When to use Reader""" + ctx = Context() + reader = Reader(SIGNED_IMAGE, context=ctx) + json_data = reader.json() # inspect the manifest + self.assertIsNotNone(json_data) + + # Extract a thumbnail + manifest_store = json.loads(json_data) + active_uri = manifest_store["active_manifest"] + manifest = manifest_store["manifests"][active_uri] + if "thumbnail" in manifest: + thumb_uri = manifest["thumbnail"]["identifier"] + thumb_stream = io.BytesIO() + reader.resource_to_stream(thumb_uri, thumb_stream) + self.assertGreater(thumb_stream.tell(), 0) + + reader.close() + ctx.close() + + def test_builder_only(self): + """faqs.md § When to use a Builder""" + ctx = Context() + manifest_json = _manifest_def() + builder = Builder(manifest_json, context=ctx) + + ingredient_json = json.dumps({"title": "Original"}) + with open(UNSIGNED_IMAGE, "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) + + signer = _make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + # Verify output was created + self.assertTrue(os.path.exists(dest)) + reader = Reader(dest) + self.assertIsNotNone(reader.json()) + reader.close() + builder.close() + ctx.close() + + def test_reader_and_builder_together(self): + """faqs.md § When to use both Reader and Builder together""" + ctx = Context() + + # Read existing + reader = Reader(SIGNED_IMAGE, context=ctx) + parsed = json.loads(reader.json()) + reader.close() + + # "Filter" - just use the parsed data as-is for testing + # (In a real app you'd filter assertions/ingredients) + kept = _manifest_def() + + # Create a new Builder with the "filtered" content + builder = Builder(json.dumps(kept), context=ctx) + signer = _make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + self.assertTrue(os.path.exists(dest)) + builder.close() + ctx.close() + + def test_archive_from_archive_with_context(self): + """faqs.md § When to use archives""" + ctx = Context.from_dict({ + "builder": {"thumbnail": {"enabled": False}} + }) + + # Create a builder and archive it + builder = Builder(_manifest_def(), context=ctx) + archive = io.BytesIO() + builder.to_archive(archive) + builder.close() + + # Restore from archive with context + archive.seek(0) + builder = Builder.from_archive(archive, context=ctx) + self.assertIsNotNone(builder) + + signer = _make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "out.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + # Verify output is readable + reader = Reader(dest) + self.assertIsNotNone(reader.json()) + reader.close() + builder.close() + ctx.close() + + +# ═══════════════════════════════════════════════════════════════ +# working-stores.md examples +# ═══════════════════════════════════════════════════════════════ + + +class TestWorkingStoresDocs(unittest.TestCase): + """Tests for docs/working-stores.md code examples.""" + + # -- Reading manifest stores from assets -------------------------- + + def test_reading_from_file(self): + """working-stores.md § Reading from a file""" + from c2pa import Reader + + try: + reader = Reader(SIGNED_IMAGE) + manifest_store_json = reader.json() + self.assertIsNotNone(manifest_store_json) + reader.close() + except Exception as e: + self.fail(f"C2PA Error: {e}") + + def test_reading_from_stream(self): + """working-stores.md § Reading from a stream""" + with open(SIGNED_IMAGE, "rb") as stream: + reader = Reader("image/jpeg", stream) + manifest_json = reader.json() + self.assertIsNotNone(manifest_json) + reader.close() + + def test_reading_with_context(self): + """working-stores.md § Using Context for configuration""" + from c2pa import Context, Reader + + ctx = Context.from_dict({ + "verify": { + "verify_after_sign": True + } + }) + + reader = Reader(SIGNED_IMAGE, context=ctx) + manifest_json = reader.json() + self.assertIsNotNone(manifest_json) + reader.close() + ctx.close() + + # -- Using working stores ---------------------------------------- + + def test_creating_working_store(self): + """working-stores.md § Creating a working store""" + manifest_json = json.dumps({ + "claim_generator_info": [{ + "name": "example-app", + "version": "0.1.0" + }], + "title": "Example asset", + "assertions": [] + }) + + builder = Builder(manifest_json) + self.assertIsNotNone(builder) + builder.close() + + # Or with custom context + ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": True} + } + }) + builder = Builder(manifest_json, context=ctx) + self.assertIsNotNone(builder) + builder.close() + ctx.close() + + def test_modifying_working_store(self): + """working-stores.md § Modifying a working store""" + manifest_json = _manifest_def() + builder = Builder(manifest_json) + + # Add binary resources (like thumbnails) + with open(THUMBNAIL_FILE, "rb") as thumb: + builder.add_resource("thumbnail", thumb) + + # Add ingredients (source files) + ingredient_json = json.dumps({ + "title": "Original asset", + "relationship": "parentOf" + }) + with open(UNSIGNED_IMAGE, "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() + + builder.close() + + def test_working_store_to_manifest_store(self): + """working-stores.md § From working store to manifest store""" + certs, private_key = _load_creds() + + from c2pa import Signer, C2paSignerInfo, C2paSigningAlg + + 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) + + manifest_json = _manifest_def() + builder = Builder(manifest_json) + + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "signed.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + + # Read it back with Reader + reader = Reader(dest) + manifest_store_json = reader.json() + self.assertIsNotNone(manifest_store_json) + reader.close() + builder.close() + + # -- Creating and signing manifests ------------------------------ + + def test_creating_signer(self): + """working-stores.md § Creating a Signer""" + from c2pa import Signer, C2paSignerInfo, C2paSigningAlg + + certs, private_key = _load_creds() + + 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) + self.assertIsNotNone(signer) + signer.close() + + def test_signing_asset_streams(self): + """working-stores.md § Signing an asset""" + builder = Builder(_manifest_def()) + signer = _make_signer() + + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "signed.jpg") + try: + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + manifest_bytes = builder.sign(signer, "image/jpeg", src, dst) + + self.assertIsNotNone(manifest_bytes) + self.assertGreater(len(manifest_bytes), 0) + + except Exception as e: + self.fail(f"Signing failed: {e}") + + def test_signing_with_file_paths(self): + """working-stores.md § Signing with file paths""" + builder = Builder(_manifest_def()) + signer = _make_signer() + + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "signed.jpg") + manifest_bytes = builder.sign_file( + UNSIGNED_IMAGE, dest, signer + ) + self.assertIsNotNone(manifest_bytes) + self.assertGreater(len(manifest_bytes), 0) + + def test_complete_sign_and_read(self): + """working-stores.md § Complete example""" + from c2pa import Builder, Reader, Signer, C2paSignerInfo, C2paSigningAlg + + try: + # 1. Define manifest for working store + manifest_json = json.dumps({ + "claim_generator_info": [{"name": "demo-app", "version": "0.1.0"}], + "title": "Signed image", + "assertions": [] + }) + + # 2. Load credentials + certs, private_key = _load_creds() + + # 3. Create 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) + + # 4. Create working store (Builder) and sign + builder = Builder(manifest_json) + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "signed.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + + # 5. Read back the manifest store + reader = Reader(dest) + data = reader.json() + self.assertIn("manifests", data) + reader.close() + + except Exception as e: + self.fail(f"Error: {e}") + + # -- Working with resources --------------------------------------- + + def test_extract_resource_from_manifest(self): + """working-stores.md § Extracting resources from a manifest store""" + reader = Reader(SIGNED_IMAGE) + 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: + thumbnail_uri = manifest["thumbnail"]["identifier"] + + with tempfile.NamedTemporaryFile(suffix=".jpg") as f: + reader.resource_to_stream(thumbnail_uri, f) + self.assertGreater(f.tell(), 0) + + reader.close() + + def test_add_resource_to_working_store(self): + """working-stores.md § Adding resources to a working store""" + manifest_json = _manifest_def() + builder = Builder(manifest_json) + + # Add resource from a stream + with open(THUMBNAIL_FILE, "rb") as thumb: + builder.add_resource("thumbnail", thumb) + + signer = _make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "signed.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + builder.close() + + # -- Working with ingredients ------------------------------------- + + def test_add_ingredient_to_working_store(self): + """working-stores.md § Adding ingredients to a working store""" + manifest_json = _manifest_def() + builder = Builder(manifest_json) + + ingredient_json = json.dumps({ + "title": "Original asset", + "relationship": "parentOf" + }) + + # Add ingredient from a stream + with open(UNSIGNED_IMAGE, "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) + + signer = _make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "signed_asset.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + + # Verify it signed + reader = Reader(dest) + data = json.loads(reader.json()) + self.assertIn("manifests", data) + reader.close() + builder.close() + + def test_ingredient_relationships(self): + """working-stores.md § Ingredient relationships""" + builder = Builder(_manifest_def()) + + ingredient_json = json.dumps({ + "title": "Base layer", + "relationship": "componentOf" + }) + + with open(UNSIGNED_IMAGE, "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) + + builder.close() + + # -- Working with archives ---------------------------------------- + + def test_save_working_store_to_archive(self): + """working-stores.md § Saving a working store to archive""" + manifest_json = _manifest_def() + ingredient_json = json.dumps({"title": "Source"}) + + builder = Builder(manifest_json) + with open(THUMBNAIL_FILE, "rb") as thumb: + builder.add_resource("thumbnail", thumb) + with open(UNSIGNED_IMAGE, "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) + + # Save working store to archive stream + archive = io.BytesIO() + builder.to_archive(archive) + self.assertGreater(archive.tell(), 0) + + # Verify we can save to a "file" + archive.seek(0) + archive_copy = io.BytesIO() + archive_copy.write(archive.read()) + self.assertGreater(archive_copy.tell(), 0) + + builder.close() + + def test_restore_working_store_from_archive(self): + """working-stores.md § Restoring a working store from archive""" + # First create an archive + builder = Builder(_manifest_def()) + archive = io.BytesIO() + builder.to_archive(archive) + builder.close() + + # Restore from stream + archive.seek(0) + builder = Builder.from_archive(archive) + self.assertIsNotNone(builder) + + # Now sign + signer = _make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "signed_asset.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + self.assertTrue(os.path.exists(dest)) + builder.close() + + def test_restore_with_context_preservation(self): + """working-stores.md § Restoring with context preservation""" + ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } + }) + + # Create archive + builder = Builder(_manifest_def(), context=ctx) + archive = io.BytesIO() + builder.to_archive(archive) + builder.close() + + # Restore from archive with context + archive.seek(0) + builder = Builder.from_archive(archive, context=ctx) + + signer = _make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "signed.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + + # Verify output is readable + reader = Reader(dest) + self.assertIsNotNone(reader.json()) + reader.close() + builder.close() + ctx.close() + + def test_two_phase_workflow(self): + """working-stores.md § Two-phase workflow example""" + # Phase 1: Prepare manifest + manifest_json = json.dumps({ + "title": "Artwork draft", + "assertions": [] + }) + + builder = Builder(manifest_json) + with open(THUMBNAIL_FILE, "rb") as thumb: + builder.add_resource("thumbnail", thumb) + with open(UNSIGNED_IMAGE, "rb") as sketch: + builder.add_ingredient( + json.dumps({"title": "Sketch"}), "image/jpeg", sketch + ) + + # Save working store as archive + archive = io.BytesIO() + builder.to_archive(archive) + builder.close() + + # Phase 2: Sign the asset + archive.seek(0) + builder = Builder.from_archive(archive) + + signer = _make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "signed_artwork.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + + self.assertTrue(os.path.exists(dest)) + reader = Reader(dest) + self.assertIsNotNone(reader.json()) + reader.close() + builder.close() + + # -- Embedded vs external manifests ------------------------------- + + def test_default_embedded_manifest(self): + """working-stores.md § Default: embedded manifest stores""" + builder = Builder(_manifest_def()) + signer = _make_signer() + + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "signed.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + + # Read it back - manifest store is embedded + reader = Reader(dest) + self.assertIsNotNone(reader.json()) + reader.close() + + def test_external_manifest_no_embed(self): + """working-stores.md § External manifest stores (no embed)""" + builder = Builder(_manifest_def()) + builder.set_no_embed() + + signer = _make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "output.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + manifest_bytes = builder.sign(signer, "image/jpeg", src, dst) + + # manifest_bytes contains the manifest store + self.assertIsNotNone(manifest_bytes) + self.assertGreater(len(manifest_bytes), 0) + + # Save it separately + c2pa_path = os.path.join(td, "output.c2pa") + with open(c2pa_path, "wb") as f: + f.write(manifest_bytes) + self.assertTrue(os.path.exists(c2pa_path)) + + # Asset should NOT have embedded manifest + with self.assertRaises(Error): + Reader(dest) + + def test_remote_manifest_url(self): + """working-stores.md § Remote manifest stores""" + builder = Builder(_manifest_def()) + builder.set_remote_url("https://example.com/manifests/") + + signer = _make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "output.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + # File should exist + self.assertTrue(os.path.exists(dest)) + + # -- Best practices ----------------------------------------------- + + def test_best_practice_context_for_config(self): + """working-stores.md § Use Context for configuration""" + ctx = Context.from_dict({ + "verify": { + "verify_after_sign": True + }, + }) + + builder = Builder(_manifest_def(), context=ctx) + reader = Reader(SIGNED_IMAGE, context=ctx) + + self.assertIsNotNone(reader.json()) + builder.close() + reader.close() + ctx.close() + + def test_best_practice_ingredients_provenance(self): + """working-stores.md § Use ingredients to build provenance chains""" + builder = Builder(_manifest_def()) + + ingredient_json = json.dumps({ + "title": "Original source", + "relationship": "parentOf" + }) + + with open(UNSIGNED_IMAGE, "rb") as ingredient: + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) + + signer = _make_signer() + with tempfile.TemporaryDirectory() as td: + dest = os.path.join(td, "signed.jpg") + with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) + self.assertTrue(os.path.exists(dest)) + builder.close() + + +if __name__ == "__main__": + unittest.main() From 2e58197fac64b6cf481ec967c96552971ca5736f Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 3 Mar 2026 21:54:24 -0800 Subject: [PATCH 04/75] fix: WIP --- examples/README.md | 1 - examples/sign.py | 2 + examples/training.py | 2 + review.md | 201 ++++++++++++++++++++++++++++++ src/c2pa/c2pa.py | 53 +++----- tests/test_docs.py | 6 +- tests/test_unit_tests.py | 22 +--- tests/test_unit_tests_threaded.py | 2 +- 8 files changed, 222 insertions(+), 67 deletions(-) create mode 100644 review.md diff --git a/examples/README.md b/examples/README.md index da7733b7..aa9c811b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,7 +7,6 @@ The examples use asset files from the `tests/fixtures` directory, save the resul The [`examples/sign.py`](https://github.com/contentauth/c2pa-python/blob/main/examples/sign.py) script shows how to sign an asset with a C2PA manifest and verify the asset. - The `examples/sign.py` script shows how to sign an asset with a C2PA manifest and verify it using a callback signer. Callback signers let you define signing logic, for example where to load keys from. The `examples/sign_info.py` script shows how to sign an asset with a C2PA manifest and verify it using a "default" signer created with the needed signer information. diff --git a/examples/sign.py b/examples/sign.py index 3df9fd5b..38a47b43 100644 --- a/examples/sign.py +++ b/examples/sign.py @@ -13,6 +13,8 @@ # This example shows how to sign an image with a C2PA manifest # using a callback signer and read the metadata added to the image. +# TMN-TODO: Use context APIs + import os import c2pa from cryptography.hazmat.primitives import hashes, serialization diff --git a/examples/training.py b/examples/training.py index b07d47ab..a103f986 100644 --- a/examples/training.py +++ b/examples/training.py @@ -13,6 +13,8 @@ # This example shows how to add a do not train assertion to an asset and then verify it # We use python crypto to sign the data using openssl with Ps256 here +# TMN-TODO: Use context APIs + import json import os diff --git a/review.md b/review.md new file mode 100644 index 00000000..c7c6a4bc --- /dev/null +++ b/review.md @@ -0,0 +1,201 @@ +# Critique: Settings and Context API design + +## Context + +The user asked for a design critique of the Settings and Context APIs in the c2pa Python SDK, focusing on what could be improved and made more Pythonic. This is a review document, not an implementation plan — the goal is to identify issues and propose improvements for discussion. + +--- + +## Bugs + +### 1. Resource leak in `Builder.from_archive()` when `context` is provided + +**File:** `src/c2pa/c2pa.py`, `Builder.from_archive()` classmethod + +When `context` is non-None, `cls({}, context=context)` runs `__init__`, which calls `_init_from_context` and allocates a native builder pointer. Then `from_archive` immediately **overwrites** `self._builder` with a new pointer from `c2pa_builder_from_archive`. The original pointer is leaked — never freed. + +**Fix:** `from_archive` should bypass `_init_from_context` when creating the initial Builder. It could pass `context=None` to `__init__` and then manually apply the context to the archive-loaded builder, or use `object.__new__(cls)` to skip `__init__` entirely. + +### 2. Dead code on every error-handling call site + +**File:** `src/c2pa/c2pa.py`, ~20 call sites + +The pattern used throughout Reader/Builder/Signer: +```python +error = _parse_operation_result_for_error(_lib.c2pa_error()) +if error: # NEVER reached — function returns None or raises + raise C2paError(error) # dead code +raise C2paError("...") # always reached +``` + +`_parse_operation_result_for_error` either raises a typed exception or returns `None`. It never returns a string. The `if error:` branch is dead code at every call site. + +**Fix:** Remove the dead `if error:` branches. Change call sites to: +```python +_parse_operation_result_for_error(_lib.c2pa_error()) +raise C2paError("...") +``` + +--- + +## Non-Pythonic patterns + +### 3. `Settings.set()` requires string values — no native Python types + +`set("builder.thumbnail.enabled", "false")` works, but `set("builder.thumbnail.enabled", False)` raises `AttributeError` (which is then mistyped as `C2paError.Encoding`). + +This is the biggest daily-use footgun. Python developers expect `True`/`False`/`42` to work, not `"true"`/`"false"`/`"42"`. + +**Fix:** Accept `Any` and auto-coerce: +```python +def set(self, path: str, value) -> 'Settings': + if isinstance(value, bool): + value_str = "true" if value else "false" + elif not isinstance(value, str): + value_str = json.dumps(value) + else: + value_str = value + ... +``` + +### 4. `Builder.sign()` makes required parameters look optional + +```python +def sign(self, signer=None, format=None, source=None, dest=None) -> bytes: +``` + +All four parameters default to `None`, but `format` and `source` are always required. Omitting them produces a runtime `C2paError` instead of Python's natural `TypeError`. This breaks IDE autocomplete hints and type checker expectations. + +**Fix:** Make the data-flow parameters positional and required, signer keyword-only: +```python +def sign(self, format: str, source, dest=None, *, signer=None) -> bytes: +``` + +However, this is a **breaking API change** since existing callers use `builder.sign(signer, "image/jpeg", src, dst)` with signer as the first positional arg. A migration path would be needed. + +### 5. `Settings` is write-only — no read/query/repr + +Once you call `settings.set(...)`, there is no way to inspect the current value, no `get()`, no `to_dict()`, no `__repr__`. Debugging requires observing side effects (e.g., "did the thumbnail get generated?"). + +This is partly a C API limitation (the opaque `C2paSettings` struct has no getter function exposed). But the Python layer could: +- Track all `set()` calls in a shadow dict for `__repr__` purposes +- Provide `__repr__` showing what was configured +- Store the original JSON/dict from `from_json`/`from_dict` for introspection + +### 6. `Settings.set()` paths are magic strings with no discoverability + +Paths like `"builder.thumbnail.enabled"` have no autocomplete, no constants, no enum. A typo like `"builder.thumbail.enabled"` silently passes (or silently fails depending on the C library behavior). + +**Possible fix:** Add a `SettingsPath` constants class: +```python +class SettingsPath: + THUMBNAIL_ENABLED = "builder.thumbnail.enabled" + VERIFY_AFTER_SIGN = "verify.verify_after_sign" + ... +``` + +Or provide a fluent builder API: +```python +settings.builder.thumbnail.enabled = False +``` + +The latter requires significant refactoring. The constants class is low-effort and immediately useful. + +### 7. `Settings.__setitem__` exists but `__getitem__` does not + +`settings["builder.thumbnail.enabled"] = "false"` works, but `settings["builder.thumbnail.enabled"]` raises `TypeError`. Half-implementing a dict interface is confusing — it violates the principle of least surprise. + +**Fix:** Either add `__getitem__` (requires C API support) or remove `__setitem__` (use `set()` only). Given the C API limitation, removing `__setitem__` is simpler and more honest. + +### 8. The `format` parameter in `Settings.update()` is vestigial + +```python +def update(self, data, format: str = "json") -> 'Settings': + if format != "json": + raise C2paError("Only JSON format is supported") +``` + +A parameter with exactly one valid value shouldn't be a parameter. It exists for forward-compatibility (TOML support was considered), but in practice it only confuses callers. + +**Fix:** Remove the `format` parameter or change to `Literal["json"]` with a deprecation warning. + +### 9. MIME types are raw strings everywhere + +`"image/jpeg"`, `"video/mp4"`, etc. appear as magic strings in `sign()`, `add_ingredient()`, `Reader()`. A typo like `"image/jpg"` fails at runtime. + +The SDK already has `Reader.get_supported_mime_types()` and `Builder.get_supported_mime_types()`, but they return lists at runtime — no static enum exists. + +**Possible fix:** A `MimeType` enum or constants namespace would catch common typos at import time: +```python +class C2paMimeType: + JPEG = "image/jpeg" + PNG = "image/png" + ... +``` + +### 10. Error type mismatch for wrong argument types + +`Settings.set("path", True)` catches the resulting `AttributeError` and re-raises it as `C2paError.Encoding`. This is misleading — it's not an encoding error, it's a type error. + +**Fix:** Validate types upfront and raise `TypeError` (or `C2paError.InvalidArgument` if one existed). + +--- + +## Inconsistencies + +### 11. `_parse_operation_result_for_error` has two calling conventions + +- Settings/Context use: `_parse_operation_result_for_error(None)` (let it call `c2pa_error()` internally) +- Reader/Builder/Signer use: `_parse_operation_result_for_error(_lib.c2pa_error())` (pre-fetch and pass in) + +Both produce identical behavior. Pick one. + +### 12. Different free strategies + +- Settings/Context use generic `c2pa_free(cast(ptr, c_void_p))` +- Reader uses `c2pa_reader_free(ptr)` +- Builder uses `c2pa_builder_free(ptr)` + +This works if the C API supports both, but mixing patterns makes code review harder. + +### 13. `close()` sets `_closed = True` twice in Reader/Builder but once in Settings/Context + +Reader/Builder set it inside `_cleanup_resources` AND in the `finally` block of `close()`. Settings/Context only set it inside `_cleanup_resources`. No functional bug, but inconsistent. + +### 14. `_has_signer` set before `build()` in Context.__init__ + +`self._has_signer = True` is set after `set_signer` succeeds but before `build()`. If `build()` fails, the flag is stale. Not exploitable (since `is_valid` would be `False`), but inaccurate internal state. + +### 15. `_c_context` is a private-convention name in a public Protocol + +`ContextProvider` is a public protocol that third-party code can implement. But it requires implementing `_c_context` — a leading-underscore property. This is an unusual contract. The underscore signals "don't use this" while the protocol signals "you must implement this." + +--- + +## Lower priority + +### 16. `_raise_typed_c2pa_error` uses a long if-elif chain + +A dict mapping `{prefix_str: ExceptionClass}` would be more maintainable than 15 if-elif branches. + +### 17. `version()` is not exported from `__init__.py` + +`sdk_version()` is exported, but `version()` (which returns both c2pa-c and c2pa-rs versions) is not. Users who want full version info must do `from c2pa.c2pa import version`. + +### 18. `Stream` is exported but is an internal implementation detail + +Users never construct `Stream` directly — the SDK wraps file objects internally. Exporting it clutters the public API surface. + +### 19. Deprecated functions remain in `__all__` + +`load_settings` and `read_ingredient_file` are deprecated but still in `__all__`, giving them equal prominence with the modern API. + +--- + +## Verification + +This is a review document — no code changes to verify. The findings can be validated by: +1. Reading the source at `src/c2pa/c2pa.py` +2. Running `settings.set("builder.thumbnail.enabled", False)` to confirm the `AttributeError` → `C2paError.Encoding` mistype +3. Confirming the dead-code `if error:` branches by tracing `_parse_operation_result_for_error` +4. Confirming the `from_archive` leak by adding a breakpoint in `_cleanup_resources` and observing the overwritten pointer is never freed diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index b779fc16..9d3d18ae 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -151,17 +151,6 @@ def _validate_library_exports(lib): _validate_library_exports(_lib) -# Signer-on-context functions may not yet be in the native library. -# Guard with hasattr checks for forward compatibility. -_SIGNER_CONTEXT_FUNCTIONS = [ - 'c2pa_context_builder_set_signer', - 'c2pa_builder_sign_context', -] - -_has_signer_context = all( - hasattr(_lib, fn) for fn in _SIGNER_CONTEXT_FUNCTIONS -) - class C2paSeekMode(enum.IntEnum): """Seek mode for stream operations.""" @@ -627,22 +616,20 @@ def _setup_function(func, argtypes, restype=None): ) _setup_function(_lib.c2pa_free, [ctypes.c_void_p], ctypes.c_int) -# Conditionally set up signer-on-context function prototypes -if _has_signer_context: - _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 - ) +_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): @@ -1544,12 +1531,6 @@ def __init__( ) if signer is not None: - if not _has_signer_context: - raise C2paError( - "Signer-on-Context requires" - " a newer c2pa-c library" - " version" - ) signer_ptr, callback_cb = ( signer._release() ) @@ -4027,12 +4008,6 @@ def _sign_context_internal( """ self._ensure_valid_state() - if not _has_signer_context: - raise C2paError( - "Signer-on-Context requires a newer" - " version of the c2pa-c library." - ) - format_lower = format.lower() if ( format_lower diff --git a/tests/test_docs.py b/tests/test_docs.py index 721fe733..dd842da8 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -40,7 +40,7 @@ ContextProvider, load_settings, ) -from c2pa.c2pa import _has_signer_context # noqa: E402 + # ── Paths ──────────────────────────────────────────────────── @@ -297,10 +297,6 @@ def test_builder_no_thumbnails_context(self): # -- Configuring a signer ----------------------------------------- - @unittest.skipUnless( - _has_signer_context, - "Signer-on-context not supported by native lib", - ) def test_signer_on_context(self): """context.md § From Settings (signer-on-context)""" from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 7356ad4c..b29ef8dd 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -35,7 +35,7 @@ from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType # noqa: E501 from c2pa import Settings, Context, ContextProvider from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable # noqa: E501 -from c2pa.c2pa import _has_signer_context + PROJECT_PATH = os.getcwd() FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") @@ -5367,10 +5367,6 @@ def test_context_satisfies_protocol(self): # ── 3. Context with Signer ────────────────────── -@unittest.skipUnless( - _has_signer_context, - "Signer-on-context not supported by native lib", -) class TestContextWithSigner(unittest.TestCase): def test_context_with_signer(self): @@ -5560,10 +5556,6 @@ def test_builder_from_json_with_context(self): builder.close() ctx.close() - @unittest.skipUnless( - _has_signer_context, - "Signer-on-context not supported", - ) def test_builder_sign_context_signer(self): signer = _ctx_make_signer() ctx = Context(signer=signer) @@ -5590,10 +5582,6 @@ def test_builder_sign_context_signer(self): builder.close() ctx.close() - @unittest.skipUnless( - _has_signer_context, - "Signer-on-context not supported", - ) def test_builder_sign_explicit_overrides(self): ctx_signer = _ctx_make_signer() ctx = Context(signer=ctx_signer) @@ -5726,10 +5714,6 @@ def test_sign_no_thumbnail_via_context(self): ctx.close() s.close() - @unittest.skipUnless( - _has_signer_context, - "Signer-on-context not supported", - ) def test_sign_read_roundtrip(self): signer = _ctx_make_signer() ctx = Context(signer=signer) @@ -5788,10 +5772,6 @@ def test_shared_context_multi_builders(self): signer2.close() ctx.close() - @unittest.skipUnless( - _has_signer_context, - "Signer-on-context not supported", - ) def test_sign_callback_signer_in_ctx(self): signer = _ctx_make_callback_signer() ctx = Context(signer=signer) diff --git a/tests/test_unit_tests_threaded.py b/tests/test_unit_tests_threaded.py index bd3d2da8..4e96a756 100644 --- a/tests/test_unit_tests_threaded.py +++ b/tests/test_unit_tests_threaded.py @@ -23,7 +23,7 @@ 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, _has_signer_context +from c2pa.c2pa import Stream PROJECT_PATH = os.getcwd() FIXTURES_FOLDER = os.path.join(os.path.dirname(__file__), "fixtures") From f0cf765f6d03c433ebce8516fdfbef1b97dbe2c1 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:03:53 -0800 Subject: [PATCH 05/75] Delete docs/faqs.md --- docs/faqs.md | 126 --------------------------------------------------- 1 file changed, 126 deletions(-) delete mode 100644 docs/faqs.md diff --git a/docs/faqs.md b/docs/faqs.md deleted file mode 100644 index 6ec055c0..00000000 --- a/docs/faqs.md +++ /dev/null @@ -1,126 +0,0 @@ -# Frequently-asked questions (FAQs) - -## When do I use `Reader` vs. `Builder`? - -### Quick reference decision tree - -```mermaid -flowchart TD - Q1{Need to read an existing manifest?} - Q1 -->|No| USE_B["Use Builder alone (new manifest from scratch)"] - Q1 -->|Yes| Q2{Need to create a new/modified manifest?} - Q2 -->|No| USE_R["Use Reader alone (inspect/extract only)"] - Q2 -->|Yes| USE_BR[Use both Reader + Builder] - USE_BR --> Q3{What to keep from the existing manifest?} - Q3 -->|Everything| P1["add_ingredient() with original asset"] - Q3 -->|Some parts| P2["1. Read: reader.json() + resource_to_stream() 2. Filter: pick ingredients & actions to keep 3. Build: new Builder with filtered JSON 4. Transfer: add_resource() for kept binaries 5. Sign: builder.sign()"] - Q3 -->|Nothing| P3["New Builder alone (fresh manifest, no prior provenance)"] -``` - -### When to use `Reader` - -**Use a `Reader` when the goal is only to inspect or extract data without creating a new manifest.** - -- Validating whether an asset has C2PA credentials -- Displaying provenance information to a user -- Extracting thumbnails for display -- Checking trust status and validation results -- Inspecting ingredient chains - -```py -reader = Reader("image.jpg", context=ctx) -json_data = reader.json() # inspect the manifest -reader.resource_to_stream(thumb_uri, stream) # extract a thumbnail -``` - -The `Reader` is read-only. It never modifies the source asset. - -### When to use a `Builder` - -**Use a `Builder` when creating a manifest from scratch on an asset that has no existing C2PA data, or when intentionally starting with a clean slate.** - -- Signing a brand-new asset for the first time -- Adding C2PA credentials to an unsigned asset -- Creating a manifest with all content defined from scratch - -```py -builder = Builder(manifest_json, context=ctx) -with open("ingredient.jpg", "rb") as ingredient: - builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) -with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) -``` - -Every call to the `Builder` constructor or `Builder.from_archive()` creates a new `Builder`. There is no way to modify an existing signed manifest directly. - -### When to use both `Reader` and `Builder` together - -**Use both when filtering content from an existing manifest into a new one. The `Reader` extracts data, application code filters it, and a new `Builder` receives only the selected parts.** - -- Filtering specific ingredients from a manifest -- Dropping specific assertions while keeping others -- Filtering actions (keeping some, removing others) -- Merging ingredients from multiple signed assets or archives -- Re-signing with different settings while keeping some original content - -```py -import json - -# Read existing (does not modify the asset) -reader = Reader("signed.jpg", context=ctx) -parsed = json.loads(reader.json()) - -# Filter what to keep (application-specific logic) -kept = filter_manifest(parsed) - -# Create a new Builder with only the filtered content -builder = Builder(json.dumps(kept), context=ctx) -# ... transfer resources ... -with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) -``` - -## How should I add ingredients? - -There are two ways: using `add_ingredient()` (or `add_ingredient_from_file_path()`) and injecting ingredient JSON directly into the manifest definition. - -| Approach | What it does | When to use | -| --- | --- | --- | -| `add_ingredient(json, format, stream)` or `add_ingredient_from_file_path(json, format, path)` | Reads the source (a signed asset, an unsigned file, or a `.c2pa` archive), extracts its manifest store automatically, generates a thumbnail | Adding an ingredient where the library should handle extraction | -| Inject via manifest JSON + `add_resource()` | Accepts the ingredient JSON and all binary resources provided manually | Reconstructing from a reader or merging from multiple readers, where the data has already been extracted | - -## When to use archives - -There are two distinct archive concepts: - -- **Builder archives (working store archives)** (`to_archive()` / `from_archive()`) serialize the full `Builder` state (manifest definition, resources, ingredients) so it can be resumed or signed later, possibly on a different machine or in a different process. The archive is not yet signed. Use builder archives when: - - Signing must happen on a different machine (e.g., an HSM server) - - Checkpointing work-in-progress before signing - - Transmitting a `Builder` state across a network boundary - -- **Ingredient archives** contain the manifest store data (`.c2pa` binary) from ingredients that were added to a `Builder`. When a signed asset is added as an ingredient via `add_ingredient()`, the library extracts and stores its manifest store as `manifest_data` within the ingredient record. When the `Builder` is then serialized via `to_archive()`, these ingredient manifest stores are included. Use ingredient archives when: - - Building an ingredients catalog for pick-and-choose workflows - - Preserving provenance history from source assets - - Transferring ingredient data between `Reader` and `Builder` - -See also [Working stores](https://opensource.contentauthenticity.org/docs/rust-sdk/docs/working-stores). - -Key consideration for builder archives: `from_archive()` creates a new `Builder` with **default** context settings. If specific settings are needed (e.g., thumbnails disabled), pass a `context` to `from_archive()`: - -```py -import io - -# Preserves the caller's context settings -archive_stream = io.BytesIO(archive_data) -builder = Builder.from_archive(archive_stream, context=ctx) -with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) -``` - -## Can a manifest be modified in place? - -**No.** C2PA manifests are cryptographically signed. Any modification invalidates the signature. The only way to "modify" a manifest is to create a new `Builder` with the desired changes and sign it. This is by design: it ensures the integrity of the provenance chain. - -## What happens to the provenance chain when rebuilding a working store? - -When creating a new manifest, the chain is preserved once the original asset is added as an ingredient. The ingredient carries the original's manifest data, so validators can trace the full history. If the original is not added as an ingredient, the provenance chain is broken: the new manifest has no link to the original. This might be intentional (starting fresh) or a mistake (losing provenance). From bc648067061a68dd1b49fe83cb941f125638133c Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:04:46 -0800 Subject: [PATCH 06/75] Update settings.md --- docs/settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/settings.md b/docs/settings.md index 715ca483..30345122 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -1,7 +1,7 @@ # Using settings You can configure SDK settings using a JSON format that controls many aspects of the library's behavior. -The settings JSON format is the same across all languages in the SDK (Rust, C/C++, Python, and so on). +The settings JSON format is the same across all languages for the C2PA SDKs (Rust, C/C++, Python, and so on). This document describes how to use settings in Python. The Settings schema is the same as the [Rust library](https://github.com/contentauth/c2pa-rs); for the complete JSON schema, see the [Settings reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/). From 9e43c5356c0f160addd2b458ad3e7bc6d16d8099 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:05:23 -0800 Subject: [PATCH 07/75] Update usage instructions for context managers Clarified usage of context managers with Builder, Reader, and Signer classes. --- docs/usage.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index b9c68fbf..4fd5db2a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -13,8 +13,7 @@ from c2pa import Builder, Reader, Signer, C2paSigningAlg, C2paSignerInfo from c2pa import Settings, Context, ContextProvider ``` -You can use `Builder`, `Reader`, `Signer`, `Settings`, and `Context` classes with context managers by using a `with` statement. -Doing this is recommended to ensure proper resource and memory cleanup. +You can use both `Builder`, `Reader` and `Signer` classes with context managers by using a `with` statement. ## Define manifest JSON From 431198cd866411f700afd0e5c218c58fedb20a1d Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 10:06:43 -0800 Subject: [PATCH 08/75] fix: WIP --- docs/settings.md | 4 ---- docs/usage.md | 4 ---- 2 files changed, 8 deletions(-) diff --git a/docs/settings.md b/docs/settings.md index 715ca483..22bbbad1 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -56,10 +56,6 @@ settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) # Merge additional configuration settings.update({"verify": {"remote_manifest_fetch": True}}) - -# Use as a context manager for automatic cleanup -with Settings() as settings: - settings.set("builder.thumbnail.enabled", "false") ``` ## Overview of the Settings structure diff --git a/docs/usage.md b/docs/usage.md index b9c68fbf..a131358e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -150,10 +150,6 @@ settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) # Merge additional configuration settings.update({"verify": {"remote_manifest_fetch": True}}) - -# Use as a context manager for automatic cleanup -with Settings() as settings: - settings.set("builder.thumbnail.enabled", "false") ``` ### Context From 55e1d29d0758c7f0cf7dd57d5042f2daae2c5250 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 10:11:44 -0800 Subject: [PATCH 09/75] fix: Docs --- docs/usage.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 089c2f4c..13fb3d67 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -153,7 +153,7 @@ settings.update({"verify": {"remote_manifest_fetch": True}}) ### Context -A `Context` carries optional `Settings` and an optional `Signer`, and is passed to `Reader` or `Builder` to control their behavior. +A `Context` can carry `Settings` and a `Signer`, and is passed to `Reader` or `Builder` to control their behavior through settings propagation. ```py from c2pa import Context, Settings, Reader, Builder, Signer @@ -178,7 +178,7 @@ builder = Builder(manifest_json, context=ctx) ### Context with a Signer -When a `Signer` is passed to `Context`, the signer is **consumed** — the `Signer` object becomes invalid after this call and must not be reused. The `Context` takes ownership of the underlying native signer. This allows signing without passing an explicit signer to `Builder.sign()`. +When a `Signer` is passed to `Context`, the `Signer` object becomes invalid after this call and must not be reused directly anymore as it became part of the Context. The `Context` takes ownership of the underlying native signer. This allows signing without passing an explicit signer to `Builder.sign()`. ```py from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg @@ -194,9 +194,9 @@ signer = Signer.from_info(signer_info) # Create context with signer (signer is consumed) ctx = Context(settings=settings, signer=signer) -# signer is now invalid and must not be used again +# The signer object is now invalid and must not be used directly again -# Build and sign — no signer argument needed +# Build and sign: no signer argument needed, since the signer is in the context! builder = Builder(manifest_json, context=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) @@ -205,7 +205,7 @@ with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: If both an explicit signer and a context signer are available, the explicit signer always takes precedence: ```py -# Explicit signer wins over context signer +# Explicit signer wins over context signer and will be used for signing in this call manifest_bytes = builder.sign(explicit_signer, "image/jpeg", source, dest) ``` @@ -218,21 +218,24 @@ from c2pa import ContextProvider, Context # The built-in Context satisfies ContextProvider ctx = Context() -assert isinstance(ctx, ContextProvider) # True +assert isinstance(ctx, ContextProvider) ``` ### Migrating from load_settings -The `load_settings()` function is deprecated. Replace it with `Settings` and `Context`: +The `load_settings()` function that set settings in a thread-local fashion is deprecated. +Replace it with `Settings` and `Context` usage to propagate configurations: ```py -# Before (deprecated): +# Before: from c2pa import load_settings load_settings({"builder": {"thumbnail": {"enabled": False}}}) reader = Reader("file.jpg") -# After (recommended): +# After: from c2pa import Settings, Context, Reader + +# Settings are on the context, and move with the context settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) ctx = Context(settings=settings) reader = Reader("file.jpg", context=ctx) From 8e34eac94cb5953969fd3edc25bc152f40e7ea19 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 13:19:13 -0800 Subject: [PATCH 10/75] fix: Clean up --- src/c2pa/c2pa.py | 60 ++++++++++++++++-------------------------------- 1 file changed, 20 insertions(+), 40 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 9d3d18ae..27dbc281 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -931,10 +931,15 @@ 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. + Settings will be set thread-local and apply to + all C2PA operations in the current thread. .. deprecated:: Use :class:`Settings` and :class:`Context` for - per-instance configuration instead. + 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 @@ -1220,9 +1225,9 @@ def sign_file( @runtime_checkable class ContextProvider(Protocol): - """Protocol for types that provide a C2PA context. + """Protocol (interface) for types that provide a C2PA context. - Allows third-party implementations of custom context providers. + Allows implementations of custom context providers. The built-in Context class satisfies this protocol. """ @@ -1236,24 +1241,9 @@ def _c_context(self): ... class Settings: """Per-instance configuration for C2PA operations. - Settings control behavior such as thumbnail generation, - trust lists, and verification flags. Use with Context to + Settings control behavior such as thumbnail generation and + trust lists configurations. Use with Context to apply settings to Reader/Builder operations. - - Example:: - - settings = Settings() - settings.set("builder.thumbnail.enabled", "false") - - # Or via from_json / from_dict: - settings = Settings.from_json('{"verify": {...}}') - settings = Settings.from_dict({"verify": {...}}) - - # Dict-like access: - settings["builder.thumbnail.enabled"] = "false" - - # Method chaining: - settings.set("a", "1").set("b", "2") """ def __init__(self): @@ -1453,25 +1443,14 @@ def __del__(self): class Context: """Per-instance context for C2PA operations. - A Context carries optional Settings and an optional Signer, - and is passed to Reader or Builder to control their behavior. - - When a Signer is provided the Signer object is **consumed** - and must not be used again. - - Example:: - - # Default context - ctx = Context() - - # With settings - settings = Settings() - settings.set("builder.thumbnail.enabled", "false") - ctx = Context(settings=settings) + 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 + object as parameter. - # With settings and signer (signer is consumed) - signer = Signer.from_info(info) - ctx = Context(settings=settings, signer=signer) + 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__( @@ -1483,9 +1462,10 @@ def __init__( Args: settings: Optional Settings for configuration. - If None, default settings are used. + If None, default SDK settings are used. signer: Optional Signer. If provided it is - CONSUMED and must not be used again. + consumed and must not be used directly again + after that call. Raises: C2paError: If creation fails or if signer is From 477b5fd39029d84c5a89b417b012fe1f722a469b Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 13:20:16 -0800 Subject: [PATCH 11/75] fix: Move docs out --- docs/context.md | 588 ----------------- docs/settings.md | 449 ------------- docs/tbd_selective-manifests.md | 1098 ------------------------------- docs/working-stores.md | 648 ------------------ 4 files changed, 2783 deletions(-) delete mode 100644 docs/context.md delete mode 100644 docs/settings.md delete mode 100644 docs/tbd_selective-manifests.md delete mode 100644 docs/working-stores.md diff --git a/docs/context.md b/docs/context.md deleted file mode 100644 index ccc84145..00000000 --- a/docs/context.md +++ /dev/null @@ -1,588 +0,0 @@ -# Using Context to configure the SDK - -Use the `Context` class to configure how `Reader`, `Builder`, and other aspects of the SDK operate. - -## What is Context? - -Context encapsulates SDK configuration: - -- **Settings**: Verification options, [Builder behavior](#configuring-builder), [Reader trust configuration](#configuring-reader), thumbnail configuration, and more. See [Using settings](settings.md) for complete details. -- [**Signer configuration**](#configuring-a-signer): Optional signer credentials that can be stored in the Context for reuse. -- **State isolation**: Each `Context` is independent, allowing different configurations to coexist in the same application. - -### Why use Context? - -`Context` is better than the deprecated global `load_settings()` function because it: - -- **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 between them. -- **Improves code clarity**: Reading `Builder(manifest_json, context=ctx)` immediately shows that configuration is being used. - -### Class diagram - -This diagram shows the public classes in the SDK and their relationships. - -```mermaid -classDiagram - direction LR - - class Settings { - +from_json(json_str) Settings$ - +from_dict(config) Settings$ - +set(path, value) Settings - +update(data, format) Settings - +close() - +is_valid bool - } - - class ContextProvider { - <> - +is_valid bool - } - - 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, context) 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) - +sign(signer, 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 : satisfies - 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 : optional context= - Context --> Builder : optional context= - Signer --> Builder : sign(signer) - C2paBuilderIntent --> Builder : set_intent - C2paDigitalSourceType --> Builder : set_intent - C2paError --> C2paError_Subtypes : subclasses -``` - -> [!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). - -## Creating a Context - -There are several ways to create a `Context`, depending on your needs: - -- [Using SDK default settings](#using-sdk-default-settings) -- [From a JSON string](#from-a-json-string) -- [From a dictionary](#from-a-dictionary) -- [From a Settings object](#from-a-settings-object) - -### Using SDK default settings - -The simplest approach is using [SDK default settings](settings.md#default-configuration). - -**When to use:** For quick prototyping, or when you're happy with default behavior (verification enabled, thumbnails enabled at 1024px, and so on). - -```py -from c2pa import Context - -ctx = Context() # Uses SDK defaults -``` - -### From a JSON string - -You can create a `Context` directly from a JSON configuration string. - -**When to use:** For simple configuration that doesn't need to be shared across the codebase, or when hard-coding settings for a specific purpose (for example, a utility script). - -```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 - -You can create a `Context` from a Python 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 - -You can build a `Settings` object programmatically, then create a `Context` from that. - -**When to use:** For configuration that needs runtime logic (such as conditional settings based on environment), or when you want to build settings incrementally. - -```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=settings) -``` - -## Common configuration patterns - -### Development environment with test certificates - -During development, you often need to trust self-signed or custom CA certificates: - -```py -# Load your test root CA -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} - } -}) -``` - -### Configuration from environment variables - -Adapt configuration based on the runtime environment: - -```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=settings) -``` - -### Layered configuration - -Load base configuration and apply runtime overrides: - -```py -import json - -# Load base configuration from a file -with open("config/base.json", "r") as f: - base_config = json.load(f) - -settings = Settings.from_dict(base_config) - -# Apply environment-specific overrides -settings.update({"builder": {"claim_generator_info": {"version": app_version}}}) - -ctx = Context(settings=settings) -``` - -For the full list of settings and defaults, see [Using settings](settings.md). - -## Configuring Reader - -Use `Context` to control how `Reader` validates manifests and handles remote resources, including: - -- **Verification behavior**: Whether to verify after reading, check trust, and so on. -- [**Trust configuration**](#trust-configuration): Which certificates to trust when validating signatures. -- [**Network access**](#offline-operation): Whether to fetch remote manifests or OCSP responses. - -> [!IMPORTANT] -> `Context` is used only at construction. `Reader` copies the configuration it needs internally, so the `Context` object does not need to outlive the `Reader`. - -```py -ctx = Context.from_dict({"verify": {"remote_manifest_fetch": False}}) -reader = Reader("image.jpg", context=ctx) -``` - -### Reading from a file - -```py -ctx = Context.from_dict({ - "verify": { - "remote_manifest_fetch": False, - "ocsp_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()) -``` - -### Trust configuration - -Example of trust configuration in a settings dictionary: - -```py -ctx = Context.from_dict({ - "trust": { - "user_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCA...\n-----END CERTIFICATE-----", - "trust_config": "1.3.6.1.4.1.311.76.59.1.9\n1.3.6.1.4.1.62558.2.1" - } -}) - -reader = Reader("signed_asset.jpg", context=ctx) -``` - -### Full validation - -To configure full validation, with all verification features enabled: - -```py -ctx = Context.from_dict({ - "verify": { - "verify_after_reading": True, - "verify_trust": True, - "verify_timestamp_trust": True, - "remote_manifest_fetch": True - } -}) - -reader = Reader("asset.jpg", context=ctx) -``` - -For more information, see [Settings - Verify](settings.md#verify). - -### Offline operation - -To configure `Reader` to work with no network access: - -```py -ctx = Context.from_dict({ - "verify": { - "remote_manifest_fetch": False, - "ocsp_fetch": False - } -}) - -reader = Reader("local_asset.jpg", context=ctx) -``` - -For more information, see [Settings - Offline or air-gapped environments](settings.md#offline-or-air-gapped-environments). - -## Configuring Builder - -`Builder` uses `Context` to control how to create and sign C2PA manifests. The `Context` affects: - -- **Claim generator information**: Application name, version, and metadata embedded in the manifest. -- **Thumbnail generation**: Whether to create thumbnails, 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 can be stored in the context for reuse. - -> [!IMPORTANT] -> The `Context` is used only when constructing the `Builder`. The `Builder` copies the configuration it needs internally, so the `Context` object does not need to outlive the `Builder`. - -### Basic use - -```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) - -# Pass signer explicitly at signing time -with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) -``` - -### Controlling thumbnail generation - -```py -# Disable thumbnails for faster processing -no_thumbnails_ctx = Context.from_dict({ - "builder": { - "claim_generator_info": {"name": "Batch Processor"}, - "thumbnail": {"enabled": False} - } -}) - -# Or customize thumbnail size and quality for mobile -mobile_ctx = Context.from_dict({ - "builder": { - "claim_generator_info": {"name": "Mobile App"}, - "thumbnail": { - "enabled": True, - "long_edge": 512, - "quality": "low", - "prefer_smallest_format": True - } - } -}) -``` - -## Configuring a signer - -You can configure a signer in two ways: - -- [From Settings (signer-on-context)](#from-settings) -- [Explicit signer passed to sign()](#explicit-signer) - -### From Settings - -Create a `Signer` and pass it to the `Context`. The signer is **consumed** — the `Signer` object becomes invalid after this call and must not be reused. The `Context` takes ownership of the underlying native signer. - -```py -from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg - -# Create a signer -signer_info = C2paSignerInfo( - alg=C2paSigningAlg.ES256, - sign_cert=cert_data, - private_key=key_data, - ta_url=b"http://timestamp.digicert.com" -) -signer = Signer.from_info(signer_info) - -# Create context with signer (signer is consumed) -ctx = Context(settings=settings, signer=signer) -# signer is now invalid and must not be used again - -# Build and sign — no signer argument needed -builder = Builder(manifest_json, context=ctx) -with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: - builder.sign(format="image/jpeg", source=src, dest=dst) -``` - -> [!NOTE] -> Signer-on-context requires a compatible version of the native c2pa-c library. If the library does not support this feature, a `C2paError` is raised when passing a `Signer` to `Context`. - -### Explicit signer - -For full programmatic control, create a `Signer` and pass it 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) -``` - -If both an explicit signer and a context signer are available, the explicit signer always takes precedence: - -```py -# Explicit signer wins over context signer -builder.sign(explicit_signer, "image/jpeg", source, dest) -``` - -## Context lifetime and usage - -### Context as a context manager - -`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: - -```py -ctx = Context(settings=settings) - -# All three use the same configuration -builder1 = Builder(manifest1, context=ctx) -builder2 = Builder(manifest2, context=ctx) -reader = Reader("image.jpg", context=ctx) - -# Context can be closed after construction; readers/builders still work -``` - -### Multiple contexts for different purposes - -Use different `Context` objects when you need different settings; for example, for development vs. production, or different trust configurations: - -```py -dev_ctx = Context(settings=dev_settings) -prod_ctx = Context(settings=prod_settings) - -# Different builders with different configurations -dev_builder = Builder(manifest, context=dev_ctx) -prod_builder = Builder(manifest, context=prod_ctx) -``` - -### ContextProvider protocol - -The `ContextProvider` protocol allows third-party implementations of custom context providers. Any class that implements `is_valid` and `_c_context` properties satisfies the protocol and can be passed to `Reader` or `Builder` as `context`. - -```py -from c2pa import ContextProvider, Context - -# The built-in Context satisfies ContextProvider -ctx = Context() -assert isinstance(ctx, ContextProvider) # True -``` - -## Migrating from load_settings - -The `load_settings()` function is deprecated. Replace it with `Settings` and `Context`: - -| 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 -``` - -**Using current APIs:** - -```py -from c2pa import Settings, Context, Reader - -settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) -ctx = Context(settings=settings) -reader = Reader("image.jpg", context=ctx) -``` - -## See also - -- [Using settings](settings.md) — schema, property reference, and examples. -- [Usage](usage.md) — reading and signing with Reader and Builder. -- [CAI settings schema](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/): full schema reference. diff --git a/docs/settings.md b/docs/settings.md deleted file mode 100644 index 89f8c859..00000000 --- a/docs/settings.md +++ /dev/null @@ -1,449 +0,0 @@ -# Using settings - -You can configure SDK settings using a JSON format that controls many aspects of the library's behavior. -The settings JSON format is the same across all languages for the C2PA SDKs (Rust, C/C++, Python, and so on). - -This document describes how to use settings in Python. The Settings schema is the same as the [Rust library](https://github.com/contentauth/c2pa-rs); for the complete JSON schema, see the [Settings reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/). - -## Using settings with Context - -The recommended approach is to pass settings to a `Context` object and then use the `Context` with `Reader` and `Builder`. This gives you explicit, isolated configuration with no global state. For details on creating and using contexts, see [Using Context to configure the SDK](context.md). - -**Legacy approach:** The deprecated `load_settings()` function sets global settings. Don't use that approach; instead pass a `Context` (with settings) to `Reader` and `Builder`. See [Using Context with Reader](context.md#configuring-reader) and [Using Context with Builder](context.md#configuring-builder). - -## Settings API - -Create and configure settings: - -| 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 (e.g. `"verify.verify_after_sign"`). Value must be a string. Returns `self` for chaining. Use this for programmatic configuration. | -| `update(data, format="json")` | Merge JSON configuration into existing settings. `data` can be a JSON string or a dict. Later keys override earlier ones. Use this to apply configuration files or JSON strings. Only `"json"` format is supported. | -| `settings["path"] = "value"` | Dict-like setter. Equivalent to `set(path, value)`. | -| `is_valid` | Property that returns `True` if the object holds valid resources (not closed). | -| `close()` | Release native resources. Called automatically when used as a context manager. | - -**Important notes:** - -- The `set()` and `update()` methods can be chained for sequential configuration. -- When using multiple configuration methods, later calls override earlier ones (last wins). -- Use the `with` statement for automatic resource cleanup. -- Only JSON format is supported for settings in the Python SDK. - -```py -from c2pa import Settings - -# Create with defaults -settings = Settings() - -# Set individual values by dot-notation path -settings.set("builder.thumbnail.enabled", "false") - -# Method chaining -settings.set("builder.thumbnail.enabled", "false").set("verify.verify_after_sign", "true") - -# Dict-like access -settings["builder.thumbnail.enabled"] = "false" - -# Create from JSON string -settings = Settings.from_json('{"builder": {"thumbnail": {"enabled": false}}}') - -# Create from a dictionary -settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) - -# Merge additional configuration -settings.update({"verify": {"remote_manifest_fetch": True}}) -``` - -## Overview of the Settings structure - -The Settings JSON has this top-level structure: - -```json -{ - "version": 1, - "trust": { ... }, - "cawg_trust": { ... }, - "core": { ... }, - "verify": { ... }, - "builder": { ... }, - "signer": { ... }, - "cawg_x509_signer": { ... } -} -``` - -### Settings format - -Settings are provided in **JSON** only. Pass JSON strings to `Settings.from_json()` or dictionaries to `Settings.from_dict()`. - -```py -# From JSON string -settings = Settings.from_json('{"verify": {"verify_after_sign": true}}') - -# From dict -settings = Settings.from_dict({"verify": {"verify_after_sign": True}}) - -# Context from JSON string -ctx = Context.from_json('{"verify": {"verify_after_sign": true}}') - -# Context from dict -ctx = Context.from_dict({"verify": {"verify_after_sign": True}}) -``` - -To load from a file, read the file contents and pass them to `Settings.from_json()`: - -```py -import json - -with open("config/settings.json", "r") as f: - settings = Settings.from_json(f.read()) -``` - -## Default configuration - -The settings JSON schema — including the complete default configuration with all properties and their default values — is shared with all languages in the SDK: - -```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 - } -} -``` - -## Overview of Settings - -For a complete reference to all the Settings 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). | - -The top-level `version` property must be `1`. All other properties are optional. - -For Boolean values, use JSON Booleans `true` and `false` in JSON strings, or Python `True` and `False` when using `from_dict()` or `update()` with a dict. - -> [!IMPORTANT] -> If you don't specify a value for a property, 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. - -### Trust configuration - -The [`trust` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#trust) control which certificates are trusted when validating C2PA manifests. - -- Using `user_anchors`: recommended for development -- Using `allowed_list` (bypass chain validation) - -| Property | Type | Description | Default | -|----------|------|-------------|---------| -| `trust.allowed_list` | string | Explicitly allowed certificates (PEM format). These certificates are trusted regardless of chain validation. Use for development/testing. | — | -| `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 (e.g., document signing: `1.3.6.1.4.1.311.76.59.1.9`). | — | -| `trust.user_anchors` | string | Additional user-provided root certificates (PEM format). Adds custom certificate authorities without replacing the SDK's built-in trust anchors. | — | - -When using self-signed certificates or custom certificate authorities during development, you need to configure trust settings so the SDK can validate your test signatures. - -#### Using `user_anchors` - -For development, you can add your test root CA to the trusted anchors without replacing the SDK's default trust store. -For example: - -```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) -``` - -#### Using `allowed_list` - -To bypass chain validation, for quick testing, explicitly allow a specific certificate without validating the chain. -For example: - -```py -with open("test_cert.pem", "r") as f: - test_cert = f.read() - -settings = Settings() -settings.update({ - "trust": { - "allowed_list": test_cert - } -}) - -ctx = Context(settings=settings) -reader = Reader("signed_asset.jpg", context=ctx) -``` - -### CAWG trust configuration - -The `cawg_trust` properties configure CAWG (Creator Assertions Working Group) validation of identity assertions in C2PA manifests. The `cawg_trust` object 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. - -Use cases: - -- **Performance tuning for large files**: Set `core.backing_store_memory_threshold_in_mb` to `2048` or higher if 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) specify how the SDK validates C2PA manifests. These settings affect both reading existing manifests and verifying newly signed content. - -Common use cases include: - -- [Offline or air-gapped environments](#offline-or-air-gapped-environments). -- [Fast development iteration](#fast-development-iteration) with verification disabled. -- [Strict validation](#strict-validation) for certification or compliance testing. - -By default, the following `verify` properties are `true`, which enables verification: - -- `remote_manifest_fetch` - Fetch remote manifests referenced in the asset. Disable in offline or air-gapped environments. -- `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_timestamp_trust` - Verify timestamp authority (TSA) certificates. WARNING: Disabling this setting makes verification non-compliant. -- `verify_trust` - Verify signing certificates against configured trust anchors. WARNING: Disabling this setting makes verification non-compliant. - -> [!WARNING] -> Disabling verification options (changing `true` to `false`) can make verification non-compliant with the C2PA specification. Only modify these settings in controlled environments or when you have specific requirements. - -#### Offline or air-gapped environments - -Set `remote_manifest_fetch` and `ocsp_fetch` to `false` to disable network-dependent verification features: - -```py -ctx = Context.from_dict({ - "verify": { - "remote_manifest_fetch": False, - "ocsp_fetch": False - } -}) - -reader = Reader("signed_asset.jpg", context=ctx) -``` - -See also [Using Context with Reader](context.md#configuring-reader). - -#### Fast development iteration - -During active development, you can disable verification for faster iteration: - -```py -# WARNING: Only use during development, not in production! -settings = Settings() -settings.set("verify.verify_after_reading", "false") -settings.set("verify.verify_after_sign", "false") - -dev_ctx = Context(settings=settings) -``` - -#### 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) -validation_result = reader.json() -``` - -### 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. **Recommended fields:** - -- `name` (string, required): Your application name (e.g., `"My Photo Editor"`) -- `version` (string, recommended): Application version (e.g., `"2.1.0"`) -- `icon` (string, optional): Icon in C2PA format -- `operating_system` (string, optional): OS identifier or `"auto"` to auto-detect - -**Example:** - -```py -ctx = Context.from_dict({ - "builder": { - "claim_generator_info": { - "name": "My Photo Editor", - "version": "2.1.0", - "operating_system": "auto" - } - } -}) -``` - -#### Thumbnail settings - -The [`builder.thumbnail`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#thumbnailsettings) properties control automatic thumbnail generation. - -For examples of configuring thumbnails for mobile bandwidth or disabling them for batch processing, see [Controlling thumbnail generation](context.md#controlling-thumbnail-generation). - -#### Action tracking settings - -| 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` | - -#### Other builder settings - -| Property | Type | Description | Default | -|----------|------|-------------|---------| -| `builder.intent` | object | Claim intent: `{"Create": "digitalCapture"}`, `{"Edit": null}`, or `{"Update": null}`. Describes the purpose of the claim. | `null` | -| `builder.generate_c2pa_archive` | Boolean | Generate content in C2PA archive format. Keep enabled for standard C2PA compliance. | `true` | - -##### Setting Builder intent - -You can use `Context` to set `Builder` intent for different workflows. - -For example, for original digital capture (photos from camera): - -```py -camera_ctx = Context.from_dict({ - "builder": { - "intent": {"Create": "digitalCapture"}, - "claim_generator_info": {"name": "Camera App", "version": "1.0"} - } -}) -``` - -Or for editing existing content: - -```py -editor_ctx = Context.from_dict({ - "builder": { - "intent": {"Edit": None}, - "claim_generator_info": {"name": "Photo Editor", "version": "2.0"} - } -}) -``` - -### Signer - -The [`signer` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signersettings) configure the primary C2PA signer configuration. Set it to `null` if you provide the signer at runtime, or configure as either a **local** or **remote** signer in settings. - -> [!NOTE] -> The typical approach in Python is to create a `Signer` object with `Signer.from_info()` and pass it directly to `Builder.sign()`. Alternatively, pass a `Signer` to `Context` for the signer-on-context pattern. See [Configuring a signer](context.md#configuring-a-signer) for details. - -#### Local signer - -Use a local signer when you have direct access to the private key and certificate. -For information on all `signer.local` properties, see [signer.local](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signerlocal) in the SDK object reference. - -#### Remote signer - -Use a remote signer when the private key is stored on a secure signing service (HSM, cloud KMS, and so on). -For information on all `signer.remote` properties, see [signer.remote](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signerremote) in the SDK object reference. - -### CAWG X.509 signer configuration - -The `cawg_x509_signer` property specifies configuration for identity assertions. This has the same structure as `signer` (can be local or remote). - -**When to use:** If you need to sign identity assertions separately from the main C2PA claim. 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` - -For additional JSON configuration examples (minimal configuration, local/remote signer, development/production configurations), see the [Rust SDK settings examples](https://github.com/contentauth/c2pa-rs/blob/main/docs/settings.md#examples). - -## See also - -- [Using Context to configure the SDK](context.md): how to create and use contexts with settings. -- [Usage](usage.md): reading and signing with `Reader` and `Builder`. -- [Rust SDK settings](https://github.com/contentauth/c2pa-rs/blob/main/docs/settings.md): the shared settings schema, default configuration, and JSON examples (language-independent). -- [CAI settings schema reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/): full schema reference. diff --git a/docs/tbd_selective-manifests.md b/docs/tbd_selective-manifests.md deleted file mode 100644 index b22f4d0a..00000000 --- a/docs/tbd_selective-manifests.md +++ /dev/null @@ -1,1098 +0,0 @@ -# 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` to extract the manifest store JSON and any binary resources (thumbnails, manifest data). The source asset is never modified. - -```cpp -c2pa::Context context; -c2pa::Reader reader(context, "image/jpeg", source_stream); - -// Get the full manifest store as JSON -std::string store_json = reader.json(); -auto parsed = json::parse(store_json); - -// Identify the active manifest, which is the current/latest manifest -std::string active = parsed["active_manifest"]; -auto manifest = parsed["manifests"][active]; - -// Access specific parts -auto ingredients = manifest["ingredients"]; -auto assertions = manifest["assertions"]; -auto thumbnail_id = manifest["thumbnail"]["identifier"]; -``` - -### Extracting binary resources - -The JSON returned by `reader.json()` only contains string identifiers (JUMBF URIs) for binary data like thumbnails and ingredient manifest stores. Extract the actual binary content by using `get_resource()`: - -```cpp -// Extract a thumbnail to a stream -std::stringstream thumb_stream(std::ios::in | std::ios::out | std::ios::binary); -reader.get_resource(thumbnail_id, thumb_stream); - -// Or extract to a file -reader.get_resource(thumbnail_id, fs::path("thumbnail.jpg")); -``` - -## Filtering into a new Builder - -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:** For each kept ingredient, call `reader.get_resource(id, stream)` for any `thumbnail` or `manifest_data` it contains, then `builder.add_resource(id, stream)` with the same identifier. - -### Keep only specific ingredients - -```cpp -c2pa::Context context; -c2pa::Reader reader(context, "image/jpeg", source_stream); -auto parsed = json::parse(reader.json()); -std::string active = parsed["active_manifest"]; -auto ingredients = parsed["manifests"][active]["ingredients"]; - -// Filter: keep only ingredients with a specific relationship -json kept_ingredients = json::array(); -for (auto& ingredient : ingredients) { - if (ingredient["relationship"] == "parentOf") { - kept_ingredients.push_back(ingredient); - } -} - -// Create a new Builder with only the kept ingredients -json new_manifest = json::parse(base_manifest_json); -new_manifest["ingredients"] = kept_ingredients; - -c2pa::Builder builder(context, new_manifest.dump()); - -// Transfer binary resources for kept ingredients (see note above) -for (auto& ingredient : kept_ingredients) { - if (ingredient.contains("thumbnail")) { - std::string id = ingredient["thumbnail"]["identifier"]; - std::stringstream s(std::ios::in | std::ios::out | std::ios::binary); - reader.get_resource(id, s); - s.seekg(0); - builder.add_resource(id, s); - } - if (ingredient.contains("manifest_data")) { - std::string id = ingredient["manifest_data"]["identifier"]; - std::stringstream s(std::ios::in | std::ios::out | std::ios::binary); - reader.get_resource(id, s); - s.seekg(0); - builder.add_resource(id, s); - } -} - -// Sign the new Builder into an output asset -builder.sign(source_path, output_path, signer); -``` - -### Keep only specific assertions - -```cpp -auto assertions = parsed["manifests"][active]["assertions"]; - -json kept_assertions = json::array(); -for (auto& assertion : assertions) { - // Keep training-mining assertions, filter out everything else - if (assertion["label"] == "c2pa.training-mining") { - kept_assertions.push_back(assertion); - } -} - -json new_manifest = json::parse(R"({ - "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}] -})"); -new_manifest["assertions"] = kept_assertions; - -// Create a new Builder with only the filtered assertions -c2pa::Builder builder(context, new_manifest.dump()); -builder.sign(source_path, output_path, signer); -``` - -### 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 -``` - - - -```cpp -// Create a new Builder with a new definition -c2pa::Builder builder(context); -builder.with_definition(R"({ - "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], - "assertions": [] -})"); - -// 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 into this new manifest. -builder.add_ingredient(R"({"title": "original.jpg", "relationship": "parentOf"})", - original_signed_path); -builder.sign(source_path, output_path, signer); -``` - -## 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. - -```cpp -builder.add_action(R"({ - "action": "c2pa.color_adjustments", - "parameters": { "name": "brightnesscontrast" } -})"); - -builder.add_action(R"({ - "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. - -```cpp -c2pa::Context context; - -auto manifest_json = R"( -{ - "claim_generator_info": [{ "name": "an-application", "version": "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"] - } - } - ] - } - } - ] -} -)"; - -c2pa::Builder builder(context, manifest_json); - -// The label on the ingredient matches the value in ingredientIds -auto ingredient_json = R"( -{ - "title": "photo.jpg", - "format": "image/jpeg", - "relationship": "componentOf", - "label": "c2pa.ingredient.v3" -} -)"; -builder.add_ingredient(ingredient_json, photo_path); - -builder.sign(source_path, output_path, signer); -``` - -##### 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. - -```cpp -auto manifest_json = R"( -{ - "claim_generator_info": [{ "name": "an-application", "version": "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"] - } - } - ] - } - } - ] -} -)"; - -c2pa::Builder builder(context, manifest_json); - -// parentOf ingredient linked to c2pa.opened -builder.add_ingredient(R"({ - "title": "original.jpg", - "format": "image/jpeg", - "relationship": "parentOf", - "label": "c2pa.ingredient.v3_1" -})", original_path); - -// componentOf ingredient linked to c2pa.placed -builder.add_ingredient(R"({ - "title": "overlay.jpg", - "format": "image/jpeg", - "relationship": "componentOf", - "label": "c2pa.ingredient.v3_2" -})", overlay_path); - -builder.sign(source_path, output_path, signer); -``` - -#### Linking with `instance_id` - -When no `label` is set on an ingredient, the SDK matches `ingredientIds` against `instance_id`. - -```cpp -c2pa::Context context; - -// instance_id is used as the linking identifier and must be unique -std::string instance_id = "xmp:iid:939a4c48-0dff-44ec-8f95-61f52b11618f"; - -json manifest_json = { - {"claim_generator_info", json::array({{{"name", "an-application"}, {"version", "1.0"}}})}, - {"assertions", json::array({ - { - {"label", "c2pa.actions"}, - {"data", { - {"actions", json::array({ - { - {"action", "c2pa.opened"}, - {"parameters", { - {"ingredientIds", json::array({instance_id})} - }} - } - })} - }} - } - })} -}; - -c2pa::Builder builder(context, manifest_json.dump()); - -// No label set: instance_id is used as the linking key -json ingredient = { - {"title", "source_photo.jpg"}, - {"relationship", "parentOf"}, - {"instance_id", instance_id} -}; -builder.add_ingredient(ingredient.dump(), source_photo_path); - -builder.sign(source_path, output_path, signer); -``` - -> [!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: - -```cpp -auto reader = c2pa::Reader(context, signed_path); -auto parsed = json::parse(reader.json()); -std::string active = parsed["active_manifest"]; -auto manifest = parsed["manifests"][active]; - -// Build a map: label -> ingredient -std::map label_to_ingredient; -for (auto& ing : manifest["ingredients"]) { - label_to_ingredient[ing["label"]] = ing; -} - -// Match each action to its ingredients by extracting labels from URLs -for (auto& assertion : manifest["assertions"]) { - if (assertion["label"] == "c2pa.actions.v2") { - for (auto& action : assertion["data"]["actions"]) { - if (action.contains("parameters") && - action["parameters"].contains("ingredients")) { - for (auto& ref : action["parameters"]["ingredients"]) { - std::string url = ref["url"]; - std::string label = url.substr(url.rfind('/') + 1); - auto& matched = label_to_ingredient[label]; - // Now the ingredient is available - } - } - } - } -} -``` - -#### 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 `read_ingredient_file()` or 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](https://opensource.contentauthenticity.org/docs/rust-sdk/docs/working-stores). - -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()` or `builder.with_archive()`. - -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 - 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)) - SEL --> FB - FB -->|sign| OUT[Signed Output Asset] - - style A3 fill:#eee,stroke:#999 - style X fill:#f99,stroke:#c00 -``` - - - -```cpp -// Read from a catalog of archived ingredients -c2pa::Context archive_ctx; // Add settings if needed, e.g. verify options - -// Open one archive from the catalog -archive_stream.seekg(0); -c2pa::Reader reader(archive_ctx, "application/c2pa", archive_stream); -auto parsed = json::parse(reader.json()); -std::string active = parsed["active_manifest"]; -auto available_ingredients = parsed["manifests"][active]["ingredients"]; - -// Pick only the needed ingredients -json selected = json::array(); -for (auto& ingredient : available_ingredients) { - if (ingredient["title"] == "photo_1.jpg" || ingredient["title"] == "logo.png") { - selected.push_back(ingredient); - } -} - -// Create a new Builder with selected ingredients -json manifest = json::parse(R"({ - "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}] -})"); -manifest["ingredients"] = selected; -c2pa::Builder builder(context, manifest.dump()); - -// Transfer binary resources for selected ingredients -for (auto& ingredient : selected) { - if (ingredient.contains("thumbnail")) { - std::string id = ingredient["thumbnail"]["identifier"]; - std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); - reader.get_resource(id, stream); - stream.seekg(0); - builder.add_resource(id, stream); - } - if (ingredient.contains("manifest_data")) { - std::string id = ingredient["manifest_data"]["identifier"]; - std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); - reader.get_resource(id, stream); - stream.seekg(0); - builder.add_resource(id, stream); - } -} - -builder.sign(source_path, output_path, signer); -``` - -### 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: - -```cpp -// Override title, relationship, and set a custom instance_id for tracking -json ingredient_override = { - {"title", "my-custom-title.jpg"}, - {"relationship", "parentOf"}, - {"instance_id", "my-tracking-id:asset-example-id"} -}; -builder.add_ingredient(ingredient_override.dump(), signed_asset_path); -``` - -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. - -```cpp -auto manifest_json = R"( -{ - "claim_generator_info": [{ "name": "an-application", "version": "1.0" }], - "assertions": [ - { - "label": "c2pa.actions.v2", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://c2pa.org/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: - -```cpp -for (auto& action : actions) { - if (action.contains("parameters") && - action["parameters"].contains("com.mycompany.layer_id") && - action["parameters"]["com.mycompany.layer_id"] == "layer-42") { - // This action is related to layer-42 - } -} -``` - -> **Naming convention:** Vendor parameters must use reverse domain notation with period-separated components (e.g., `com.mycompany.tool`, `net.example.session_id`). Some namespaces (e.g., `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 - subgraph Step3["Step 3: Reuse in a new Builder"] - SEL -->|"new Builder + add_resource()"| B2[New Builder] - B2 -->|sign| OUT[Signed Output] - end -``` - - - -**Step 1:** Build a working store and archive it: - -```cpp -c2pa::Context context; -c2pa::Builder builder(context, manifest_json); - -// Add ingredients to the working store -builder.add_ingredient(R"({"title": "A.jpg", "relationship": "componentOf"})", - path_to_A); -builder.add_ingredient(R"({"title": "B.jpg", "relationship": "componentOf"})", - path_to_B); - -// Save the working store as an archive -std::stringstream archive_stream(std::ios::in | std::ios::out | std::ios::binary); -builder.to_archive(archive_stream); -``` - -**Step 2:** Read the archive and extract ingredients: - -```cpp -// Read the archive (does not modify it) -archive_stream.seekg(0); -c2pa::Reader reader(context, "application/c2pa", archive_stream); -auto parsed = json::parse(reader.json()); -std::string active = parsed["active_manifest"]; -auto ingredients = parsed["manifests"][active]["ingredients"]; -``` - -**Step 3:** Create a new Builder with the extracted ingredients: - -```cpp -// Pick the desired ingredients -json selected = json::array(); -for (auto& ingredient : ingredients) { - if (ingredient["title"] == "A.jpg") { - selected.push_back(ingredient); - } -} - -// Create a new Builder with only the selected ingredients -json new_manifest = json::parse(base_manifest_json); -new_manifest["ingredients"] = selected; -c2pa::Builder new_builder(context, new_manifest.dump()); - -// Transfer binary resources for the selected ingredients -for (auto& ingredient : selected) { - if (ingredient.contains("thumbnail")) { - std::string id = ingredient["thumbnail"]["identifier"]; - std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); - reader.get_resource(id, stream); - stream.seekg(0); - new_builder.add_resource(id, stream); - } - if (ingredient.contains("manifest_data")) { - std::string id = ingredient["manifest_data"]["identifier"]; - std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); - reader.get_resource(id, stream); - stream.seekg(0); - new_builder.add_resource(id, stream); - } -} - -new_builder.sign(source_path, output_path, signer); -``` - -### Merging multiple working stores - -In some cases you may need 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 `get_resource()`, renamed ID for `add_resource()` when collisions occurred). - -```cpp -std::set used_ids; -int suffix_counter = 0; -json all_ingredients = json::array(); -std::vector> archive_info; // (stream, ingredient count) - -// Pass 1: Collect ingredients, renaming IDs on collision -for (auto& archive_stream : archives) { - archive_stream.seekg(0); - c2pa::Reader reader(archive_ctx, "application/c2pa", archive_stream); - auto parsed = json::parse(reader.json()); - auto ingredients = parsed["manifests"][parsed["active_manifest"]]["ingredients"]; - - for (auto& ingredient : ingredients) { - for (const char* key : {"thumbnail", "manifest_data"}) { - if (!ingredient.contains(key)) continue; - std::string id = ingredient[key]["identifier"]; - if (used_ids.count(id)) { - ingredient[key]["identifier"] = id + "__" + std::to_string(++suffix_counter); - } - used_ids.insert(ingredient[key]["identifier"].get()); - } - all_ingredients.push_back(ingredient); - } - archive_info.emplace_back(&archive_stream, ingredients.size()); -} - -json manifest = json::parse(R"({ - "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}] -})"); -manifest["ingredients"] = all_ingredients; -c2pa::Builder builder(context, manifest.dump()); - -// Pass 2: Transfer resources (match by ingredient index) -size_t idx = 0; -for (auto& [stream, count] : archive_info) { - stream->seekg(0); - c2pa::Reader reader(archive_ctx, "application/c2pa", *stream); - auto parsed = json::parse(reader.json()); - auto orig = parsed["manifests"][parsed["active_manifest"]]["ingredients"]; - - for (size_t i = 0; i < count; ++i) { - auto& o = orig[i]; - auto& m = all_ingredients[idx++]; - for (const char* key : {"thumbnail", "manifest_data"}) { - if (!o.contains(key)) continue; - std::stringstream s(std::ios::in | std::ios::out | std::ios::binary); - reader.get_resource(o[key]["identifier"].get(), s); - s.seekg(0); - builder.add_resource(m[key]["identifier"].get(), s); - } - } -} - -builder.sign(source_path, output_path, signer); -``` - -## Retrieving actions from a working store - -Actions are stored in the `c2pa.actions.v2` assertion. Use `Reader` to extract them from a signed asset or an archived Builder. - -### Reading actions - -```cpp -c2pa::Context context; -c2pa::Reader reader(context, "image/jpeg", source_stream); -auto parsed = json::parse(reader.json()); -std::string active = parsed["active_manifest"]; -auto assertions = parsed["manifests"][active]["assertions"]; - -// Find the actions assertion -for (auto& assertion : assertions) { - if (assertion["label"] == "c2pa.actions.v2") { - auto actions = assertion["data"]["actions"]; - for (auto& action : actions) { - std::cout << "Action: " << action["action"] << std::endl; - if (action.contains("description")) { - std::cout << " Description: " << action["description"] << std::endl; - } - } - } -} -``` - -### Reading actions from an archive - -Use the same approach with format `"application/c2pa"` and an archive stream: - -```cpp -std::ifstream archive_file("builder_archive.c2pa", std::ios::binary); -c2pa::Reader reader(context, "application/c2pa", archive_file); -// Then parse and iterate assertions as in the example above -``` - -### Understanding the manifest tree - -The `Reader` returns a manifest store—a dictionary of manifests keyed by label (a URN like `contentauth:urn:uuid:...`). Conceptually it forms a tree: each manifest has assertions and ingredients; ingredients with `manifest_data` carry their own manifest store, which can have its own ingredients and assertions recursively. The `active_manifest` key indicates the root. - -```mermaid -flowchart TD - subgraph Store["Manifest Store"] - M1["Active Manifest\n- assertions (including c2pa.actions.v2)\n- ingredients"] - M2["Ingredient A's manifest\n- its own c2pa.actions.v2\n- its own ingredients"] - M3["Ingredient B's manifest\n- its own c2pa.actions.v2"] - end - M1 -->|"ingredient A has manifest_data"| M2 - M1 -->|"ingredient B has manifest_data"| M3 - M1 -.-|"ingredient C has no manifest_data"| M5["Ingredient C\n(unsigned asset, no provenance)"] - M2 -->|"may have its own ingredients..."| M4["...deeper in the tree"] - - style M5 fill:#eee,stroke:#999,stroke-dasharray: 5 5 -``` - - - -Not every ingredient has provenance. An unsigned asset added as an ingredient has `title`, `format`, and `relationship`, but no `manifest_data` and no entry in the `"manifests"` dictionary. Walking the tree reveals the full provenance chain: what each actor did at each step, including actions performed and ingredients used. - -**To walk the tree and find actions at each level:** - -```cpp -auto parsed = json::parse(reader.json()); -std::string active = parsed["active_manifest"]; -auto active_manifest = parsed["manifests"][active]; - -// Read the active manifest's actions -for (auto& assertion : active_manifest["assertions"]) { - if (assertion["label"] == "c2pa.actions.v2") { - std::cout << "Active manifest actions:" << std::endl; - for (auto& action : assertion["data"]["actions"]) { - std::cout << " " << action["action"].get() << std::endl; - } - } -} - -// Walk into each ingredient's manifest -for (auto& ingredient : active_manifest["ingredients"]) { - std::cout << "Ingredient: " << ingredient["title"].get() << std::endl; - - // If this ingredient has its own manifest (it was a signed asset), - // its manifest label is in "active_manifest" - if (ingredient.contains("active_manifest")) { - std::string ing_manifest_label = ingredient["active_manifest"]; - if (parsed["manifests"].contains(ing_manifest_label)) { - auto ing_manifest = parsed["manifests"][ing_manifest_label]; - - // This ingredient's manifest has its own actions - for (auto& assertion : ing_manifest["assertions"]) { - if (assertion["label"] == "c2pa.actions.v2") { - std::cout << " Ingredient's actions:" << std::endl; - for (auto& action : assertion["data"]["actions"]) { - std::cout << " " << action["action"].get() << std::endl; - } - } - } - - // And its own ingredients (deeper in the tree)... - } - } else { - // This ingredient has no manifest of its own (it was an unsigned asset). - // It still has a title, format, and relationship, but no manifest_data, - // no actions, and no deeper provenance chain. - std::cout << " (no content credentials)" << std::endl; - } -} -``` - -## Filtering actions - -To remove actions, use the same read–filter–rebuild pattern: **read, pick the ones to keep, create a new Builder**. - -```mermaid -flowchart TD - SA["Signed Asset with 3 actions: opened, placed, filtered"] -->|Reader| JSON[Parse JSON] - JSON -->|"Keep only opened + placed"| FILT[Filtered actions] - FILT -->|"New Builder with 2 actions"| NB[New Builder] - NB -->|sign| OUT["New with 2 actions only: opened, placed"] -``` - - - -### Basic action filtering - -When filtering, remember that the first action must remain `c2pa.created` or `c2pa.opened` for the manifest to be valid. If the first action is removed, a new one must be added. - -```cpp -c2pa::Context context; -c2pa::Reader reader(context, "image/jpeg", source_stream); -auto parsed = json::parse(reader.json()); -std::string active = parsed["active_manifest"]; -auto manifest = parsed["manifests"][active]; - -// Filter actions: keep c2pa.created/c2pa.opened (mandatory) and c2pa.placed, drop the rest -json kept_actions = json::array(); -for (auto& assertion : manifest["assertions"]) { - if (assertion["label"] == "c2pa.actions.v2") { - for (auto& action : assertion["data"]["actions"]) { - std::string action_type = action["action"]; - if (action_type == "c2pa.created" || action_type == "c2pa.opened" || - action_type == "c2pa.placed") { - kept_actions.push_back(action); - } - // Skip c2pa.filtered, c2pa.color_adjustments, etc. - } - } -} - -// Build a new manifest with only the kept actions -json new_manifest = json::parse(R"({ - "claim_generator_info": [{"name": "an-application", "version": "1.0"}] -})"); - -if (!kept_actions.empty()) { - new_manifest["assertions"] = json::array({ - { - {"label", "c2pa.actions"}, - {"data", {{"actions", kept_actions}}} - } - }); -} - -c2pa::Builder builder(context, new_manifest.dump()); -builder.sign(source_path, output_path, signer); -``` - -### Filtering actions that reference ingredients - -Some actions reference ingredients (via `parameters.ingredients[].url` after signing). If keeping an action that references an ingredient, **the corresponding ingredient and its binary resources must also be kept**. If an ingredient is dropped, any actions that reference it must also be dropped (or updated). - -#### `c2pa.opened` action - -The `c2pa.opened` action is special because it must be the first action and it references the asset that was opened (the `parentOf` ingredient). When filtering: - -- **Always keep `c2pa.opened` or `c2pa.created`**: it is required for a valid manifest -- **Keep the ingredient it references**: the `parentOf` ingredient linked via its `parameters.ingredients[].url` -- Removing the ingredient that `c2pa.opened` points to will make the manifest invalid - -#### `c2pa.placed` action - -The `c2pa.placed` action references a `componentOf` ingredient that was composited into the asset. When filtering: - -- If keeping `c2pa.placed`, keep the ingredient it references -- If the ingredient is dropped, also drop the `c2pa.placed` action -- If `c2pa.placed` is not required: it can safely be removed (and the ingredient it references, if it is the only reference) - -#### Example - -The code below provides an example of filtering with linked ingredients. - -```cpp -c2pa::Context context; -c2pa::Reader reader(context, "image/jpeg", source_stream); -auto parsed = json::parse(reader.json()); -std::string active = parsed["active_manifest"]; -auto manifest = parsed["manifests"][active]; - -// Filter actions and track which ingredients are needed -json kept_actions = json::array(); -std::set needed_ingredient_labels; - -for (auto& assertion : manifest["assertions"]) { - if (assertion["label"] == "c2pa.actions.v2") { - for (auto& action : assertion["data"]["actions"]) { - std::string action_type = action["action"]; - - // Always keep c2pa.opened/c2pa.created (required for valid manifest) - // Keep c2pa.placed (optional -- kept here as an example) - // Drop everything else - bool keep = (action_type == "c2pa.opened" || - action_type == "c2pa.created" || - action_type == "c2pa.placed"); - - if (keep) { - kept_actions.push_back(action); - - // Track which ingredients this action needs - if (action.contains("parameters") && - action["parameters"].contains("ingredients")) { - for (auto& ing_ref : action["parameters"]["ingredients"]) { - std::string url = ing_ref["url"]; - std::string label = url.substr(url.rfind('/') + 1); - needed_ingredient_labels.insert(label); - } - } - } - } - } -} - -// Keep only the ingredients that are referenced by kept actions -json kept_ingredients = json::array(); -for (auto& ingredient : manifest["ingredients"]) { - if (ingredient.contains("label") && - needed_ingredient_labels.count(ingredient["label"])) { - kept_ingredients.push_back(ingredient); - } -} - -// Build the new manifest with filtered actions and matching ingredients -json new_manifest = json::parse(R"({ - "claim_generator_info": [{"name": "an-application", "version": "1.0"}] -})"); -new_manifest["ingredients"] = kept_ingredients; -if (!kept_actions.empty()) { - new_manifest["assertions"] = json::array({ - { - {"label", "c2pa.actions"}, - {"data", {{"actions", kept_actions}}} - } - }); -} - -c2pa::Builder builder(context, new_manifest.dump()); - -// Transfer binary resources for kept ingredients -for (auto& ingredient : kept_ingredients) { - if (ingredient.contains("thumbnail")) { - std::string id = ingredient["thumbnail"]["identifier"]; - std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); - reader.get_resource(id, stream); - stream.seekg(0); - builder.add_resource(id, stream); - } - if (ingredient.contains("manifest_data")) { - std::string id = ingredient["manifest_data"]["identifier"]; - std::stringstream stream(std::ios::in | std::ios::out | std::ios::binary); - reader.get_resource(id, stream); - stream.seekg(0); - builder.add_resource(id, stream); - } -} - -builder.sign(source_path, output_path, signer); -``` - -> [!NOTE] -> When copying ingredient JSON objects from a reader, they keep their `label` field. Since the action URLs reference ingredients by label, the links resolve correctly as long as ingredients are not renamed or reindexed. If ingredients are re-added via `add_ingredient()` (which generates new labels), the action URLs will also need to be updated. - -## Controlling manifest embedding - -By default, `sign()` embeds the manifest directly inside the output asset file. - -### Remove the manifest from the asset entirely - -Use `set_no_embed()` so the signed asset contains no embedded manifest. The manifest bytes are returned from `sign()` and can be stored separately (as a sidecar file, on a server, etc.): - -```mermaid -flowchart LR - subgraph Default["Default (embedded)"] - A1[Output Asset] --- A2[Image data + C2PA manifest] - end - - subgraph NoEmbed["With set_no_embed()"] - B1[Output Asset] ~~~ B2[Manifest bytes with store as sidecar or uploaded to server] - end -``` - - - -```cpp -c2pa::Builder builder(context, manifest_json); -builder.set_no_embed(); -builder.set_remote_url("<>"); - -auto manifest_bytes = builder.sign("image/jpeg", source, dest, signer); -// manifest_bytes contains the full manifest store -// Upload manifest_bytes to the remote URL -// The output asset has no embedded manifest -``` - -Reading back: - -```cpp -c2pa::Reader reader(context, "image/jpeg", dest); -reader.is_embedded(); // false -reader.remote_url(); // "<>" -``` - -## Complete workflow diagram - -```mermaid -flowchart TD - subgraph Step1["Step 1: READ"] - SA[Signed Asset] -->|Reader| RD["reader.json() -- full manifest JSON\nreader.get_resource(id, stream) -- binary"] - end - - subgraph Step2["Step 2: FILTER"] - RD --> FI[Parse JSON] - FI --> F1[Pick ingredients to keep] - FI --> F3[Pick actions to keep] - FI --> F4[Ensure kept actions' ingredients are also kept] - FI --> F5["Ensure c2pa.created/opened is still the first action"] - F1 & F3 & F4 & F5 --> FM[Build new manifest JSON with only filtered items] - end - - subgraph Step3["Step 3: BUILD new Builder"] - FM --> BLD["new Builder with context and filtered_json"] - BLD --> AR[".add_resource for each kept binary resource"] - AR --> AI[".add_ingredient to add original as parent (optional)"] - AI --> AA[".add_action to record new actions (optional)"] - end - - subgraph Step4["Step 4: SIGN"] - AA --> SIGN["builder.sign(source, output, signer)"] - SIGN --> OUT[Output asset with new manifest containing only filtered content] - end -``` - diff --git a/docs/working-stores.md b/docs/working-stores.md deleted file mode 100644 index 801816dd..00000000 --- a/docs/working-stores.md +++ /dev/null @@ -1,648 +0,0 @@ -# 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.from_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 -->|from_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 vs external manifests](#embedded-vs-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.from_archive()`. -- 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 Reader - -try: - # Create a Reader from a signed asset file - reader = Reader("signed_image.jpg") - - # Get the manifest store as JSON - manifest_store_json = reader.json() -except Exception as e: - print(f"C2PA Error: {e}") -``` - -### Reading from a stream - -```py -with open("signed_image.jpg", "rb") as stream: - # Create Reader from stream with MIME type - reader = Reader("image/jpeg", stream) - manifest_json = reader.json() -``` - -### Using Context for configuration - -For more control over validation and trust settings, use a `Context`: - -```py -from c2pa import Context, Reader - -# Create context with custom validation settings -ctx = Context.from_dict({ - "verify": { - "verify_after_sign": True - } -}) - -# Use context when creating Reader -reader = Reader("signed_image.jpg", context=ctx) -manifest_json = reader.json() -``` - -## 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 - -# Create a working store with a manifest definition -manifest_json = json.dumps({ - "claim_generator_info": [{ - "name": "example-app", - "version": "0.1.0" - }], - "title": "Example asset", - "assertions": [] -}) - -builder = Builder(manifest_json) - -# Or with custom context -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 - -# 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) - -# 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(signer, "image/jpeg", src, dst) - -# Now "signed.jpg" contains a manifest store -# You can read it back with Reader -reader = Reader("signed.jpg") -manifest_store_json = reader.json() -``` - -## Creating and signing manifests - -### Creating a Builder (working store) - -```py -# Create with manifest definition -builder = Builder(manifest_json) - -# Or with custom context -ctx = Context.from_dict({ - "builder": { - "thumbnail": {"enabled": True} - } -}) -builder = Builder(manifest_json, context=ctx) -``` - -### 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 - -```py -try: - # Sign using streams - with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: - manifest_bytes = builder.sign(signer, "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 -# Sign using file paths (uses native Rust file I/O for better performance) -manifest_bytes = builder.sign_file( - "source.jpg", "signed.jpg", signer -) -``` - -### Complete example - -This code combines the above examples to create, sign, and read a manifest. - -```py -import json -from c2pa import Builder, Reader, Signer, C2paSignerInfo, C2paSigningAlg - -try: - # 1. Define manifest for working store - manifest_json = json.dumps({ - "claim_generator_info": [{"name": "demo-app", "version": "0.1.0"}], - "title": "Signed image", - "assertions": [] - }) - - # 2. Load credentials - with open("certs.pem", "rb") as f: - certs = f.read() - with open("private_key.pem", "rb") as f: - private_key = f.read() - - # 3. Create 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) - - # 4. Create working store (Builder) and sign - builder = Builder(manifest_json) - with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - - print("Asset signed - working store is now a manifest store") - - # 5. Read back the manifest store - reader = Reader("signed.jpg") - 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. - -### 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") -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 -builder = Builder(manifest_json) - -# 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(signer, "image/jpeg", src, dst) -``` - -## Working with ingredients - -Ingredients represent source materials used to create an asset, preserving the provenance chain. Ingredients themselves can be turned into ingredient archives (`.c2pa`). - -An ingredient archive is a serialized `Builder` with _exactly one_ ingredient. Once archived with only one ingredient, the Builder archive is an ingredient archive. Such ingredient archives can be used as ingredient in other working stores. - -### Adding ingredients to a working store - -When creating a manifest, add ingredients to preserve the provenance chain: - -```py -builder = Builder(manifest_json) - -# 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) - -# Or add ingredient from a file path -builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", "source.jpg") - -# 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(signer, "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 - -# Create and configure a working store -builder = Builder(manifest_json) -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 - -Create a new `Builder` (working store) from an archive: - -```py -# Restore from stream -with open("manifest.c2pa", "rb") as archive: - builder = Builder.from_archive(archive) - -# Now you can sign with the restored working store -with open("asset.jpg", "rb") as src, open("signed_asset.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) -``` - -### Restoring with context preservation - -Pass a `context` to `from_archive()` to preserve custom settings: - -```py -# Create context with custom settings -ctx = Context.from_dict({ - "builder": { - "thumbnail": {"enabled": False} - } -}) - -# Load archive with context -with open("manifest.c2pa", "rb") as archive: - builder = Builder.from_archive(archive, context=ctx) - -# The builder has the archived manifest but keeps the custom context -with open("asset.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) -``` - -### Two-phase workflow example - -#### Phase 1: Prepare manifest - -```py -import io -import json - -manifest_json = json.dumps({ - "title": "Artwork draft", - "assertions": [] -}) - -builder = Builder(manifest_json) -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 - -```py -# Restore the working store -with open("artwork_manifest.c2pa", "rb") as archive: - builder = Builder.from_archive(archive) - -# Sign -with open("artwork.jpg", "rb") as src, open("signed_artwork.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - -print("Asset signed with manifest store") -``` - -## Embedded vs 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 - -```py -builder = Builder(manifest_json) - -# 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(signer, "image/jpeg", src, dst) - -# Read it back — manifest store is embedded -reader = Reader("signed.jpg") -``` - -### External manifest stores (no embed) - -Prevent embedding the manifest store in the asset: - -```py -builder = Builder(manifest_json) -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(signer, "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 - -Reference a manifest store stored at a remote URL: - -```py -builder = Builder(manifest_json) -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(signer, "image/jpeg", src, dst) -``` - -## Best practices - -### Use Context for configuration - -Always use `Context` objects for SDK configuration: - -```py -ctx = Context.from_dict({ - "verify": { - "verify_after_sign": True - }, - "trust": { - "user_anchors": trust_anchors_pem - } -}) - -builder = Builder(manifest_json, context=ctx) -reader = Reader("asset.jpg", context=ctx) -``` - -### Use ingredients to build provenance chains - -Add ingredients to your manifests to maintain a clear provenance chain: - -```py -ingredient_json = json.dumps({ - "title": "Original source", - "relationship": "parentOf" -}) - -with open("original.jpg", "rb") as ingredient: - builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) - -with open("edited.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) -``` - -## 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/) From 5c97dcc30c6151cd5ab28bb2ac81c8f489b24864 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:21:51 -0800 Subject: [PATCH 12/75] Delete tests/test_docs.py --- tests/test_docs.py | 1194 -------------------------------------------- 1 file changed, 1194 deletions(-) delete mode 100644 tests/test_docs.py diff --git a/tests/test_docs.py b/tests/test_docs.py deleted file mode 100644 index dd842da8..00000000 --- a/tests/test_docs.py +++ /dev/null @@ -1,1194 +0,0 @@ -# Copyright 2024 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. - -""" -Tests that verify code examples from the documentation actually work. - -Each test corresponds to one or more code snippets from the docs/ folder. -The doc file and section are noted in each test's docstring. -""" - -import os -import io -import json -import unittest -import tempfile -import warnings - -warnings.filterwarnings("ignore", category=DeprecationWarning) -warnings.filterwarnings("ignore", message="load_settings\\(\\) is deprecated") - -from c2pa import ( # noqa: E402 - Builder, - C2paError as Error, - Reader, - C2paSigningAlg as SigningAlg, - C2paSignerInfo, - Signer, - Settings, - Context, - ContextProvider, - load_settings, -) - - -# ── Paths ──────────────────────────────────────────────────── - -FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") -SIGNED_IMAGE = os.path.join(FIXTURES_DIR, "C.jpg") # has C2PA manifest -UNSIGNED_IMAGE = os.path.join(FIXTURES_DIR, "A.jpg") # no manifest -CERTS_FILE = os.path.join(FIXTURES_DIR, "es256_certs.pem") -KEY_FILE = os.path.join(FIXTURES_DIR, "es256_private.key") -THUMBNAIL_FILE = os.path.join(FIXTURES_DIR, "A_thumbnail.jpg") - - -def _load_creds(): - """Load test signing credentials.""" - with open(CERTS_FILE, "rb") as f: - certs = f.read() - with open(KEY_FILE, "rb") as f: - key = f.read() - return certs, key - - -def _make_signer(): - """Create a fresh Signer for tests.""" - certs, key = _load_creds() - info = C2paSignerInfo( - alg=b"es256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com", - ) - return Signer.from_info(info) - - -def _manifest_def(): - """Return a basic manifest definition dict.""" - return { - "claim_generator_info": [{"name": "doc-tests", "version": "0.1.0"}], - "title": "Doc Test Image", - "assertions": [], - } - - -def _manifest_def_json(): - """Return a basic manifest definition as JSON string.""" - return json.dumps(_manifest_def()) - - -# ═══════════════════════════════════════════════════════════════ -# context.md examples -# ═══════════════════════════════════════════════════════════════ - - -class TestContextDocs(unittest.TestCase): - """Tests for docs/context.md code examples.""" - - # -- Creating a Context ------------------------------------------- - - def test_context_default(self): - """context.md § Using SDK default settings""" - from c2pa import Context - - ctx = Context() # Uses SDK defaults - self.assertTrue(ctx.is_valid) - ctx.close() - - def test_context_from_json(self): - """context.md § From a JSON string""" - ctx = Context.from_json('''{ - "verify": {"verify_after_sign": true}, - "builder": { - "thumbnail": {"enabled": false}, - "claim_generator_info": {"name": "An app", "version": "0.1.0"} - } - }''') - self.assertTrue(ctx.is_valid) - ctx.close() - - def test_context_from_dict(self): - """context.md § From a dictionary""" - ctx = Context.from_dict({ - "verify": {"verify_after_sign": True}, - "builder": { - "thumbnail": {"enabled": False}, - "claim_generator_info": {"name": "An app", "version": "0.1.0"} - } - }) - self.assertTrue(ctx.is_valid) - ctx.close() - - def test_context_from_settings_object(self): - """context.md § From a Settings object""" - 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=settings) - self.assertTrue(ctx.is_valid) - ctx.close() - settings.close() - - # -- Common configuration patterns -------------------------------- - - def test_env_var_config(self): - """context.md § Configuration from environment variables""" - 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=settings) - self.assertTrue(ctx.is_valid) - ctx.close() - settings.close() - - # -- Configuring Reader ------------------------------------------- - - def test_reader_with_context_from_file(self): - """context.md § Reading from a file""" - ctx = Context.from_dict({ - "verify": { - "remote_manifest_fetch": False, - "ocsp_fetch": False - } - }) - - reader = Reader(SIGNED_IMAGE, context=ctx) - json_data = reader.json() - self.assertIsNotNone(json_data) - reader.close() - ctx.close() - - def test_reader_with_context_from_stream(self): - """context.md § Reading from a stream""" - ctx = Context.from_dict({ - "verify": { - "remote_manifest_fetch": False, - "ocsp_fetch": False - } - }) - - with open(SIGNED_IMAGE, "rb") as stream: - reader = Reader("image/jpeg", stream, context=ctx) - json_data = reader.json() - self.assertIsNotNone(json_data) - reader.close() - ctx.close() - - def test_reader_full_validation(self): - """context.md § Full validation""" - ctx = Context.from_dict({ - "verify": { - "verify_after_reading": True, - "verify_trust": True, - "verify_timestamp_trust": True, - "remote_manifest_fetch": True - } - }) - - reader = Reader(SIGNED_IMAGE, context=ctx) - self.assertIsNotNone(reader.json()) - reader.close() - ctx.close() - - def test_reader_offline(self): - """context.md § Offline operation""" - ctx = Context.from_dict({ - "verify": { - "remote_manifest_fetch": False, - "ocsp_fetch": False - } - }) - - reader = Reader(SIGNED_IMAGE, context=ctx) - self.assertIsNotNone(reader.json()) - reader.close() - ctx.close() - - # -- Configuring Builder ------------------------------------------ - - def test_builder_with_context(self): - """context.md § Basic use""" - ctx = Context.from_dict({ - "builder": { - "claim_generator_info": { - "name": "An app", - "version": "0.1.0" - }, - } - }) - - manifest_json = _manifest_def() - builder = Builder(manifest_json, context=ctx) - - signer = _make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - # Verify output is valid - reader = Reader(dest) - self.assertIsNotNone(reader.json()) - reader.close() - builder.close() - ctx.close() - - def test_builder_no_thumbnails_context(self): - """context.md § Controlling thumbnail generation""" - no_thumbnails_ctx = Context.from_dict({ - "builder": { - "claim_generator_info": {"name": "Batch Processor"}, - "thumbnail": {"enabled": False} - } - }) - self.assertTrue(no_thumbnails_ctx.is_valid) - - mobile_ctx = Context.from_dict({ - "builder": { - "claim_generator_info": {"name": "Mobile App"}, - "thumbnail": { - "enabled": True, - "long_edge": 512, - "quality": "low", - "prefer_smallest_format": True - } - } - }) - self.assertTrue(mobile_ctx.is_valid) - - # Verify no thumbnails - builder = Builder(_manifest_def(), context=no_thumbnails_ctx) - signer = _make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - reader = Reader(dest) - manifest = reader.get_active_manifest() - self.assertIsNone(manifest.get("thumbnail")) - reader.close() - builder.close() - no_thumbnails_ctx.close() - mobile_ctx.close() - - # -- Configuring a signer ----------------------------------------- - - def test_signer_on_context(self): - """context.md § From Settings (signer-on-context)""" - from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg - - certs, key = _load_creds() - - signer_info = C2paSignerInfo( - alg=C2paSigningAlg.ES256, - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - signer = Signer.from_info(signer_info) - - settings = Settings() - ctx = Context(settings=settings, signer=signer) - # signer is now consumed - self.assertTrue(signer._closed) - - manifest_json = _manifest_def() - builder = Builder(manifest_json, context=ctx) - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(format="image/jpeg", source=src, dest=dst) - reader = Reader(dest) - self.assertIsNotNone(reader.json()) - reader.close() - builder.close() - ctx.close() - settings.close() - - def test_explicit_signer(self): - """context.md § Explicit signer""" - signer = _make_signer() - ctx = Context() - manifest_json = _manifest_def() - builder = Builder(manifest_json, context=ctx) - - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - builder.close() - signer.close() - ctx.close() - - # -- Context lifetime and usage ----------------------------------- - - def test_context_as_context_manager(self): - """context.md § Context as a context manager""" - with Context() as ctx: - reader = Reader(SIGNED_IMAGE, context=ctx) - json_data = reader.json() - self.assertIsNotNone(json_data) - reader.close() - - def test_reusable_contexts(self): - """context.md § Reusable contexts""" - settings = Settings() - ctx = Context(settings=settings) - - manifest1 = _manifest_def() - manifest2 = _manifest_def() - manifest2["title"] = "Second Image" - - builder1 = Builder(manifest1, context=ctx) - builder2 = Builder(manifest2, context=ctx) - reader = Reader(SIGNED_IMAGE, context=ctx) - - self.assertIsNotNone(reader.json()) - builder1.close() - builder2.close() - reader.close() - ctx.close() - settings.close() - - def test_multiple_contexts(self): - """context.md § Multiple contexts for different purposes""" - dev_settings = Settings.from_dict({ - "builder": {"thumbnail": {"enabled": False}} - }) - prod_settings = Settings.from_dict({ - "builder": {"thumbnail": {"enabled": True}} - }) - dev_ctx = Context(settings=dev_settings) - prod_ctx = Context(settings=prod_settings) - - manifest = _manifest_def() - dev_builder = Builder(manifest, context=dev_ctx) - prod_builder = Builder(manifest, context=prod_ctx) - - self.assertIsNotNone(dev_builder) - self.assertIsNotNone(prod_builder) - - dev_builder.close() - prod_builder.close() - dev_ctx.close() - prod_ctx.close() - dev_settings.close() - prod_settings.close() - - def test_context_provider_protocol(self): - """context.md § ContextProvider protocol""" - from c2pa import ContextProvider, Context - - ctx = Context() - self.assertIsInstance(ctx, ContextProvider) # True - ctx.close() - - # -- Migrating from load_settings --------------------------------- - - def test_migration_from_load_settings(self): - """context.md § Migrating from load_settings - new API""" - from c2pa import Settings, Context, Reader - - settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) - ctx = Context(settings=settings) - reader = Reader(SIGNED_IMAGE, context=ctx) - self.assertIsNotNone(reader.json()) - reader.close() - ctx.close() - settings.close() - - -# ═══════════════════════════════════════════════════════════════ -# settings.md examples -# ═══════════════════════════════════════════════════════════════ - - -class TestSettingsDocs(unittest.TestCase): - """Tests for docs/settings.md code examples.""" - - def test_settings_api(self): - """settings.md § Settings API""" - from c2pa import Settings - - # Create with defaults - settings = Settings() - - # Set individual values by dot-notation path - settings.set("builder.thumbnail.enabled", "false") - - # Method chaining - settings.set("builder.thumbnail.enabled", "false").set( - "verify.verify_after_sign", "true" - ) - - # Dict-like access - settings["builder.thumbnail.enabled"] = "false" - - settings.close() - - # Create from JSON string - settings = Settings.from_json('{"builder": {"thumbnail": {"enabled": false}}}') - settings.close() - - # Create from a dictionary - settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) - - # Merge additional configuration - settings.update({"verify": {"remote_manifest_fetch": True}}) - settings.close() - - # Use as a context manager for automatic cleanup - with Settings() as settings: - settings.set("builder.thumbnail.enabled", "false") - - def test_settings_from_json_string(self): - """settings.md § Settings format - From JSON string""" - settings = Settings.from_json('{"verify": {"verify_after_sign": true}}') - self.assertTrue(settings.is_valid) - settings.close() - - def test_settings_from_dict(self): - """settings.md § Settings format - From dict""" - settings = Settings.from_dict({"verify": {"verify_after_sign": True}}) - self.assertTrue(settings.is_valid) - settings.close() - - def test_context_from_json_string(self): - """settings.md § Settings format - Context from JSON""" - ctx = Context.from_json('{"verify": {"verify_after_sign": true}}') - self.assertTrue(ctx.is_valid) - ctx.close() - - def test_context_from_dict(self): - """settings.md § Settings format - Context from dict""" - ctx = Context.from_dict({"verify": {"verify_after_sign": True}}) - self.assertTrue(ctx.is_valid) - ctx.close() - - def test_offline_settings(self): - """settings.md § Offline or air-gapped environments""" - ctx = Context.from_dict({ - "verify": { - "remote_manifest_fetch": False, - "ocsp_fetch": False - } - }) - - reader = Reader(SIGNED_IMAGE, context=ctx) - self.assertIsNotNone(reader.json()) - reader.close() - ctx.close() - - def test_fast_dev_iteration_settings(self): - """settings.md § Fast development iteration""" - settings = Settings() - settings.set("verify.verify_after_reading", "false") - settings.set("verify.verify_after_sign", "false") - - dev_ctx = Context(settings=settings) - self.assertTrue(dev_ctx.is_valid) - dev_ctx.close() - settings.close() - - def test_strict_validation_settings(self): - """settings.md § Strict validation""" - ctx = Context.from_dict({ - "verify": { - "strict_v1_validation": True, - "ocsp_fetch": True, - "verify_trust": True, - "verify_timestamp_trust": True - } - }) - - reader = Reader(SIGNED_IMAGE, context=ctx) - validation_result = reader.json() - self.assertIsNotNone(validation_result) - reader.close() - ctx.close() - - def test_claim_generator_info(self): - """settings.md § Claim generator information""" - ctx = Context.from_dict({ - "builder": { - "claim_generator_info": { - "name": "My Photo Editor", - "version": "2.1.0", - "operating_system": "auto" - } - } - }) - self.assertTrue(ctx.is_valid) - ctx.close() - - def test_builder_intent_create(self): - """settings.md § Setting Builder intent - Create""" - camera_ctx = Context.from_dict({ - "builder": { - "intent": {"Create": "digitalCapture"}, - "claim_generator_info": {"name": "Camera App", "version": "1.0"} - } - }) - self.assertTrue(camera_ctx.is_valid) - camera_ctx.close() - - def test_builder_intent_edit(self): - """settings.md § Setting Builder intent - Edit""" - editor_ctx = Context.from_dict({ - "builder": { - "intent": {"Edit": None}, - "claim_generator_info": {"name": "Photo Editor", "version": "2.0"} - } - }) - self.assertTrue(editor_ctx.is_valid) - editor_ctx.close() - - def test_update_only_json(self): - """settings.md - Only JSON format is supported""" - s = Settings() - with self.assertRaises(Error): - s.update("data", format="toml") - s.close() - - -# ═══════════════════════════════════════════════════════════════ -# faqs.md examples -# ═══════════════════════════════════════════════════════════════ - - -class TestFaqsDocs(unittest.TestCase): - """Tests for docs/faqs.md code examples.""" - - def test_reader_only(self): - """faqs.md § When to use Reader""" - ctx = Context() - reader = Reader(SIGNED_IMAGE, context=ctx) - json_data = reader.json() # inspect the manifest - self.assertIsNotNone(json_data) - - # Extract a thumbnail - manifest_store = json.loads(json_data) - active_uri = manifest_store["active_manifest"] - manifest = manifest_store["manifests"][active_uri] - if "thumbnail" in manifest: - thumb_uri = manifest["thumbnail"]["identifier"] - thumb_stream = io.BytesIO() - reader.resource_to_stream(thumb_uri, thumb_stream) - self.assertGreater(thumb_stream.tell(), 0) - - reader.close() - ctx.close() - - def test_builder_only(self): - """faqs.md § When to use a Builder""" - ctx = Context() - manifest_json = _manifest_def() - builder = Builder(manifest_json, context=ctx) - - ingredient_json = json.dumps({"title": "Original"}) - with open(UNSIGNED_IMAGE, "rb") as ingredient: - builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) - - signer = _make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - # Verify output was created - self.assertTrue(os.path.exists(dest)) - reader = Reader(dest) - self.assertIsNotNone(reader.json()) - reader.close() - builder.close() - ctx.close() - - def test_reader_and_builder_together(self): - """faqs.md § When to use both Reader and Builder together""" - ctx = Context() - - # Read existing - reader = Reader(SIGNED_IMAGE, context=ctx) - parsed = json.loads(reader.json()) - reader.close() - - # "Filter" - just use the parsed data as-is for testing - # (In a real app you'd filter assertions/ingredients) - kept = _manifest_def() - - # Create a new Builder with the "filtered" content - builder = Builder(json.dumps(kept), context=ctx) - signer = _make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - self.assertTrue(os.path.exists(dest)) - builder.close() - ctx.close() - - def test_archive_from_archive_with_context(self): - """faqs.md § When to use archives""" - ctx = Context.from_dict({ - "builder": {"thumbnail": {"enabled": False}} - }) - - # Create a builder and archive it - builder = Builder(_manifest_def(), context=ctx) - archive = io.BytesIO() - builder.to_archive(archive) - builder.close() - - # Restore from archive with context - archive.seek(0) - builder = Builder.from_archive(archive, context=ctx) - self.assertIsNotNone(builder) - - signer = _make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - # Verify output is readable - reader = Reader(dest) - self.assertIsNotNone(reader.json()) - reader.close() - builder.close() - ctx.close() - - -# ═══════════════════════════════════════════════════════════════ -# working-stores.md examples -# ═══════════════════════════════════════════════════════════════ - - -class TestWorkingStoresDocs(unittest.TestCase): - """Tests for docs/working-stores.md code examples.""" - - # -- Reading manifest stores from assets -------------------------- - - def test_reading_from_file(self): - """working-stores.md § Reading from a file""" - from c2pa import Reader - - try: - reader = Reader(SIGNED_IMAGE) - manifest_store_json = reader.json() - self.assertIsNotNone(manifest_store_json) - reader.close() - except Exception as e: - self.fail(f"C2PA Error: {e}") - - def test_reading_from_stream(self): - """working-stores.md § Reading from a stream""" - with open(SIGNED_IMAGE, "rb") as stream: - reader = Reader("image/jpeg", stream) - manifest_json = reader.json() - self.assertIsNotNone(manifest_json) - reader.close() - - def test_reading_with_context(self): - """working-stores.md § Using Context for configuration""" - from c2pa import Context, Reader - - ctx = Context.from_dict({ - "verify": { - "verify_after_sign": True - } - }) - - reader = Reader(SIGNED_IMAGE, context=ctx) - manifest_json = reader.json() - self.assertIsNotNone(manifest_json) - reader.close() - ctx.close() - - # -- Using working stores ---------------------------------------- - - def test_creating_working_store(self): - """working-stores.md § Creating a working store""" - manifest_json = json.dumps({ - "claim_generator_info": [{ - "name": "example-app", - "version": "0.1.0" - }], - "title": "Example asset", - "assertions": [] - }) - - builder = Builder(manifest_json) - self.assertIsNotNone(builder) - builder.close() - - # Or with custom context - ctx = Context.from_dict({ - "builder": { - "thumbnail": {"enabled": True} - } - }) - builder = Builder(manifest_json, context=ctx) - self.assertIsNotNone(builder) - builder.close() - ctx.close() - - def test_modifying_working_store(self): - """working-stores.md § Modifying a working store""" - manifest_json = _manifest_def() - builder = Builder(manifest_json) - - # Add binary resources (like thumbnails) - with open(THUMBNAIL_FILE, "rb") as thumb: - builder.add_resource("thumbnail", thumb) - - # Add ingredients (source files) - ingredient_json = json.dumps({ - "title": "Original asset", - "relationship": "parentOf" - }) - with open(UNSIGNED_IMAGE, "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() - - builder.close() - - def test_working_store_to_manifest_store(self): - """working-stores.md § From working store to manifest store""" - certs, private_key = _load_creds() - - from c2pa import Signer, C2paSignerInfo, C2paSigningAlg - - 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) - - manifest_json = _manifest_def() - builder = Builder(manifest_json) - - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "signed.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - - # Read it back with Reader - reader = Reader(dest) - manifest_store_json = reader.json() - self.assertIsNotNone(manifest_store_json) - reader.close() - builder.close() - - # -- Creating and signing manifests ------------------------------ - - def test_creating_signer(self): - """working-stores.md § Creating a Signer""" - from c2pa import Signer, C2paSignerInfo, C2paSigningAlg - - certs, private_key = _load_creds() - - 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) - self.assertIsNotNone(signer) - signer.close() - - def test_signing_asset_streams(self): - """working-stores.md § Signing an asset""" - builder = Builder(_manifest_def()) - signer = _make_signer() - - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "signed.jpg") - try: - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - manifest_bytes = builder.sign(signer, "image/jpeg", src, dst) - - self.assertIsNotNone(manifest_bytes) - self.assertGreater(len(manifest_bytes), 0) - - except Exception as e: - self.fail(f"Signing failed: {e}") - - def test_signing_with_file_paths(self): - """working-stores.md § Signing with file paths""" - builder = Builder(_manifest_def()) - signer = _make_signer() - - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "signed.jpg") - manifest_bytes = builder.sign_file( - UNSIGNED_IMAGE, dest, signer - ) - self.assertIsNotNone(manifest_bytes) - self.assertGreater(len(manifest_bytes), 0) - - def test_complete_sign_and_read(self): - """working-stores.md § Complete example""" - from c2pa import Builder, Reader, Signer, C2paSignerInfo, C2paSigningAlg - - try: - # 1. Define manifest for working store - manifest_json = json.dumps({ - "claim_generator_info": [{"name": "demo-app", "version": "0.1.0"}], - "title": "Signed image", - "assertions": [] - }) - - # 2. Load credentials - certs, private_key = _load_creds() - - # 3. Create 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) - - # 4. Create working store (Builder) and sign - builder = Builder(manifest_json) - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "signed.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - - # 5. Read back the manifest store - reader = Reader(dest) - data = reader.json() - self.assertIn("manifests", data) - reader.close() - - except Exception as e: - self.fail(f"Error: {e}") - - # -- Working with resources --------------------------------------- - - def test_extract_resource_from_manifest(self): - """working-stores.md § Extracting resources from a manifest store""" - reader = Reader(SIGNED_IMAGE) - 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: - thumbnail_uri = manifest["thumbnail"]["identifier"] - - with tempfile.NamedTemporaryFile(suffix=".jpg") as f: - reader.resource_to_stream(thumbnail_uri, f) - self.assertGreater(f.tell(), 0) - - reader.close() - - def test_add_resource_to_working_store(self): - """working-stores.md § Adding resources to a working store""" - manifest_json = _manifest_def() - builder = Builder(manifest_json) - - # Add resource from a stream - with open(THUMBNAIL_FILE, "rb") as thumb: - builder.add_resource("thumbnail", thumb) - - signer = _make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "signed.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - builder.close() - - # -- Working with ingredients ------------------------------------- - - def test_add_ingredient_to_working_store(self): - """working-stores.md § Adding ingredients to a working store""" - manifest_json = _manifest_def() - builder = Builder(manifest_json) - - ingredient_json = json.dumps({ - "title": "Original asset", - "relationship": "parentOf" - }) - - # Add ingredient from a stream - with open(UNSIGNED_IMAGE, "rb") as ingredient: - builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) - - signer = _make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "signed_asset.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - - # Verify it signed - reader = Reader(dest) - data = json.loads(reader.json()) - self.assertIn("manifests", data) - reader.close() - builder.close() - - def test_ingredient_relationships(self): - """working-stores.md § Ingredient relationships""" - builder = Builder(_manifest_def()) - - ingredient_json = json.dumps({ - "title": "Base layer", - "relationship": "componentOf" - }) - - with open(UNSIGNED_IMAGE, "rb") as ingredient: - builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) - - builder.close() - - # -- Working with archives ---------------------------------------- - - def test_save_working_store_to_archive(self): - """working-stores.md § Saving a working store to archive""" - manifest_json = _manifest_def() - ingredient_json = json.dumps({"title": "Source"}) - - builder = Builder(manifest_json) - with open(THUMBNAIL_FILE, "rb") as thumb: - builder.add_resource("thumbnail", thumb) - with open(UNSIGNED_IMAGE, "rb") as ingredient: - builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) - - # Save working store to archive stream - archive = io.BytesIO() - builder.to_archive(archive) - self.assertGreater(archive.tell(), 0) - - # Verify we can save to a "file" - archive.seek(0) - archive_copy = io.BytesIO() - archive_copy.write(archive.read()) - self.assertGreater(archive_copy.tell(), 0) - - builder.close() - - def test_restore_working_store_from_archive(self): - """working-stores.md § Restoring a working store from archive""" - # First create an archive - builder = Builder(_manifest_def()) - archive = io.BytesIO() - builder.to_archive(archive) - builder.close() - - # Restore from stream - archive.seek(0) - builder = Builder.from_archive(archive) - self.assertIsNotNone(builder) - - # Now sign - signer = _make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "signed_asset.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - self.assertTrue(os.path.exists(dest)) - builder.close() - - def test_restore_with_context_preservation(self): - """working-stores.md § Restoring with context preservation""" - ctx = Context.from_dict({ - "builder": { - "thumbnail": {"enabled": False} - } - }) - - # Create archive - builder = Builder(_manifest_def(), context=ctx) - archive = io.BytesIO() - builder.to_archive(archive) - builder.close() - - # Restore from archive with context - archive.seek(0) - builder = Builder.from_archive(archive, context=ctx) - - signer = _make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "signed.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - - # Verify output is readable - reader = Reader(dest) - self.assertIsNotNone(reader.json()) - reader.close() - builder.close() - ctx.close() - - def test_two_phase_workflow(self): - """working-stores.md § Two-phase workflow example""" - # Phase 1: Prepare manifest - manifest_json = json.dumps({ - "title": "Artwork draft", - "assertions": [] - }) - - builder = Builder(manifest_json) - with open(THUMBNAIL_FILE, "rb") as thumb: - builder.add_resource("thumbnail", thumb) - with open(UNSIGNED_IMAGE, "rb") as sketch: - builder.add_ingredient( - json.dumps({"title": "Sketch"}), "image/jpeg", sketch - ) - - # Save working store as archive - archive = io.BytesIO() - builder.to_archive(archive) - builder.close() - - # Phase 2: Sign the asset - archive.seek(0) - builder = Builder.from_archive(archive) - - signer = _make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "signed_artwork.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - - self.assertTrue(os.path.exists(dest)) - reader = Reader(dest) - self.assertIsNotNone(reader.json()) - reader.close() - builder.close() - - # -- Embedded vs external manifests ------------------------------- - - def test_default_embedded_manifest(self): - """working-stores.md § Default: embedded manifest stores""" - builder = Builder(_manifest_def()) - signer = _make_signer() - - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "signed.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - - # Read it back - manifest store is embedded - reader = Reader(dest) - self.assertIsNotNone(reader.json()) - reader.close() - - def test_external_manifest_no_embed(self): - """working-stores.md § External manifest stores (no embed)""" - builder = Builder(_manifest_def()) - builder.set_no_embed() - - signer = _make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "output.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - manifest_bytes = builder.sign(signer, "image/jpeg", src, dst) - - # manifest_bytes contains the manifest store - self.assertIsNotNone(manifest_bytes) - self.assertGreater(len(manifest_bytes), 0) - - # Save it separately - c2pa_path = os.path.join(td, "output.c2pa") - with open(c2pa_path, "wb") as f: - f.write(manifest_bytes) - self.assertTrue(os.path.exists(c2pa_path)) - - # Asset should NOT have embedded manifest - with self.assertRaises(Error): - Reader(dest) - - def test_remote_manifest_url(self): - """working-stores.md § Remote manifest stores""" - builder = Builder(_manifest_def()) - builder.set_remote_url("https://example.com/manifests/") - - signer = _make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "output.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - # File should exist - self.assertTrue(os.path.exists(dest)) - - # -- Best practices ----------------------------------------------- - - def test_best_practice_context_for_config(self): - """working-stores.md § Use Context for configuration""" - ctx = Context.from_dict({ - "verify": { - "verify_after_sign": True - }, - }) - - builder = Builder(_manifest_def(), context=ctx) - reader = Reader(SIGNED_IMAGE, context=ctx) - - self.assertIsNotNone(reader.json()) - builder.close() - reader.close() - ctx.close() - - def test_best_practice_ingredients_provenance(self): - """working-stores.md § Use ingredients to build provenance chains""" - builder = Builder(_manifest_def()) - - ingredient_json = json.dumps({ - "title": "Original source", - "relationship": "parentOf" - }) - - with open(UNSIGNED_IMAGE, "rb") as ingredient: - builder.add_ingredient(ingredient_json, "image/jpeg", ingredient) - - signer = _make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "signed.jpg") - with open(UNSIGNED_IMAGE, "rb") as src, open(dest, "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) - self.assertTrue(os.path.exists(dest)) - builder.close() - - -if __name__ == "__main__": - unittest.main() From a2c2eb9397280fa4061fc365cac17c1b2f22f7de Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 13:48:10 -0800 Subject: [PATCH 13/75] fix: Switch to Interface and not protocol --- src/c2pa/c2pa.py | 25 +++++++++++++------------ tests/test_unit_tests.py | 8 ++++---- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 27dbc281..d4a86846 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -19,9 +19,9 @@ import os import warnings from pathlib import Path +from abc import ABC, abstractmethod from typing import ( Optional, Union, Callable, Any, overload, - Protocol, runtime_checkable, ) import io from .lib import dynamically_load_library @@ -1223,19 +1223,20 @@ def sign_file( signer.close() -@runtime_checkable -class ContextProvider(Protocol): - """Protocol (interface) for types that provide a C2PA context. +class ContextProvider(ABC): + """Abstract base class for types that provide a C2PA context. - Allows implementations of custom context providers. - The built-in Context class satisfies this protocol. + Subclass this to implement a custom context provider. + The built-in Context class is the standard implementation. """ @property + @abstractmethod def is_valid(self) -> bool: ... @property - def _c_context(self): ... + @abstractmethod + def execution_context(self): ... class Settings: @@ -1440,7 +1441,7 @@ def __del__(self): self._cleanup_resources() -class Context: +class Context(ContextProvider): """Per-instance context for C2PA operations. A Context may carry Settings and a Signer, @@ -1608,8 +1609,8 @@ def has_signer(self) -> bool: return self._has_signer @property - def _c_context(self): - """Expose the raw pointer (ContextProvider protocol).""" + def execution_context(self): + """Return the raw C2paContext pointer.""" self._ensure_valid_state() return self._context @@ -2430,7 +2431,7 @@ def _init_from_context(self, context, format_or_path, try: # Create base reader from context reader_ptr = _lib.c2pa_reader_from_context( - context._c_context, + context.execution_context, ) if not reader_ptr: _parse_operation_result_for_error( @@ -3438,7 +3439,7 @@ def _init_from_context(self, context, json_str): raise C2paError("Context is not valid") builder_ptr = _lib.c2pa_builder_from_context( - context._c_context, + context.execution_context, ) if not builder_ptr: _parse_operation_result_for_error( diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index b29ef8dd..9b46a296 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -5639,14 +5639,14 @@ def test_isinstance_check(self): def test_custom_context_provider(self): real_ctx = Context() - class MyProvider: + class MyProvider(ContextProvider): @property def is_valid(self) -> bool: return True @property - def _c_context(self): - return real_ctx._c_context + def execution_context(self): + return real_ctx.execution_context provider = MyProvider() self.assertIsInstance( @@ -5668,7 +5668,7 @@ def is_valid(self) -> bool: return False @property - def _c_context(self): + def execution_context(self): return None with self.assertRaises(Error): From 2b0318efe9a93923469e48f0a44632caa98fab47 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 15:38:10 -0800 Subject: [PATCH 14/75] fix: Updates --- review.md | 27 ++++++++++++++++++++------- src/c2pa/c2pa.py | 34 ++++++++++++++++++---------------- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/review.md b/review.md index c7c6a4bc..0d708ed6 100644 --- a/review.md +++ b/review.md @@ -2,19 +2,29 @@ ## Context -The user asked for a design critique of the Settings and Context APIs in the c2pa Python SDK, focusing on what could be improved and made more Pythonic. This is a review document, not an implementation plan — the goal is to identify issues and propose improvements for discussion. +Design critique of the Settings and Context APIs in the c2pa Python SDK, focusing on what could be improved and made more Pythonic. This is a review document, not an implementation plan — the goal is to identify issues and propose improvements for discussion. + +**Last updated:** After commits through a2c2eb9 (ContextProvider switched to ABC, `_c_context` → `execution_context`). --- ## Bugs -### 1. Resource leak in `Builder.from_archive()` when `context` is provided +### 1. Resource leaks in `Builder.from_archive()` **File:** `src/c2pa/c2pa.py`, `Builder.from_archive()` classmethod -When `context` is non-None, `cls({}, context=context)` runs `__init__`, which calls `_init_from_context` and allocates a native builder pointer. Then `from_archive` immediately **overwrites** `self._builder` with a new pointer from `c2pa_builder_from_archive`. The original pointer is leaked — never freed. +Two leaks exist: + +**a) Native builder pointer leak when `context` is provided.** +`cls({}, context=context)` runs `__init__`, which allocates a native builder pointer. Then `from_archive` immediately **overwrites** `self._builder` with a new pointer from `c2pa_builder_from_archive`. The original pointer is leaked — never freed. + +**b) `stream_obj` never closed on the success path.** +`stream_obj = Stream(stream)` is created without a `with` statement. `stream_obj.close()` is only called in the error branch (`if not builder._builder`). On success, the `Stream` is abandoned — cleanup depends on `__del__`, which is non-deterministic. -**Fix:** `from_archive` should bypass `_init_from_context` when creating the initial Builder. It could pass `context=None` to `__init__` and then manually apply the context to the archive-loaded builder, or use `object.__new__(cls)` to skip `__init__` entirely. +**Fix (a):** `from_archive` should bypass `_init_from_context` when creating the initial Builder. It could pass `context=None` to `__init__` and then manually apply the context to the archive-loaded builder, or use `object.__new__(cls)` to skip `__init__` entirely. + +**Fix (b):** Use a `try/finally` or `with` block to ensure `stream_obj` is closed regardless of outcome. ### 2. Dead code on every error-handling call site @@ -166,9 +176,11 @@ Reader/Builder set it inside `_cleanup_resources` AND in the `finally` block of `self._has_signer = True` is set after `set_signer` succeeds but before `build()`. If `build()` fails, the flag is stale. Not exploitable (since `is_valid` would be `False`), but inaccurate internal state. -### 15. `_c_context` is a private-convention name in a public Protocol +### ~~15. `_c_context` is a private-convention name in a public Protocol~~ RESOLVED + +~~`ContextProvider` is a public protocol that third-party code can implement. But it requires implementing `_c_context` — a leading-underscore property.~~ -`ContextProvider` is a public protocol that third-party code can implement. But it requires implementing `_c_context` — a leading-underscore property. This is an unusual contract. The underscore signals "don't use this" while the protocol signals "you must implement this." +**Fixed in a2c2eb9:** `ContextProvider` is now an `ABC` (not `Protocol`) with two abstract properties: `is_valid` and `execution_context`. The underscore-prefixed `_c_context` was renamed to `execution_context`. `Context` now explicitly extends `ContextProvider`. --- @@ -198,4 +210,5 @@ This is a review document — no code changes to verify. The findings can be val 1. Reading the source at `src/c2pa/c2pa.py` 2. Running `settings.set("builder.thumbnail.enabled", False)` to confirm the `AttributeError` → `C2paError.Encoding` mistype 3. Confirming the dead-code `if error:` branches by tracing `_parse_operation_result_for_error` -4. Confirming the `from_archive` leak by adding a breakpoint in `_cleanup_resources` and observing the overwritten pointer is never freed +4. Confirming the `from_archive` leaks: (a) add a breakpoint in `_cleanup_resources` and observe the overwritten pointer is never freed, (b) observe `stream_obj` is not closed on success path +5. Confirming item #15 is resolved: `ContextProvider` at line 1226 uses `execution_context` (no underscore) as an `@abstractmethod` on an `ABC` diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index d4a86846..178f3b72 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -3331,25 +3331,27 @@ def from_archive( builder = cls({}, context=context) stream_obj = Stream(stream) - builder._builder = ( - _lib.c2pa_builder_from_archive( - stream_obj._stream + try: + builder._builder = ( + _lib.c2pa_builder_from_archive( + stream_obj._stream + ) ) - ) - if not builder._builder: - stream_obj.close() - error = _parse_operation_result_for_error( - _lib.c2pa_error() - ) - if error: - raise C2paError(error) - raise C2paError( - "Failed to create builder from archive" - ) + if not builder._builder: + error = _parse_operation_result_for_error( + _lib.c2pa_error() + ) + if error: + raise C2paError(error) + raise C2paError( + "Failed to create builder from archive" + ) - builder._initialized = True - return builder + builder._initialized = True + return builder + finally: + stream_obj.close() def __init__( self, From 64c076d26fb1ed609e91160b1aef5384c9ef7c5d Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 15:49:59 -0800 Subject: [PATCH 15/75] fix: Update things --- src/c2pa/c2pa.py | 173 +++++++++++++++++++++++++++++---------- tests/test_unit_tests.py | 12 +-- 2 files changed, 139 insertions(+), 46 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 178f3b72..ef4121af 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -42,13 +42,11 @@ 'c2pa_read_ingredient_file', '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_builder_from_json', 'c2pa_builder_from_archive', - 'c2pa_builder_free', 'c2pa_builder_set_no_embed', 'c2pa_builder_set_remote_url', 'c2pa_builder_set_intent', @@ -64,7 +62,6 @@ 'c2pa_signer_create', 'c2pa_signer_from_info', 'c2pa_signer_reserve_size', - 'c2pa_signer_free', 'c2pa_ed25519_sign', 'c2pa_signature_free', 'c2pa_free_string_array', @@ -435,7 +432,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) @@ -467,7 +463,6 @@ 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_set_no_embed, [ ctypes.POINTER(C2paBuilder)], None) _setup_function( @@ -545,7 +540,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( @@ -1273,7 +1267,7 @@ def from_json(cls, json_str: str) -> 'Settings': A new Settings instance with the given configuration. """ settings = cls() - settings.update(json_str, format="json") + settings.update(json_str) return settings @classmethod @@ -1319,15 +1313,13 @@ def set(self, path: str, value: str) -> 'Settings': return self def update( - self, data: Union[str, dict], format: str = "json" + self, data: Union[str, dict], ) -> 'Settings': """Merge configuration from a JSON string or dict. Args: data: A JSON string or dict with configuration to merge. - format: Format of the data string. Only "json" - is supported. Returns: self, for method chaining. @@ -1335,34 +1327,24 @@ def update( self._ensure_valid_state() _clear_error_state() - if format != "json": - raise C2paError( - "Only JSON format is supported for settings" - ) - if isinstance(data, dict): data = json.dumps(data) try: data_bytes = data.encode('utf-8') - format_bytes = format.encode('utf-8') except (UnicodeEncodeError, AttributeError) as e: raise C2paError.Encoding( f"Encoding: {str(e)}" ) from e result = _lib.c2pa_settings_update_from_string( - self._settings, data_bytes, format_bytes + self._settings, data_bytes, b"json" ) if result != 0: _parse_operation_result_for_error(None) return self - def __setitem__(self, path: str, value: str) -> None: - """Dict-like setter: settings["path"] = "value".""" - self.set(path, value) - @property def _c_settings(self): """Expose the raw pointer for Context to consume.""" @@ -2514,7 +2496,12 @@ def _cleanup_resources(self): # Clean up reader if hasattr(self, '_reader') and self._reader: try: - _lib.c2pa_reader_free(self._reader) + _lib.c2pa_free( + ctypes.cast( + self._reader, + ctypes.c_void_p, + ) + ) except Exception: # Cleanup failure doesn't raise exceptions logger.error( @@ -3090,7 +3077,12 @@ def _cleanup_resources(self): self._closed = True try: - _lib.c2pa_signer_free(self._signer) + _lib.c2pa_free( + ctypes.cast( + self._signer, + ctypes.c_void_p, + ) + ) except Exception: # Log cleanup errors but don't raise exceptions logger.error("Failed to free native Signer resources") @@ -3511,7 +3503,12 @@ def _cleanup_resources(self): self, '_builder') and self._builder and self._builder != 0: try: - _lib.c2pa_builder_free(self._builder) + _lib.c2pa_free( + ctypes.cast( + self._builder, + ctypes.c_void_p, + ) + ) except Exception: # Log cleanup errors but don't raise exceptions logger.error( @@ -3918,18 +3915,40 @@ def _sign_internal( return manifest_bytes + @overload def sign( self, - signer=None, - format=None, - source=None, - dest=None, - ) -> bytes: + 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, *args, **kwargs) -> bytes: """Sign the builder's content. + Can be called in two ways: + + With an explicit signer:: + + builder.sign(signer, "image/jpeg", source, dest) + + With a context signer (builder must have been created + with a Context that has a signer):: + + builder.sign("image/jpeg", source, dest) + Args: - signer: The signer to use. If None, the - context's signer is used. + signer: The signer to use (optional if context + has a signer). format: The MIME type of the content. source: The source stream. dest: The destination stream (optional). @@ -3940,11 +3959,9 @@ def sign( Raises: C2paError: If there was an error during signing """ - if format is None or source is None: - raise C2paError( - "format and source are required" - " for sign()" - ) + signer, format, source, dest = ( + self._parse_sign_args(args, kwargs) + ) source_stream = Stream(source) @@ -3978,6 +3995,54 @@ def sign( return manifest_bytes + @staticmethod + def _parse_sign_args(args, kwargs): + """Parse sign() arguments for both overloads. + + Returns (signer, format, source, dest). + """ + signer = None + format = None + source = None + dest = None + + if args: + if isinstance(args[0], Signer): + # sign(signer, format, source, dest=None) + signer = args[0] + if len(args) > 1: + format = args[1] + if len(args) > 2: + source = args[2] + if len(args) > 3: + dest = args[3] + elif isinstance(args[0], str): + # sign(format, source, dest=None) + format = args[0] + if len(args) > 1: + source = args[1] + if len(args) > 2: + dest = args[2] + else: + raise C2paError( + "First argument to sign() must be" + " a Signer or a format string" + ) + + # Keyword args override positional + signer = kwargs.get('signer', signer) + format = kwargs.get('format', format) + source = kwargs.get('source', source) + dest = kwargs.get('dest', dest) + + if format is None or source is None: + raise C2paError( + "format and source are required" + " for sign()" + ) + + return signer, format, source, dest + def _sign_context_internal( self, format: str, @@ -4057,14 +4122,34 @@ def _sign_context_internal( return manifest_bytes + @overload def sign_file( self, source_path: Union[str, Path], dest_path: Union[str, Path], - signer=None, + 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. dest_path: Path to write the signed file to. @@ -4086,10 +4171,16 @@ def sign_file( 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, - ) + 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 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 9b46a296..1968a83e 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -5268,10 +5268,11 @@ def test_settings_update_dict(self): self.assertIs(result, s) s.close() - def test_settings_setitem(self): + def test_settings_no_setitem(self): + """__setitem__ was removed; use set() instead.""" s = Settings() - s["builder.thumbnail.enabled"] = "false" - self.assertTrue(s.is_valid) + with self.assertRaises(TypeError): + s["builder.thumbnail.enabled"] = "false" s.close() def test_settings_context_manager(self): @@ -5296,9 +5297,10 @@ def test_settings_raises_after_close(self): "builder.thumbnail.enabled", "false" ) - def test_settings_update_only_json(self): + def test_settings_update_no_format_param(self): + """format parameter was removed; passing it raises TypeError.""" s = Settings() - with self.assertRaises(Error): + with self.assertRaises(TypeError): s.update("data", format="toml") s.close() From 042910c09b2078c19e0482acbe37bd1a4fe70598 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 15:53:28 -0800 Subject: [PATCH 16/75] fix: CLean up notes --- review.md | 214 ------------------------------------------------------ 1 file changed, 214 deletions(-) delete mode 100644 review.md diff --git a/review.md b/review.md deleted file mode 100644 index 0d708ed6..00000000 --- a/review.md +++ /dev/null @@ -1,214 +0,0 @@ -# Critique: Settings and Context API design - -## Context - -Design critique of the Settings and Context APIs in the c2pa Python SDK, focusing on what could be improved and made more Pythonic. This is a review document, not an implementation plan — the goal is to identify issues and propose improvements for discussion. - -**Last updated:** After commits through a2c2eb9 (ContextProvider switched to ABC, `_c_context` → `execution_context`). - ---- - -## Bugs - -### 1. Resource leaks in `Builder.from_archive()` - -**File:** `src/c2pa/c2pa.py`, `Builder.from_archive()` classmethod - -Two leaks exist: - -**a) Native builder pointer leak when `context` is provided.** -`cls({}, context=context)` runs `__init__`, which allocates a native builder pointer. Then `from_archive` immediately **overwrites** `self._builder` with a new pointer from `c2pa_builder_from_archive`. The original pointer is leaked — never freed. - -**b) `stream_obj` never closed on the success path.** -`stream_obj = Stream(stream)` is created without a `with` statement. `stream_obj.close()` is only called in the error branch (`if not builder._builder`). On success, the `Stream` is abandoned — cleanup depends on `__del__`, which is non-deterministic. - -**Fix (a):** `from_archive` should bypass `_init_from_context` when creating the initial Builder. It could pass `context=None` to `__init__` and then manually apply the context to the archive-loaded builder, or use `object.__new__(cls)` to skip `__init__` entirely. - -**Fix (b):** Use a `try/finally` or `with` block to ensure `stream_obj` is closed regardless of outcome. - -### 2. Dead code on every error-handling call site - -**File:** `src/c2pa/c2pa.py`, ~20 call sites - -The pattern used throughout Reader/Builder/Signer: -```python -error = _parse_operation_result_for_error(_lib.c2pa_error()) -if error: # NEVER reached — function returns None or raises - raise C2paError(error) # dead code -raise C2paError("...") # always reached -``` - -`_parse_operation_result_for_error` either raises a typed exception or returns `None`. It never returns a string. The `if error:` branch is dead code at every call site. - -**Fix:** Remove the dead `if error:` branches. Change call sites to: -```python -_parse_operation_result_for_error(_lib.c2pa_error()) -raise C2paError("...") -``` - ---- - -## Non-Pythonic patterns - -### 3. `Settings.set()` requires string values — no native Python types - -`set("builder.thumbnail.enabled", "false")` works, but `set("builder.thumbnail.enabled", False)` raises `AttributeError` (which is then mistyped as `C2paError.Encoding`). - -This is the biggest daily-use footgun. Python developers expect `True`/`False`/`42` to work, not `"true"`/`"false"`/`"42"`. - -**Fix:** Accept `Any` and auto-coerce: -```python -def set(self, path: str, value) -> 'Settings': - if isinstance(value, bool): - value_str = "true" if value else "false" - elif not isinstance(value, str): - value_str = json.dumps(value) - else: - value_str = value - ... -``` - -### 4. `Builder.sign()` makes required parameters look optional - -```python -def sign(self, signer=None, format=None, source=None, dest=None) -> bytes: -``` - -All four parameters default to `None`, but `format` and `source` are always required. Omitting them produces a runtime `C2paError` instead of Python's natural `TypeError`. This breaks IDE autocomplete hints and type checker expectations. - -**Fix:** Make the data-flow parameters positional and required, signer keyword-only: -```python -def sign(self, format: str, source, dest=None, *, signer=None) -> bytes: -``` - -However, this is a **breaking API change** since existing callers use `builder.sign(signer, "image/jpeg", src, dst)` with signer as the first positional arg. A migration path would be needed. - -### 5. `Settings` is write-only — no read/query/repr - -Once you call `settings.set(...)`, there is no way to inspect the current value, no `get()`, no `to_dict()`, no `__repr__`. Debugging requires observing side effects (e.g., "did the thumbnail get generated?"). - -This is partly a C API limitation (the opaque `C2paSettings` struct has no getter function exposed). But the Python layer could: -- Track all `set()` calls in a shadow dict for `__repr__` purposes -- Provide `__repr__` showing what was configured -- Store the original JSON/dict from `from_json`/`from_dict` for introspection - -### 6. `Settings.set()` paths are magic strings with no discoverability - -Paths like `"builder.thumbnail.enabled"` have no autocomplete, no constants, no enum. A typo like `"builder.thumbail.enabled"` silently passes (or silently fails depending on the C library behavior). - -**Possible fix:** Add a `SettingsPath` constants class: -```python -class SettingsPath: - THUMBNAIL_ENABLED = "builder.thumbnail.enabled" - VERIFY_AFTER_SIGN = "verify.verify_after_sign" - ... -``` - -Or provide a fluent builder API: -```python -settings.builder.thumbnail.enabled = False -``` - -The latter requires significant refactoring. The constants class is low-effort and immediately useful. - -### 7. `Settings.__setitem__` exists but `__getitem__` does not - -`settings["builder.thumbnail.enabled"] = "false"` works, but `settings["builder.thumbnail.enabled"]` raises `TypeError`. Half-implementing a dict interface is confusing — it violates the principle of least surprise. - -**Fix:** Either add `__getitem__` (requires C API support) or remove `__setitem__` (use `set()` only). Given the C API limitation, removing `__setitem__` is simpler and more honest. - -### 8. The `format` parameter in `Settings.update()` is vestigial - -```python -def update(self, data, format: str = "json") -> 'Settings': - if format != "json": - raise C2paError("Only JSON format is supported") -``` - -A parameter with exactly one valid value shouldn't be a parameter. It exists for forward-compatibility (TOML support was considered), but in practice it only confuses callers. - -**Fix:** Remove the `format` parameter or change to `Literal["json"]` with a deprecation warning. - -### 9. MIME types are raw strings everywhere - -`"image/jpeg"`, `"video/mp4"`, etc. appear as magic strings in `sign()`, `add_ingredient()`, `Reader()`. A typo like `"image/jpg"` fails at runtime. - -The SDK already has `Reader.get_supported_mime_types()` and `Builder.get_supported_mime_types()`, but they return lists at runtime — no static enum exists. - -**Possible fix:** A `MimeType` enum or constants namespace would catch common typos at import time: -```python -class C2paMimeType: - JPEG = "image/jpeg" - PNG = "image/png" - ... -``` - -### 10. Error type mismatch for wrong argument types - -`Settings.set("path", True)` catches the resulting `AttributeError` and re-raises it as `C2paError.Encoding`. This is misleading — it's not an encoding error, it's a type error. - -**Fix:** Validate types upfront and raise `TypeError` (or `C2paError.InvalidArgument` if one existed). - ---- - -## Inconsistencies - -### 11. `_parse_operation_result_for_error` has two calling conventions - -- Settings/Context use: `_parse_operation_result_for_error(None)` (let it call `c2pa_error()` internally) -- Reader/Builder/Signer use: `_parse_operation_result_for_error(_lib.c2pa_error())` (pre-fetch and pass in) - -Both produce identical behavior. Pick one. - -### 12. Different free strategies - -- Settings/Context use generic `c2pa_free(cast(ptr, c_void_p))` -- Reader uses `c2pa_reader_free(ptr)` -- Builder uses `c2pa_builder_free(ptr)` - -This works if the C API supports both, but mixing patterns makes code review harder. - -### 13. `close()` sets `_closed = True` twice in Reader/Builder but once in Settings/Context - -Reader/Builder set it inside `_cleanup_resources` AND in the `finally` block of `close()`. Settings/Context only set it inside `_cleanup_resources`. No functional bug, but inconsistent. - -### 14. `_has_signer` set before `build()` in Context.__init__ - -`self._has_signer = True` is set after `set_signer` succeeds but before `build()`. If `build()` fails, the flag is stale. Not exploitable (since `is_valid` would be `False`), but inaccurate internal state. - -### ~~15. `_c_context` is a private-convention name in a public Protocol~~ RESOLVED - -~~`ContextProvider` is a public protocol that third-party code can implement. But it requires implementing `_c_context` — a leading-underscore property.~~ - -**Fixed in a2c2eb9:** `ContextProvider` is now an `ABC` (not `Protocol`) with two abstract properties: `is_valid` and `execution_context`. The underscore-prefixed `_c_context` was renamed to `execution_context`. `Context` now explicitly extends `ContextProvider`. - ---- - -## Lower priority - -### 16. `_raise_typed_c2pa_error` uses a long if-elif chain - -A dict mapping `{prefix_str: ExceptionClass}` would be more maintainable than 15 if-elif branches. - -### 17. `version()` is not exported from `__init__.py` - -`sdk_version()` is exported, but `version()` (which returns both c2pa-c and c2pa-rs versions) is not. Users who want full version info must do `from c2pa.c2pa import version`. - -### 18. `Stream` is exported but is an internal implementation detail - -Users never construct `Stream` directly — the SDK wraps file objects internally. Exporting it clutters the public API surface. - -### 19. Deprecated functions remain in `__all__` - -`load_settings` and `read_ingredient_file` are deprecated but still in `__all__`, giving them equal prominence with the modern API. - ---- - -## Verification - -This is a review document — no code changes to verify. The findings can be validated by: -1. Reading the source at `src/c2pa/c2pa.py` -2. Running `settings.set("builder.thumbnail.enabled", False)` to confirm the `AttributeError` → `C2paError.Encoding` mistype -3. Confirming the dead-code `if error:` branches by tracing `_parse_operation_result_for_error` -4. Confirming the `from_archive` leaks: (a) add a breakpoint in `_cleanup_resources` and observe the overwritten pointer is never freed, (b) observe `stream_obj` is not closed on success path -5. Confirming item #15 is resolved: `ContextProvider` at line 1226 uses `execution_context` (no underscore) as an `@abstractmethod` on an `ABC` From 90012c10cc042c7cc110ad6d5a5eafbd92585a6b Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 16:04:32 -0800 Subject: [PATCH 17/75] fix: Require funcs --- src/c2pa/c2pa.py | 2 ++ state_handling.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 state_handling.md diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index ef4121af..65a6f3c0 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -55,6 +55,7 @@ 'c2pa_builder_add_action', 'c2pa_builder_to_archive', 'c2pa_builder_sign', + 'c2pa_builder_sign_context', 'c2pa_manifest_bytes_free', 'c2pa_builder_data_hashed_placeholder', 'c2pa_builder_sign_data_hashed_embeddable', @@ -75,6 +76,7 @@ 'c2pa_context_builder_new', 'c2pa_context_builder_set_settings', 'c2pa_context_builder_build', + 'c2pa_context_builder_set_signer', 'c2pa_context_new', 'c2pa_reader_from_context', 'c2pa_reader_with_stream', diff --git a/state_handling.md b/state_handling.md new file mode 100644 index 00000000..d51956af --- /dev/null +++ b/state_handling.md @@ -0,0 +1,65 @@ +# Integrate LifecycleState from PR #228 into Settings, Context, and all objects + +## Context + +PR #228 (`mathern/improvs`) refactors Reader and Builder to replace dual-boolean state tracking (`_closed` + `_initialized`) with a single `LifecycleState` enum. The current branch (`mathern/context`) has Settings and Context classes that use the same dual-boolean pattern. This plan extends the LifecycleState pattern to **all** stateful objects: Settings, Context, Reader, Builder, and Signer. Stream is excluded (callbacks check booleans in hot paths, and PR #228 also excluded it). + +**Approach**: Manually integrate the patterns from PR #228 (not a git merge, since the branches have diverged significantly — PR #228 removed Context/Settings entirely). + +## Changes + +### 1. Add LifecycleState enum (~line 202, after `C2paBuilderIntent`) + +```python +class LifecycleState(enum.IntEnum): + """Internal state for lifecycle management. + Object transitions: UNINITIALIZED -> ACTIVE -> CLOSED + """ + UNINITIALIZED = 0 + ACTIVE = 1 + CLOSED = 2 +``` + +`enum` import already exists at line 15. + +### 2. Convert each class (in [c2pa.py](src/c2pa/c2pa.py)) + +For **each** of Settings, Context, Reader, Builder, Signer — apply the same mechanical transformation: + +| Before | After | +|--------|-------| +| `self._closed = False; self._initialized = False` | `self._state = LifecycleState.UNINITIALIZED` | +| `self._initialized = True` | `self._state = LifecycleState.ACTIVE` | +| `if self._closed: return` | `if self._state == LifecycleState.CLOSED: return` | +| `self._closed = True` | `self._state = LifecycleState.CLOSED` | +| `not self._closed and self._initialized` | `self._state == LifecycleState.ACTIVE` | +| `hasattr(self, '_closed') and not self._closed` | `hasattr(self, '_state') and self._state != LifecycleState.CLOSED` | + +Class-specific notes: +- **All classes**: Keep using `_lib.c2pa_free(ctypes.cast(...))` — no dedicated typed free functions exist in the native library +- **Signer**: Only has `_closed` (no `_initialized`), so init directly to `ACTIVE` +- **Stream**: Leave unchanged (hot-path callbacks, public `closed`/`initialized` properties) + +### 4. Reader `__init__` file management improvement + +Change `with open(path, 'rb') as file:` to manual `file = open(path, 'rb')` with explicit cleanup in error paths. This prevents the context manager from closing the file while Reader still needs it. + +### 5. frozenset cache for MIME types (Reader + Builder) + +- Change `_supported_mime_types_cache` from list to `frozenset` +- Return `list(cache)` from `get_supported_mime_types()` (defensive copy) +- Add `_is_mime_type_supported(cls, mime_type)` classmethod for O(1) lookup + +### 6. Update tests ([test_unit_tests.py](tests/test_unit_tests.py)) + +- Add `LifecycleState` to imports from `c2pa.c2pa` +- Replace all `reader._closed` / `reader._initialized` assertions with `reader._state == LifecycleState.CLOSED` / `LifecycleState.ACTIVE` +- Same for Builder, Signer test assertions +- Settings/Context tests already use `is_valid` property — no changes needed + +## Verification + +1. Run `python -m pytest tests/test_unit_tests.py -v` — all tests pass +2. Verify Settings/Context lifecycle: create, use, close, verify `_state` transitions +3. Verify Stream is unchanged and still works +4. Formatting must pass From b878b21b5c6372303137bf7e69bf73f8a8efcd0a Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 16:30:12 -0800 Subject: [PATCH 18/75] fix: Examples --- examples/no_thumbnails.py | 113 +++++++++++++++ examples/read.py | 18 ++- examples/sign.py | 49 +++---- examples/training.py | 72 +++++----- review_2.md | 164 ++++++++++++++++++++++ src/c2pa/c2pa.py | 281 +++++++++++++++++++++----------------- state_handling.md | 65 --------- tests/test_unit_tests.py | 70 ++++------ 8 files changed, 534 insertions(+), 298 deletions(-) create mode 100644 examples/no_thumbnails.py create mode 100644 review_2.md delete mode 100644 state_handling.md diff --git a/examples/no_thumbnails.py b/examples/no_thumbnails.py new file mode 100644 index 00000000..325530a6 --- /dev/null +++ b/examples/no_thumbnails.py @@ -0,0 +1,113 @@ +# Copyright 2025 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. + +# This example shows how to use Context and Settings to disable +# automatic thumbnail addition to the manifest. +# +# By default, the SDK adds a thumbnail to the manifest. This example +# uses Settings to turn off that behavior. + +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 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.0.1", + }], + "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 = c2pa.Settings.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } +}) + +print("Signing image with thumbnails disabled...") + +with c2pa.Context(settings=settings) as context: + 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, context=context) as builder: + builder.sign_file( + source_path=fixtures_dir + "A.jpg", + dest_path=output_dir + "A_no_thumbnail.jpg", + signer=signer + ) + + # Read back the signed image and verify no thumbnail is present. + print("\nReading signed image to verify no thumbnail...") + 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("Confirmed: No thumbnail in the manifest.") + else: + print("Unexpected: Thumbnail found in the manifest.") + +print("\nExample completed successfully!") diff --git a/examples/read.py b/examples/read.py index ea30126b..94d0d5e6 100644 --- a/examples/read.py +++ b/examples/read.py @@ -6,32 +6,37 @@ # This example shows how to read a C2PA manifest embedded in a media file, and validate # that it is trusted according to the official trust anchor certificate list. # The output is printed as prettified JSON. +# +# This example uses Context with custom Settings for trust anchor configuration. TRUST_ANCHORS_URL = "https://contentcredentials.org/trust/anchors.pem" -def load_trust_anchors(): +def load_trust_settings(): + """Load trust anchors and return a Settings object configured for trust validation.""" 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_settings() + with c2pa.Context(settings=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 38a47b43..5bbd328d 100644 --- a/examples/sign.py +++ b/examples/sign.py @@ -12,8 +12,8 @@ # This example shows how to sign an image with a C2PA manifest # using a callback signer and read the metadata added to the image. - -# TMN-TODO: Use context APIs +# +# This example uses default Context and Settings. import os import c2pa @@ -91,27 +91,28 @@ def callback_signer_es256(data: bytes) -> bytes: # which will use the callback signer. print("\nSigning the image file...") -with c2pa.Signer.from_callback( - callback=callback_signer_es256, - alg=c2pa.C2paSigningAlg.ES256, - certs=certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" -) as signer: - with c2pa.Builder(manifest_definition) as builder: - builder.sign_file( - source_path=fixtures_dir + "A.jpg", - dest_path=output_dir + "A_signed.jpg", - signer=signer - ) - -# Re-Read the signed image to verify -print("\nReading signed image metadata:") -with open(output_dir + "A_signed.jpg", "rb") as file: - with c2pa.Reader("image/jpeg", file) as reader: - # The validation state will depend on loaded trust settings. - # Without loaded trust settings, - # the manifest validation_state will be "Invalid". - print(reader.json()) +# Use default Context and Settings. +with c2pa.Context() as context: + 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, context=context) as builder: + builder.sign_file( + source_path=fixtures_dir + "A.jpg", + dest_path=output_dir + "A_signed.jpg", + signer=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, context=context) as reader: + # The validation state will depend on loaded trust settings. + # Without loaded trust settings, + # the manifest validation_state will be "Invalid". + print(reader.json()) print("\nExample completed successfully!") - diff --git a/examples/training.py b/examples/training.py index a103f986..5a76b599 100644 --- a/examples/training.py +++ b/examples/training.py @@ -12,8 +12,8 @@ # This example shows how to add a do not train assertion to an asset and then verify it # We use python crypto to sign the data using openssl with Ps256 here - -# TMN-TODO: Use context APIs +# +# This example uses default Context and Settings. import json import os @@ -92,7 +92,7 @@ def getitem(d, key): } } -# V2 signing API example +# V2 signing API example using default Context and Settings. try: # Read the private key and certificate files with open(keyFile, "rb") as key_file: @@ -108,26 +108,27 @@ 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: + with c2pa.Signer.from_info(signer_info) as signer: + with c2pa.Builder(manifest_json, context=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 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) - 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}") @@ -138,22 +139,23 @@ 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: - # Retrieve the manifest store - manifest_store = json.loads(reader.json()) - - # Look at data in the active manifest - manifest = manifest_store["manifests"][manifest_store["active_manifest"]] - for assertion in manifest["assertions"]: - if assertion["label"] == "cawg.training-mining": - if getitem(assertion, ("data","entries","cawg.ai_generative_training","use")) == "notAllowed": - allowed = False - - # Get the ingredient thumbnail and save it to a file using resource_to_stream - uri = getitem(manifest,("ingredients", 0, "thumbnail", "identifier")) - with open(output_dir + "thumbnail_v2.jpg", "wb") as thumbnail_output: - reader.resource_to_stream(uri, thumbnail_output) + # Create reader using the Reader API with default Context + with c2pa.Context() as context: + with c2pa.Reader(testOutputFile, context=context) as reader: + # Retrieve the manifest store + manifest_store = json.loads(reader.json()) + + # Look at data in the active manifest + manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + for assertion in manifest["assertions"]: + if assertion["label"] == "cawg.training-mining": + if getitem(assertion, ("data","entries","cawg.ai_generative_training","use")) == "notAllowed": + allowed = False + + # Get the ingredient thumbnail and save it to a file using resource_to_stream + uri = getitem(manifest,("ingredients", 0, "thumbnail", "identifier")) + with open(output_dir + "thumbnail_v2.jpg", "wb") as thumbnail_output: + reader.resource_to_stream(uri, thumbnail_output) except Exception as err: print(f"Exception during assertions reading: {err}") diff --git a/review_2.md b/review_2.md new file mode 100644 index 00000000..cf04f245 --- /dev/null +++ b/review_2.md @@ -0,0 +1,164 @@ +# Critical Review & Improvement Plan for c2pa-python + +## Context + +The `c2pa-python` library is a ~4400-line monolithic Python FFI binding (`src/c2pa/c2pa.py`) over the `c2pa-rs` Rust/C library. After thorough review, there are memory safety bugs, API design issues, and idiomatic Python violations that need addressing. This plan covers fixes, API improvements, and fluent API additions. + +--- + +## Phase 1: Critical Memory & Safety Fixes + +### 1.1 Add missing `__del__` to Signer class +- **File:** `src/c2pa/c2pa.py:3098` (after `__exit__`) +- **Bug:** Every other resource-holding class (Settings:1434, Context:1680, Stream:1906, Reader:2495, Builder:3502) has `__del__` → `_cleanup_resources()`. Signer does not. Leaked Signers never free native memory. +- **Fix:** Add `def __del__(self): self._cleanup_resources()` + +### 1.2 Fix `ed25519_sign` undefined behavior on immutable bytes +- **File:** `src/c2pa/c2pa.py:4394-4396` +- **Bug:** `ctypes.memset(key_bytes, 0, len(key_bytes))` where `key_bytes` is a Python `bytes` (immutable). This is UB — can corrupt CPython internals. Gives false sense of security. +- **Fix:** Use a mutable `bytearray` + ctypes array, zero the mutable buffer in `finally`. Add comment documenting the inherent limitation that Python may cache copies. + +### 1.3 Fix `_convert_to_py_string` silently swallowing decode errors +- **File:** `src/c2pa/c2pa.py:791-792` +- **Bug:** Returns `""` on UTF-8 decode failure. Callers assume success. Masks data corruption from the native library. +- **Fix:** Log the error, free the pointer, raise `C2paError.Encoding`. + +### 1.4 Fix Reader `__init__` wrapping C2paError in C2paError.Io +- **File:** `src/c2pa/c2pa.py:2247-2256` and `2311-2320` +- **Bug:** `except Exception` catches `C2paError.NotSupported`, `C2paError.ManifestNotFound`, etc. and re-wraps them as `C2paError.Io`, losing the original error type. +- **Fix:** Add `except C2paError: ... raise` before the generic `except Exception` block. Apply to both code paths. + +### 1.5 Remove hardcoded 1MB limit in signer callback +- **File:** `src/c2pa/c2pa.py:2980-2981` +- **Bug:** `if data_len > 1024 * 1024: return -1` silently rejects large documents. No error message. Large PDFs or high-res images will fail with no indication why. +- **Fix:** Raise the limit to 100MB (or remove it — the native library enforces its own limits). Log an error when the limit is hit. + +### 1.6 Remove unnecessary temp buffer + zeroing in write_callback +- **File:** `src/c2pa/c2pa.py:1840-1850` +- **Bug:** Creates temp ctypes buffer, copies data, writes, then zeros the buffer. The zeroing serves no purpose (media content, not secrets). The copy is also unnecessary. +- **Fix:** Read directly from the C buffer via `(ctypes.c_ubyte * length).from_address(data)` → `bytes(buffer)`. + +### 1.7 Fix `format_embeddable` missing try/finally for memory free +- **File:** `src/c2pa/c2pa.py:4256-4259` +- **Bug:** `result_bytes_ptr[:size]` is sliced, then `c2pa_manifest_bytes_free` is called on the next line. If the slice fails, memory leaks. Rest of codebase uses try/finally. +- **Fix:** Wrap in try/finally like `_sign_internal` does. + +--- + +## Phase 2: API Design Improvements + +### 2.1 Split the 4400-line monolith into modules +Proposed structure: +``` +src/c2pa/ + _ffi.py # Library loading, _lib, function prototypes, validation + _errors.py # C2paError hierarchy, _raise_typed_c2pa_error, _parse_operation_result_for_error + _enums.py # C2paSigningAlg, C2paSeekMode, C2paDigitalSourceType, C2paBuilderIntent, LifecycleState + _types.py # Opaque ctypes structures, callback types, C2paSignerInfo + _utils.py # _convert_to_py_string, _clear_error_state, _get_mime_type_from_path, _StringContainer + stream.py # Stream class + settings.py # Settings class + context.py # ContextProvider, Context class + reader.py # Reader class + builder.py # Builder class + signer.py # Signer class + _deprecated.py # load_settings, read_file, read_ingredient_file, sign_file, create_signer, etc. + _standalone.py # ed25519_sign, format_embeddable, version, sdk_version +``` +- Keep `c2pa.py` as a backward-compat re-export shim temporarily +- `__init__.py` imports from new modules + +### 2.2 Use dict dispatch for `_raise_typed_c2pa_error` +- **File:** `src/c2pa/c2pa.py:807-860` +- Replace the 30-line if/elif chain with a `_ERROR_TYPE_MAP` dictionary lookup. + +### 2.3 Replace `Builder.sign()` *args/**kwargs with explicit methods +- **File:** `src/c2pa/c2pa.py:3971-4080` +- **Problem:** `_parse_sign_args` manually inspects positional args by type. Breaks IDE autocompletion, type checking, is un-Pythonic. +- **Fix:** Create `sign(signer, format, source, dest=None)` and `sign_with_context(format, source, dest=None)` as explicit methods. Keep current signature as deprecated wrapper. + +### 2.4 Replace `C2paSignerInfo` ctypes.Structure with a dataclass +- **File:** `src/c2pa/c2pa.py:313-374` +- Expose a `@dataclass SignerInfo` to users. Convert to ctypes internally. Keep `C2paSignerInfo` as deprecated alias. + +### 2.5 De-duplicate `get_supported_mime_types` +- **File:** Reader:2029, Builder:3252 — nearly identical ~50-line methods +- Extract a shared `_get_supported_mime_types(lib_func)` helper. + +### 2.6 Refactor Reader.__init__ to eliminate duplication +- **File:** `src/c2pa/c2pa.py:2150-2364` — 200+ lines, three near-identical branches +- Add `Reader.from_file(path)` and `Reader.from_stream(format, stream)` classmethods. Have `__init__` delegate to them. + +### 2.7 Fix `_parse_operation_result_for_error` dead code +- **File:** `src/c2pa/c2pa.py:863-898` +- Returns `Optional[str]` but always returns `None` or raises. Callers check `if error:` on the return — always False. Fix return type and remove dead checks. + +### 2.8 Clean up deprecated exports +- Remove `read_ingredient_file` and `load_settings` from `__all__` in `__init__.py`. Keep importable but not discoverable. + +--- + +## Phase 3: Fluent API + +### 3.1 Builder method chaining (backward-compatible) +Add `return self` to these Builder methods (currently return `None`): +- `set_no_embed()` → `return self` +- `set_remote_url(url)` → `return self` +- `set_intent(intent, digital_source_type)` → `return self` +- `add_resource(uri, stream)` → `return self` +- `add_ingredient(json, format, source)` → `return self` +- `add_action(action_json)` → `return self` + +Enables: +```python +manifest_bytes = ( + Builder(manifest_def) + .add_ingredient(ingredient_json, "image/jpeg", stream) + .add_action(action_json) + .set_intent(C2paBuilderIntent.EDIT) + .sign(signer, "image/jpeg", source, dest) +) +``` + +### 3.2 Context fluent construction (optional) +Add `Context.builder()` returning a `ContextBuilder` with `.with_settings()` / `.with_signer()` / `.build()`. The current constructor API remains unchanged. + +### 3.3 Settings already supports chaining +`Settings.set()` and `Settings.update()` already return `self`. No changes needed. + +--- + +## Phase 4: Idiomatic Python Polish + +### 4.1 Add `__repr__` to all public classes +No class has `__repr__` currently. Add for debugging: +```python +def __repr__(self): return f"Reader(state={self._state.name})" +``` + +### 4.2 Add `__slots__` to resource classes +Reduces per-instance memory ~40% for Reader, Builder, Signer, Stream, Settings, Context. + +### 4.3 Use `threading.Lock` for Stream counter overflow +- **File:** `src/c2pa/c2pa.py:1730-1733` — counter reset is not atomic under threading. + +### 4.4 Define a `StreamLike` Protocol +Replace runtime duck-type checks (line 1738-1749) with `typing.Protocol` for better static analysis. + +### 4.5 Use `pathlib.Path` consistently +Accept `Path` objects throughout. Convert internally. Update docstrings. + +--- + +## Verification + +1. **Run existing tests:** `pytest tests/test_unit_tests.py -v` — all must pass after each phase +2. **Run threaded tests:** `pytest tests/test_unit_tests_threaded.py -v` +3. **Test fluent API:** Add new tests exercising method chaining on Builder +4. **Test error type preservation:** Verify `C2paError.ManifestNotFound` is not wrapped as `C2paError.Io` +5. **Test Signer cleanup:** Create Signer without `with` block, verify `__del__` cleans up +6. **Manual smoke test:** Run `examples/sign.py` and `examples/read.py` end-to-end + +## Implementation Order + +Phase 1 first (all items are independent, can be separate commits). Phase 2.1 (module split) is the largest change — do after Phase 1. Phase 3 is backward-compatible and can be done anytime. Phase 4 is polish. diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 65a6f3c0..d0229944 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -200,6 +200,15 @@ class C2paBuilderIntent(enum.IntEnum): UPDATE = 2 # Restricted version of Edit for non-editorial changes +class LifecycleState(enum.IntEnum): + """Internal state for lifecycle management. + Object transitions: UNINITIALIZED -> ACTIVE -> CLOSED + """ + UNINITIALIZED = 0 + ACTIVE = 1 + CLOSED = 2 + + # Mapping from C2paSigningAlg enum to string representation, # as the enum value currently maps by default to an integer value. _ALG_TO_STRING_BYTES_MAPPING = { @@ -1246,8 +1255,7 @@ class Settings: def __init__(self): """Create new Settings with default values.""" _clear_error_state() - self._closed = False - self._initialized = False + self._state = LifecycleState.UNINITIALIZED self._settings = None ptr = _lib.c2pa_settings_new() @@ -1256,7 +1264,7 @@ def __init__(self): raise C2paError("Failed to create Settings") self._settings = ptr - self._initialized = True + self._state = LifecycleState.ACTIVE @classmethod def from_json(cls, json_str: str) -> 'Settings': @@ -1357,8 +1365,7 @@ def _c_settings(self): def is_valid(self) -> bool: """Check if the Settings is in a valid state.""" return ( - not self._closed - and self._initialized + self._state == LifecycleState.ACTIVE and self._settings is not None ) @@ -1368,9 +1375,9 @@ def _ensure_valid_state(self): Raises: C2paError: If the settings are closed or invalid. """ - if self._closed: + if self._state == LifecycleState.CLOSED: raise C2paError("Settings is closed") - if not self._initialized: + if self._state != LifecycleState.ACTIVE: raise C2paError( "Settings is not properly initialized" ) @@ -1380,8 +1387,11 @@ def _ensure_valid_state(self): def _cleanup_resources(self): """Release native resources safely.""" try: - if hasattr(self, '_closed') and not self._closed: - self._closed = True + if ( + hasattr(self, '_state') + and self._state != LifecycleState.CLOSED + ): + self._state = LifecycleState.CLOSED if ( hasattr(self, '_settings') and self._settings @@ -1405,7 +1415,7 @@ def _cleanup_resources(self): def close(self) -> None: """Release the Settings resources.""" - if self._closed: + if self._state == LifecycleState.CLOSED: return try: self._cleanup_resources() @@ -1458,8 +1468,7 @@ def __init__( signer-on-context. """ _clear_error_state() - self._closed = False - self._initialized = False + self._state = LifecycleState.UNINITIALIZED self._context = None self._has_signer = False self._signer_callback_cb = None @@ -1545,7 +1554,7 @@ def __init__( pass raise - self._initialized = True + self._state = LifecycleState.ACTIVE @classmethod def from_json( @@ -1602,8 +1611,7 @@ def execution_context(self): def is_valid(self) -> bool: """Check if the Context is in a valid state.""" return ( - not self._closed - and self._initialized + self._state == LifecycleState.ACTIVE and self._context is not None ) @@ -1613,9 +1621,9 @@ def _ensure_valid_state(self): Raises: C2paError: If the context is closed or invalid. """ - if self._closed: + if self._state == LifecycleState.CLOSED: raise C2paError("Context is closed") - if not self._initialized: + if self._state != LifecycleState.ACTIVE: raise C2paError( "Context is not properly initialized" ) @@ -1626,10 +1634,10 @@ def _cleanup_resources(self): """Release native resources safely.""" try: if ( - hasattr(self, '_closed') - and not self._closed + hasattr(self, '_state') + and self._state != LifecycleState.CLOSED ): - self._closed = True + self._state = LifecycleState.CLOSED if ( hasattr(self, '_context') and self._context @@ -1653,7 +1661,7 @@ def _cleanup_resources(self): def close(self) -> None: """Release the Context resources.""" - if self._closed: + if self._state == LifecycleState.CLOSED: return try: self._cleanup_resources() @@ -2029,7 +2037,7 @@ def get_supported_mime_types(cls) -> list[str]: 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 + return list(cls._supported_mime_types_cache) count = ctypes.c_size_t() arr = _lib.c2pa_reader_supported_mime_types(ctypes.byref(count)) @@ -2074,11 +2082,27 @@ def get_supported_mime_types(cls) -> list[str]: # Ignore cleanup errors pass - # Cache the result + # Cache as frozenset for O(1) lookups if result: - cls._supported_mime_types_cache = result + cls._supported_mime_types_cache = frozenset(result) + + if cls._supported_mime_types_cache: + return list(cls._supported_mime_types_cache) + return [] - return cls._supported_mime_types_cache + @classmethod + def _is_mime_type_supported(cls, mime_type: str) -> bool: + """Check if a MIME type is supported. + + Args: + mime_type: The MIME type to check + + Returns: + True if the MIME type is supported + """ + if cls._supported_mime_types_cache is None: + cls.get_supported_mime_types() + return mime_type in cls._supported_mime_types_cache @classmethod def try_create( @@ -2149,8 +2173,7 @@ def __init__( # Clear any stale error state from previous operations _clear_error_state() - self._closed = False - self._initialized = False + self._state = LifecycleState.UNINITIALIZED self._reader = None self._own_stream = None @@ -2194,37 +2217,40 @@ def __init__( Reader._ERROR_MESSAGES['encoding_error'].format( str(e))) + file = None try: - with open(path, 'rb') as file: - self._own_stream = Stream(file) + file = open(path, 'rb') + self._own_stream = Stream(file) - self._reader = _lib.c2pa_reader_from_stream( - mime_type_str, - self._own_stream._stream - ) + 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" - ) + if not self._reader: + self._own_stream.close() + self._own_stream = None + 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 + # Store the file to close it later + self._backing_file = file + self._state = LifecycleState.ACTIVE 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() + self._own_stream = None + if file: + file.close() + self._backing_file = None raise C2paError.Io( Reader._ERROR_MESSAGES['io_error'].format( str(e))) @@ -2238,54 +2264,57 @@ def __init__( raise C2paError.NotSupported( f"Reader does not support {format_or_path}") + file = None 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), - ) + file = open(stream, 'rb') + self._own_stream = Stream(file) + + format_str = str(format_or_path) + format_bytes = format_str.encode('utf-8') + + if manifest_data is None: + self._reader = _lib.c2pa_reader_from_stream( + format_bytes, self._own_stream._stream) + else: + if not isinstance(manifest_data, bytes): + raise TypeError( + Reader._ERROR_MESSAGES['manifest_error']) + manifest_array = ( + ctypes.c_ubyte * + len(manifest_data))( + * + manifest_data) + self._reader = ( + _lib.c2pa_reader_from_manifest_data_and_stream( + format_bytes, + self._own_stream._stream, + manifest_array, + len(manifest_data), ) + ) - if not self._reader: - self._own_stream.close() - error = _parse_operation_result_for_error( - _lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Reader._ERROR_MESSAGES['reader_error'].format( - "Unknown error" - ) + if not self._reader: + self._own_stream.close() + self._own_stream = None + 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 + self._backing_file = file + self._state = LifecycleState.ACTIVE 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() + self._own_stream = None + if file: + file.close() + self._backing_file = None raise C2paError.Io( Reader._ERROR_MESSAGES['io_error'].format( str(e))) @@ -2332,7 +2361,7 @@ def __init__( ) ) - self._initialized = True + self._state = LifecycleState.ACTIVE def _init_from_context(self, context, format_or_path, stream): @@ -2446,7 +2475,7 @@ def _init_from_context(self, context, format_or_path, ) self._reader = new_ptr - self._initialized = True + self._state = LifecycleState.ACTIVE except Exception: if self._own_stream: self._own_stream.close() @@ -2477,9 +2506,9 @@ def _ensure_valid_state(self): Raises: C2paError: If the reader is closed, not initialized, or invalid """ - if self._closed: + if self._state == LifecycleState.CLOSED: raise C2paError(Reader._ERROR_MESSAGES['closed_error']) - if not self._initialized: + if self._state != LifecycleState.ACTIVE: raise C2paError("Reader is not properly initialized") if not self._reader: raise C2paError(Reader._ERROR_MESSAGES['closed_error']) @@ -2492,8 +2521,11 @@ def _cleanup_resources(self): """ 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 ( + hasattr(self, '_state') + and self._state != LifecycleState.CLOSED + ): + self._state = LifecycleState.CLOSED # Clean up reader if hasattr(self, '_reader') and self._reader: @@ -2535,9 +2567,6 @@ def _cleanup_resources(self): finally: self._backing_file = None - # Reset initialized state after cleanup - self._initialized = False - except Exception: # Ensure we don't raise exceptions during cleanup pass @@ -2577,7 +2606,7 @@ def close(self): Errors during cleanup are logged but not raised to ensure cleanup. Multiple calls to close() are handled gracefully. """ - if self._closed: + if self._state == LifecycleState.CLOSED: return try: @@ -2592,7 +2621,7 @@ def close(self): # Clear the cache when closing self._manifest_json_str_cache = None self._manifest_data_cache = None - self._closed = True + self._state = LifecycleState.CLOSED def json(self) -> str: """Get the manifest store as a JSON string. @@ -3050,7 +3079,7 @@ def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): raise C2paError("Invalid signer pointer: pointer is null") self._signer = signer_ptr - self._closed = False + self._state = LifecycleState.ACTIVE # Set only for signers which are callback signers self._callback_cb = None @@ -3075,8 +3104,11 @@ def _cleanup_resources(self): from both close() and __del__ without causing double frees. """ try: - if not self._closed and self._signer: - self._closed = True + if ( + self._state != LifecycleState.CLOSED + and self._signer + ): + self._state = LifecycleState.CLOSED try: _lib.c2pa_free( @@ -3105,7 +3137,7 @@ def _ensure_valid_state(self): Raises: C2paError: If the signer is closed or invalid """ - if self._closed: + if self._state == LifecycleState.CLOSED: raise C2paError(Signer._ERROR_MESSAGES['closed_error']) if not self._signer: raise C2paError(Signer._ERROR_MESSAGES['closed_error']) @@ -3134,7 +3166,7 @@ def _release(self): # Detach pointer without freeing — caller now owns it self._signer = None self._callback_cb = None - self._closed = True + self._state = LifecycleState.CLOSED return ptr, callback_cb @@ -3149,7 +3181,7 @@ def close(self): Errors during cleanup are logged but not raised to ensure cleanup. """ - if self._closed: + if self._state == LifecycleState.CLOSED: return try: @@ -3169,7 +3201,7 @@ def close(self): str(e))) finally: # Always mark as closed, regardless of cleanup success - self._closed = True + self._state = LifecycleState.CLOSED def reserve_size(self) -> int: """Get the size to reserve for signatures from this signer. @@ -3228,7 +3260,7 @@ def get_supported_mime_types(cls) -> list[str]: 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 + return list(cls._supported_mime_types_cache) count = ctypes.c_size_t() arr = _lib.c2pa_builder_supported_mime_types(ctypes.byref(count)) @@ -3273,11 +3305,13 @@ def get_supported_mime_types(cls) -> list[str]: # Ignore cleanup errors pass - # Cache the result + # Cache as frozenset for O(1) lookups if result: - cls._supported_mime_types_cache = result + cls._supported_mime_types_cache = frozenset(result) - return cls._supported_mime_types_cache + if cls._supported_mime_types_cache: + return list(cls._supported_mime_types_cache) + return [] @classmethod def from_json( @@ -3342,7 +3376,7 @@ def from_archive( "Failed to create builder from archive" ) - builder._initialized = True + builder._state = LifecycleState.ACTIVE return builder finally: stream_obj.close() @@ -3372,8 +3406,7 @@ def __init__( # Clear any stale error state from previous ops _clear_error_state() - self._closed = False - self._initialized = False + self._state = LifecycleState.UNINITIALIZED self._builder = None # Keep context reference alive for lifetime @@ -3423,7 +3456,7 @@ def __init__( ].format("Unknown error") ) - self._initialized = True + self._state = LifecycleState.ACTIVE def _init_from_context(self, context, json_str): """Initialize Builder from a ContextProvider. @@ -3483,9 +3516,9 @@ def _ensure_valid_state(self): Raises: C2paError: If the builder is closed, not initialized, or invalid """ - if self._closed: + if self._state == LifecycleState.CLOSED: raise C2paError(Builder._ERROR_MESSAGES['closed_error']) - if not self._initialized: + if self._state != LifecycleState.ACTIVE: raise C2paError("Builder is not properly initialized") if not self._builder: raise C2paError(Builder._ERROR_MESSAGES['closed_error']) @@ -3498,8 +3531,11 @@ def _cleanup_resources(self): """ 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, '_state') + and self._state != LifecycleState.CLOSED + ): + self._state = LifecycleState.CLOSED if hasattr( self, @@ -3520,8 +3556,6 @@ def _cleanup_resources(self): finally: self._builder = None - # Reset initialized state after cleanup - self._initialized = False except Exception: # Ensure we don't raise exceptions during cleanup pass @@ -3534,7 +3568,7 @@ def close(self): Errors during cleanup are logged but not raised to ensure cleanup. Multiple calls to close() are handled gracefully. """ - if self._closed: + if self._state == LifecycleState.CLOSED: return try: @@ -3546,7 +3580,7 @@ def close(self): Builder._ERROR_MESSAGES['cleanup_error'].format( str(e))) finally: - self._closed = True + self._state = LifecycleState.CLOSED def set_no_embed(self): """Set the no-embed flag. @@ -4369,6 +4403,7 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'C2paDigitalSourceType', 'C2paSignerInfo', 'C2paBuilderIntent', + 'LifecycleState', 'ContextProvider', 'Settings', 'Context', diff --git a/state_handling.md b/state_handling.md deleted file mode 100644 index d51956af..00000000 --- a/state_handling.md +++ /dev/null @@ -1,65 +0,0 @@ -# Integrate LifecycleState from PR #228 into Settings, Context, and all objects - -## Context - -PR #228 (`mathern/improvs`) refactors Reader and Builder to replace dual-boolean state tracking (`_closed` + `_initialized`) with a single `LifecycleState` enum. The current branch (`mathern/context`) has Settings and Context classes that use the same dual-boolean pattern. This plan extends the LifecycleState pattern to **all** stateful objects: Settings, Context, Reader, Builder, and Signer. Stream is excluded (callbacks check booleans in hot paths, and PR #228 also excluded it). - -**Approach**: Manually integrate the patterns from PR #228 (not a git merge, since the branches have diverged significantly — PR #228 removed Context/Settings entirely). - -## Changes - -### 1. Add LifecycleState enum (~line 202, after `C2paBuilderIntent`) - -```python -class LifecycleState(enum.IntEnum): - """Internal state for lifecycle management. - Object transitions: UNINITIALIZED -> ACTIVE -> CLOSED - """ - UNINITIALIZED = 0 - ACTIVE = 1 - CLOSED = 2 -``` - -`enum` import already exists at line 15. - -### 2. Convert each class (in [c2pa.py](src/c2pa/c2pa.py)) - -For **each** of Settings, Context, Reader, Builder, Signer — apply the same mechanical transformation: - -| Before | After | -|--------|-------| -| `self._closed = False; self._initialized = False` | `self._state = LifecycleState.UNINITIALIZED` | -| `self._initialized = True` | `self._state = LifecycleState.ACTIVE` | -| `if self._closed: return` | `if self._state == LifecycleState.CLOSED: return` | -| `self._closed = True` | `self._state = LifecycleState.CLOSED` | -| `not self._closed and self._initialized` | `self._state == LifecycleState.ACTIVE` | -| `hasattr(self, '_closed') and not self._closed` | `hasattr(self, '_state') and self._state != LifecycleState.CLOSED` | - -Class-specific notes: -- **All classes**: Keep using `_lib.c2pa_free(ctypes.cast(...))` — no dedicated typed free functions exist in the native library -- **Signer**: Only has `_closed` (no `_initialized`), so init directly to `ACTIVE` -- **Stream**: Leave unchanged (hot-path callbacks, public `closed`/`initialized` properties) - -### 4. Reader `__init__` file management improvement - -Change `with open(path, 'rb') as file:` to manual `file = open(path, 'rb')` with explicit cleanup in error paths. This prevents the context manager from closing the file while Reader still needs it. - -### 5. frozenset cache for MIME types (Reader + Builder) - -- Change `_supported_mime_types_cache` from list to `frozenset` -- Return `list(cache)` from `get_supported_mime_types()` (defensive copy) -- Add `_is_mime_type_supported(cls, mime_type)` classmethod for O(1) lookup - -### 6. Update tests ([test_unit_tests.py](tests/test_unit_tests.py)) - -- Add `LifecycleState` to imports from `c2pa.c2pa` -- Replace all `reader._closed` / `reader._initialized` assertions with `reader._state == LifecycleState.CLOSED` / `LifecycleState.ACTIVE` -- Same for Builder, Signer test assertions -- Settings/Context tests already use `is_valid` property — no changes needed - -## Verification - -1. Run `python -m pytest tests/test_unit_tests.py -v` — all tests pass -2. Verify Settings/Context lifecycle: create, use, close, verify `_state` transitions -3. Verify Stream is unchanged and still works -4. Formatting must pass diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 1968a83e..bdf5c62b 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -34,7 +34,7 @@ from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType # noqa: E501 from c2pa import Settings, Context, ContextProvider -from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable # noqa: E501 +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 # noqa: E501 PROJECT_PATH = os.getcwd() @@ -337,7 +337,7 @@ def test_reader_close_cleanup(self): self.assertIsNone(reader._reader) self.assertIsNone(reader._own_stream) # Verify reader is marked as closed - self.assertTrue(reader._closed) + self.assertEqual(reader._state, LifecycleState.CLOSED) def test_resource_to_stream_on_closed_reader(self): """Test that resource_to_stream correctly raises error on closed.""" @@ -699,8 +699,7 @@ 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.assertEqual(reader._state, LifecycleState.ACTIVE) self.assertIsNotNone(reader._reader) self.assertIsNotNone(reader._own_stream) self.assertIsNotNone(reader._backing_file) @@ -709,18 +708,16 @@ def test_reader_context_manager_with_exception(self): pass # After exception - should still be closed - self.assertTrue(reader._closed) - self.assertFalse(reader._initialized) + self.assertEqual(reader._state, LifecycleState.CLOSED) self.assertIsNone(reader._reader) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) def test_reader_partial_initialization_states(self): """Test Reader behavior with partial initialization failures.""" - # Test with _reader = None but _initialized = True + # Test with _reader = None but _state = ACTIVE reader = Reader.__new__(Reader) - reader._closed = False - reader._initialized = True + reader._state = LifecycleState.ACTIVE reader._reader = None reader._own_stream = None reader._backing_file = None @@ -733,8 +730,7 @@ def test_reader_cleanup_state_transitions(self): reader = Reader(self.testPath) reader._cleanup_resources() - self.assertTrue(reader._closed) - self.assertFalse(reader._initialized) + self.assertEqual(reader._state, LifecycleState.CLOSED) self.assertIsNone(reader._reader) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) @@ -745,12 +741,11 @@ def test_reader_cleanup_idempotency(self): # First cleanup reader._cleanup_resources() - self.assertTrue(reader._closed) + self.assertEqual(reader._state, LifecycleState.CLOSED) # Second cleanup should not change state reader._cleanup_resources() - self.assertTrue(reader._closed) - self.assertFalse(reader._initialized) + self.assertEqual(reader._state, LifecycleState.CLOSED) self.assertIsNone(reader._reader) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) @@ -3272,30 +3267,26 @@ def test_builder_state_transitions(self): builder = Builder(self.manifestDefinition) # Initial state - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) + self.assertEqual(builder._state, LifecycleState.ACTIVE) self.assertIsNotNone(builder._builder) # After close builder.close() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) + self.assertEqual(builder._state, LifecycleState.CLOSED) self.assertIsNone(builder._builder) 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.assertEqual(builder._state, LifecycleState.ACTIVE) self.assertIsNotNone(builder._builder) # Placeholder operation builder.set_no_embed() # After context exit - should be closed - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) + self.assertEqual(builder._state, LifecycleState.CLOSED) self.assertIsNone(builder._builder) def test_builder_context_manager_with_exception(self): @@ -3303,24 +3294,21 @@ def test_builder_context_manager_with_exception(self): try: with Builder(self.manifestDefinition) as builder: # Inside context - should be valid - self.assertFalse(builder._closed) - self.assertTrue(builder._initialized) + self.assertEqual(builder._state, LifecycleState.ACTIVE) self.assertIsNotNone(builder._builder) raise ValueError("Test exception") except ValueError: pass # After exception - should still be closed - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) + self.assertEqual(builder._state, LifecycleState.CLOSED) self.assertIsNone(builder._builder) 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._state = LifecycleState.ACTIVE builder._builder = None with self.assertRaises(Error): @@ -3332,8 +3320,7 @@ def test_builder_cleanup_state_transitions(self): # Test _cleanup_resources method builder._cleanup_resources() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) + self.assertEqual(builder._state, LifecycleState.CLOSED) self.assertIsNone(builder._builder) def test_builder_cleanup_idempotency(self): @@ -3342,12 +3329,11 @@ def test_builder_cleanup_idempotency(self): # First cleanup builder._cleanup_resources() - self.assertTrue(builder._closed) + self.assertEqual(builder._state, LifecycleState.CLOSED) # Second cleanup should not change state builder._cleanup_resources() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) + self.assertEqual(builder._state, LifecycleState.CLOSED) self.assertIsNone(builder._builder) def test_builder_state_after_sign_operations(self): @@ -3358,8 +3344,7 @@ def test_builder_state_after_sign_operations(self): 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.assertEqual(builder._state, LifecycleState.ACTIVE) self.assertIsNotNone(builder._builder) # Should be able to sign again @@ -3375,8 +3360,7 @@ 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.assertEqual(builder._state, LifecycleState.ACTIVE) self.assertIsNotNone(builder._builder) def test_builder_state_after_double_close(self): @@ -3385,14 +3369,12 @@ def test_builder_state_after_double_close(self): # First close builder.close() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) + self.assertEqual(builder._state, LifecycleState.CLOSED) self.assertIsNone(builder._builder) # Second close should not change state builder.close() - self.assertTrue(builder._closed) - self.assertFalse(builder._initialized) + self.assertEqual(builder._state, LifecycleState.CLOSED) self.assertIsNone(builder._builder) def test_builder_state_with_invalid_native_pointer(self): @@ -5390,7 +5372,7 @@ def test_context_with_settings_and_signer(self): def test_consumed_signer_is_closed(self): signer = _ctx_make_signer() ctx = Context(signer=signer) - self.assertTrue(signer._closed) + self.assertEqual(signer._state, LifecycleState.CLOSED) ctx.close() def test_consumed_signer_raises_on_use(self): @@ -5419,7 +5401,7 @@ def test_context_from_json_with_signer(self): signer=signer, ) self.assertTrue(ctx.has_signer) - self.assertTrue(signer._closed) + self.assertEqual(signer._state, LifecycleState.CLOSED) ctx.close() From e2621df7c7826c7ee3df6a4d202f62ae8251180c Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 19:55:45 -0800 Subject: [PATCH 19/75] fix: Refactor --- Makefile | 1 + examples/no_thumbnails.py | 29 ++-- review_2.md | 6 +- src/c2pa/c2pa.py | 355 ++++++++++++++++---------------------- tests/test_unit_tests.py | 10 +- 5 files changed, 165 insertions(+), 236 deletions(-) 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/examples/no_thumbnails.py b/examples/no_thumbnails.py index 325530a6..cd664d84 100644 --- a/examples/no_thumbnails.py +++ b/examples/no_thumbnails.py @@ -1,4 +1,4 @@ -# Copyright 2025 Adobe. All rights reserved. +# 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), @@ -10,11 +10,11 @@ # specific language governing permissions and limitations under # each license. -# This example shows how to use Context and Settings to disable -# automatic thumbnail addition to the manifest. +# Shows how to use Context+Settings API to control +# thumbnails added to the manifest. # -# By default, the SDK adds a thumbnail to the manifest. This example -# uses Settings to turn off that behavior. +# This example uses Settings to explicitly turn off +# thumbnail addition when signing. import json import os @@ -30,13 +30,12 @@ if not os.path.exists(output_dir): os.makedirs(output_dir) -# Load certificates and private key (here from the test fixtures). +# 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.""" @@ -51,12 +50,11 @@ def callback_signer_es256(data: bytes) -> bytes: ) return signature - # Create a manifest definition. manifest_definition = { "claim_generator_info": [{ "name": "python_no_thumbnail_example", - "version": "0.0.1", + "version": "0.1.0", }], "format": "image/jpeg", "title": "No Thumbnail Example", @@ -76,15 +74,15 @@ def callback_signer_es256(data: bytes) -> bytes: ] } -# Use Settings to disable thumbnail generation. +# 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...") - +print("Signing image with thumbnails disabled through settings...") with c2pa.Context(settings=settings) as context: with c2pa.Signer.from_callback( callback=callback_signer_es256, @@ -99,15 +97,14 @@ def callback_signer_es256(data: bytes) -> bytes: signer=signer ) - # Read back the signed image and verify no thumbnail is present. - print("\nReading signed image to verify no thumbnail...") + # 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("Confirmed: No thumbnail in the manifest.") + print("No thumbnail in the manifest as per settings.") else: - print("Unexpected: Thumbnail found in the manifest.") + print("Thumbnail found in the manifest.") print("\nExample completed successfully!") diff --git a/review_2.md b/review_2.md index cf04f245..806dc7c8 100644 --- a/review_2.md +++ b/review_2.md @@ -75,7 +75,7 @@ src/c2pa/ ### 2.3 Replace `Builder.sign()` *args/**kwargs with explicit methods - **File:** `src/c2pa/c2pa.py:3971-4080` - **Problem:** `_parse_sign_args` manually inspects positional args by type. Breaks IDE autocompletion, type checking, is un-Pythonic. -- **Fix:** Create `sign(signer, format, source, dest=None)` and `sign_with_context(format, source, dest=None)` as explicit methods. Keep current signature as deprecated wrapper. +- **Fix:** Create `sign(signer, format, source, dest=None)` and `sign_with_context(format, source, dest=None)` as explicit methods. ### 2.4 Replace `C2paSignerInfo` ctypes.Structure with a dataclass - **File:** `src/c2pa/c2pa.py:313-374` @@ -158,7 +158,3 @@ Accept `Path` objects throughout. Convert internally. Update docstrings. 4. **Test error type preservation:** Verify `C2paError.ManifestNotFound` is not wrapped as `C2paError.Io` 5. **Test Signer cleanup:** Create Signer without `with` block, verify `__del__` cleans up 6. **Manual smoke test:** Run `examples/sign.py` and `examples/read.py` end-to-end - -## Implementation Order - -Phase 1 first (all items are independent, can be separate commits). Phase 2.1 (module split) is the largest change — do after Phase 1. Phase 3 is backward-compatible and can be done anytime. Phase 4 is polish. diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index d0229944..cb4d3dde 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -2197,7 +2197,6 @@ def __init__( return 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) @@ -2211,157 +2210,116 @@ def __init__( f"Reader does not support {mime_type}") try: - mime_type_str = mime_type.encode('utf-8') + format_bytes = mime_type.encode('utf-8') except UnicodeError as e: raise C2paError.Encoding( Reader._ERROR_MESSAGES['encoding_error'].format( str(e))) - file = None - try: - file = open(path, 'rb') - self._own_stream = Stream(file) + self._init_from_file(path, format_bytes) - self._reader = _lib.c2pa_reader_from_stream( - mime_type_str, - self._own_stream._stream - ) - - if not self._reader: - self._own_stream.close() - self._own_stream = None - 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._state = LifecycleState.ACTIVE - - except Exception as e: - if self._own_stream: - self._own_stream.close() - self._own_stream = None - if file: - file.close() - self._backing_file = None - 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 + # stream is a file path, format_or_path is the 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}") - file = None - try: - file = open(stream, 'rb') - 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), - ) - ) + format_bytes = str(format_or_path).encode('utf-8') + self._init_from_file( + stream, format_bytes, manifest_data) - if not self._reader: - self._own_stream.close() - self._own_stream = None - 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._state = LifecycleState.ACTIVE - except Exception as e: - if self._own_stream: - self._own_stream.close() - self._own_stream = None - if file: - file.close() - self._backing_file = None - raise C2paError.Io( - Reader._ERROR_MESSAGES['io_error'].format( - str(e))) else: - # format_or_path is a format string + # format_or_path is a format string, stream is a stream object 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_bytes = format_str.encode('utf-8') 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._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 native reader from a Stream. - self._state = LifecycleState.ACTIVE + Calls the appropriate FFI function and raises on failure. + + 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._reader = _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._reader = ( + _lib.c2pa_reader_from_manifest_data_and_stream( + format_bytes, + stream_obj._stream, + manifest_array, + len(manifest_data), + ) + ) + + if not self._reader: + error = _parse_operation_result_for_error( + _lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError( + Reader._ERROR_MESSAGES['reader_error'].format( + "Unknown error" + ) + ) + + def _init_from_file(self, path, format_bytes, + manifest_data=None): + """Open a file and create a reader from it. + + Args: + path: File path to open + format_bytes: UTF-8 encoded format/MIME type + manifest_data: Optional manifest bytes + """ + file = None + try: + file = open(path, 'rb') + self._own_stream = Stream(file) + self._create_reader( + format_bytes, self._own_stream, manifest_data) + self._backing_file = file + self._state = LifecycleState.ACTIVE + except C2paError: + if self._own_stream: + self._own_stream.close() + self._own_stream = None + if file: + file.close() + self._backing_file = None + raise + except Exception as e: + if self._own_stream: + self._own_stream.close() + self._own_stream = None + if file: + file.close() + self._backing_file = None + raise C2paError.Io( + Reader._ERROR_MESSAGES['io_error'].format( + str(e))) def _init_from_context(self, context, format_or_path, stream): @@ -3951,54 +3909,24 @@ def _sign_internal( return manifest_bytes - @overload - def sign( - self, - signer: Signer, - format: str, - source: Any, - dest: Any = None, - ) -> bytes: ... - - @overload - def sign( + def _sign_common( self, + signer: Optional[Signer], format: str, source: Any, dest: Any = None, - ) -> bytes: ... - - def sign(self, *args, **kwargs) -> bytes: - """Sign the builder's content. - - Can be called in two ways: - - With an explicit signer:: - - builder.sign(signer, "image/jpeg", source, dest) - - With a context signer (builder must have been created - with a Context that has a signer):: - - builder.sign("image/jpeg", source, dest) + ) -> bytes: + """Shared signing logic for sign() and sign_with_context(). Args: - signer: The signer to use (optional if context - has a signer). + 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 - - Raises: - C2paError: If there was an error during signing """ - signer, format, source, dest = ( - self._parse_sign_args(args, kwargs) - ) - source_stream = Stream(source) if dest: @@ -4008,13 +3936,11 @@ def sign(self, *args, **kwargs) -> bytes: dest_stream = Stream(mem_buffer) if signer is not None: - # Explicit signer always wins manifest_bytes = self._sign_internal( signer, format, source_stream, dest_stream, ) elif self._has_context_signer: - # Context signer as fallback manifest_bytes = self._sign_context_internal( format, source_stream, dest_stream, ) @@ -4031,53 +3957,60 @@ def sign(self, *args, **kwargs) -> bytes: return manifest_bytes - @staticmethod - def _parse_sign_args(args, kwargs): - """Parse sign() arguments for both overloads. + def sign( + self, + signer: Signer, + format: str, + source: Any, + dest: Any = None, + ) -> bytes: + """Sign the builder's content with an explicit signer. + + Example:: + + builder.sign(signer, "image/jpeg", source, dest) + + Args: + signer: The signer to use. + format: The MIME type of the content. + source: The source stream. + dest: The destination stream (optional). - Returns (signer, format, source, dest). + Returns: + Manifest bytes + + Raises: + C2paError: If there was an error during signing """ - signer = None - format = None - source = None - dest = None - - if args: - if isinstance(args[0], Signer): - # sign(signer, format, source, dest=None) - signer = args[0] - if len(args) > 1: - format = args[1] - if len(args) > 2: - source = args[2] - if len(args) > 3: - dest = args[3] - elif isinstance(args[0], str): - # sign(format, source, dest=None) - format = args[0] - if len(args) > 1: - source = args[1] - if len(args) > 2: - dest = args[2] - else: - raise C2paError( - "First argument to sign() must be" - " a Signer or a format string" - ) + return self._sign_common(signer, format, source, dest) + + def sign_with_context( + self, + format: str, + source: Any, + dest: Any = None, + ) -> bytes: + """Sign using the context's signer. - # Keyword args override positional - signer = kwargs.get('signer', signer) - format = kwargs.get('format', format) - source = kwargs.get('source', source) - dest = kwargs.get('dest', dest) + The builder must have been created with a Context + that has a signer. - if format is None or source is None: - raise C2paError( - "format and source are required" - " for sign()" - ) + Example:: - return signer, format, source, dest + builder.sign_with_context("image/jpeg", source, dest) + + Args: + format: The MIME type of the content. + source: The source stream. + dest: The destination stream (optional). + + Returns: + Manifest bytes + + Raises: + C2paError: If there was an error during signing + """ + return self._sign_common(None, format, source, dest) def _sign_context_internal( self, @@ -4255,8 +4188,10 @@ def format_embeddable(format: str, manifest_bytes: bytes) -> tuple[int, bytes]: # 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]) + finally: + _lib.c2pa_manifest_bytes_free(result_bytes_ptr) return size, result_bytes diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index bdf5c62b..c0d57045 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -5552,7 +5552,7 @@ def test_builder_sign_context_signer(self): open(DEFAULT_TEST_FILE, "rb") as src, open(dest, "w+b") as dst, ): - mb = builder.sign( + mb = builder.sign_with_context( format="image/jpeg", source=src, dest=dst, @@ -5601,7 +5601,7 @@ def test_builder_sign_no_signer_raises(self): open(dest, "w+b") as dst, ): with self.assertRaises(Error): - builder.sign( + builder.sign_with_context( format="image/jpeg", source=src, dest=dst, @@ -5710,7 +5710,7 @@ def test_sign_read_roundtrip(self): open(DEFAULT_TEST_FILE, "rb") as src, open(dest, "w+b") as dst, ): - builder.sign( + builder.sign_with_context( format="image/jpeg", source=src, dest=dst, @@ -5768,7 +5768,7 @@ def test_sign_callback_signer_in_ctx(self): open(DEFAULT_TEST_FILE, "rb") as src, open(dest, "w+b") as dst, ): - mb = builder.sign( + mb = builder.sign_with_context( format="image/jpeg", source=src, dest=dst, @@ -5818,7 +5818,7 @@ def test_existing_sign_file_positional(self): def test_sign_format_source_required(self): builder = Builder(_CTX_MANIFEST_DEF) signer = _ctx_make_signer() - with self.assertRaises(Error): + with self.assertRaises(TypeError): builder.sign(signer) builder.close() signer.close() From 8b9fb93380f8afb9fcc7d41bd5e828845a6a92e8 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 21:36:08 -0800 Subject: [PATCH 20/75] fix: Refactor --- docs/usage.md | 80 +++++++++++++++++++- review_2.md | 160 --------------------------------------- src/c2pa/__init__.py | 2 + src/c2pa/c2pa.py | 38 ++++++++++ tests/test_unit_tests.py | 59 ++++++++++++++- 5 files changed, 177 insertions(+), 162 deletions(-) delete mode 100644 review_2.md diff --git a/docs/usage.md b/docs/usage.md index 13fb3d67..6aa74794 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -10,7 +10,7 @@ Import the objects needed from the API: ```py from c2pa import Builder, Reader, Signer, C2paSigningAlg, C2paSignerInfo -from c2pa import Settings, Context, ContextProvider +from c2pa import Settings, Context, ContextBuilder, ContextProvider ``` You can use both `Builder`, `Reader` and `Signer` classes with context managers by using a `with` statement. @@ -122,6 +122,62 @@ except Exception as e: The `Settings` and `Context` classes provide **per-instance configuration** for Reader and Builder operations. This replaces the global `load_settings()` function, which is now deprecated. +```mermaid +classDiagram + class ContextProvider { + <> + +is_valid bool + +execution_context + } + + class Settings { + +set(path, value) Settings + +update(data) Settings + +from_json(json_str)$ Settings + +from_dict(config)$ Settings + +close() + } + + class Context { + +has_signer bool + +builder()$ ContextBuilder + +from_json(json_str, signer)$ Context + +from_dict(config, signer)$ Context + +close() + } + + class ContextBuilder { + +with_settings(settings) ContextBuilder + +with_signer(signer) ContextBuilder + +build() Context + } + + class Signer { + +from_info(signer_info)$ Signer + +from_callback(callback, alg, certs, tsa_url)$ Signer + +close() + } + + class Reader { + +json() str + +resource_to_stream(uri, stream) + +close() + } + + class Builder { + +add_ingredient(json, format, stream) + +sign(signer, format, source, dest) bytes + +close() + } + + ContextProvider <|-- Context + ContextBuilder --> Context : builds + Context o-- Settings : optional + Context o-- Signer : optional, consumed + Reader ..> ContextProvider : uses + Builder ..> ContextProvider : uses +``` + ### Settings `Settings` controls behavior such as thumbnail generation, trust lists, and verification flags. @@ -176,6 +232,28 @@ reader = Reader("path/to/media_file.jpg", context=ctx) builder = Builder(manifest_json, context=ctx) ``` +### ContextBuilder (fluent API) + +`ContextBuilder` provides a fluent interface for constructing a `Context`, matching the c2pa-rs `ContextBuilder` pattern. Use `Context.builder()` to get started. + +```py +from c2pa import Context, ContextBuilder, Settings, Signer + +# Fluent construction with settings and signer +ctx = ( + Context.builder() + .with_settings(settings) + .with_signer(signer) + .build() +) + +# Settings only +ctx = Context.builder().with_settings(settings).build() + +# Default context (equivalent to Context()) +ctx = Context.builder().build() +``` + ### Context with a Signer When a `Signer` is passed to `Context`, the `Signer` object becomes invalid after this call and must not be reused directly anymore as it became part of the Context. The `Context` takes ownership of the underlying native signer. This allows signing without passing an explicit signer to `Builder.sign()`. diff --git a/review_2.md b/review_2.md deleted file mode 100644 index 806dc7c8..00000000 --- a/review_2.md +++ /dev/null @@ -1,160 +0,0 @@ -# Critical Review & Improvement Plan for c2pa-python - -## Context - -The `c2pa-python` library is a ~4400-line monolithic Python FFI binding (`src/c2pa/c2pa.py`) over the `c2pa-rs` Rust/C library. After thorough review, there are memory safety bugs, API design issues, and idiomatic Python violations that need addressing. This plan covers fixes, API improvements, and fluent API additions. - ---- - -## Phase 1: Critical Memory & Safety Fixes - -### 1.1 Add missing `__del__` to Signer class -- **File:** `src/c2pa/c2pa.py:3098` (after `__exit__`) -- **Bug:** Every other resource-holding class (Settings:1434, Context:1680, Stream:1906, Reader:2495, Builder:3502) has `__del__` → `_cleanup_resources()`. Signer does not. Leaked Signers never free native memory. -- **Fix:** Add `def __del__(self): self._cleanup_resources()` - -### 1.2 Fix `ed25519_sign` undefined behavior on immutable bytes -- **File:** `src/c2pa/c2pa.py:4394-4396` -- **Bug:** `ctypes.memset(key_bytes, 0, len(key_bytes))` where `key_bytes` is a Python `bytes` (immutable). This is UB — can corrupt CPython internals. Gives false sense of security. -- **Fix:** Use a mutable `bytearray` + ctypes array, zero the mutable buffer in `finally`. Add comment documenting the inherent limitation that Python may cache copies. - -### 1.3 Fix `_convert_to_py_string` silently swallowing decode errors -- **File:** `src/c2pa/c2pa.py:791-792` -- **Bug:** Returns `""` on UTF-8 decode failure. Callers assume success. Masks data corruption from the native library. -- **Fix:** Log the error, free the pointer, raise `C2paError.Encoding`. - -### 1.4 Fix Reader `__init__` wrapping C2paError in C2paError.Io -- **File:** `src/c2pa/c2pa.py:2247-2256` and `2311-2320` -- **Bug:** `except Exception` catches `C2paError.NotSupported`, `C2paError.ManifestNotFound`, etc. and re-wraps them as `C2paError.Io`, losing the original error type. -- **Fix:** Add `except C2paError: ... raise` before the generic `except Exception` block. Apply to both code paths. - -### 1.5 Remove hardcoded 1MB limit in signer callback -- **File:** `src/c2pa/c2pa.py:2980-2981` -- **Bug:** `if data_len > 1024 * 1024: return -1` silently rejects large documents. No error message. Large PDFs or high-res images will fail with no indication why. -- **Fix:** Raise the limit to 100MB (or remove it — the native library enforces its own limits). Log an error when the limit is hit. - -### 1.6 Remove unnecessary temp buffer + zeroing in write_callback -- **File:** `src/c2pa/c2pa.py:1840-1850` -- **Bug:** Creates temp ctypes buffer, copies data, writes, then zeros the buffer. The zeroing serves no purpose (media content, not secrets). The copy is also unnecessary. -- **Fix:** Read directly from the C buffer via `(ctypes.c_ubyte * length).from_address(data)` → `bytes(buffer)`. - -### 1.7 Fix `format_embeddable` missing try/finally for memory free -- **File:** `src/c2pa/c2pa.py:4256-4259` -- **Bug:** `result_bytes_ptr[:size]` is sliced, then `c2pa_manifest_bytes_free` is called on the next line. If the slice fails, memory leaks. Rest of codebase uses try/finally. -- **Fix:** Wrap in try/finally like `_sign_internal` does. - ---- - -## Phase 2: API Design Improvements - -### 2.1 Split the 4400-line monolith into modules -Proposed structure: -``` -src/c2pa/ - _ffi.py # Library loading, _lib, function prototypes, validation - _errors.py # C2paError hierarchy, _raise_typed_c2pa_error, _parse_operation_result_for_error - _enums.py # C2paSigningAlg, C2paSeekMode, C2paDigitalSourceType, C2paBuilderIntent, LifecycleState - _types.py # Opaque ctypes structures, callback types, C2paSignerInfo - _utils.py # _convert_to_py_string, _clear_error_state, _get_mime_type_from_path, _StringContainer - stream.py # Stream class - settings.py # Settings class - context.py # ContextProvider, Context class - reader.py # Reader class - builder.py # Builder class - signer.py # Signer class - _deprecated.py # load_settings, read_file, read_ingredient_file, sign_file, create_signer, etc. - _standalone.py # ed25519_sign, format_embeddable, version, sdk_version -``` -- Keep `c2pa.py` as a backward-compat re-export shim temporarily -- `__init__.py` imports from new modules - -### 2.2 Use dict dispatch for `_raise_typed_c2pa_error` -- **File:** `src/c2pa/c2pa.py:807-860` -- Replace the 30-line if/elif chain with a `_ERROR_TYPE_MAP` dictionary lookup. - -### 2.3 Replace `Builder.sign()` *args/**kwargs with explicit methods -- **File:** `src/c2pa/c2pa.py:3971-4080` -- **Problem:** `_parse_sign_args` manually inspects positional args by type. Breaks IDE autocompletion, type checking, is un-Pythonic. -- **Fix:** Create `sign(signer, format, source, dest=None)` and `sign_with_context(format, source, dest=None)` as explicit methods. - -### 2.4 Replace `C2paSignerInfo` ctypes.Structure with a dataclass -- **File:** `src/c2pa/c2pa.py:313-374` -- Expose a `@dataclass SignerInfo` to users. Convert to ctypes internally. Keep `C2paSignerInfo` as deprecated alias. - -### 2.5 De-duplicate `get_supported_mime_types` -- **File:** Reader:2029, Builder:3252 — nearly identical ~50-line methods -- Extract a shared `_get_supported_mime_types(lib_func)` helper. - -### 2.6 Refactor Reader.__init__ to eliminate duplication -- **File:** `src/c2pa/c2pa.py:2150-2364` — 200+ lines, three near-identical branches -- Add `Reader.from_file(path)` and `Reader.from_stream(format, stream)` classmethods. Have `__init__` delegate to them. - -### 2.7 Fix `_parse_operation_result_for_error` dead code -- **File:** `src/c2pa/c2pa.py:863-898` -- Returns `Optional[str]` but always returns `None` or raises. Callers check `if error:` on the return — always False. Fix return type and remove dead checks. - -### 2.8 Clean up deprecated exports -- Remove `read_ingredient_file` and `load_settings` from `__all__` in `__init__.py`. Keep importable but not discoverable. - ---- - -## Phase 3: Fluent API - -### 3.1 Builder method chaining (backward-compatible) -Add `return self` to these Builder methods (currently return `None`): -- `set_no_embed()` → `return self` -- `set_remote_url(url)` → `return self` -- `set_intent(intent, digital_source_type)` → `return self` -- `add_resource(uri, stream)` → `return self` -- `add_ingredient(json, format, source)` → `return self` -- `add_action(action_json)` → `return self` - -Enables: -```python -manifest_bytes = ( - Builder(manifest_def) - .add_ingredient(ingredient_json, "image/jpeg", stream) - .add_action(action_json) - .set_intent(C2paBuilderIntent.EDIT) - .sign(signer, "image/jpeg", source, dest) -) -``` - -### 3.2 Context fluent construction (optional) -Add `Context.builder()` returning a `ContextBuilder` with `.with_settings()` / `.with_signer()` / `.build()`. The current constructor API remains unchanged. - -### 3.3 Settings already supports chaining -`Settings.set()` and `Settings.update()` already return `self`. No changes needed. - ---- - -## Phase 4: Idiomatic Python Polish - -### 4.1 Add `__repr__` to all public classes -No class has `__repr__` currently. Add for debugging: -```python -def __repr__(self): return f"Reader(state={self._state.name})" -``` - -### 4.2 Add `__slots__` to resource classes -Reduces per-instance memory ~40% for Reader, Builder, Signer, Stream, Settings, Context. - -### 4.3 Use `threading.Lock` for Stream counter overflow -- **File:** `src/c2pa/c2pa.py:1730-1733` — counter reset is not atomic under threading. - -### 4.4 Define a `StreamLike` Protocol -Replace runtime duck-type checks (line 1738-1749) with `typing.Protocol` for better static analysis. - -### 4.5 Use `pathlib.Path` consistently -Accept `Path` objects throughout. Convert internally. Update docstrings. - ---- - -## Verification - -1. **Run existing tests:** `pytest tests/test_unit_tests.py -v` — all must pass after each phase -2. **Run threaded tests:** `pytest tests/test_unit_tests_threaded.py -v` -3. **Test fluent API:** Add new tests exercising method chaining on Builder -4. **Test error type preservation:** Verify `C2paError.ManifestNotFound` is not wrapped as `C2paError.Io` -5. **Test Signer cleanup:** Create Signer without `with` block, verify `__del__` cleans up -6. **Manual smoke test:** Run `examples/sign.py` and `examples/read.py` end-to-end diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index 8fd1b888..5a5bfe78 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -29,6 +29,7 @@ Stream, Settings, Context, + ContextBuilder, ContextProvider, sdk_version, read_ingredient_file, @@ -48,6 +49,7 @@ 'Stream', 'Settings', 'Context', + 'ContextBuilder', 'ContextProvider', 'sdk_version', 'read_ingredient_file', diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index cb4d3dde..07aa4f10 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1435,6 +1435,39 @@ def __del__(self): self._cleanup_resources() +class ContextBuilder: + """Fluent builder for Context. + + Matches the c2pa-rs ContextBuilder pattern. + Use Context.builder() to create an instance. + """ + + def __init__(self): + self._settings = None + self._signer = None + + def with_settings( + self, settings: 'Settings', + ) -> 'ContextBuilder': + """Attach Settings to the context being built.""" + self._settings = settings + return self + + def with_signer( + self, signer: 'Signer', + ) -> 'ContextBuilder': + """Attach a Signer (will be consumed on build).""" + self._signer = signer + return self + + def build(self) -> 'Context': + """Build and return a configured Context.""" + return Context( + settings=self._settings, + signer=self._signer, + ) + + class Context(ContextProvider): """Per-instance context for C2PA operations. @@ -1556,6 +1589,11 @@ def __init__( self._state = LifecycleState.ACTIVE + @classmethod + def builder(cls) -> 'ContextBuilder': + """Return a fluent ContextBuilder.""" + return ContextBuilder() + @classmethod def from_json( cls, diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index c0d57045..0193d15a 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -33,7 +33,7 @@ ) from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType # noqa: E501 -from c2pa import Settings, Context, ContextProvider +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 # noqa: E501 @@ -5348,6 +5348,63 @@ def test_context_satisfies_protocol(self): ctx.close() +# ── 2b. ContextBuilder ────────────────────────── + + +class TestContextBuilder(unittest.TestCase): + + def test_builder_default(self): + ctx = Context.builder().build() + self.assertTrue(ctx.is_valid) + self.assertFalse(ctx.has_signer) + ctx.close() + + def test_builder_with_settings(self): + s = Settings() + ctx = Context.builder().with_settings(s).build() + self.assertTrue(ctx.is_valid) + ctx.close() + s.close() + + def test_builder_with_signer(self): + signer = _ctx_make_signer() + ctx = ( + Context.builder() + .with_signer(signer) + .build() + ) + self.assertTrue(ctx.is_valid) + self.assertTrue(ctx.has_signer) + ctx.close() + + def test_builder_with_settings_and_signer(self): + s = Settings() + signer = _ctx_make_signer() + ctx = ( + Context.builder() + .with_settings(s) + .with_signer(signer) + .build() + ) + self.assertTrue(ctx.is_valid) + self.assertTrue(ctx.has_signer) + ctx.close() + s.close() + + def test_builder_returns_context_builder(self): + b = Context.builder() + self.assertIsInstance(b, ContextBuilder) + + def test_builder_chaining_returns_self(self): + s = Settings() + b = Context.builder() + result = b.with_settings(s) + self.assertIs(result, b) + ctx = b.build() + ctx.close() + s.close() + + # ── 3. Context with Signer ────────────────────── From d37245b72434c9b58faefdb75c9a91df5c60c50b Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 22:19:41 -0800 Subject: [PATCH 21/75] fix: Unnest example --- examples/sign.py | 2 -- examples/training.py | 70 ++++++++++++++++++++++---------------------- 2 files changed, 35 insertions(+), 37 deletions(-) diff --git a/examples/sign.py b/examples/sign.py index 5bbd328d..731dbedf 100644 --- a/examples/sign.py +++ b/examples/sign.py @@ -12,8 +12,6 @@ # This example shows how to sign an image with a C2PA manifest # using a callback signer and read the metadata added to the image. -# -# This example uses default Context and Settings. import os import c2pa diff --git a/examples/training.py b/examples/training.py index 5a76b599..85f0cc17 100644 --- a/examples/training.py +++ b/examples/training.py @@ -12,8 +12,6 @@ # This example shows how to add a do not train assertion to an asset and then verify it # We use python crypto to sign the data using openssl with Ps256 here -# -# This example uses default Context and Settings. import json import os @@ -108,27 +106,28 @@ def getitem(d, key): ta_url=b"http://timestamp.digicert.com" ) - with c2pa.Context() as context: - with c2pa.Signer.from_info(signer_info) as signer: - with c2pa.Builder(manifest_json, context=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) + context = c2pa.Context() + with c2pa.Signer.from_info(signer_info) as signer: + with c2pa.Builder(manifest_json, context=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 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) - 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) + context.close() except Exception as err: print(f"Exception during signing: {err}") @@ -140,22 +139,23 @@ 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 default Context - with c2pa.Context() as context: - with c2pa.Reader(testOutputFile, context=context) as reader: - # Retrieve the manifest store - manifest_store = json.loads(reader.json()) - - # Look at data in the active manifest - manifest = manifest_store["manifests"][manifest_store["active_manifest"]] - for assertion in manifest["assertions"]: - if assertion["label"] == "cawg.training-mining": - if getitem(assertion, ("data","entries","cawg.ai_generative_training","use")) == "notAllowed": - allowed = False - - # Get the ingredient thumbnail and save it to a file using resource_to_stream - uri = getitem(manifest,("ingredients", 0, "thumbnail", "identifier")) - with open(output_dir + "thumbnail_v2.jpg", "wb") as thumbnail_output: - reader.resource_to_stream(uri, thumbnail_output) + context = c2pa.Context() + with c2pa.Reader(testOutputFile, context=context) as reader: + # Retrieve the manifest store + manifest_store = json.loads(reader.json()) + + # Look at data in the active manifest + manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + for assertion in manifest["assertions"]: + if assertion["label"] == "cawg.training-mining": + if getitem(assertion, ("data","entries","cawg.ai_generative_training","use")) == "notAllowed": + allowed = False + + # Get the ingredient thumbnail and save it to a file using resource_to_stream + uri = getitem(manifest,("ingredients", 0, "thumbnail", "identifier")) + with open(output_dir + "thumbnail_v2.jpg", "wb") as thumbnail_output: + reader.resource_to_stream(uri, thumbnail_output) + context.close() except Exception as err: print(f"Exception during assertions reading: {err}") From 8a55f5a14174da584b9fc176578e795c2fe99735 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 22:34:38 -0800 Subject: [PATCH 22/75] fix: The exampels --- examples/no_thumbnails.py | 43 ++++++++++++++++++++------------------- examples/read.py | 7 ++++--- examples/sign.py | 43 ++++++++++++++++++++------------------- 3 files changed, 48 insertions(+), 45 deletions(-) diff --git a/examples/no_thumbnails.py b/examples/no_thumbnails.py index cd664d84..dfe29cc2 100644 --- a/examples/no_thumbnails.py +++ b/examples/no_thumbnails.py @@ -83,28 +83,29 @@ def callback_signer_es256(data: bytes) -> bytes: }) print("Signing image with thumbnails disabled through settings...") -with c2pa.Context(settings=settings) as context: - 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, context=context) as builder: - builder.sign_file( - source_path=fixtures_dir + "A.jpg", - dest_path=output_dir + "A_no_thumbnail.jpg", - signer=signer - ) +context = c2pa.Context(settings=settings) +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, context=context) as builder: + builder.sign_file( + source_path=fixtures_dir + "A.jpg", + dest_path=output_dir + "A_no_thumbnail.jpg", + signer=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"]] +# 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.") + if manifest.get("thumbnail") is None: + print("No thumbnail in the manifest as per settings.") + else: + print("Thumbnail found in the manifest.") +context.close() print("\nExample completed successfully!") diff --git a/examples/read.py b/examples/read.py index 94d0d5e6..32c1226d 100644 --- a/examples/read.py +++ b/examples/read.py @@ -34,9 +34,10 @@ def read_c2pa_data(media_path: str): print(f"Reading {media_path}") try: settings = load_trust_settings() - with c2pa.Context(settings=settings) as context: - with c2pa.Reader(media_path, context=context) as reader: - print(reader.detailed_json()) + context = c2pa.Context(settings=settings) + with c2pa.Reader(media_path, context=context) as reader: + print(reader.detailed_json()) + context.close() except Exception as e: print(f"Error reading C2PA data from {media_path}: {e}") sys.exit(1) diff --git a/examples/sign.py b/examples/sign.py index 731dbedf..38e22da2 100644 --- a/examples/sign.py +++ b/examples/sign.py @@ -90,27 +90,28 @@ def callback_signer_es256(data: bytes) -> bytes: print("\nSigning the image file...") # Use default Context and Settings. -with c2pa.Context() as context: - 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, context=context) as builder: - builder.sign_file( - source_path=fixtures_dir + "A.jpg", - dest_path=output_dir + "A_signed.jpg", - signer=signer - ) +context = c2pa.Context() +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, context=context) as builder: + builder.sign_file( + source_path=fixtures_dir + "A.jpg", + dest_path=output_dir + "A_signed.jpg", + signer=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, 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()) +# 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()) +context.close() print("\nExample completed successfully!") From ba6875572a959620a710b6e7176213cb259c0686 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 4 Mar 2026 22:59:44 -0800 Subject: [PATCH 23/75] fix: The useless refactors --- src/c2pa/c2pa.py | 109 ++++++++++++++++++----------------------------- 1 file changed, 41 insertions(+), 68 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 07aa4f10..f2d6d354 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -20,9 +20,7 @@ import warnings from pathlib import Path from abc import ABC, abstractmethod -from typing import ( - Optional, Union, Callable, Any, overload, -) +from typing import Optional, Union, Callable, Any, overload import io from .lib import dynamically_load_library import mimetypes @@ -650,8 +648,8 @@ def __init__(self, message: str = ""): # 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 +# These are attached to C2paError as class attributes +# (eg., C2paError.ManifestNotFound), and also to ensure inheritance hierarchy class _C2paAssertion(C2paError): """Exception raised for assertion errors.""" @@ -733,7 +731,7 @@ class _C2paVerify(C2paError): # Attach exception subclasses to C2paError for backward compatibility -# Preserves behavior for exception catching like except C2paError.ManifestNotFound, +# Preserves behavior for exception catching, # also reduces imports (think of it as an alias of sorts) C2paError.Assertion = _C2paAssertion C2paError.AssertionNotFound = _C2paAssertionNotFound @@ -817,7 +815,7 @@ def _raise_typed_c2pa_error(error_str: str) -> None: Raises: C2paError subclass: The appropriate typed exception based on error_str """ - # Error format from native library is "ErrorType: message" or "ErrorType message" + # Error format from lib 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) @@ -879,7 +877,7 @@ def _parse_operation_result_for_error( None if no error occurred Raises: - C2paError subclass: The appropriate typed exception if an error occurred + C2paError subclass: typed exception if an error occurred """ if not result: # pragma: no cover if check_error: @@ -948,12 +946,11 @@ def load_settings(settings: Union[str, dict], format: str = "json") -> None: Args: settings: The settings string or dict to load - format: The format of the settings string - (default: "json"). + format: The format of the settings string (default: "json"). Ignored when settings is a dict. Raises: - C2paError: If there was an error loading settings + C2paError: If there was an error loading the settings """ warnings.warn( "load_settings() is deprecated. Use Settings" @@ -1231,7 +1228,7 @@ def sign_file( class ContextProvider(ABC): """Abstract base class for types that provide a C2PA context. - Subclass this to implement a custom context provider. + Subclass to implement a custom context provider. The built-in Context class is the standard implementation. """ @@ -1247,8 +1244,7 @@ def execution_context(self): ... class Settings: """Per-instance configuration for C2PA operations. - Settings control behavior such as thumbnail generation and - trust lists configurations. Use with Context to + Settings configure SDK behavior. Use with Context class to apply settings to Reader/Builder operations. """ @@ -2120,7 +2116,6 @@ def get_supported_mime_types(cls) -> list[str]: # Ignore cleanup errors pass - # Cache as frozenset for O(1) lookups if result: cls._supported_mime_types_cache = frozenset(result) @@ -2152,30 +2147,26 @@ def try_create( 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 - exception). + 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. + 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 + stream: Optional stream to read from (Python stream-like object) manifest_data: Optional manifest data in bytes context: Optional ContextProvider for settings Returns: Reader instance if the asset contains C2PA data, - None if no manifest found + None if no manifest found (ManifestNotFound: no JUMBF data found) Raises: - C2paError: If there was an error other than - ManifestNotFound + C2paError: If there was an error other than ManifestNotFound """ try: return cls( @@ -2197,13 +2188,12 @@ def __init__( Args: format_or_path: The format or path to read from - stream: Optional stream 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 Raises: - C2paError: If there was an error creating - the reader + C2paError: If there was an error creating the reader C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters """ @@ -2217,15 +2207,14 @@ def __init__( 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 + # we may have opened ourselves, and that we need to close later self._backing_file = None # Caches for manifest JSON string and parsed data self._manifest_json_str_cache = None self._manifest_data_cache = None - # Keep context reference alive for lifetime + # Keep context reference alive self._context = context if context is not None: @@ -3326,8 +3315,7 @@ def from_json( A new Builder instance Raises: - C2paError: If there was an error creating - the builder + C2paError: If there was an error creating the builder """ return cls(manifest_json, context=context) @@ -3349,8 +3337,7 @@ def from_archive( A new Builder instance 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({}, context=context) stream_obj = Stream(stream) @@ -3386,26 +3373,22 @@ def __init__( """Initialize a new Builder instance. Args: - manifest_json: The manifest JSON definition - (string or dict) + manifest_json: The manifest JSON definition (string or dict) context: Optional 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 + 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 ops + # Clear any stale error state from previous operations _clear_error_state() self._state = LifecycleState.UNINITIALIZED self._builder = None - # Keep context reference alive for lifetime + # Keep context reference alive self._context = context self._has_context_signer = ( context is not None @@ -3418,38 +3401,29 @@ def __init__( manifest_json = json.dumps(manifest_json) except (TypeError, ValueError) as e: raise C2paError.Json( - Builder._ERROR_MESSAGES[ - 'json_error' - ].format(str(e)) - ) + Builder._ERROR_MESSAGES['json_error'].format( + str(e))) try: json_str = manifest_json.encode('utf-8') except UnicodeError as e: raise C2paError.Encoding( - Builder._ERROR_MESSAGES[ - 'encoding_error' - ].format(str(e)) - ) + Builder._ERROR_MESSAGES['encoding_error'].format( + str(e))) if context is not None: self._init_from_context(context, json_str) else: - self._builder = ( - _lib.c2pa_builder_from_json(json_str) - ) + self._builder = _lib.c2pa_builder_from_json(json_str) + if not self._builder: - error = ( - _parse_operation_result_for_error( - _lib.c2pa_error() - ) - ) + error = _parse_operation_result_for_error(_lib.c2pa_error()) if error: raise C2paError(error) raise C2paError( - Builder._ERROR_MESSAGES[ - 'builder_error' - ].format("Unknown error") + Builder._ERROR_MESSAGES['builder_error'].format( + "Unknown error" + ) ) self._state = LifecycleState.ACTIVE @@ -4376,7 +4350,6 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'C2paDigitalSourceType', 'C2paSignerInfo', 'C2paBuilderIntent', - 'LifecycleState', 'ContextProvider', 'Settings', 'Context', From d214ed628f29c2a1c27bc5f6e010985536e0be9f Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 10:09:00 -0800 Subject: [PATCH 24/75] fix: Refactor --- src/c2pa/c2pa.py | 293 +++++++++++++++++------------------------------ 1 file changed, 108 insertions(+), 185 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index f2d6d354..a098629b 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -2031,6 +2031,87 @@ def initialized(self) -> bool: return self._initialized +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 + + 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: """High-level wrapper for C2PA Reader operations. @@ -2070,58 +2151,10 @@ 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 list(cls._supported_mime_types_cache) - - count = ctypes.c_size_t() - arr = _lib.c2pa_reader_supported_mime_types(ctypes.byref(count)) - - # Validate the returned array pointer - if not arr: - # If no array returned, check for errors - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(f"Failed to get supported MIME types: {error}") - # Return empty list if no error but no array - return [] - - # Validate count value - if count.value <= 0: - # Free the array even if count is invalid - try: - _lib.c2pa_free_string_array(arr, count.value) - except Exception: - pass - return [] - - 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 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 - - if result: - cls._supported_mime_types_cache = frozenset(result) - - if cls._supported_mime_types_cache: - return list(cls._supported_mime_types_cache) - return [] + result, cls._supported_mime_types_cache = _get_supported_mime_types( + _lib.c2pa_reader_supported_mime_types, cls._supported_mime_types_cache + ) + return result @classmethod def _is_mime_type_supported(cls, mime_type: str) -> bool: @@ -2223,6 +2256,8 @@ def __init__( ) return + supported = Reader.get_supported_mime_types() + if stream is None: # Create a stream from the file path in format_or_path path = str(format_or_path) @@ -2232,38 +2267,21 @@ def __init__( 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: - format_bytes = mime_type.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Reader._ERROR_MESSAGES['encoding_error'].format( - str(e))) - + format_bytes = _validate_and_encode_format( + mime_type, supported, "Reader") self._init_from_file(path, format_bytes) elif isinstance(stream, str): # stream is a file path, format_or_path is the 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}") - - format_bytes = str(format_or_path).encode('utf-8') + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") self._init_from_file( stream, format_bytes, manifest_data) else: # format_or_path is a format string, stream is a stream object - 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}") - - format_bytes = format_str.encode('utf-8') + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") with Stream(stream) as stream_obj: self._create_reader( @@ -2358,72 +2376,26 @@ def _init_from_context(self, context, format_or_path, raise C2paError("Context is not valid") # Determine format and open stream + supported = Reader.get_supported_mime_types() + if stream is None: path = str(format_or_path) mime_type = _get_mime_type_from_path(path) if not mime_type: raise C2paError.NotSupported( - "Could not determine MIME type" - f" for file: {path}" - ) - if mime_type not in ( - Reader.get_supported_mime_types() - ): - raise C2paError.NotSupported( - "Reader does not support" - f" {mime_type}" - ) - try: - format_bytes = mime_type.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Reader._ERROR_MESSAGES[ - 'encoding_error' - ].format(str(e)) - ) + 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 - ) + self._own_stream = Stream(self._backing_file) elif isinstance(stream, str): - fmt = format_or_path.lower() - if fmt not in Reader.get_supported_mime_types(): - raise C2paError.NotSupported( - "Reader does not support" - f" {format_or_path}" - ) - try: - format_bytes = str( - format_or_path - ).encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Reader._ERROR_MESSAGES[ - 'encoding_error' - ].format(str(e)) - ) + 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 - ) + self._own_stream = Stream(self._backing_file) else: - fmt_str = str(format_or_path) - if ( - fmt_str.lower() - not in Reader.get_supported_mime_types() - ): - raise C2paError.NotSupported( - "Reader does not support" - f" {fmt_str}" - ) - try: - format_bytes = fmt_str.encode('utf-8') - except UnicodeError as e: - raise C2paError.Encoding( - Reader._ERROR_MESSAGES[ - 'encoding_error' - ].format(str(e)) - ) + format_bytes = _validate_and_encode_format( + str(format_or_path), supported, "Reader") self._own_stream = Stream(stream) try: @@ -3244,59 +3216,10 @@ 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 list(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 - - # Cache as frozenset for O(1) lookups - if result: - cls._supported_mime_types_cache = frozenset(result) - - if cls._supported_mime_types_cache: - return list(cls._supported_mime_types_cache) - return [] + result, cls._supported_mime_types_cache = _get_supported_mime_types( + _lib.c2pa_builder_supported_mime_types, cls._supported_mime_types_cache + ) + return result @classmethod def from_json( From 4f581860ca3127950ea23806fb3bb3f77e670bfc Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 10:17:32 -0800 Subject: [PATCH 25/75] fix: Refactor 2 --- src/c2pa/c2pa.py | 196 +++++++++++++++-------------------------------- 1 file changed, 62 insertions(+), 134 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index a098629b..2d577dc7 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -3767,19 +3767,24 @@ def to_archive(self, stream: Any) -> None: 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 used by both explicit-signer and + context-signer code paths. + + 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: Optional explicit signer. When None the context + signer is used instead. Returns: Manifest bytes @@ -3789,31 +3794,34 @@ 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, '_signer') or not signer._signer: + 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._builder, + format_bytes, + source_stream._stream, + dest_stream._stream, + signer._signer, + ctypes.byref(manifest_bytes_ptr) + ) + else: + result = _lib.c2pa_builder_sign_context( + self._builder, + format_bytes, + source_stream._stream, + dest_stream._stream, + ctypes.byref(manifest_bytes_ptr), + ) except Exception as e: - # Handle errors during the C function call - raise C2paError(f"Error calling c2pa_builder_sign: {str(e)}") + raise C2paError(f"Error during signing: {e}") if result < 0: error = _parse_operation_result_for_error(_lib.c2pa_error()) @@ -3825,22 +3833,18 @@ def _sign_internal( manifest_bytes = b"" if manifest_bytes_ptr and result > 0: try: - # Convert the C pointer to Python bytes temp_buffer = (ctypes.c_ubyte * result)() ctypes.memmove(temp_buffer, manifest_bytes_ptr, result) manifest_bytes = bytes(temp_buffer) except Exception: manifest_bytes = b"" finally: - # Always free the C-allocated memory, - # even if we failed to copy manifest bytes try: _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) except Exception: logger.error( "Failed to release native manifest bytes memory" ) - pass return manifest_bytes @@ -3863,32 +3867,35 @@ def _sign_common( Manifest bytes """ source_stream = Stream(source) + try: + if dest: + dest_stream = Stream(dest) + else: + mem_buffer = io.BytesIO() + dest_stream = Stream(mem_buffer) - if dest: - dest_stream = Stream(dest) - else: - mem_buffer = io.BytesIO() - dest_stream = Stream(mem_buffer) - - if signer is not None: - manifest_bytes = self._sign_internal( - signer, format, - source_stream, dest_stream, - ) - elif self._has_context_signer: - manifest_bytes = self._sign_context_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." - ) - - if not dest: - dest_stream.close() + 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: + if not dest: + dest_stream.close() + finally: + source_stream.close() return manifest_bytes @@ -3947,85 +3954,6 @@ def sign_with_context( """ return self._sign_common(None, format, source, dest) - def _sign_context_internal( - self, - format: str, - source_stream: 'Stream', - dest_stream: 'Stream', - ) -> bytes: - """Sign using the signer stored in the context. - - Uses c2pa_builder_sign_context instead of - c2pa_builder_sign. - """ - self._ensure_valid_state() - - format_lower = format.lower() - if ( - format_lower - not in Builder.get_supported_mime_types() - ): - raise C2paError.NotSupported( - "Builder does not support" - f" {format}" - ) - - format_str = format.encode('utf-8') - manifest_bytes_ptr = ( - ctypes.POINTER(ctypes.c_ubyte)() - ) - - try: - result = _lib.c2pa_builder_sign_context( - self._builder, - format_str, - source_stream._stream, - dest_stream._stream, - ctypes.byref(manifest_bytes_ptr), - ) - except Exception as e: - raise C2paError( - "Error calling" - f" c2pa_builder_sign_context: {e}" - ) - - if result < 0: - error = _parse_operation_result_for_error( - _lib.c2pa_error() - ) - if error: - raise C2paError(error) - raise C2paError( - "Error during context-based signing" - ) - - manifest_bytes = b"" - if manifest_bytes_ptr and result > 0: - try: - temp_buffer = ( - ctypes.c_ubyte * result - )() - ctypes.memmove( - temp_buffer, - manifest_bytes_ptr, - result, - ) - manifest_bytes = bytes(temp_buffer) - except Exception: - manifest_bytes = b"" - finally: - try: - _lib.c2pa_manifest_bytes_free( - manifest_bytes_ptr - ) - except Exception: - logger.error( - "Failed to release native" - " manifest bytes memory" - ) - - return manifest_bytes - @overload def sign_file( self, From 55a44187fdc71546fabf4140dc9ad6044a57b024 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 10:19:58 -0800 Subject: [PATCH 26/75] fix: Refactor 3 --- src/c2pa/c2pa.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 2d577dc7..9b25ca41 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -648,8 +648,8 @@ def __init__(self, message: str = ""): # Define typed exception subclasses that inherit from C2paError -# These are attached to C2paError as class attributes -# (eg., C2paError.ManifestNotFound), and also to ensure inheritance hierarchy +# 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.""" @@ -731,7 +731,7 @@ class _C2paVerify(C2paError): # Attach exception subclasses to C2paError for backward compatibility -# Preserves behavior for exception catching, +# 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 From 8f3f21e49d9af6601415fb76624c75a489dd156e Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 10:32:18 -0800 Subject: [PATCH 27/75] fix: Refactor --- src/c2pa/c2pa.py | 93 ++++++++++++++++++------------------------------ 1 file changed, 34 insertions(+), 59 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 9b25ca41..bb1d93c2 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -308,6 +308,11 @@ def _clear_error_state(): _lib.c2pa_string_free(error) +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)) + + class C2paSignerInfo(ctypes.Structure): """Configuration for a Signer.""" _fields_ = [ @@ -1393,12 +1398,7 @@ def _cleanup_resources(self): and self._settings ): try: - _lib.c2pa_free( - ctypes.cast( - self._settings, - ctypes.c_void_p - ) - ) + _free_native_ptr(self._settings) except Exception: logger.error( "Failed to free native" @@ -1573,12 +1573,7 @@ def __init__( # Free builder if build was not reached if builder_ptr is not None: try: - _lib.c2pa_free( - ctypes.cast( - builder_ptr, - ctypes.c_void_p, - ) - ) + _free_native_ptr(builder_ptr) except Exception: pass raise @@ -1677,12 +1672,7 @@ def _cleanup_resources(self): and self._context ): try: - _lib.c2pa_free( - ctypes.cast( - self._context, - ctypes.c_void_p, - ) - ) + _free_native_ptr(self._context) except Exception: logger.error( "Failed to free native" @@ -2487,12 +2477,7 @@ def _cleanup_resources(self): # Clean up reader if hasattr(self, '_reader') and self._reader: try: - _lib.c2pa_free( - ctypes.cast( - self._reader, - ctypes.c_void_p, - ) - ) + _free_native_ptr(self._reader) except Exception: # Cleanup failure doesn't raise exceptions logger.error( @@ -3068,12 +3053,7 @@ def _cleanup_resources(self): self._state = LifecycleState.CLOSED try: - _lib.c2pa_free( - ctypes.cast( - self._signer, - ctypes.c_void_p, - ) - ) + _free_native_ptr(self._signer) except Exception: # Log cleanup errors but don't raise exceptions logger.error("Failed to free native Signer resources") @@ -3242,11 +3222,25 @@ def from_json( """ return cls(manifest_json, context=context) + @classmethod + @overload + def from_archive( + cls, + stream: Any, + ) -> 'Builder': ... + + @classmethod + @overload + def from_archive( + cls, + stream: Any, + context: 'ContextProvider', + ) -> 'Builder': ... + @classmethod def from_archive( cls, stream: Any, - *, context: Optional['ContextProvider'] = None, ) -> 'Builder': """Create a new Builder from an archive stream. @@ -3378,7 +3372,6 @@ def _init_from_context(self, context, json_str): new_ptr = _lib.c2pa_builder_with_definition( builder_ptr, json_str, ) - # builder_ptr is NOW INVALID if not new_ptr: _parse_operation_result_for_error( @@ -3434,12 +3427,7 @@ def _cleanup_resources(self): self, '_builder') and self._builder and self._builder != 0: try: - _lib.c2pa_free( - ctypes.cast( - self._builder, - ctypes.c_void_p, - ) - ) + _free_native_ptr(self._builder) except Exception: # Log cleanup errors but don't raise exceptions logger.error( @@ -3783,7 +3771,7 @@ def _sign_internal( source_stream: The source stream dest_stream: The destination stream, opened in w+b (write+read binary) mode. - signer: Optional explicit signer. When None the context + signer: Signer to use. When None the context signer is used instead. Returns: @@ -3833,12 +3821,15 @@ def _sign_internal( manifest_bytes = b"" if manifest_bytes_ptr and result > 0: try: + # Convert the C pointer to Python bytes temp_buffer = (ctypes.c_ubyte * result)() ctypes.memmove(temp_buffer, manifest_bytes_ptr, result) manifest_bytes = bytes(temp_buffer) except Exception: manifest_bytes = b"" finally: + # Always free the C-allocated memory, + # even if we failed to copy manifest bytes try: _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) except Exception: @@ -3908,10 +3899,6 @@ def sign( ) -> bytes: """Sign the builder's content with an explicit signer. - Example:: - - builder.sign(signer, "image/jpeg", source, dest) - Args: signer: The signer to use. format: The MIME type of the content. @@ -3937,10 +3924,6 @@ def sign_with_context( The builder must have been created with a Context that has a signer. - Example:: - - builder.sign_with_context("image/jpeg", source, dest) - Args: format: The MIME type of the content. source: The source stream. @@ -3994,9 +3977,7 @@ def sign_file( Raises: C2paError: If there was an error during signing """ - mime_type = _get_mime_type_from_path( - source_path - ) + mime_type = _get_mime_type_from_path(source_path) try: with ( @@ -4004,15 +3985,9 @@ def sign_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, - ) + 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 From 0552f5d716daeccf35724e53f20848b8809758d1 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 10:36:47 -0800 Subject: [PATCH 28/75] fix: Refactor 4 --- src/c2pa/c2pa.py | 68 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index bb1d93c2..958650ee 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -2160,13 +2160,31 @@ def _is_mime_type_supported(cls, mime_type: str) -> bool: cls.get_supported_mime_types() return mime_type in cls._supported_mime_types_cache + @classmethod + @overload + def try_create( + cls, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None, + ) -> Optional["Reader"]: ... + + @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, @@ -2199,12 +2217,28 @@ def try_create( except C2paError.ManifestNotFound: return 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. @@ -3201,11 +3235,25 @@ def get_supported_mime_types(cls) -> list[str]: ) return result + @classmethod + @overload + def from_json( + cls, + manifest_json: Any, + ) -> 'Builder': ... + + @classmethod + @overload + def from_json( + cls, + manifest_json: Any, + context: 'ContextProvider', + ) -> 'Builder': ... + @classmethod def from_json( cls, manifest_json: Any, - *, context: Optional['ContextProvider'] = None, ) -> 'Builder': """Create a new Builder from a JSON manifest. @@ -3281,10 +3329,22 @@ def from_archive( finally: stream_obj.close() + @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. From d02be00b26d176d46e5c6b5966f7bf2d2a84ff50 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 10:44:44 -0800 Subject: [PATCH 29/75] fix: Version bump --- tests/test_unit_tests.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 0193d15a..38e05abb 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -32,9 +32,9 @@ "ignore", message="load_settings\\(\\) is deprecated" ) -from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType # noqa: E501 +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 # noqa: E501 +from c2pa.c2pa import Stream, LifecycleState, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable PROJECT_PATH = os.getcwd() @@ -73,7 +73,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): # This test verifies the native libraries used match the expected version. - self.assertIn("0.76.2", sdk_version()) + self.assertIn("0.77.0", sdk_version()) class TestReader(unittest.TestCase): From 28d683f918064d8655c74a40c20b13302e01caf8 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 10:54:46 -0800 Subject: [PATCH 30/75] fix: Deprec warning --- c2pa-native-version.txt | 2 +- tests/test_unit_tests.py | 11 ++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index f4c22cb4..c418262f 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.76.2 +c2pa-v0.77.0 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 38e05abb..66beca67 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -27,10 +27,7 @@ import threading # Suppress deprecation warnings -warnings.filterwarnings("ignore", category=DeprecationWarning) -warnings.filterwarnings( - "ignore", message="load_settings\\(\\) is deprecated" -) +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 @@ -939,10 +936,6 @@ def test_stream_read_and_parse_cached(self): class TestBuilderWithSigner(unittest.TestCase): def setUp(self): - warnings.filterwarnings( - "ignore", - message="load_settings\\(\\) is deprecated", - ) # Use the fixtures_dir fixture to set up paths self.data_dir = FIXTURES_DIR self.testPath = DEFAULT_TEST_FILE @@ -5882,4 +5875,4 @@ def test_sign_format_source_required(self): if __name__ == '__main__': - unittest.main() + unittest.main(warnings='ignore') From f38e21d05553f9dc9987437ad89f4ca80b8b6935 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 11:11:52 -0800 Subject: [PATCH 31/75] fix: Test also the contextual APIs in threading --- tests/test_unit_tests_threaded.py | 1368 +++++++++++++++++++++++------ 1 file changed, 1097 insertions(+), 271 deletions(-) diff --git a/tests/test_unit_tests_threaded.py b/tests/test_unit_tests_threaded.py index 4e96a756..2a7a330e 100644 --- a/tests/test_unit_tests_threaded.py +++ b/tests/test_unit_tests_threaded.py @@ -84,28 +84,6 @@ def read_and_parse(): thread1.join() thread2.join() - def test_stream_read_and_parse_with_context(self): - def read_and_parse(): - ctx = Context() - with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file, context=ctx) - manifest_store = json.loads(reader.json()) - title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] - self.assertEqual(title, "C.jpg") - return manifest_store - - # Create two threads - thread1 = threading.Thread(target=read_and_parse) - thread2 = threading.Thread(target=read_and_parse) - - # Start both threads - thread1.start() - thread2.start() - - # Wait for both threads to complete - thread1.join() - thread2.join() - def test_read_all_files(self): """Test reading C2PA metadata from all files in the fixtures/files-for-reading-tests directory""" reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") @@ -339,6 +317,213 @@ def process_file_with_cache(filename): if errors: self.fail("\n".join(errors)) + +class TestContextualReaderWithThreads(unittest.TestCase): + def setUp(self): + self.data_dir = FIXTURES_FOLDER + self.testPath = DEFAULT_TEST_FILE + + def test_stream_read(self): + def read_metadata(): + ctx = Context() + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file, context=ctx) + json_data = reader.json() + self.assertIn("C.jpg", json_data) + return json_data + + thread1 = threading.Thread(target=read_metadata) + thread2 = threading.Thread(target=read_metadata) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + def test_stream_read_and_parse(self): + def read_and_parse(): + ctx = Context() + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file, context=ctx) + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, "C.jpg") + return manifest_store + + thread1 = threading.Thread(target=read_and_parse) + thread2 = threading.Thread(target=read_and_parse) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + def test_read_all_files(self): + """Test reading C2PA metadata from all files using context APIs.""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.pdf': 'application/pdf', + } + skip_files = {'.DS_Store'} + + def process_file(filename): + if filename in skip_files: + return None + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + ctx = Context() + with open(file_path, "rb") as file: + reader = Reader(mime_type, file, context=ctx) + json_data = reader.json() + manifest = json.loads(json_data) + if "manifests" not in manifest or "active_manifest" not in manifest: + return f"Invalid manifest structure in {filename}" + return None + except Exception as e: + return f"Failed to read metadata from {filename}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + future_to_file = { + executor.submit(process_file, filename): filename + for filename in os.listdir(reading_dir) + } + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename}: {str(e)}") + if errors: + self.fail("\n".join(errors)) + + def test_read_cached_all_files(self): + """Test reading C2PA metadata with cache using context APIs.""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.pdf': 'application/pdf', + } + skip_files = {'.DS_Store'} + + def process_file_with_cache(filename): + if filename in skip_files: + return None + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + return None + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + mime_type = mime_types[ext] + try: + ctx = Context() + with open(file_path, "rb") as file: + reader = Reader(mime_type, file, context=ctx) + if reader._manifest_json_str_cache is not None: + return f"JSON cache should be None initially for {filename}" + if reader._manifest_data_cache is not None: + return f"Manifest data cache should be None initially for {filename}" + json_data_1 = reader.json() + if reader._manifest_json_str_cache is None: + return f"JSON cache not set after first json() call for {filename}" + if json_data_1 != reader._manifest_json_str_cache: + return f"JSON cache doesn't match return value for {filename}" + json_data_2 = reader.json() + if json_data_1 != json_data_2: + return f"JSON inconsistency for {filename}" + if not isinstance(json_data_1, str): + return f"JSON data is not a string for {filename}" + try: + active_manifest = reader.get_active_manifest() + if not isinstance(active_manifest, dict): + return f"Active manifest not dict for {filename}" + if reader._manifest_json_str_cache is None: + return f"JSON cache not set after get_active_manifest for {filename}" + if reader._manifest_data_cache is None: + return f"Manifest data cache not set after get_active_manifest for {filename}" + active_manifest_2 = reader.get_active_manifest() + if active_manifest != active_manifest_2: + return f"Active manifest cache inconsistency for {filename}" + validation_state = reader.get_validation_state() + validation_results = reader.get_validation_results() + validation_state_2 = reader.get_validation_state() + if validation_state != validation_state_2: + return f"Validation state cache inconsistency for {filename}" + validation_results_2 = reader.get_validation_results() + if validation_results != validation_results_2: + return f"Validation results cache inconsistency for {filename}" + except KeyError: + pass + manifest = json.loads(json_data_1) + if "manifests" not in manifest: + return f"Missing 'manifests' key in {filename}" + if "active_manifest" not in manifest: + return f"Missing 'active_manifest' key in {filename}" + reader.close() + if reader._manifest_json_str_cache is not None: + return f"JSON cache not cleared for {filename}" + if reader._manifest_data_cache is not None: + return f"Manifest data cache not cleared for {filename}" + return None + except Exception as e: + return f"Failed to read cached metadata from {filename}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + future_to_file = { + executor.submit(process_file_with_cache, filename): filename + for filename in os.listdir(reading_dir) + } + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename}: {str(e)}") + if errors: + self.fail("\n".join(errors)) + + class TestBuilderWithThreads(unittest.TestCase): def setUp(self): # Use the fixtures_dir fixture to set up paths @@ -560,8 +745,8 @@ def sign_file(filename, thread_id): if errors: self.fail("\n".join(errors)) - def test_sign_all_files_with_context(self): - """Test signing all files using a thread pool with Context""" + def test_sign_all_files_async(self): + """Test signing all files using asyncio with a pool of workers""" signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") @@ -589,7 +774,8 @@ def test_sign_all_files_with_context(self): 'sample3.invalid.wav', # Invalid file } - def sign_file(filename, thread_id): + async def async_sign_file(filename, thread_id): + """Async version of file signing operation""" if filename in skip_files: return None @@ -611,15 +797,13 @@ def sign_file(filename, thread_id): 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, context=ctx) + builder = Builder(manifest_def) output = io.BytesIO(bytearray()) builder.sign(self.signer, mime_type, file, output) output.seek(0) - # Verify the signed file using context - read_ctx = Context() - reader = Reader(mime_type, output, context=read_ctx) + # Verify the signed file + reader = Reader(mime_type, output) json_data = reader.json() manifest_store = json.loads(json_data) active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] @@ -647,253 +831,20 @@ def sign_file(filename, thread_id): return f"Failed to sign { filename} in thread {thread_id}: {str(e)}" - # Create a thread pool with 6 workers - with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + async def run_async_tests(): # Get all files from both directories all_files = [] for directory in [signing_dir, reading_dir]: all_files.extend(os.listdir(directory)) - # Submit all files to the thread pool with thread IDs - future_to_file = { - executor.submit(sign_file, filename, i): (filename, i) - for i, filename in enumerate(all_files) - } + # Create tasks for all files + tasks = [] + for i, filename in enumerate(all_files): + task = asyncio.create_task(async_sign_file(filename, i)) + tasks.append(task) - # Collect results as they complete - 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 any errors occurred, fail the test with all error messages - if errors: - self.fail("\n".join(errors)) - - def test_sign_all_files_async(self): - """Test signing all files using asyncio with a pool of workers""" - 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 - } - - async def async_sign_file(filename, thread_id): - """Async version of file signing operation""" - if filename in skip_files: - return None - - file_path = os.path.join(signing_dir, filename) - if not os.path.isfile(file_path): - return None - - # Get file extension and corresponding MIME type - _, 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: - # Choose manifest based on thread number - 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" - - builder = Builder(manifest_def) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, mime_type, file, output) - output.seek(0) - - # Verify the signed file - reader = Reader(mime_type, output) - json_data = reader.json() - manifest_store = json.loads(json_data) - active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] - - # Verify the correct manifest was used - 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) - - # Verify the author is correct - assertions = active_manifest["assertions"] - for assertion in assertions: - if assertion["label"] == "com.unit.test": - author_name = assertion["data"]["author"][0]["name"] - self.assertEqual(author_name, expected_author) - break - - output.close() - return None # Success case - 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(): - # Get all files from both directories - all_files = [] - for directory in [signing_dir, reading_dir]: - all_files.extend(os.listdir(directory)) - - # Create tasks for all files - tasks = [] - for i, filename in enumerate(all_files): - task = asyncio.create_task(async_sign_file(filename, i)) - tasks.append(task) - - # Wait for all tasks to complete and collect results - results = await asyncio.gather(*tasks, return_exceptions=True) - - # Process results - errors = [] - for result in results: - if isinstance(result, Exception): - errors.append(str(result)) - elif result: # Non-None result indicates an error - errors.append(result) - - # If any errors occurred, fail the test with all error messages - if errors: - self.fail("\n".join(errors)) - - # Run the async tests - asyncio.run(run_async_tests()) - - def test_sign_all_files_async_with_context(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") - - # 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 - } - - async def async_sign_file(filename, thread_id): - """Async version of file signing operation with Context""" - if filename in skip_files: - return None - - file_path = os.path.join(signing_dir, filename) - if not os.path.isfile(file_path): - return None - - # Get file extension and corresponding MIME type - _, 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: - # Choose manifest based on thread number - 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, context=ctx) - output = io.BytesIO(bytearray()) - builder.sign(self.signer, mime_type, file, output) - output.seek(0) - - # Verify the signed file using context - 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"]] - - # Verify the correct manifest was used - 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) - - # Verify the author is correct - assertions = active_manifest["assertions"] - for assertion in assertions: - if assertion["label"] == "com.unit.test": - author_name = assertion["data"]["author"][0]["name"] - self.assertEqual(author_name, expected_author) - break - - output.close() - return None # Success case - 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(): - # Get all files from both directories - all_files = [] - for directory in [signing_dir, reading_dir]: - all_files.extend(os.listdir(directory)) - - # Create tasks for all files - tasks = [] - for i, filename in enumerate(all_files): - task = asyncio.create_task(async_sign_file(filename, i)) - tasks.append(task) - - # Wait for all tasks to complete and collect results - results = await asyncio.gather(*tasks, return_exceptions=True) + # Wait for all tasks to complete and collect results + results = await asyncio.gather(*tasks, return_exceptions=True) # Process results errors = [] @@ -2234,5 +2185,880 @@ 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, context=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, context=ctx) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + read_ctx = Context() + reader = Reader(mime_type, output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + expected_claim_generator = f"python_test_{2 if thread_id % 2 == 0 else 1}/0.0.1" + self.assertEqual(active_manifest["claim_generator"], expected_claim_generator) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + output.close() + return None + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign {filename} in thread {thread_id}: {str(e)}" + + async def run_async_tests(): + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + tasks = [asyncio.create_task(async_sign_file(f, i)) for i, f in enumerate(all_files)] + results = await asyncio.gather(*tasks, return_exceptions=True) + errors = [] + for result in results: + if isinstance(result, Exception): + errors.append(str(result)) + elif result: + errors.append(result) + if errors: + self.fail("\n".join(errors)) + asyncio.run(run_async_tests()) + + def test_parallel_manifest_writing(self): + """Test writing different manifests in parallel using context APIs""" + output1 = io.BytesIO(bytearray()) + output2 = io.BytesIO(bytearray()) + + def write_manifest(manifest_def, output_stream, thread_id): + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(manifest_def, context=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, context=ctx) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + read_ctx = Context() + reader = Reader(mime_type, output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + expected_claim_generator = "python_test/0.0.1" if thread_id % 3 == 0 else f"python_test_{expected_thread}/0.0.1" + self.assertEqual(active_manifest["claim_generator"], expected_claim_generator) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + output.close() + return None + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign {filename} in thread {thread_id}: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + future_to_file = {executor.submit(sign_file, filename, i): (filename, i) for i, filename in enumerate(all_files)} + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename, thread_id = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing {filename} in thread {thread_id}: {str(e)}") + max_same_thread_sequence = 3 + current_sequence = 1 + current_thread = thread_execution_order[0][1] if thread_execution_order else None + for i in range(1, len(thread_execution_order)): + if thread_execution_order[i][1] == current_thread: + current_sequence += 1 + if current_sequence > max_same_thread_sequence: + self.fail(f"Thread {current_thread} executed {current_sequence} times in sequence") + else: + current_sequence = 1 + current_thread = thread_execution_order[i][1] + if errors: + self.fail("\n".join(errors)) + + def test_concurrent_read_after_write(self): + """Test reading from a file after writing is complete, using context APIs""" + output = io.BytesIO(bytearray()) + write_complete = threading.Event() + write_errors = [] + read_errors = [] + + def write_manifest(): + try: + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1, context=ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + def read_manifest(): + try: + write_complete.wait() + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Read error: {str(e)}") + + read_thread = threading.Thread(target=read_manifest) + write_thread = threading.Thread(target=write_manifest) + read_thread.start() + write_thread.start() + write_thread.join() + read_thread.join() + output.close() + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + + def test_concurrent_read_write_multiple_readers(self): + """Test multiple readers reading after write, using context APIs""" + output = io.BytesIO(bytearray()) + write_complete = threading.Event() + write_errors = [] + read_errors = [] + reader_count = 3 + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() + + def write_manifest(): + try: + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1, context=ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + write_complete.wait() + with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + + write_thread = threading.Thread(target=write_manifest) + write_thread.start() + read_threads = [threading.Thread(target=read_manifest, args=(i,)) for i in range(reader_count)] + for t in read_threads: + t.start() + write_thread.join() + for t in read_threads: + t.join() + output.close() + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + def test_resource_contention_read(self): + """Test multiple threads reading the same file with context APIs""" + output = io.BytesIO(bytearray()) + read_errors = [] + reader_count = 5 + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() + + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1, context=ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + time.sleep(0.01) + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + + read_threads = [threading.Thread(target=read_manifest, args=(i,)) for i in range(reader_count)] + for t in read_threads: + t.start() + for t in read_threads: + t.join() + output.close() + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + def test_resource_contention_read_parallel(self): + """Test multiple threads starting simultaneously to read with context APIs""" + output = io.BytesIO(bytearray()) + read_errors = [] + reader_count = 5 + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() + start_barrier = threading.Barrier(reader_count) + + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1, context=ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + start_barrier.wait() + with stream_lock: + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + break + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + + read_threads = [threading.Thread(target=read_manifest, args=(i,)) for i in range(reader_count)] + for t in read_threads: + t.start() + for t in read_threads: + t.join() + output.close() + if read_errors: + self.fail("\n".join(read_errors)) + self.assertEqual(active_readers, 0) + + def test_sign_all_files_twice(self): + """Test signing the same file twice with different manifests using context APIs""" + output1 = io.BytesIO(bytearray()) + output2 = io.BytesIO(bytearray()) + sign_errors = [] + thread_results = {} + thread_lock = threading.Lock() + + def sign_file(output_stream, manifest_def, thread_id): + try: + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(manifest_def, context=ctx) + builder.sign(self.signer, "image/jpeg", file, output_stream) + output_stream.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output_stream, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + if thread_id == 1: + expected_claim_generator = "python_test_1/0.0.1" + expected_author = "Tester One" + else: + expected_claim_generator = "python_test_2/0.0.1" + expected_author = "Tester Two" + with thread_lock: + thread_results[thread_id] = {'manifest': active_manifest} + self.assertEqual(active_manifest["claim_generator"], expected_claim_generator) + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], expected_author) + break + return None + except Exception as e: + return f"Thread {thread_id} error: {str(e)}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + future1 = executor.submit(sign_file, output1, self.manifestDefinition_1, 1) + future2 = executor.submit(sign_file, output2, self.manifestDefinition_2, 2) + for future in concurrent.futures.as_completed([future1, future2]): + error = future.result() + if error: + sign_errors.append(error) + if sign_errors: + self.fail("\n".join(sign_errors)) + self.assertEqual(len(thread_results), 2) + output1.seek(0) + output2.seek(0) + read_ctx1 = Context() + read_ctx2 = Context() + reader1 = Reader("image/jpeg", output1, context=read_ctx1) + reader2 = Reader("image/jpeg", output2, context=read_ctx2) + manifest_store1 = json.loads(reader1.json()) + manifest_store2 = json.loads(reader2.json()) + active_manifest1 = manifest_store1["manifests"][manifest_store1["active_manifest"]] + active_manifest2 = manifest_store2["manifests"][manifest_store2["active_manifest"]] + self.assertNotEqual(active_manifest1["claim_generator"], active_manifest2["claim_generator"]) + self.assertNotEqual(active_manifest1["title"], active_manifest2["title"]) + output1.close() + output2.close() + + def test_concurrent_read_after_write_async(self): + """Test read after write using asyncio with context APIs""" + output = io.BytesIO(bytearray()) + write_complete = asyncio.Event() + write_errors = [] + read_errors = [] + write_success = False + + async def write_manifest(): + nonlocal write_success + try: + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1, context=ctx) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + write_success = True + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + async def read_manifest(): + try: + await write_complete.wait() + if not write_success: + raise Exception("Write operation did not complete successfully") + self.assertGreater(len(output.getvalue()), 0) + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_store = json.loads(json_data) + self.assertIn("manifests", manifest_store) + self.assertIn("active_manifest", manifest_store) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + self.assertEqual(active_manifest["claim_generator"], "python_test_1/0.0.1") + self.assertEqual(active_manifest["title"], "Python Test Image 1") + author_found = False + for assertion in active_manifest["assertions"]: + if assertion["label"] == "com.unit.test": + self.assertEqual(assertion["data"]["author"][0]["name"], "Tester One") + author_found = True + break + self.assertTrue(author_found) + except Exception as e: + read_errors.append(f"Read error: {str(e)}") + + async def run_async_tests(): + write_task = asyncio.create_task(write_manifest()) + await write_task + read_task = asyncio.create_task(read_manifest()) + await read_task + asyncio.run(run_async_tests()) + output.close() + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + + def test_resource_contention_read_parallel_async(self): + """Test multiple async tasks reading the same file with context APIs""" + output = io.BytesIO(bytearray()) + read_errors = [] + reader_count = 5 + active_readers = 0 + readers_lock = asyncio.Lock() + stream_lock = asyncio.Lock() + start_barrier = asyncio.Barrier(reader_count) + + ctx = Context() + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1, context=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_ingredients_from_stream(self): + """Test Builder with multiple ingredients from streams using context APIs""" + ctx = Context() + builder = Builder.from_json(self.manifestDefinition, context=ctx) + assert builder._builder is not None + add_errors = [] + add_lock = threading.Lock() + completed_threads = 0 + completion_lock = threading.Lock() + + def add_ingredient_from_stream(ingredient_json, file_path, 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) + except Exception as e: + with add_lock: + add_errors.append(f"Thread {thread_id} error: {str(e)}") + finally: + with completion_lock: + completed_threads += 1 + + 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)) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + if any(e for e in add_errors if e is not None): + self.fail("\n".join(e for e in add_errors if e is not None)) + self.assertEqual(completed_threads, 2) + self.assertEqual(len(add_errors), 2) + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_data = json.loads(json_data) + 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"]), 2) + 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 with same ingredient added multiple times from different threads using context APIs""" + ctx = Context() + builder = Builder.from_json(self.manifestDefinition, context=ctx) + assert builder._builder is not None + 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) + except Exception as e: + with add_lock: + add_errors.append(f"Thread {thread_id} error: {str(e)}") + finally: + with completion_lock: + completed_threads += 1 + + threads = [] + for i in range(1, 6): + 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() + for thread in threads: + thread.join() + if any(e for e in add_errors if e is not None): + self.fail("\n".join(e for e in add_errors if e is not None)) + self.assertEqual(completed_threads, 5) + self.assertEqual(len(add_errors), 5) + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + read_ctx = Context() + reader = Reader("image/jpeg", output, context=read_ctx) + json_data = reader.json() + manifest_data = json.loads(json_data) + 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"]), 5) + ingredient_titles = [ing["title"] for ing in active_manifest["ingredients"]] + self.assertEqual(len(set(ingredient_titles)), 5) + for i in range(1, 6): + thread_ingredients = [ing for ing in active_manifest["ingredients"] if ing["title"] == f"Test Ingredient Thread {i}"] + self.assertEqual(len(thread_ingredients), 1) + builder.close() + + 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() From e39a64c761ee42f7f1d3a0f26b686d28204a2021 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 11:20:56 -0800 Subject: [PATCH 32/75] fix: Refactor --- src/c2pa/c2pa.py | 38 ++++++++++---------------------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 958650ee..26774010 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1529,9 +1529,7 @@ def __init__( ) ) if result != 0: - _parse_operation_result_for_error( - None - ) + _parse_operation_result_for_error(None) if signer is not None: signer_ptr, callback_cb = ( @@ -1547,9 +1545,7 @@ def __init__( ) ) if result != 0: - _parse_operation_result_for_error( - None - ) + _parse_operation_result_for_error(None) self._has_signer = True # Build consumes builder_ptr @@ -1558,13 +1554,10 @@ def __init__( builder_ptr ) ) - # builder_ptr is now invalid builder_ptr = None if not ptr: - _parse_operation_result_for_error( - None - ) + _parse_operation_result_for_error(None) raise C2paError( "Failed to build Context" ) @@ -2344,8 +2337,7 @@ def _create_reader(self, format_bytes, stream_obj, ) if not self._reader: - error = _parse_operation_result_for_error( - _lib.c2pa_error()) + error = _parse_operation_result_for_error(_lib.c2pa_error()) if error: raise C2paError(error) raise C2paError( @@ -2428,9 +2420,7 @@ def _init_from_context(self, context, format_or_path, context.execution_context, ) if not reader_ptr: - _parse_operation_result_for_error( - _lib.c2pa_error() - ) + _parse_operation_result_for_error(_lib.c2pa_error()) raise C2paError( Reader._ERROR_MESSAGES[ 'reader_error' @@ -2443,12 +2433,10 @@ def _init_from_context(self, context, format_or_path, reader_ptr, format_bytes, self._own_stream._stream, ) - # reader_ptr is NOW INVALID + # reader_ptr has been invalidated(consumed) if not new_ptr: - _parse_operation_result_for_error( - _lib.c2pa_error() - ) + _parse_operation_result_for_error(_lib.c2pa_error()) raise C2paError( Reader._ERROR_MESSAGES[ 'reader_error' @@ -3315,9 +3303,7 @@ def from_archive( ) if not builder._builder: - error = _parse_operation_result_for_error( - _lib.c2pa_error() - ) + error = _parse_operation_result_for_error(_lib.c2pa_error()) if error: raise C2paError(error) raise C2paError( @@ -3418,9 +3404,7 @@ def _init_from_context(self, context, json_str): context.execution_context, ) if not builder_ptr: - _parse_operation_result_for_error( - _lib.c2pa_error() - ) + _parse_operation_result_for_error(_lib.c2pa_error()) raise C2paError( Builder._ERROR_MESSAGES[ 'builder_error' @@ -3434,9 +3418,7 @@ def _init_from_context(self, context, json_str): ) if not new_ptr: - _parse_operation_result_for_error( - _lib.c2pa_error() - ) + _parse_operation_result_for_error(_lib.c2pa_error()) raise C2paError( Builder._ERROR_MESSAGES[ 'builder_error' From de05365435000aa8b258f8d56080914a941c9e3e Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:52:43 -0800 Subject: [PATCH 33/75] fix: Native handles handling refactoring (#232) * fix: Native handles handling * fix: Clean up notes * fix: Native handles handling --------- Co-authored-by: Tania Mathern --- src/c2pa/c2pa.py | 628 +++++++++--------------------- tests/test_unit_tests.py | 74 ++-- tests/test_unit_tests_threaded.py | 8 +- 3 files changed, 215 insertions(+), 495 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 26774010..12138491 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -207,6 +207,95 @@ class LifecycleState(enum.IntEnum): CLOSED = 2 +class ManagedResource: + """Base class for objects that hold a native (FFI) resource. + This is an internal base class that provides lifecycle management + for native resources (pointers). + + Subclasses must: + - Set `self._handle` to the native pointer after creation. + - Set `self._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._state = LifecycleState.UNINITIALIZED + self._handle = None + + @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._state == LifecycleState.CLOSED: + raise C2paError(f"{name} is closed") + if self._state != LifecycleState.ACTIVE: + raise C2paError(f"{name} is not properly initialized") + if not self._handle: + raise C2paError(f"{name} is closed") + + 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 _cleanup_resources(self): + """Release native resources idempotently.""" + try: + if ( + hasattr(self, '_state') + and self._state != LifecycleState.CLOSED + ): + self._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 + + def close(self) -> None: + """Release the resource (idempotent, never raises + because we don't want to error on clean-up fail).""" + if self._state == LifecycleState.CLOSED: + return + try: + self._cleanup_resources() + except Exception as e: + logger.error("Error during %s close: %s", type(self).__name__, e) + finally: + self._state = LifecycleState.CLOSED + + 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): + """For classes with context manager (with) pattern""" + 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 = { @@ -308,11 +397,6 @@ def _clear_error_state(): _lib.c2pa_string_free(error) -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)) - - class C2paSignerInfo(ctypes.Structure): """Configuration for a Signer.""" _fields_ = [ @@ -1246,25 +1330,25 @@ def is_valid(self) -> bool: ... def execution_context(self): ... -class Settings: +class Settings(ManagedResource): """Per-instance configuration for C2PA operations. Settings configure SDK behavior. Use with Context class to apply settings to Reader/Builder operations. """ + def __init__(self): """Create new Settings with default values.""" + super().__init__() _clear_error_state() - self._state = LifecycleState.UNINITIALIZED - self._settings = None ptr = _lib.c2pa_settings_new() if not ptr: _parse_operation_result_for_error(None) raise C2paError("Failed to create Settings") - self._settings = ptr + self._handle = ptr self._state = LifecycleState.ACTIVE @classmethod @@ -1316,7 +1400,7 @@ def set(self, path: str, value: str) -> 'Settings': ) from e result = _lib.c2pa_settings_set_value( - self._settings, path_bytes, value_bytes + self._handle, path_bytes, value_bytes ) if result != 0: _parse_operation_result_for_error(None) @@ -1349,7 +1433,7 @@ def update( ) from e result = _lib.c2pa_settings_update_from_string( - self._settings, data_bytes, b"json" + self._handle, data_bytes, b"json" ) if result != 0: _parse_operation_result_for_error(None) @@ -1360,76 +1444,16 @@ def update( def _c_settings(self): """Expose the raw pointer for Context to consume.""" self._ensure_valid_state() - return self._settings + return self._handle @property def is_valid(self) -> bool: """Check if the Settings is in a valid state.""" return ( self._state == LifecycleState.ACTIVE - and self._settings is not None + and self._handle is not None ) - def _ensure_valid_state(self): - """Ensure the settings are in a valid state. - - Raises: - C2paError: If the settings are closed or invalid. - """ - if self._state == LifecycleState.CLOSED: - raise C2paError("Settings is closed") - if self._state != LifecycleState.ACTIVE: - raise C2paError( - "Settings is not properly initialized" - ) - if not self._settings: - raise C2paError("Settings is closed") - - def _cleanup_resources(self): - """Release native resources safely.""" - try: - if ( - hasattr(self, '_state') - and self._state != LifecycleState.CLOSED - ): - self._state = LifecycleState.CLOSED - if ( - hasattr(self, '_settings') - and self._settings - ): - try: - _free_native_ptr(self._settings) - except Exception: - logger.error( - "Failed to free native" - " Settings resources" - ) - finally: - self._settings = None - except Exception: - pass - - def close(self) -> None: - """Release the Settings resources.""" - if self._state == LifecycleState.CLOSED: - return - try: - self._cleanup_resources() - except Exception as e: - logger.error( - f"Error during Settings close: {e}" - ) - - def __enter__(self) -> 'Settings': - self._ensure_valid_state() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() - - def __del__(self): - self._cleanup_resources() - class ContextBuilder: """Fluent builder for Context. @@ -1464,7 +1488,7 @@ def build(self) -> 'Context': ) -class Context(ContextProvider): +class Context(ManagedResource, ContextProvider): """Per-instance context for C2PA operations. A Context may carry Settings and a Signer, @@ -1477,6 +1501,7 @@ class Context(ContextProvider): used directly again after that. """ + def __init__( self, settings: Optional['Settings'] = None, @@ -1496,9 +1521,8 @@ def __init__( provided but the library does not support signer-on-context. """ + super().__init__() _clear_error_state() - self._state = LifecycleState.UNINITIALIZED - self._context = None self._has_signer = False self._signer_callback_cb = None @@ -1510,7 +1534,7 @@ def __init__( raise C2paError( "Failed to create Context" ) - self._context = ptr + self._handle = ptr else: # Use ContextBuilder for settings/signer builder_ptr = _lib.c2pa_context_builder_new() @@ -1533,7 +1557,7 @@ def __init__( if signer is not None: signer_ptr, callback_cb = ( - signer._release() + signer._transfer_ownership() ) self._signer_callback_cb = ( callback_cb @@ -1561,12 +1585,12 @@ def __init__( raise C2paError( "Failed to build Context" ) - self._context = ptr + self._handle = ptr except Exception: # Free builder if build was not reached if builder_ptr is not None: try: - _free_native_ptr(builder_ptr) + ManagedResource._free_native_ptr(builder_ptr) except Exception: pass raise @@ -1627,76 +1651,16 @@ def has_signer(self) -> bool: def execution_context(self): """Return the raw C2paContext pointer.""" self._ensure_valid_state() - return self._context + return self._handle @property def is_valid(self) -> bool: """Check if the Context is in a valid state.""" return ( self._state == LifecycleState.ACTIVE - and self._context is not None + and self._handle is not None ) - def _ensure_valid_state(self): - """Ensure the context is in a valid state. - - Raises: - C2paError: If the context is closed or invalid. - """ - if self._state == LifecycleState.CLOSED: - raise C2paError("Context is closed") - if self._state != LifecycleState.ACTIVE: - raise C2paError( - "Context is not properly initialized" - ) - if not self._context: - raise C2paError("Context is closed") - - def _cleanup_resources(self): - """Release native resources safely.""" - try: - if ( - hasattr(self, '_state') - and self._state != LifecycleState.CLOSED - ): - self._state = LifecycleState.CLOSED - if ( - hasattr(self, '_context') - and self._context - ): - try: - _free_native_ptr(self._context) - except Exception: - logger.error( - "Failed to free native" - " Context resources" - ) - finally: - self._context = None - except Exception: - pass - - def close(self) -> None: - """Release the Context resources.""" - if self._state == LifecycleState.CLOSED: - return - try: - self._cleanup_resources() - except Exception as e: - logger.error( - f"Error during Context close: {e}" - ) - - def __enter__(self) -> 'Context': - self._ensure_valid_state() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() - - def __del__(self): - self._cleanup_resources() - class Stream: # Class-level somewhat atomic counter for generating @@ -2095,7 +2059,7 @@ def _validate_and_encode_format( f"Invalid UTF-8 characters in input: {e}") -class Reader: +class Reader(ManagedResource): """High-level wrapper for C2PA Reader operations. Example: @@ -2106,6 +2070,7 @@ class Reader: Where `output` is either an in-memory stream or an opened file. """ + # Supported mimetypes cache _supported_mime_types_cache = None @@ -2249,11 +2214,9 @@ def __init__( """ # Native libs plumbing: # Clear any stale error state from previous operations + super().__init__() _clear_error_state() - self._state = LifecycleState.UNINITIALIZED - - self._reader = None self._own_stream = None # This is used to keep track of a file @@ -2317,7 +2280,7 @@ def _create_reader(self, format_bytes, stream_obj, manifest_data: Optional manifest bytes """ if manifest_data is None: - self._reader = _lib.c2pa_reader_from_stream( + self._handle = _lib.c2pa_reader_from_stream( format_bytes, stream_obj._stream) else: if not isinstance(manifest_data, bytes): @@ -2327,7 +2290,7 @@ def _create_reader(self, format_bytes, stream_obj, ctypes.c_ubyte * len(manifest_data))( *manifest_data) - self._reader = ( + self._handle = ( _lib.c2pa_reader_from_manifest_data_and_stream( format_bytes, stream_obj._stream, @@ -2336,7 +2299,7 @@ def _create_reader(self, format_bytes, stream_obj, ) ) - if not self._reader: + if not self._handle: error = _parse_operation_result_for_error(_lib.c2pa_error()) if error: raise C2paError(error) @@ -2443,7 +2406,7 @@ def _init_from_context(self, context, format_or_path, ].format("Unknown error") ) - self._reader = new_ptr + self._handle = new_ptr self._state = LifecycleState.ACTIVE except Exception: if self._own_stream: @@ -2454,86 +2417,23 @@ def _init_from_context(self, context, format_or_path, self._backing_file = None raise - def __enter__(self): - self._ensure_valid_state() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() - - 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. - """ - 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 - """ - if self._state == LifecycleState.CLOSED: - raise C2paError(Reader._ERROR_MESSAGES['closed_error']) - if self._state != LifecycleState.ACTIVE: - raise C2paError("Reader is not properly initialized") - if not self._reader: - raise C2paError(Reader._ERROR_MESSAGES['closed_error']) - - 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: - # Only cleanup if not already closed and we have a valid reader - if ( - hasattr(self, '_state') - and self._state != LifecycleState.CLOSED - ): - self._state = LifecycleState.CLOSED - - # Clean up reader - if hasattr(self, '_reader') and self._reader: - try: - _free_native_ptr(self._reader) - except Exception: - # Cleanup failure doesn't raise exceptions - logger.error( - "Failed to free native Reader resources" - ) - pass - finally: - self._reader = None - - # 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 - - # 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 + def _release(self): + """Release Reader-specific resources (stream, backing file).""" + if hasattr(self, '_own_stream') and self._own_stream: + try: + self._own_stream.close() + except Exception: + logger.error("Failed to close Reader stream") + finally: + self._own_stream = None - except Exception: - # Ensure we don't raise exceptions during cleanup - pass + if self._backing_file: + try: + self._backing_file.close() + except Exception: + logger.warning("Failed to close Reader backing file") + finally: + self._backing_file = None def _get_cached_manifest_data(self) -> Optional[dict]: """Get the cached manifest data, fetching and parsing if not cached. @@ -2563,29 +2463,10 @@ def _get_cached_manifest_data(self) -> Optional[dict]: return self._manifest_data_cache def close(self): - """Release the reader resources. - - This method ensures all resources are properly cleaned up, - even if errors occur during cleanup. - Errors during cleanup are logged but not raised to ensure cleanup. - Multiple calls to close() are handled gracefully. - """ - if self._state == LifecycleState.CLOSED: - return - - 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._state = LifecycleState.CLOSED + """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. @@ -2603,7 +2484,7 @@ 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) + result = _lib.c2pa_reader_json(self._handle) if result is None: error = _parse_operation_result_for_error(_lib.c2pa_error()) @@ -2633,7 +2514,7 @@ def detailed_json(self) -> str: self._ensure_valid_state() - result = _lib.c2pa_reader_detailed_json(self._reader) + result = _lib.c2pa_reader_detailed_json(self._handle) if result is None: error = _parse_operation_result_for_error(_lib.c2pa_error()) @@ -2780,7 +2661,7 @@ 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) + self._handle, uri_str, stream_obj._stream) if result < 0: error = _parse_operation_result_for_error(_lib.c2pa_error()) @@ -2804,7 +2685,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) @@ -2821,7 +2702,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) @@ -2832,9 +2713,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", @@ -3034,74 +2916,24 @@ def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): Raises: C2paError: If the signer pointer is invalid """ - # Native libs plumbing: - # Clear any stale error state from previous operations + super().__init__() _clear_error_state() - # Validate pointer before assignment if not signer_ptr: raise C2paError("Invalid signer pointer: pointer is null") - self._signer = signer_ptr + self._handle = signer_ptr self._state = LifecycleState.ACTIVE # 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 ( - self._state != LifecycleState.CLOSED - and self._signer - ): - self._state = LifecycleState.CLOSED - - try: - _free_native_ptr(self._signer) - except Exception: - # Log cleanup errors but don't raise exceptions - logger.error("Failed to free native Signer resources") - finally: - self._signer = None - - # Clean up callback reference - if self._callback_cb: - self._callback_cb = None - - except Exception: - # Ensure we don't raise exceptions during cleanup - pass - - def _ensure_valid_state(self): - """Ensure the signer is in a valid state for operations. - - Raises: - C2paError: If the signer is closed or invalid - """ - if self._state == LifecycleState.CLOSED: - raise C2paError(Signer._ERROR_MESSAGES['closed_error']) - if not self._signer: - raise C2paError(Signer._ERROR_MESSAGES['closed_error']) - def _release(self): + """Release Signer-specific resources (callback reference).""" + if self._callback_cb: + self._callback_cb = None + + def _transfer_ownership(self): """Release ownership of the native signer pointer. After this call the Signer is marked closed and must @@ -3119,49 +2951,16 @@ def _release(self): """ self._ensure_valid_state() - ptr = self._signer + ptr = self._handle callback_cb = self._callback_cb # Detach pointer without freeing — caller now owns it - self._signer = None + self._handle = None self._callback_cb = None self._state = LifecycleState.CLOSED return ptr, callback_cb - def close(self): - """Release the signer resources. - - This method ensures all resources are properly cleaned up, - even if errors occur during cleanup. - - Note: - Multiple calls to close() are handled gracefully. - Errors during cleanup are logged but not raised - to ensure cleanup. - """ - if self._state == LifecycleState.CLOSED: - return - - 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 - - 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._state = LifecycleState.CLOSED - def reserve_size(self) -> int: """Get the size to reserve for signatures from this signer. @@ -3173,7 +2972,7 @@ 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()) @@ -3184,9 +2983,10 @@ def reserve_size(self) -> int: return result -class Builder: +class Builder(ManagedResource): """High-level wrapper for C2PA Builder operations.""" + # Supported mimetypes cache _supported_mime_types_cache = None @@ -3296,13 +3096,13 @@ def from_archive( stream_obj = Stream(stream) try: - builder._builder = ( + builder._handle = ( _lib.c2pa_builder_from_archive( stream_obj._stream ) ) - if not builder._builder: + if not builder._handle: error = _parse_operation_result_for_error(_lib.c2pa_error()) if error: raise C2paError(error) @@ -3346,11 +3146,9 @@ def __init__( """ # Native libs plumbing: # Clear any stale error state from previous operations + super().__init__() _clear_error_state() - self._state = LifecycleState.UNINITIALIZED - self._builder = None - # Keep context reference alive self._context = context self._has_context_signer = ( @@ -3377,9 +3175,9 @@ def __init__( if context is not None: self._init_from_context(context, json_str) else: - self._builder = _lib.c2pa_builder_from_json(json_str) + self._handle = _lib.c2pa_builder_from_json(json_str) - if not self._builder: + if not self._handle: error = _parse_operation_result_for_error(_lib.c2pa_error()) if error: raise C2paError(error) @@ -3425,85 +3223,7 @@ def _init_from_context(self, context, json_str): ].format("Unknown error") ) - self._builder = new_ptr - - 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._state == LifecycleState.CLOSED: - raise C2paError(Builder._ERROR_MESSAGES['closed_error']) - if self._state != LifecycleState.ACTIVE: - raise C2paError("Builder is not properly initialized") - if not self._builder: - raise C2paError(Builder._ERROR_MESSAGES['closed_error']) - - 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: - # Only cleanup if not already closed and we have a valid builder - if ( - hasattr(self, '_state') - and self._state != LifecycleState.CLOSED - ): - self._state = LifecycleState.CLOSED - - if hasattr( - self, - '_builder') and self._builder and self._builder != 0: - try: - _free_native_ptr(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 - - except Exception: - # Ensure we don't raise exceptions during cleanup - pass - - def close(self): - """Release the builder resources. - - This method ensures all resources are properly cleaned up, - even if errors occur during cleanup. - Errors during cleanup are logged but not raised to ensure cleanup. - Multiple calls to close() are handled gracefully. - """ - if self._state == LifecycleState.CLOSED: - return - - 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._state = LifecycleState.CLOSED + self._handle = new_ptr def set_no_embed(self): """Set the no-embed flag. @@ -3513,7 +3233,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. @@ -3530,7 +3250,7 @@ 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) + result = _lib.c2pa_builder_set_remote_url(self._handle, url_str) if result != 0: error = _parse_operation_result_for_error(_lib.c2pa_error()) @@ -3568,7 +3288,7 @@ def set_intent( self._ensure_valid_state() result = _lib.c2pa_builder_set_intent( - self._builder, + self._handle, ctypes.c_uint(intent), ctypes.c_uint(digital_source_type), ) @@ -3595,7 +3315,7 @@ def add_resource(self, uri: str, stream: Any): uri_str = uri.encode('utf-8') with Stream(stream) as stream_obj: result = _lib.c2pa_builder_add_resource( - self._builder, uri_str, stream_obj._stream) + self._handle, uri_str, stream_obj._stream) if result != 0: error = _parse_operation_result_for_error(_lib.c2pa_error()) @@ -3670,7 +3390,7 @@ def add_ingredient_from_stream( 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 @@ -3757,7 +3477,7 @@ def add_action(self, action_json: Union[str, dict]) -> None: Builder._ERROR_MESSAGES['encoding_error'].format(str(e)) ) - result = _lib.c2pa_builder_add_action(self._builder, action_str) + result = _lib.c2pa_builder_add_action(self._handle, action_str) if result != 0: error = _parse_operation_result_for_error(_lib.c2pa_error()) @@ -3783,7 +3503,7 @@ 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) if result != 0: error = _parse_operation_result_for_error(_lib.c2pa_error()) @@ -3804,9 +3524,9 @@ def _sign_internal( """Internal signing implementation used by both explicit-signer and context-signer code paths. - When ``signer`` is provided, calls ``c2pa_builder_sign`` (explicit - signer). When ``signer`` is ``None``, calls - ``c2pa_builder_sign_context`` (context-based signer). + When `signer` is provided, calls `c2pa_builder_sign` (explicit + signer). When `signer` is `None`, calls + `c2pa_builder_sign_context` (context-based signer). Args: format: The MIME type or extension of the content @@ -3825,7 +3545,7 @@ def _sign_internal( self._ensure_valid_state() if signer is not None: - if not hasattr(signer, '_signer') or not signer._signer: + if not hasattr(signer, '_handle') or not signer._handle: raise C2paError("Invalid or closed signer") format_bytes = _validate_and_encode_format( @@ -3835,16 +3555,16 @@ def _sign_internal( try: if signer is not None: result = _lib.c2pa_builder_sign( - self._builder, + self._handle, format_bytes, source_stream._stream, dest_stream._stream, - signer._signer, + signer._handle, ctypes.byref(manifest_bytes_ptr) ) else: result = _lib.c2pa_builder_sign_context( - self._builder, + self._handle, format_bytes, source_stream._stream, dest_stream._stream, @@ -4088,7 +3808,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) ``` @@ -4123,7 +3843,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) ``` diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 66beca67..397cea4c 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -331,7 +331,7 @@ 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.assertEqual(reader._state, LifecycleState.CLOSED) @@ -697,7 +697,7 @@ def test_reader_context_manager_with_exception(self): with Reader(self.testPath) as reader: # Inside context - should be valid self.assertEqual(reader._state, LifecycleState.ACTIVE) - self.assertIsNotNone(reader._reader) + self.assertIsNotNone(reader._handle) self.assertIsNotNone(reader._own_stream) self.assertIsNotNone(reader._backing_file) raise ValueError("Test exception") @@ -706,7 +706,7 @@ def test_reader_context_manager_with_exception(self): # After exception - should still be closed self.assertEqual(reader._state, LifecycleState.CLOSED) - self.assertIsNone(reader._reader) + self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) @@ -715,7 +715,7 @@ def test_reader_partial_initialization_states(self): # Test with _reader = None but _state = ACTIVE reader = Reader.__new__(Reader) reader._state = LifecycleState.ACTIVE - reader._reader = None + reader._handle = None reader._own_stream = None reader._backing_file = None @@ -728,7 +728,7 @@ def test_reader_cleanup_state_transitions(self): reader._cleanup_resources() self.assertEqual(reader._state, LifecycleState.CLOSED) - self.assertIsNone(reader._reader) + self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) @@ -743,7 +743,7 @@ def test_reader_cleanup_idempotency(self): # Second cleanup should not change state reader._cleanup_resources() self.assertEqual(reader._state, LifecycleState.CLOSED) - self.assertIsNone(reader._reader) + self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) @@ -752,7 +752,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): @@ -2129,7 +2129,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"}' @@ -2140,7 +2140,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"} @@ -2151,7 +2151,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() @@ -2171,7 +2171,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() @@ -2191,7 +2191,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() @@ -2220,7 +2220,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) @@ -2243,7 +2243,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" }' @@ -2287,7 +2287,7 @@ def test_builder_sign_with_ingredient(self): def test_builder_sign_with_ingredients_edit_intent(self): """Test signing with EDIT intent and ingredient.""" builder = Builder.from_json({}) - assert builder._builder is not None + assert builder._handle is not None # Set the intent for editing existing content builder.set_intent(C2paBuilderIntent.EDIT) @@ -2391,7 +2391,7 @@ def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): load_settings('{"builder": { "thumbnail": {"enabled": false}}}') builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{ "title": "Test Ingredient" }' @@ -2438,7 +2438,7 @@ def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): load_settings({"builder": {"thumbnail": {"enabled": False}}}) builder = Builder.from_json(self.manifestDefinition) - assert builder._builder is not None + assert builder._handle is not None # Test adding ingredient ingredient_json = '{ "title": "Test Ingredient" }' @@ -2482,7 +2482,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"}' @@ -2528,7 +2528,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"}' @@ -2568,7 +2568,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"} @@ -2608,7 +2608,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"}' @@ -2653,7 +2653,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"}' @@ -3140,7 +3140,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() @@ -3261,26 +3261,26 @@ def test_builder_state_transitions(self): # Initial state self.assertEqual(builder._state, LifecycleState.ACTIVE) - self.assertIsNotNone(builder._builder) + self.assertIsNotNone(builder._handle) # After close builder.close() self.assertEqual(builder._state, LifecycleState.CLOSED) - self.assertIsNone(builder._builder) + self.assertIsNone(builder._handle) def test_builder_context_manager_states(self): """Test Builder state management in context manager.""" with Builder(self.manifestDefinition) as builder: # Inside context - should be valid self.assertEqual(builder._state, LifecycleState.ACTIVE) - self.assertIsNotNone(builder._builder) + self.assertIsNotNone(builder._handle) # Placeholder operation builder.set_no_embed() # After context exit - should be closed self.assertEqual(builder._state, LifecycleState.CLOSED) - self.assertIsNone(builder._builder) + self.assertIsNone(builder._handle) def test_builder_context_manager_with_exception(self): """Test Builder state after exception in context manager.""" @@ -3288,21 +3288,21 @@ def test_builder_context_manager_with_exception(self): with Builder(self.manifestDefinition) as builder: # Inside context - should be valid self.assertEqual(builder._state, LifecycleState.ACTIVE) - self.assertIsNotNone(builder._builder) + self.assertIsNotNone(builder._handle) raise ValueError("Test exception") except ValueError: pass # After exception - should still be closed self.assertEqual(builder._state, LifecycleState.CLOSED) - self.assertIsNone(builder._builder) + self.assertIsNone(builder._handle) def test_builder_partial_initialization_states(self): """Test Builder behavior with partial initialization failures.""" # Test with _builder = None but _state = ACTIVE builder = Builder.__new__(Builder) builder._state = LifecycleState.ACTIVE - builder._builder = None + builder._handle = None with self.assertRaises(Error): builder._ensure_valid_state() @@ -3314,7 +3314,7 @@ def test_builder_cleanup_state_transitions(self): # Test _cleanup_resources method builder._cleanup_resources() self.assertEqual(builder._state, LifecycleState.CLOSED) - self.assertIsNone(builder._builder) + self.assertIsNone(builder._handle) def test_builder_cleanup_idempotency(self): """Test that cleanup operations are idempotent.""" @@ -3327,7 +3327,7 @@ def test_builder_cleanup_idempotency(self): # Second cleanup should not change state builder._cleanup_resources() self.assertEqual(builder._state, LifecycleState.CLOSED) - self.assertIsNone(builder._builder) + self.assertIsNone(builder._handle) def test_builder_state_after_sign_operations(self): """Test Builder state after signing operations.""" @@ -3338,7 +3338,7 @@ def test_builder_state_after_sign_operations(self): # State should still be valid after signing self.assertEqual(builder._state, LifecycleState.ACTIVE) - self.assertIsNotNone(builder._builder) + self.assertIsNotNone(builder._handle) # Should be able to sign again with open(self.testPath, "rb") as file: @@ -3354,7 +3354,7 @@ def test_builder_state_after_archive_operations(self): # State should still be valid self.assertEqual(builder._state, LifecycleState.ACTIVE) - self.assertIsNotNone(builder._builder) + self.assertIsNotNone(builder._handle) def test_builder_state_after_double_close(self): """Test Builder state after double close operations.""" @@ -3363,19 +3363,19 @@ def test_builder_state_after_double_close(self): # First close builder.close() self.assertEqual(builder._state, LifecycleState.CLOSED) - self.assertIsNone(builder._builder) + self.assertIsNone(builder._handle) # Second close should not change state builder.close() self.assertEqual(builder._state, LifecycleState.CLOSED) - self.assertIsNone(builder._builder) + 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): diff --git a/tests/test_unit_tests_threaded.py b/tests/test_unit_tests_threaded.py index 2a7a330e..418e80f7 100644 --- a/tests/test_unit_tests_threaded.py +++ b/tests/test_unit_tests_threaded.py @@ -1822,7 +1822,7 @@ 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 + assert builder._handle is not None # Thread synchronization add_errors = [] @@ -1914,7 +1914,7 @@ 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 + assert builder._handle is not None # Thread synchronization add_errors = [] @@ -2861,7 +2861,7 @@ def test_builder_sign_with_multiple_ingredients_from_stream(self): """Test Builder with multiple ingredients from streams using context APIs""" ctx = Context() builder = Builder.from_json(self.manifestDefinition, context=ctx) - assert builder._builder is not None + assert builder._handle is not None add_errors = [] add_lock = threading.Lock() completed_threads = 0 @@ -2915,7 +2915,7 @@ def test_builder_sign_with_same_ingredient_multiple_times(self): """Test Builder with same ingredient added multiple times from different threads using context APIs""" ctx = Context() builder = Builder.from_json(self.manifestDefinition, context=ctx) - assert builder._builder is not None + assert builder._handle is not None add_errors = [] add_lock = threading.Lock() completed_threads = 0 From 21f3d9505e329b428e05f127a5756f7308ad9132 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 13:34:43 -0800 Subject: [PATCH 34/75] fix: Format --- src/c2pa/c2pa.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 12138491..d3988ade 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -904,7 +904,7 @@ def _raise_typed_c2pa_error(error_str: str) -> None: Raises: C2paError subclass: The appropriate typed exception based on error_str """ - # Error format from lib is "ErrorType: message" or "ErrorType message" + # 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) @@ -966,7 +966,7 @@ def _parse_operation_result_for_error( None if no error occurred Raises: - C2paError subclass: typed exception if an error occurred + C2paError subclass: The appropriate typed exception if an error occurred """ if not result: # pragma: no cover if check_error: From bd9c172fbb374c3aeb3d14ad33a60a36de0a1ba9 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 14:07:42 -0800 Subject: [PATCH 35/75] fix: Tests --- tests/test_unit_tests.py | 856 +++++++++++++++------------------------ 1 file changed, 337 insertions(+), 519 deletions(-) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 397cea4c..6914a5a9 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -75,10 +75,7 @@ def test_sdk_version(self): class TestReader(unittest.TestCase): def setUp(self): - warnings.filterwarnings( - "ignore", - message="load_settings\\(\\) is deprecated", - ) + warnings.filterwarnings("ignore", message="load_settings\\(\\) is deprecated") self.data_dir = FIXTURES_DIR self.testPath = DEFAULT_TEST_FILE @@ -5098,646 +5095,508 @@ def test_create_signer_from_info(self): self.assertIsNotNone(signer) -# ── Context API manifest definition ────────────── - -_CTX_MANIFEST_DEF = { - "claim_generator": "python_test/context", - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - "format": "image/jpeg", - "title": "Context Test Image", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [{ - "action": "c2pa.created", - }] +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(): - """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(): - """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, + ] + } + + def _ctx_make_signer(self): + """Create a Signer for context tests.""" + certs_path = os.path.join( + FIXTURES_DIR, "es256_certs.pem" ) - sig = private_key.sign( - data, ec.ECDSA(hashes.SHA256()), + key_path = os.path.join( + FIXTURES_DIR, "es256_private.key" ) - r, s = asym_utils.decode_dss_signature(sig) - return ( - r.to_bytes(32, byteorder='big') - + s.to_bytes(32, byteorder='big') + 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) - return Signer.from_callback( - sign_cb, - SigningAlg.ES256, - certs.decode('utf-8'), - "http://timestamp.digicert.com", - ) + 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(), + ) -# ── 1. Settings basics ────────────────────────── + 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", + ) -class TestSettings(unittest.TestCase): +class TestSettings(TestContextAPIs): def test_settings_default_construction(self): - s = Settings() - self.assertTrue(s.is_valid) - s.close() + settings = Settings() + self.assertTrue(settings.is_valid) + settings.close() def test_settings_set_chaining(self): - s = Settings() + settings = Settings() result = ( - s.set( + settings.set( "builder.thumbnail.enabled", "false" ).set( "builder.thumbnail.enabled", "true" ) ) - self.assertIs(result, s) - s.close() + self.assertIs(result, settings) + settings.close() def test_settings_from_json(self): - s = Settings.from_json( + settings = Settings.from_json( '{"builder":{"thumbnail":' '{"enabled":false}}}' ) - self.assertTrue(s.is_valid) - s.close() + self.assertTrue(settings.is_valid) + settings.close() def test_settings_from_dict(self): - s = Settings.from_dict({ + settings = Settings.from_dict({ "builder": { "thumbnail": {"enabled": False} } }) - self.assertTrue(s.is_valid) - s.close() + self.assertTrue(settings.is_valid) + settings.close() def test_settings_update_json(self): - s = Settings() - result = s.update( + settings = Settings() + result = settings.update( '{"builder":{"thumbnail":' '{"enabled":false}}}' ) - self.assertIs(result, s) - s.close() + self.assertIs(result, settings) + settings.close() def test_settings_update_dict(self): - s = Settings() - result = s.update({ + settings = Settings() + result = settings.update({ "builder": { "thumbnail": {"enabled": False} } }) - self.assertIs(result, s) - s.close() - - def test_settings_no_setitem(self): - """__setitem__ was removed; use set() instead.""" - s = Settings() - with self.assertRaises(TypeError): - s["builder.thumbnail.enabled"] = "false" - s.close() - - def test_settings_context_manager(self): - with Settings() as s: - self.assertTrue(s.is_valid) - - def test_settings_close_idempotency(self): - s = Settings() - s.close() - s.close() + self.assertIs(result, settings) + settings.close() def test_settings_is_valid_after_close(self): - s = Settings() - s.close() - self.assertFalse(s.is_valid) + settings = Settings() + settings.close() + self.assertFalse(settings.is_valid) def test_settings_raises_after_close(self): - s = Settings() - s.close() + settings = Settings() + settings.close() with self.assertRaises(Error): - s.set( + settings.set( "builder.thumbnail.enabled", "false" ) - def test_settings_update_no_format_param(self): - """format parameter was removed; passing it raises TypeError.""" - s = Settings() - with self.assertRaises(TypeError): - s.update("data", format="toml") - s.close() - - -# ── 2. Context basics ─────────────────────────── - -class TestContext(unittest.TestCase): +class TestContext(TestContextAPIs): def test_context_default(self): - ctx = Context() - self.assertTrue(ctx.is_valid) - self.assertFalse(ctx.has_signer) - ctx.close() + context = Context() + self.assertTrue(context.is_valid) + self.assertFalse(context.has_signer) + context.close() def test_context_from_settings(self): - s = Settings() - ctx = Context(settings=s) - self.assertTrue(ctx.is_valid) - ctx.close() - s.close() + settings = Settings() + context = Context(settings=settings) + self.assertTrue(context.is_valid) + context.close() + settings.close() def test_context_from_json(self): - ctx = Context.from_json( + context = Context.from_json( '{"builder":{"thumbnail":' '{"enabled":false}}}' ) - self.assertTrue(ctx.is_valid) - ctx.close() + self.assertTrue(context.is_valid) + context.close() def test_context_from_dict(self): - ctx = Context.from_dict({ + context = Context.from_dict({ "builder": { "thumbnail": {"enabled": False} } }) - self.assertTrue(ctx.is_valid) - ctx.close() + self.assertTrue(context.is_valid) + context.close() def test_context_context_manager(self): - with Context() as ctx: - self.assertTrue(ctx.is_valid) - - def test_context_close_idempotency(self): - ctx = Context() - ctx.close() - ctx.close() + with Context() as context: + self.assertTrue(context.is_valid) def test_context_is_valid_after_close(self): - ctx = Context() - ctx.close() - self.assertFalse(ctx.is_valid) - - def test_context_invalid_settings_raises(self): - s = Settings() - s.close() - with self.assertRaises(Error): - Context(settings=s) - - def test_context_satisfies_protocol(self): - ctx = Context() - self.assertIsInstance(ctx, ContextProvider) - ctx.close() + context = Context() + context.close() + self.assertFalse(context.is_valid) -# ── 2b. ContextBuilder ────────────────────────── +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() -class TestContextBuilder(unittest.TestCase): + 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_builder_default(self): - ctx = Context.builder().build() - self.assertTrue(ctx.is_valid) - self.assertFalse(ctx.has_signer) - ctx.close() - - def test_builder_with_settings(self): - s = Settings() - ctx = Context.builder().with_settings(s).build() - self.assertTrue(ctx.is_valid) - ctx.close() - s.close() - - def test_builder_with_signer(self): - signer = _ctx_make_signer() - ctx = ( + def test_context_builder_with_signer(self): + signer = self._ctx_make_signer() + context = ( Context.builder() .with_signer(signer) .build() ) - self.assertTrue(ctx.is_valid) - self.assertTrue(ctx.has_signer) - ctx.close() - - def test_builder_with_settings_and_signer(self): - s = Settings() - signer = _ctx_make_signer() - ctx = ( + 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(s) + .with_settings(settings) .with_signer(signer) .build() ) - self.assertTrue(ctx.is_valid) - self.assertTrue(ctx.has_signer) - ctx.close() - s.close() - - def test_builder_returns_context_builder(self): - b = Context.builder() - self.assertIsInstance(b, ContextBuilder) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + settings.close() - def test_builder_chaining_returns_self(self): - s = Settings() - b = Context.builder() - result = b.with_settings(s) - self.assertIs(result, b) - ctx = b.build() - ctx.close() - s.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() -# ── 3. Context with Signer ────────────────────── - - -class TestContextWithSigner(unittest.TestCase): +class TestContextWithSigner(TestContextAPIs): def test_context_with_signer(self): - signer = _ctx_make_signer() - ctx = Context(signer=signer) - self.assertTrue(ctx.is_valid) - self.assertTrue(ctx.has_signer) - ctx.close() + 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): - s = Settings() - signer = _ctx_make_signer() - ctx = Context(settings=s, signer=signer) - self.assertTrue(ctx.is_valid) - self.assertTrue(ctx.has_signer) - ctx.close() - s.close() + settings = Settings() + signer = self._ctx_make_signer() + context = Context(settings=settings, signer=signer) + self.assertTrue(context.is_valid) + self.assertTrue(context.has_signer) + context.close() + settings.close() def test_consumed_signer_is_closed(self): - signer = _ctx_make_signer() - ctx = Context(signer=signer) + signer = self._ctx_make_signer() + context = Context(signer=signer) self.assertEqual(signer._state, LifecycleState.CLOSED) - ctx.close() + context.close() def test_consumed_signer_raises_on_use(self): - signer = _ctx_make_signer() - ctx = Context(signer=signer) + signer = self._ctx_make_signer() + context = Context(signer=signer) with self.assertRaises(Error): signer._ensure_valid_state() - ctx.close() + context.close() def test_context_has_signer_flag(self): - signer = _ctx_make_signer() - ctx = Context(signer=signer) - self.assertTrue(ctx.has_signer) - ctx.close() + signer = self._ctx_make_signer() + context = Context(signer=signer) + self.assertTrue(context.has_signer) + context.close() def test_context_no_signer_flag(self): - ctx = Context() - self.assertFalse(ctx.has_signer) - ctx.close() + context = Context() + self.assertFalse(context.has_signer) + context.close() def test_context_from_json_with_signer(self): - signer = _ctx_make_signer() - ctx = Context.from_json( + signer = self._ctx_make_signer() + context = Context.from_json( '{"builder":{"thumbnail":' '{"enabled":false}}}', signer=signer, ) - self.assertTrue(ctx.has_signer) + self.assertTrue(context.has_signer) self.assertEqual(signer._state, LifecycleState.CLOSED) - ctx.close() - + context.close() -# ── 4. Reader with Context ────────────────────── +class TestReaderWithContext(TestContextAPIs): -class TestReaderWithContext(unittest.TestCase): + # TODO-TMN: Tests with trust def test_reader_with_default_context(self): - ctx = Context() - with open(DEFAULT_TEST_FILE, "rb") as f: - reader = Reader( - "image/jpeg", f, context=ctx, - ) + 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() - ctx.close() + context.close() def test_reader_with_settings_context(self): - s = Settings() - ctx = Context(settings=s) - with open(DEFAULT_TEST_FILE, "rb") as f: - reader = Reader( - "image/jpeg", f, context=ctx, - ) + settings = Settings() + context = Context(settings=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() - ctx.close() - s.close() + context.close() + settings.close() def test_reader_without_context(self): - with open(DEFAULT_TEST_FILE, "rb") as f: - reader = Reader("image/jpeg", f) + 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): - ctx = Context() - reader = Reader.try_create( - DEFAULT_TEST_FILE, context=ctx, - ) + context = Context() + reader = Reader.try_create(DEFAULT_TEST_FILE, context=context,) self.assertIsNotNone(reader) data = reader.json() self.assertIsNotNone(data) reader.close() - ctx.close() + context.close() def test_reader_try_create_no_manifest(self): - ctx = Context() - reader = Reader.try_create( - INGREDIENT_TEST_FILE, context=ctx, - ) + context = Context() + reader = Reader.try_create(INGREDIENT_TEST_FILE, context=context,) self.assertIsNone(reader) - ctx.close() + context.close() def test_reader_file_path_with_context(self): - ctx = Context() - reader = Reader( - DEFAULT_TEST_FILE, context=ctx, - ) + context = Context() + reader = Reader(DEFAULT_TEST_FILE, context=context,) data = reader.json() self.assertIsNotNone(data) reader.close() - ctx.close() + context.close() def test_reader_format_and_path_with_ctx(self): - ctx = Context() - reader = Reader( - "image/jpeg", DEFAULT_TEST_FILE, - context=ctx, - ) + context = Context() + reader = Reader("image/jpeg", DEFAULT_TEST_FILE, context=context) data = reader.json() self.assertIsNotNone(data) reader.close() - ctx.close() - - -# ── 5. Builder with Context ───────────────────── - + context.close() -class TestBuilderWithContext(unittest.TestCase): +class TestBuilderWithContext(TestContextAPIs): - def test_builder_with_default_context(self): - ctx = Context() - builder = Builder( - _CTX_MANIFEST_DEF, context=ctx, - ) + def test_contextual_builder_with_default_context(self): + context = Context() + builder = Builder(self.test_manifest, context=context) self.assertIsNotNone(builder) builder.close() - ctx.close() + context.close() - def test_builder_with_settings_context(self): - s = Settings.from_dict({ + def test_contextual_builder_with_settings_context(self): + settings = Settings.from_dict({ "builder": { "thumbnail": {"enabled": False} } }) - ctx = Context(settings=s) - builder = Builder( - _CTX_MANIFEST_DEF, context=ctx, - ) - signer = _ctx_make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") + context = Context(settings=settings) + builder = Builder(self.test_manifest, context=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 src, - open(dest, "w+b") as dst, + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, ): builder.sign( - signer, "image/jpeg", src, dst, + signer, "image/jpeg", source_file, dest_file, ) - reader = Reader(dest) + reader = Reader(dest_path) manifest = reader.get_active_manifest() self.assertIsNone( manifest.get("thumbnail") ) reader.close() builder.close() - ctx.close() - s.close() - - def test_builder_without_context(self): - builder = Builder(_CTX_MANIFEST_DEF) - self.assertIsNotNone(builder) - builder.close() + context.close() + settings.close() - def test_builder_from_json_with_context(self): - ctx = Context() - builder = Builder.from_json( - _CTX_MANIFEST_DEF, context=ctx, - ) + def test_contextual_builder_from_json_with_context(self): + context = Context() + builder = Builder.from_json(self.test_manifest, context=context) self.assertIsNotNone(builder) builder.close() - ctx.close() + context.close() - def test_builder_sign_context_signer(self): - signer = _ctx_make_signer() - ctx = Context(signer=signer) + def test_contextual_builder_sign_context_signer(self): + signer = self._ctx_make_signer() + context = Context(signer=signer) builder = Builder( - _CTX_MANIFEST_DEF, context=ctx, + self.test_manifest, context=context, ) - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") with ( - open(DEFAULT_TEST_FILE, "rb") as src, - open(dest, "w+b") as dst, + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, ): - mb = builder.sign_with_context( + manifest_bytes = builder.sign_with_context( format="image/jpeg", - source=src, - dest=dst, + source=source_file, + dest=dest_file, ) - self.assertIsNotNone(mb) - self.assertGreater(len(mb), 0) - reader = Reader(dest) + self.assertIsNotNone(manifest_bytes) + self.assertGreater(len(manifest_bytes), 0) + reader = Reader(dest_path) data = reader.json() self.assertIsNotNone(data) reader.close() builder.close() - ctx.close() + context.close() - def test_builder_sign_explicit_overrides(self): - ctx_signer = _ctx_make_signer() - ctx = Context(signer=ctx_signer) + def test_contextual_builder_sign_signer_ovverride(self): + context_signer = self._ctx_make_signer() + context = Context(signer=context_signer) builder = Builder( - _CTX_MANIFEST_DEF, context=ctx, + self.test_manifest, context=context, ) - explicit_signer = _ctx_make_signer() - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") + 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 src, - open(dest, "w+b") as dst, + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, ): - mb = builder.sign( + manifest_bytes = builder.sign( explicit_signer, - "image/jpeg", src, dst, + "image/jpeg", source_file, dest_file, ) - self.assertIsNotNone(mb) - self.assertGreater(len(mb), 0) + self.assertIsNotNone(manifest_bytes) + self.assertGreater(len(manifest_bytes), 0) builder.close() explicit_signer.close() - ctx.close() + context.close() - def test_builder_sign_no_signer_raises(self): - ctx = Context() + def test_contextual_builder_sign_no_signer_raises(self): + context = Context() builder = Builder( - _CTX_MANIFEST_DEF, context=ctx, + self.test_manifest, context=context, ) - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") with ( - open(DEFAULT_TEST_FILE, "rb") as src, - open(dest, "w+b") as dst, + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, ): with self.assertRaises(Error): builder.sign_with_context( format="image/jpeg", - source=src, - dest=dst, + source=source_file, + dest=dest_file, ) builder.close() - ctx.close() - - -# ── 6. ContextProvider protocol ────────────────── - - -class TestContextProvider(unittest.TestCase): - - def test_isinstance_check(self): - ctx = Context() - self.assertIsInstance(ctx, ContextProvider) - ctx.close() - - def test_custom_context_provider(self): - real_ctx = Context() - - class MyProvider(ContextProvider): - @property - def is_valid(self) -> bool: - return True - - @property - def execution_context(self): - return real_ctx.execution_context - - provider = MyProvider() - self.assertIsInstance( - provider, ContextProvider - ) - reader = Reader( - DEFAULT_TEST_FILE, context=provider, - ) - data = reader.json() - self.assertIsNotNone(data) - reader.close() - real_ctx.close() - - def test_invalid_provider_rejected(self): - - class BadProvider: - @property - def is_valid(self) -> bool: - return False - - @property - def execution_context(self): - return None - - with self.assertRaises(Error): - Reader( - DEFAULT_TEST_FILE, - context=BadProvider(), - ) + context.close() -# ── 7. Integration tests ──────────────────────── +class TestContextIntegration(TestContextAPIs): - -class TestContextIntegration(unittest.TestCase): +# TODO-TMN: Test with trust on context def test_sign_no_thumbnail_via_context(self): - s = Settings.from_dict({ + settings = Settings.from_dict({ "builder": { "thumbnail": {"enabled": False} } }) - ctx = Context(settings=s) - signer = _ctx_make_signer() + context = Context(settings=settings) + signer = self._ctx_make_signer() builder = Builder( - _CTX_MANIFEST_DEF, context=ctx, + self.test_manifest, context=context, ) - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") with ( - open(DEFAULT_TEST_FILE, "rb") as src, - open(dest, "w+b") as dst, + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, ): builder.sign( - signer, "image/jpeg", src, dst, + signer, "image/jpeg", source_file, dest_file, ) - reader = Reader(dest) + reader = Reader(dest_path) manifest = reader.get_active_manifest() self.assertIsNone( manifest.get("thumbnail") @@ -5745,133 +5604,92 @@ def test_sign_no_thumbnail_via_context(self): reader.close() builder.close() signer.close() - ctx.close() - s.close() + context.close() + settings.close() def test_sign_read_roundtrip(self): - signer = _ctx_make_signer() - ctx = Context(signer=signer) + signer = self._ctx_make_signer() + context = Context(signer=signer) builder = Builder( - _CTX_MANIFEST_DEF, context=ctx, + self.test_manifest, context=context, ) - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") with ( - open(DEFAULT_TEST_FILE, "rb") as src, - open(dest, "w+b") as dst, + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, ): builder.sign_with_context( format="image/jpeg", - source=src, - dest=dst, + source=source_file, + dest=dest_file, ) - reader = Reader(dest) + reader = Reader(dest_path) data = reader.json() self.assertIsNotNone(data) self.assertIn("manifests", data) reader.close() builder.close() - ctx.close() + context.close() def test_shared_context_multi_builders(self): - ctx = Context() - signer1 = _ctx_make_signer() - signer2 = _ctx_make_signer() + # TODO-TMN: COntext manager example + context = Context() + signer1 = self._ctx_make_signer() + signer2 = self._ctx_make_signer() - b1 = Builder(_CTX_MANIFEST_DEF, context=ctx) - b2 = Builder(_CTX_MANIFEST_DEF, context=ctx) + builder1 = Builder(self.test_manifest, context=context) + builder2 = Builder(self.test_manifest, context=context) - with tempfile.TemporaryDirectory() as td: - for i, (builder, signer) in enumerate( - [(b1, signer1), (b2, signer2)] + with tempfile.TemporaryDirectory() as temp_dir: + for index, (builder, signer) in enumerate( + [(builder1, signer1), (builder2, signer2)] ): - dest = os.path.join( - td, f"out{i}.jpg" + dest_path = os.path.join( + temp_dir, f"out{index}.jpg" ) with ( open( DEFAULT_TEST_FILE, "rb" - ) as src, - open(dest, "w+b") as dst, + ) as source_file, + open(dest_path, "w+b") as dest_file, ): - mb = builder.sign( + manifest_bytes = builder.sign( signer, "image/jpeg", - src, dst, + source_file, dest_file, ) - self.assertGreater(len(mb), 0) + self.assertGreater(len(manifest_bytes), 0) - b1.close() - b2.close() + builder1.close() + builder2.close() signer1.close() signer2.close() - ctx.close() + context.close() def test_sign_callback_signer_in_ctx(self): - signer = _ctx_make_callback_signer() - ctx = Context(signer=signer) + signer = self._ctx_make_callback_signer() + context = Context(signer=signer) builder = Builder( - _CTX_MANIFEST_DEF, context=ctx, + self.test_manifest, context=context, ) - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "out.jpg") with ( - open(DEFAULT_TEST_FILE, "rb") as src, - open(dest, "w+b") as dst, + open(DEFAULT_TEST_FILE, "rb") as source_file, + open(dest_path, "w+b") as dest_file, ): - mb = builder.sign_with_context( + manifest_bytes = builder.sign_with_context( format="image/jpeg", - source=src, - dest=dst, + source=source_file, + dest=dest_file, ) - self.assertGreater(len(mb), 0) - reader = Reader(dest) + self.assertGreater(len(manifest_bytes), 0) + reader = Reader(dest_path) data = reader.json() self.assertIsNotNone(data) reader.close() builder.close() - ctx.close() - - -# ── 8. Backward compatibility ─────────────────── - - -class TestBackwardCompat(unittest.TestCase): - - def test_existing_sign_api_positional(self): - signer = _ctx_make_signer() - builder = Builder(_CTX_MANIFEST_DEF) - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") - with ( - open(DEFAULT_TEST_FILE, "rb") as src, - open(dest, "w+b") as dst, - ): - mb = builder.sign( - signer, "image/jpeg", src, dst, - ) - self.assertGreater(len(mb), 0) - builder.close() - signer.close() - - def test_existing_sign_file_positional(self): - signer = _ctx_make_signer() - builder = Builder(_CTX_MANIFEST_DEF) - with tempfile.TemporaryDirectory() as td: - dest = os.path.join(td, "out.jpg") - mb = builder.sign_file( - DEFAULT_TEST_FILE, dest, signer, - ) - self.assertGreater(len(mb), 0) - builder.close() - signer.close() - - def test_sign_format_source_required(self): - builder = Builder(_CTX_MANIFEST_DEF) - signer = _ctx_make_signer() - with self.assertRaises(TypeError): - builder.sign(signer) - builder.close() - signer.close() + context.close() if __name__ == '__main__': From c75fc27bbe883d4740c4a08aaf50136e7c72cdb6 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 14:21:47 -0800 Subject: [PATCH 36/75] fix: Test with trust --- tests/fixtures/settings.toml | 230 ----------------------------------- tests/test_unit_tests.py | 103 ++++++++++++++-- 2 files changed, 91 insertions(+), 242 deletions(-) delete mode 100644 tests/fixtures/settings.toml 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 6914a5a9..80eec68c 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -196,7 +196,7 @@ def test_stream_read_get_validation_state_with_trust_config(self): def read_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1477,7 +1477,7 @@ def test_streams_sign_with_es256_alg_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1551,7 +1551,7 @@ def test_sign_with_ed25519_alg_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1691,7 +1691,7 @@ def test_sign_with_ps256_alg_2_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1769,7 +1769,7 @@ def test_archive_sign_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1841,7 +1841,7 @@ def test_archive_sign_with_added_ingredient_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -1937,7 +1937,7 @@ def test_remote_sign_using_returned_bytes_V2_with_trust_config(self): def sign_and_validate_with_trust_config(): try: - # Load trust configuration from test_settings.toml + # Load trust configuration settings_dict = load_test_settings_json() # Apply the settings (including trust configuration) @@ -5398,8 +5398,6 @@ def test_context_from_json_with_signer(self): class TestReaderWithContext(TestContextAPIs): - # TODO-TMN: Tests with trust - def test_reader_with_default_context(self): context = Context() with open(DEFAULT_TEST_FILE, "rb") as file_handle: @@ -5574,8 +5572,6 @@ def test_contextual_builder_sign_no_signer_raises(self): class TestContextIntegration(TestContextAPIs): -# TODO-TMN: Test with trust on context - def test_sign_no_thumbnail_via_context(self): settings = Settings.from_dict({ "builder": { @@ -5633,7 +5629,6 @@ def test_sign_read_roundtrip(self): context.close() def test_shared_context_multi_builders(self): - # TODO-TMN: COntext manager example context = Context() signer1 = self._ctx_make_signer() signer2 = self._ctx_make_signer() @@ -5666,6 +5661,90 @@ def test_shared_context_multi_builders(self): 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=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=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_sign_callback_signer_in_ctx(self): signer = self._ctx_make_callback_signer() context = Context(signer=signer) From 60380d136e9905f0adb1e31ee08d0aef1bf771d8 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:37:18 -0800 Subject: [PATCH 37/75] Clean up comments in read.py Removed unnecessary comments about trust anchor configuration. --- examples/read.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/read.py b/examples/read.py index 32c1226d..518406bd 100644 --- a/examples/read.py +++ b/examples/read.py @@ -6,8 +6,7 @@ # This example shows how to read a C2PA manifest embedded in a media file, and validate # that it is trusted according to the official trust anchor certificate list. # The output is printed as prettified JSON. -# -# This example uses Context with custom Settings for trust anchor configuration. + TRUST_ANCHORS_URL = "https://contentcredentials.org/trust/anchors.pem" From 2aeb453f22e8b19347ec8eceb280411285a5968d Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:37:30 -0800 Subject: [PATCH 38/75] Update read.py --- examples/read.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/read.py b/examples/read.py index 518406bd..1137a305 100644 --- a/examples/read.py +++ b/examples/read.py @@ -7,7 +7,6 @@ # that it is trusted according to the official trust anchor certificate list. # The output is printed as prettified JSON. - TRUST_ANCHORS_URL = "https://contentcredentials.org/trust/anchors.pem" From daf43dd0f647e30c818eec8bc8f4b0b900c5668b Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 14:39:38 -0800 Subject: [PATCH 39/75] fix: Typos --- examples/read.py | 2 - tests/test_unit_tests.py | 222 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+), 2 deletions(-) diff --git a/examples/read.py b/examples/read.py index 32c1226d..1137a305 100644 --- a/examples/read.py +++ b/examples/read.py @@ -6,8 +6,6 @@ # This example shows how to read a C2PA manifest embedded in a media file, and validate # that it is trusted according to the official trust anchor certificate list. # The output is printed as prettified JSON. -# -# This example uses Context with custom Settings for trust anchor configuration. TRUST_ANCHORS_URL = "https://contentcredentials.org/trust/anchors.pem" diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 80eec68c..883721ab 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -5180,6 +5180,42 @@ def sign_cb(data: bytes) -> bytes: "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): @@ -5456,6 +5492,7 @@ def test_reader_format_and_path_with_ctx(self): reader.close() context.close() + class TestBuilderWithContext(TestContextAPIs): def test_contextual_builder_with_default_context(self): @@ -5745,6 +5782,191 @@ def test_shared_trusted_context_multi_builders(self): 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=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=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=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=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=settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder.from_archive( + archive, context=context, + ) + 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=settings) + signer = self._ctx_make_signer() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + builder = Builder.from_archive( + archive, context=context, + ) + 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_sign_callback_signer_in_ctx(self): signer = self._ctx_make_callback_signer() context = Context(signer=signer) From b99f8196d4a4b5ea02f39244c829c47d5f0b342a Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 14:41:06 -0800 Subject: [PATCH 40/75] fix: Typos --- examples/read.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/read.py b/examples/read.py index 1137a305..3e25ad53 100644 --- a/examples/read.py +++ b/examples/read.py @@ -10,7 +10,7 @@ TRUST_ANCHORS_URL = "https://contentcredentials.org/trust/anchors.pem" -def load_trust_settings(): +def load_trust_anchors(): """Load trust anchors and return a Settings object configured for trust validation.""" try: with urllib.request.urlopen(TRUST_ANCHORS_URL) as response: @@ -31,7 +31,7 @@ def load_trust_settings(): def read_c2pa_data(media_path: str): print(f"Reading {media_path}") try: - settings = load_trust_settings() + settings = load_trust_anchors() context = c2pa.Context(settings=settings) with c2pa.Reader(media_path, context=context) as reader: print(reader.detailed_json()) From ceeae56cd5270b22047a3bea6010b8522b1bfcf2 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 14:48:26 -0800 Subject: [PATCH 41/75] fix: Notes for WIP --- examples/no_thumbnails.py | 2 ++ examples/read.py | 1 + examples/sign.py | 2 ++ examples/training.py | 2 ++ 4 files changed, 7 insertions(+) diff --git a/examples/no_thumbnails.py b/examples/no_thumbnails.py index dfe29cc2..831d30ff 100644 --- a/examples/no_thumbnails.py +++ b/examples/no_thumbnails.py @@ -106,6 +106,8 @@ def callback_signer_es256(data: bytes) -> bytes: print("No thumbnail in the manifest as per settings.") else: print("Thumbnail found in the manifest.") + +# TODO-TMN: use with context here context.close() print("\nExample completed successfully!") diff --git a/examples/read.py b/examples/read.py index 3e25ad53..e9850828 100644 --- a/examples/read.py +++ b/examples/read.py @@ -31,6 +31,7 @@ def load_trust_anchors(): def read_c2pa_data(media_path: str): print(f"Reading {media_path}") try: + # TODO-TMN: use with context here settings = load_trust_anchors() context = c2pa.Context(settings=settings) with c2pa.Reader(media_path, context=context) as reader: diff --git a/examples/sign.py b/examples/sign.py index 38e22da2..7ccd467c 100644 --- a/examples/sign.py +++ b/examples/sign.py @@ -112,6 +112,8 @@ def callback_signer_es256(data: bytes) -> bytes: # Without loaded trust settings, # the manifest validation_state will be "Invalid". print(reader.json()) + +# TODO-TMN: use with context here context.close() print("\nExample completed successfully!") diff --git a/examples/training.py b/examples/training.py index 85f0cc17..f9b94b46 100644 --- a/examples/training.py +++ b/examples/training.py @@ -127,6 +127,7 @@ def getitem(d, key): # As an alternative, you can also use file paths directly during signing: # builder.sign_file(testFile, testOutputFile, signer) + # TODO-TMN: use with context here context.close() except Exception as err: @@ -155,6 +156,7 @@ def getitem(d, key): uri = getitem(manifest,("ingredients", 0, "thumbnail", "identifier")) with open(output_dir + "thumbnail_v2.jpg", "wb") as thumbnail_output: reader.resource_to_stream(uri, thumbnail_output) + # TODO-TMN: use with context here context.close() except Exception as err: From 5f038b6ebb2c3b9d2f669b33c18320d413a7c1dc Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 15:48:04 -0800 Subject: [PATCH 42/75] fix: Refactor --- examples/README.md | 2 - examples/no_thumbnails.py | 45 +++++++++--------- examples/read.py | 8 ++-- examples/sign.py | 50 +++++++++----------- examples/training.py | 78 +++++++++++++++---------------- tests/test_unit_tests.py | 66 +++++++++++++------------- tests/test_unit_tests_threaded.py | 22 ++++----- 7 files changed, 128 insertions(+), 143 deletions(-) diff --git a/examples/README.md b/examples/README.md index aa9c811b..191e88f4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,8 +7,6 @@ The examples use asset files from the `tests/fixtures` directory, save the resul The [`examples/sign.py`](https://github.com/contentauth/c2pa-python/blob/main/examples/sign.py) script shows how to sign an asset with a C2PA manifest and verify the asset. -The `examples/sign.py` script shows how to sign an asset with a C2PA manifest and verify it using a callback signer. Callback signers let you define signing logic, for example where to load keys from. - The `examples/sign_info.py` script shows how to sign an asset with a C2PA manifest and verify it using a "default" signer created with the needed signer information. These statements create a `builder` object with the specified manifest JSON (omitted in the snippet below), call `builder.sign()` to sign and attach the manifest to the source file, `tests/fixtures/C.jpg`, and save the signed asset to the output file, `output/C_signed.jpg`: diff --git a/examples/no_thumbnails.py b/examples/no_thumbnails.py index 831d30ff..2886bc43 100644 --- a/examples/no_thumbnails.py +++ b/examples/no_thumbnails.py @@ -83,31 +83,28 @@ def callback_signer_es256(data: bytes) -> bytes: }) print("Signing image with thumbnails disabled through settings...") -context = c2pa.Context(settings=settings) -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, context=context) as builder: - builder.sign_file( - source_path=fixtures_dir + "A.jpg", - dest_path=output_dir + "A_no_thumbnail.jpg", - signer=signer - ) +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"]] + # 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.") - -# TODO-TMN: use with context here -context.close() + 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 e9850828..d0032205 100644 --- a/examples/read.py +++ b/examples/read.py @@ -31,12 +31,10 @@ def load_trust_anchors(): def read_c2pa_data(media_path: str): print(f"Reading {media_path}") try: - # TODO-TMN: use with context here settings = load_trust_anchors() - context = c2pa.Context(settings=settings) - with c2pa.Reader(media_path, context=context) as reader: - print(reader.detailed_json()) - context.close() + 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) diff --git a/examples/sign.py b/examples/sign.py index 7ccd467c..e6c14859 100644 --- a/examples/sign.py +++ b/examples/sign.py @@ -19,9 +19,8 @@ from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.backends import default_backend -# Note: Builder, Reader, and Signer support being used as context managers -# (with 'with' statements), but this example shows manual usage which requires -# explicitly calling the close() function to clean up resources. +# Note: Builder, Reader, Signer, and Context support being used as context managers +# (with 'with' statements) to automatically clean up resources. fixtures_dir = os.path.join(os.path.dirname(__file__), "../tests/fixtures/") output_dir = os.path.join(os.path.dirname(__file__), "../output/") @@ -90,30 +89,27 @@ def callback_signer_es256(data: bytes) -> bytes: print("\nSigning the image file...") # Use default Context and Settings. -context = c2pa.Context() -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, context=context) as builder: - builder.sign_file( - source_path=fixtures_dir + "A.jpg", - dest_path=output_dir + "A_signed.jpg", - signer=signer - ) +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, 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()) - -# TODO-TMN: use with context here -context.close() + # Re-Read the signed image to verify + print("\nReading signed image metadata:") + with open(output_dir + "A_signed.jpg", "rb") as file: + with c2pa.Reader("image/jpeg", file, context=context) as reader: + # The validation state will depend on loaded trust settings. + # Without loaded trust settings, + # the manifest validation_state will be "Invalid". + print(reader.json()) print("\nExample completed successfully!") diff --git a/examples/training.py b/examples/training.py index f9b94b46..c248b00d 100644 --- a/examples/training.py +++ b/examples/training.py @@ -100,35 +100,33 @@ def getitem(d, key): # Create a signer using the new API signer_info = c2pa.C2paSignerInfo( - alg=b"ps256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" + b"ps256", + certs, + key, + b"http://timestamp.digicert.com" ) - context = c2pa.Context() - with c2pa.Signer.from_info(signer_info) as signer: - with c2pa.Builder(manifest_json, context=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) + with c2pa.Context() as context: + with c2pa.Signer.from_info(signer_info) as signer: + with 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 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) - 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) - # TODO-TMN: use with context here - context.close() + # 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}") @@ -140,24 +138,22 @@ 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 default Context - context = c2pa.Context() - with c2pa.Reader(testOutputFile, context=context) as reader: - # Retrieve the manifest store - manifest_store = json.loads(reader.json()) - - # Look at data in the active manifest - manifest = manifest_store["manifests"][manifest_store["active_manifest"]] - for assertion in manifest["assertions"]: - if assertion["label"] == "cawg.training-mining": - if getitem(assertion, ("data","entries","cawg.ai_generative_training","use")) == "notAllowed": - allowed = False - - # Get the ingredient thumbnail and save it to a file using resource_to_stream - uri = getitem(manifest,("ingredients", 0, "thumbnail", "identifier")) - with open(output_dir + "thumbnail_v2.jpg", "wb") as thumbnail_output: - reader.resource_to_stream(uri, thumbnail_output) - # TODO-TMN: use with context here - context.close() + with c2pa.Context() as context: + with c2pa.Reader(testOutputFile, context=context) as reader: + # Retrieve the manifest store + manifest_store = json.loads(reader.json()) + + # Look at data in the active manifest + manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + for assertion in manifest["assertions"]: + if assertion["label"] == "cawg.training-mining": + if getitem(assertion, ("data","entries","cawg.ai_generative_training","use")) == "notAllowed": + allowed = False + + # Get the ingredient thumbnail and save it to a file using resource_to_stream + uri = getitem(manifest,("ingredients", 0, "thumbnail", "identifier")) + with open(output_dir + "thumbnail_v2.jpg", "wb") as thumbnail_output: + reader.resource_to_stream(uri, thumbnail_output) except Exception as err: print(f"Exception during assertions reading: {err}") diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 883721ab..0dade99c 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -5296,7 +5296,7 @@ def test_context_default(self): def test_context_from_settings(self): settings = Settings() - context = Context(settings=settings) + context = Context(settings) self.assertTrue(context.is_valid) context.close() settings.close() @@ -5390,7 +5390,7 @@ def test_context_with_signer(self): def test_context_with_settings_and_signer(self): settings = Settings() signer = self._ctx_make_signer() - context = Context(settings=settings, signer=signer) + context = Context(settings, signer) self.assertTrue(context.is_valid) self.assertTrue(context.has_signer) context.close() @@ -5425,7 +5425,7 @@ def test_context_from_json_with_signer(self): context = Context.from_json( '{"builder":{"thumbnail":' '{"enabled":false}}}', - signer=signer, + signer, ) self.assertTrue(context.has_signer) self.assertEqual(signer._state, LifecycleState.CLOSED) @@ -5445,7 +5445,7 @@ def test_reader_with_default_context(self): def test_reader_with_settings_context(self): settings = Settings() - context = Context(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() @@ -5497,7 +5497,7 @@ class TestBuilderWithContext(TestContextAPIs): def test_contextual_builder_with_default_context(self): context = Context() - builder = Builder(self.test_manifest, context=context) + builder = Builder(self.test_manifest, context) self.assertIsNotNone(builder) builder.close() context.close() @@ -5508,8 +5508,8 @@ def test_contextual_builder_with_settings_context(self): "thumbnail": {"enabled": False} } }) - context = Context(settings=settings) - builder = Builder(self.test_manifest, context=context,) + 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") @@ -5532,7 +5532,7 @@ def test_contextual_builder_with_settings_context(self): def test_contextual_builder_from_json_with_context(self): context = Context() - builder = Builder.from_json(self.test_manifest, context=context) + builder = Builder.from_json(self.test_manifest, context) self.assertIsNotNone(builder) builder.close() context.close() @@ -5550,9 +5550,9 @@ def test_contextual_builder_sign_context_signer(self): open(dest_path, "w+b") as dest_file, ): manifest_bytes = builder.sign_with_context( - format="image/jpeg", - source=source_file, - dest=dest_file, + "image/jpeg", + source_file, + dest_file, ) self.assertIsNotNone(manifest_bytes) self.assertGreater(len(manifest_bytes), 0) @@ -5599,9 +5599,9 @@ def test_contextual_builder_sign_no_signer_raises(self): ): with self.assertRaises(Error): builder.sign_with_context( - format="image/jpeg", - source=source_file, - dest=dest_file, + "image/jpeg", + source_file, + dest_file, ) builder.close() context.close() @@ -5615,7 +5615,7 @@ def test_sign_no_thumbnail_via_context(self): "thumbnail": {"enabled": False} } }) - context = Context(settings=settings) + context = Context(settings) signer = self._ctx_make_signer() builder = Builder( self.test_manifest, context=context, @@ -5653,9 +5653,9 @@ def test_sign_read_roundtrip(self): open(dest_path, "w+b") as dest_file, ): builder.sign_with_context( - format="image/jpeg", - source=source_file, - dest=dest_file, + "image/jpeg", + source_file, + dest_file, ) reader = Reader(dest_path) data = reader.json() @@ -5670,8 +5670,8 @@ def test_shared_context_multi_builders(self): signer1 = self._ctx_make_signer() signer2 = self._ctx_make_signer() - builder1 = Builder(self.test_manifest, context=context) - builder2 = Builder(self.test_manifest, context=context) + builder1 = Builder(self.test_manifest, context) + builder2 = Builder(self.test_manifest, context) with tempfile.TemporaryDirectory() as temp_dir: for index, (builder, signer) in enumerate( @@ -5704,7 +5704,7 @@ def test_trusted_sign_no_thumbnail_via_context(self): "enabled": False, } settings = Settings.from_dict(trust_dict) - context = Context(settings=settings) + context = Context(settings) signer = self._ctx_make_signer() builder = Builder( self.test_manifest, context=context, @@ -5733,7 +5733,7 @@ def test_trusted_sign_no_thumbnail_via_context(self): def test_shared_trusted_context_multi_builders(self): trust_dict = load_test_settings_json() settings = Settings.from_dict(trust_dict) - context = Context(settings=settings) + context = Context(settings) signer1 = self._ctx_make_signer() signer2 = self._ctx_make_signer() @@ -5785,7 +5785,7 @@ def test_shared_trusted_context_multi_builders(self): def test_read_validation_trusted_via_context(self): trust_dict = load_test_settings_json() settings = Settings.from_dict(trust_dict) - context = Context(settings=settings) + context = Context(settings) with open(DEFAULT_TEST_FILE, "rb") as f: reader = Reader("image/jpeg", f, context=context) validation_state = ( @@ -5801,7 +5801,7 @@ def test_read_validation_trusted_via_context(self): def test_sign_es256_trusted_via_context(self): trust_dict = load_test_settings_json() settings = Settings.from_dict(trust_dict) - context = Context(settings=settings) + context = Context(settings) signer = self._ctx_make_signer() builder = Builder( self.test_manifest, context=context, @@ -5831,7 +5831,7 @@ def test_sign_es256_trusted_via_context(self): def test_sign_ed25519_trusted_via_context(self): trust_dict = load_test_settings_json() settings = Settings.from_dict(trust_dict) - context = Context(settings=settings) + context = Context(settings) signer = self._ctx_make_ed25519_signer() builder = Builder( self.test_manifest, context=context, @@ -5861,7 +5861,7 @@ def test_sign_ed25519_trusted_via_context(self): def test_sign_ps256_trusted_via_context(self): trust_dict = load_test_settings_json() settings = Settings.from_dict(trust_dict) - context = Context(settings=settings) + context = Context(settings) signer = self._ctx_make_ps256_signer() builder = Builder( self.test_manifest, context=context, @@ -5891,7 +5891,7 @@ def test_sign_ps256_trusted_via_context(self): def test_archive_sign_trusted_via_context(self): trust_dict = load_test_settings_json() settings = Settings.from_dict(trust_dict) - context = Context(settings=settings) + context = Context(settings) signer = self._ctx_make_signer() builder = Builder( self.test_manifest, context=context, @@ -5899,7 +5899,7 @@ def test_archive_sign_trusted_via_context(self): archive = io.BytesIO(bytearray()) builder.to_archive(archive) builder = Builder.from_archive( - archive, context=context, + archive, context, ) with ( open(DEFAULT_TEST_FILE, "rb") as source, @@ -5928,7 +5928,7 @@ def test_archive_sign_trusted_via_context(self): 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=settings) + context = Context(settings) signer = self._ctx_make_signer() builder = Builder( self.test_manifest, context=context, @@ -5936,7 +5936,7 @@ def test_archive_sign_with_ingredient_trusted_via_context(self): archive = io.BytesIO(bytearray()) builder.to_archive(archive) builder = Builder.from_archive( - archive, context=context, + archive, context, ) ingredient_json = '{"test": "ingredient"}' with open(DEFAULT_TEST_FILE, "rb") as f: @@ -5980,9 +5980,9 @@ def test_sign_callback_signer_in_ctx(self): open(dest_path, "w+b") as dest_file, ): manifest_bytes = builder.sign_with_context( - format="image/jpeg", - source=source_file, - dest=dest_file, + "image/jpeg", + source_file, + dest_file, ) self.assertGreater(len(manifest_bytes), 0) reader = Reader(dest_path) diff --git a/tests/test_unit_tests_threaded.py b/tests/test_unit_tests_threaded.py index 418e80f7..eab6de8d 100644 --- a/tests/test_unit_tests_threaded.py +++ b/tests/test_unit_tests_threaded.py @@ -2218,7 +2218,7 @@ def sign_file(filename, thread_id): 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, context=ctx) + builder = Builder(manifest_def, ctx) output = io.BytesIO(bytearray()) builder.sign(self.signer, mime_type, file, output) output.seek(0) @@ -2289,7 +2289,7 @@ async def async_sign_file(filename, thread_id): 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, context=ctx) + builder = Builder(manifest_def, ctx) output = io.BytesIO(bytearray()) builder.sign(self.signer, mime_type, file, output) output.seek(0) @@ -2335,7 +2335,7 @@ def test_parallel_manifest_writing(self): def write_manifest(manifest_def, output_stream, thread_id): ctx = Context() with open(self.testPath, "rb") as file: - builder = Builder(manifest_def, context=ctx) + builder = Builder(manifest_def, ctx) builder.sign(self.signer, "image/jpeg", file, output_stream) output_stream.seek(0) read_ctx = Context() @@ -2422,7 +2422,7 @@ def sign_file(filename, thread_id): thread_execution_order.append((current_count, thread_id)) time.sleep(0.01) ctx = Context() - builder = Builder(manifest_def, context=ctx) + builder = Builder(manifest_def, ctx) output = io.BytesIO(bytearray()) builder.sign(self.signer, mime_type, file, output) output.seek(0) @@ -2483,7 +2483,7 @@ def write_manifest(): try: ctx = Context() with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition_1, context=ctx) + builder = Builder(self.manifestDefinition_1, ctx) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) write_complete.set() @@ -2536,7 +2536,7 @@ def write_manifest(): try: ctx = Context() with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition_1, context=ctx) + builder = Builder(self.manifestDefinition_1, ctx) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) write_complete.set() @@ -2595,7 +2595,7 @@ def test_resource_contention_read(self): ctx = Context() with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition_1, context=ctx) + builder = Builder(self.manifestDefinition_1, ctx) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) @@ -2646,7 +2646,7 @@ def test_resource_contention_read_parallel(self): ctx = Context() with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition_1, context=ctx) + builder = Builder(self.manifestDefinition_1, ctx) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) @@ -2697,7 +2697,7 @@ def sign_file(output_stream, manifest_def, thread_id): try: ctx = Context() with open(self.testPath, "rb") as file: - builder = Builder(manifest_def, context=ctx) + builder = Builder(manifest_def, ctx) builder.sign(self.signer, "image/jpeg", file, output_stream) output_stream.seek(0) read_ctx = Context() @@ -2760,7 +2760,7 @@ async def write_manifest(): try: ctx = Context() with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition_1, context=ctx) + builder = Builder(self.manifestDefinition_1, ctx) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) write_success = True @@ -2819,7 +2819,7 @@ def test_resource_contention_read_parallel_async(self): ctx = Context() with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition_1, context=ctx) + builder = Builder(self.manifestDefinition_1, ctx) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) From 52889b073321f26b4645035b0c556b4847158864 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:15:31 -0800 Subject: [PATCH 43/75] Remove version vNext details from release notes Removed details about new features and deprecations for version vNext. --- docs/release-notes.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/docs/release-notes.md b/docs/release-notes.md index a51d63e4..24c4048a 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,21 +1,5 @@ # Release notes -## Version vNext - -New features: - -- **`Settings` class**: Per-instance configuration for C2PA operations. Supports `set()` with dot-notation paths, `from_json()`, `from_dict()`, `update()`, dict-like `[]` access, and method chaining. Replaces the global `load_settings()` function. -- **`Context` class**: Carries optional `Settings` and an optional `Signer` for `Reader` and `Builder` operations. Supports `from_json()` and `from_dict()` convenience constructors. When a `Signer` is provided, it is consumed (ownership is transferred to the context). -- **`ContextProvider` protocol**: A `runtime_checkable` protocol that allows third-party implementations of custom context providers. Both `Reader` and `Builder` accept `context` as a keyword-only parameter. -- **`Signer._release()` internal method**: Transfers ownership of the native signer pointer without freeing it, enabling the signer-on-context pattern. -- **`Builder.sign()` with optional signer**: The `signer` parameter is now optional. When omitted, the context's signer is used. Explicit signer always takes precedence over context signer. -- **`Builder.sign_file()` with optional signer**: The `signer` parameter is now optional, matching `sign()`. -- **`Reader` and `Builder` context integration**: Both accept `context=` keyword-only parameter. Reader uses `c2pa_reader_from_context` + `c2pa_reader_with_stream`. Builder uses `c2pa_builder_from_context` + `c2pa_builder_with_definition`. - -Deprecations: - -- **`load_settings()`** is deprecated with a `DeprecationWarning`. Use `Settings` and `Context` for per-instance configuration instead. The function remains fully functional for backward compatibility. - ## Version 0.6.0 From 42d52f213513ffb4e2f40bdf6dbd48cc3a40c631 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 21:54:40 -0800 Subject: [PATCH 44/75] fix: docs --- docs/usage.md | 220 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 161 insertions(+), 59 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 6aa74794..db6eacb5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -6,14 +6,19 @@ For complete working examples, see the [examples folder](https://github.com/cont ## Import -Import the objects needed from the API: +Import the classes needed from the API: + +```py +from c2pa import Builder, Reader, Signer, C2paSignerInfo, C2paSigningAlg +``` + +If you want to use per-instance configuration with `Context` and `Settings`: ```py -from c2pa import Builder, Reader, Signer, C2paSigningAlg, C2paSignerInfo from c2pa import Settings, Context, ContextBuilder, ContextProvider ``` -You can use both `Builder`, `Reader` and `Signer` classes with context managers by using a `with` statement. +All of `Builder`, `Reader`, `Signer`, `Context`, and `Settings` support context managers (the `with` statement) for automatic resource cleanup. ## Define manifest JSON @@ -41,18 +46,18 @@ manifest_json = json.dumps({ }) ``` -## File-based operation +## File-based operations ### Read and validate C2PA data -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. +Use the `Reader` to read C2PA data from a file. The Reader examines the 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`. NOTE: For a comprehensive reference to the JSON manifest structure, see the [Manifest store reference](https://opensource.contentauthenticity.org/docs/manifest/manifest-ref). +#### Reading without Context + ```py try: # Create a reader from a file path @@ -60,55 +65,75 @@ try: # Print manifest store as JSON print("Manifest store:", reader.json()) - # Get the active manifest. + # Get the active manifest manifest = json.loads(reader.json()) active_manifest = manifest["manifests"][manifest["active_manifest"]] if active_manifest: # Get the uri to the manifest's thumbnail and write it to a file uri = active_manifest["thumbnail"]["identifier"] - with open("thumbnail_v2.jpg", "wb") as f: + with open("thumbnail.jpg", "wb") as f: reader.resource_to_stream(uri, f) except Exception as err: print(err) ``` +#### Reading with Context + +Pass a `Context` to apply custom settings to the Reader, such as trust anchors or verification flags. + +```py +try: + settings = Settings.from_dict({ + "verify": {"verify_cert_anchors": True}, + "trust": {"trust_anchors": anchors_pem} + }) + + with Context(settings) as ctx: + with Reader("path/to/media_file.jpg", context=ctx) as reader: + print("Manifest store:", reader.json()) + +except Exception as err: + print(err) +``` + ### Add a signed manifest -**WARNING**: This example accesses the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as show in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). +**WARNING**: These examples access the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as shown in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). + +#### Signing without Context -Use a `Builder` to add a manifest to an asset: +Use a `Builder` and `Signer` to add a manifest to an asset: ```py try: - # Create a signer from certificate and key files + # Load certificate and key files with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: cert_data = cert_file.read() key_data = key_file.read() - # Create signer info using cert and key info + # Create signer info with the correct field names signer_info = C2paSignerInfo( alg=C2paSigningAlg.PS256, - cert=cert_data, - key=key_data, - timestamp_url="http://timestamp.digicert.com" + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" ) - # Create signer using the defined SignerInfo + # Create signer from the signer info 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) - # Sign the file - with open("path/to/source.jpg", "rb") as source_file, open("path/to/output.jpg", "wb") as dest_file: - manifest_bytes = builder.sign(signer, "image/jpeg", source_file, dest_file) + # Sign the file (dest must be opened in w+b mode) + with open("path/to/source.jpg", "rb") as source, open("path/to/output.jpg", "w+b") as dest: + builder.sign(signer, "image/jpeg", source, dest) - # Verify the signed file by reading data from the signed output file + # Verify the signed file with Reader("path/to/output.jpg") as reader: manifest_store = json.loads(reader.json()) active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] @@ -118,9 +143,46 @@ except Exception as e: print("Failed to sign manifest store: " + str(e)) ``` +#### Signing with Context + +Pass a `Context` to the Builder to apply custom settings during signing. The signer is still passed explicitly to `builder.sign()`. + +```py +try: + with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: + cert_data = cert_file.read() + key_data = key_file.read() + + signer_info = C2paSignerInfo( + alg=C2paSigningAlg.PS256, + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" + ) + + with Context() as ctx: + with Signer.from_info(signer_info) as signer: + with Builder(manifest_json, ctx) as builder: + with open("path/to/ingredient.jpg", "rb") as ingredient_file: + ingredient_json = json.dumps({"title": "Ingredient Image"}) + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) + + # Sign using file paths (convenience method) + builder.sign_file("path/to/source.jpg", "path/to/output.jpg", signer) + + # Verify the signed file with the same context + with Reader("path/to/output.jpg", context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + print("Signed manifest:", active_manifest) + +except Exception as e: + print("Failed to sign manifest store: " + str(e)) +``` + ## Settings, Context, and ContextProvider -The `Settings` and `Context` classes provide **per-instance configuration** for Reader and Builder operations. This replaces the global `load_settings()` function, which is now deprecated. +The `Settings` and `Context` classes provide per-instance configuration for Reader and Builder operations. This replaces the global `load_settings()` function, which is now deprecated. ```mermaid classDiagram @@ -225,11 +287,11 @@ ctx = Context(settings=settings) ctx = Context.from_json('{"builder": {"thumbnail": {"enabled": false}}}') ctx = Context.from_dict({"builder": {"thumbnail": {"enabled": False}}}) -# Use with Reader +# Use with Reader (keyword argument) reader = Reader("path/to/media_file.jpg", context=ctx) -# Use with Builder -builder = Builder(manifest_json, context=ctx) +# Use with Builder (positional or keyword argument) +builder = Builder(manifest_json, ctx) ``` ### ContextBuilder (fluent API) @@ -256,7 +318,7 @@ ctx = Context.builder().build() ### Context with a Signer -When a `Signer` is passed to `Context`, the `Signer` object becomes invalid after this call and must not be reused directly anymore as it became part of the Context. The `Context` takes ownership of the underlying native signer. This allows signing without passing an explicit signer to `Builder.sign()`. +When a `Signer` is passed to `Context`, the `Signer` object is consumed and must not be reused directly. The `Context` takes ownership of the underlying native signer. This allows signing without passing an explicit signer to `Builder.sign()`. ```py from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg @@ -274,22 +336,22 @@ signer = Signer.from_info(signer_info) ctx = Context(settings=settings, signer=signer) # The signer object is now invalid and must not be used directly again -# Build and sign: no signer argument needed, since the signer is in the context! -builder = Builder(manifest_json, context=ctx) +# Build and sign without passing a signer, since the signer is in the context +builder = Builder(manifest_json, ctx) with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: - manifest_bytes = builder.sign(format="image/jpeg", source=src, dest=dst) + manifest_bytes = builder.sign_with_context(format="image/jpeg", source=src, dest=dst) ``` -If both an explicit signer and a context signer are available, the explicit signer always takes precedence: +If both an explicit signer and a context signer are available, the explicit signer takes precedence: ```py -# Explicit signer wins over context signer and will be used for signing in this call +# Explicit signer wins over context signer manifest_bytes = builder.sign(explicit_signer, "image/jpeg", source, dest) ``` -### ContextProvider protocol +### ContextProvider (abstract base class) -The `ContextProvider` protocol allows third-party implementations of custom context providers. Any class that implements `is_valid` and `_c_context` properties satisfies the protocol and can be passed to `Reader` or `Builder` as `context`. +`ContextProvider` is an abstract base class (ABC) that allows third-party implementations of custom context providers. Any class that implements the `is_valid` and `execution_context` properties satisfies the interface and can be passed to `Reader` or `Builder` as `context`. ```py from c2pa import ContextProvider, Context @@ -319,74 +381,78 @@ ctx = Context(settings=settings) reader = Reader("file.jpg", context=ctx) ``` -## 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. +Instead of working with files, you can read, validate, and add a signed manifest to streamed data. ### Read and validate C2PA data using streams +#### Stream reading without Context + ```py try: - # Create a reader from a format and stream with open("path/to/media_file.jpg", "rb") as stream: - # First parameter should be the type of the file (here, we use the mimetype) - # But in any case we need something to identify the file type with Reader("image/jpeg", stream) as reader: - # Print manifest store as JSON, as extracted by the Reader - print("manifest store:", reader.json()) + print("Manifest store:", reader.json()) - # Get the active manifest manifest = json.loads(reader.json()) active_manifest = manifest["manifests"][manifest["active_manifest"]] if active_manifest: - # get the uri to the manifest's thumbnail and write it to a file uri = active_manifest["thumbnail"]["identifier"] - with open("thumbnail_v2.jpg", "wb") as f: + with open("thumbnail.jpg", "wb") as f: reader.resource_to_stream(uri, f) except Exception as err: print(err) ``` +#### Stream reading with Context + +```py +try: + settings = Settings.from_dict({"verify": {"verify_cert_anchors": True}}) + + with Context(settings) as ctx: + with open("path/to/media_file.jpg", "rb") as stream: + with Reader("image/jpeg", stream, context=ctx) as reader: + print("Manifest store:", reader.json()) + +except Exception as err: + print(err) +``` + ### Add a signed manifest to a stream -**WARNING**: This example accesses the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as show in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). +**WARNING**: These examples access the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as shown in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). -Use a `Builder` to add a manifest to an asset: +#### Stream signing without Context ```py try: - # Create a signer from certificate and key files with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: cert_data = cert_file.read() key_data = key_file.read() - # Create signer info using the read certificate and key data signer_info = C2paSignerInfo( alg=C2paSigningAlg.PS256, - cert=cert_data, - key=key_data, - timestamp_url="http://timestamp.digicert.com" + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" ) - # Create a Signer using the SignerInfo defined previously signer = Signer.from_info(signer_info) - # Create a Builder with manifest and add ingredients with Builder(manifest_json) as builder: - # Add any ingredients as needed with open("path/to/ingredient.jpg", "rb") as ingredient_file: ingredient_json = json.dumps({"title": "Ingredient Image"}) - # Here the ingredient is added using streams builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) - # Sign using streams - with open("path/to/source.jpg", "rb") as source_file, open("path/to/output.jpg", "wb") as dest_file: - manifest_bytes = builder.sign(signer, "image/jpeg", source_file, dest_file) + # Sign using streams (dest must be opened in w+b mode) + with open("path/to/source.jpg", "rb") as source, open("path/to/output.jpg", "w+b") as dest: + builder.sign(signer, "image/jpeg", source, dest) # Verify the signed file with open("path/to/output.jpg", "rb") as stream: - # Create a Reader to read data with Reader("image/jpeg", stream) as reader: manifest_store = json.loads(reader.json()) active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] @@ -395,3 +461,39 @@ try: except Exception as e: print("Failed to sign manifest store: " + str(e)) ``` + +#### Stream signing with Context + +```py +try: + with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: + cert_data = cert_file.read() + key_data = key_file.read() + + signer_info = C2paSignerInfo( + alg=C2paSigningAlg.PS256, + sign_cert=cert_data, + private_key=key_data, + ta_url=b"http://timestamp.digicert.com" + ) + + with Context() as ctx: + with Signer.from_info(signer_info) as signer: + with Builder(manifest_json, ctx) as builder: + with open("path/to/ingredient.jpg", "rb") as ingredient_file: + ingredient_json = json.dumps({"title": "Ingredient Image"}) + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) + + with open("path/to/source.jpg", "rb") as source, open("path/to/output.jpg", "w+b") as dest: + builder.sign(signer, "image/jpeg", source, dest) + + # Verify the signed file with the same context + with open("path/to/output.jpg", "rb") as stream: + with Reader("image/jpeg", stream, context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + print("Signed manifest:", active_manifest) + +except Exception as e: + print("Failed to sign manifest store: " + str(e)) +``` From 2bb9697f6067f35d90b6f49275afcbce5e3be0ce Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 5 Mar 2026 22:12:54 -0800 Subject: [PATCH 45/75] fix:signfile --- src/c2pa/c2pa.py | 2 +- tests/test_unit_tests.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index d3988ade..d58ee8a3 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -3749,7 +3749,7 @@ def sign_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) + return self.sign_with_context(mime_type, source_file, dest_file) except Exception as e: raise C2paError(f"Error signing file: {str(e)}") from e diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 0dade99c..812e2088 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -5606,6 +5606,42 @@ def test_contextual_builder_sign_no_signer_raises(self): 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() + class TestContextIntegration(TestContextAPIs): From f6ba198c358e70fb9363e030db9971510f52b1ca Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 09:32:08 -0800 Subject: [PATCH 46/75] fix: Clean up --- examples/training.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/training.py b/examples/training.py index c248b00d..3e76f2e5 100644 --- a/examples/training.py +++ b/examples/training.py @@ -90,7 +90,7 @@ def getitem(d, key): } } -# V2 signing API example using default Context and Settings. +# Signing API example (v2 claims) try: # Read the private key and certificate files with open(keyFile, "rb") as key_file: From de15a8b30997d0ef8220e4e7ef1bc48b202c05b3 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 09:38:22 -0800 Subject: [PATCH 47/75] fix: Clean up --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 0b689975..4798e55a 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,6 @@ Features: - Create and sign C2PA manifests using various signing algorithms. - Verify C2PA manifests and extract metadata. - Add assertions and ingredients to assets. -- Per-instance configuration via `Settings` and `Context` (replaces global `load_settings`). -- Embed a `Signer` in a `Context` for simplified signing workflows. -- `ContextProvider` protocol for custom third-party context implementations. - Examples and unit tests to demonstrate usage. ## Prerequisites From 5525809da754e85fc6f99d81e668b025f4c9fece Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 10:11:41 -0800 Subject: [PATCH 48/75] fix: Remove unused APIs --- ffi_improvs.md | 27 +++++++++++++++++++++++++++ src/c2pa/c2pa.py | 21 ++------------------- src/c2pa/lib.py | 2 +- 3 files changed, 30 insertions(+), 20 deletions(-) create mode 100644 ffi_improvs.md diff --git a/ffi_improvs.md b/ffi_improvs.md new file mode 100644 index 00000000..1a140759 --- /dev/null +++ b/ffi_improvs.md @@ -0,0 +1,27 @@ +# C FFI Improvement Opportunities + +The Rust C FFI (`c2pa_c_ffi`) exports ~70 functions. The Python layer wraps ~50. This document identifies concrete opportunities to better leverage the FFI. + +### BMFF Fragment Support + +`c2pa_reader_with_fragment(reader, format, stream, fragment_stream) -> *mut C2paReader` + +Allows reading fragmented BMFF media (e.g., fragmented MP4). Consume-and-return pattern like `c2pa_reader_with_stream`. + +### Context-Aware Manifest Data Reader + +`c2pa_reader_with_context_from_manifest_data_and_stream(reader, format, stream, manifest_data, manifest_len) -> *mut C2paReader` + +Context-aware variant of `c2pa_reader_from_manifest_data_and_stream`. Currently Python only has the non-context version, so passing context + manifest_data together is not possible. + +## Underutilized FFI Functions + +### `from_archive()` ignores context + +`Builder.from_archive()` (c2pa.py:3077-3116) accepts a `context` parameter but never uses it in the archive loading path. It always calls the old `c2pa_builder_from_archive()`. The FFI has `c2pa_builder_with_archive()` (c_api.rs:1310) for the context-based flow — a consume-and-return function designed for this purpose. + +## Not Gaps + +- **JSON parsing for field extraction** — The FFI doesn't offer field-specific getters (e.g., get_active_manifest). Python's approach of parsing `c2pa_reader_json()` output is the correct and only option. +- **`c2pa_reader_new()`** — Creates an empty reader. Python always creates readers with streams, so this isn't needed. +- **Error codes** — The FFI uses string-prefix error typing (`"ErrorType: message"`). Python already parses these into typed exceptions via `_raise_typed_c2pa_error()`. diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index d58ee8a3..b1e46cef 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -55,8 +55,6 @@ 'c2pa_builder_sign', 'c2pa_builder_sign_context', 'c2pa_manifest_bytes_free', - 'c2pa_builder_data_hashed_placeholder', - 'c2pa_builder_sign_data_hashed_embeddable', 'c2pa_format_embeddable', 'c2pa_signer_create', 'c2pa_signer_from_info', @@ -596,21 +594,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( @@ -3645,8 +3628,7 @@ def _sign_common( " a signer." ) finally: - if not dest: - dest_stream.close() + dest_stream.close() finally: source_stream.close() @@ -3938,6 +3920,7 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'C2paDigitalSourceType', 'C2paSignerInfo', 'C2paBuilderIntent', + 'ContextBuilder', 'ContextProvider', 'Settings', 'Context', 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: From 75c4ff7b2340d84082828c66ca9a567b161fa263 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 13:52:57 -0800 Subject: [PATCH 49/75] fix: Remove unused APIs, with_archive added --- ffi_improvs.md | 54 +++++++++++--- src/c2pa/c2pa.py | 70 +++++++++++++----- tests/test_unit_tests.py | 154 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 244 insertions(+), 34 deletions(-) diff --git a/ffi_improvs.md b/ffi_improvs.md index 1a140759..08d235d5 100644 --- a/ffi_improvs.md +++ b/ffi_improvs.md @@ -2,23 +2,59 @@ The Rust C FFI (`c2pa_c_ffi`) exports ~70 functions. The Python layer wraps ~50. This document identifies concrete opportunities to better leverage the FFI. -### BMFF Fragment Support +### Release Library Verification -`c2pa_reader_with_fragment(reader, format, stream, fragment_stream) -> *mut C2paReader` +| FFI Function | In Release Library? | +|---|---| +| `c2pa_builder_with_archive` | YES | +| `c2pa_reader_with_fragment` | YES | +| `c2pa_reader_with_context_from_manifest_data_and_stream` | NO | -Allows reading fragmented BMFF media (e.g., fragmented MP4). Consume-and-return pattern like `c2pa_reader_with_stream`. +--- -### Context-Aware Manifest Data Reader +## 1. Add `Builder.with_archive()` instance method -`c2pa_reader_with_context_from_manifest_data_and_stream(reader, format, stream, manifest_data, manifest_len) -> *mut C2paReader` +The FFI exports `c2pa_builder_with_archive(builder, stream) -> *mut C2paBuilder` — a consume-and-return function that loads an archive into an existing builder, preserving its context/settings. Python has no equivalent. The existing `from_archive()` is a static factory that creates a context-free builder. -Context-aware variant of `c2pa_reader_from_manifest_data_and_stream`. Currently Python only has the non-context version, so passing context + manifest_data together is not possible. +Mirrors the C++ API in `contentauth/c2pa-c`: -## Underutilized FFI Functions +- `Builder::from_archive(stream)` — static factory, no context +- `Builder::with_archive(stream)` — instance method, preserves context, returns `*this` -### `from_archive()` ignores context +**Add:** `Builder.with_archive(stream)` instance method. Leave `from_archive()` unchanged. -`Builder.from_archive()` (c2pa.py:3077-3116) accepts a `context` parameter but never uses it in the archive loading path. It always calls the old `c2pa_builder_from_archive()`. The FFI has `c2pa_builder_with_archive()` (c_api.rs:1310) for the context-based flow — a consume-and-return function designed for this purpose. +**Breaking change:** Remove `context` parameter from `from_archive()`. It currently accepts context but never uses it in the archive loading path (always calls `c2pa_builder_from_archive`). Two existing tests pass context to `from_archive()` — these must be migrated to use `with_archive()` instead. + +### Existing tests to migrate + +These tests currently call `Builder.from_archive(archive, context)` — they must switch to the `with_archive()` API: + +- `test_archive_sign_trusted_via_context` (line 5927) → use `Builder({}, context).with_archive(archive)` +- `test_archive_sign_with_ingredient_trusted_via_context` (line 5964) → same migration + +### Tests for `Builder.with_archive()` + +In `TestBuilderWithContext` class. Mirrors C++ tests from `contentauth/c2pa-c` (`LoadArchiveWithContext`, `ArchiveRoundTripSettingsBehavior`) and existing `test_archive_sign*` patterns. + +#### New tests + +Tests 1-2 replace the migrated tests. Tests 3-7 cover new behavior. + +Pruned: `test_with_archive_sign` and `test_with_archive_sign_with_added_ingredient` (without trust) would be redundant — test 1 already covers the basic archive→sign flow, and test 5 covers definition replacement. The non-trust `test_archive_sign*` variants in `TestBuilderWithSigner` already cover `from_archive` round-trips without trust and remain unchanged. + +1. **`test_with_archive_sign_trusted_via_context`** (replaces existing) — Builder with trust context, `to_archive()`, new Builder with context, `with_archive()`, sign, verify "Trusted" + +2. **`test_with_archive_sign_with_ingredient_trusted_via_context`** (replaces existing) — Same as above but with added ingredient after `with_archive()` + +3. **`test_with_archive_preserves_settings`** (mirrors C++ `LoadArchiveWithContext`) — Builder with context that disables thumbnails, `to_archive()`, new Builder with same no-thumbnail context, `with_archive()`, sign, verify manifest has **no** `thumbnail` key + +4. **`test_with_archive_returns_self`** — `result = builder.with_archive(stream)`, assert `result is builder` + +5. **`test_with_archive_replaces_definition`** — Builder with title "Original" → archive, new Builder with title "Replaced" + context → `with_archive()`, sign, verify "Original" in output + +6. **`test_with_archive_on_closed_builder_raises`** — Close builder, assert `with_archive()` raises `C2paError` + +7. **`test_from_archive_roundtrip`** (mirrors C++ `ArchiveRoundTripSettingsBehavior`) — Builder with no-thumbnail context → archive → `from_archive(archive)` (no context) → sign, verify manifest **has** thumbnail (settings lost). Characterization test. ## Not Gaps diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index b1e46cef..8481dbb3 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -78,6 +78,7 @@ 'c2pa_reader_with_stream', 'c2pa_builder_from_context', 'c2pa_builder_with_definition', + 'c2pa_builder_with_archive', 'c2pa_free', ] @@ -559,6 +560,10 @@ def _setup_function(func, argtypes, restype=None): _setup_function(_lib.c2pa_builder_from_archive, [ctypes.POINTER(C2paStream)], ctypes.POINTER(C2paBuilder)) +_setup_function( + _lib.c2pa_builder_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( @@ -3041,41 +3046,29 @@ def from_json( """ return cls(manifest_json, context=context) - @classmethod - @overload - def from_archive( - cls, - stream: Any, - ) -> 'Builder': ... - - @classmethod - @overload - def from_archive( - cls, - stream: Any, - context: 'ContextProvider', - ) -> 'Builder': ... - @classmethod def from_archive( cls, stream: Any, - context: Optional['ContextProvider'] = None, ) -> 'Builder': """Create a new Builder from an archive stream. + This creates a context-free builder. To preserve context + settings, 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) - context: Optional ContextProvider for settings Returns: A new Builder instance 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({}, context=context) + builder = cls({}) stream_obj = Stream(stream) try: @@ -3498,6 +3491,45 @@ def to_archive(self, stream: Any) -> None: ) ) + def with_archive(self, stream: Any) -> 'Builder': + """Load an archive into this builder, replacing its definition. + + This preserves the builder's context and settings while + replacing the manifest definition with the archived state. + Use this instead of from_archive() when you need to + preserve context settings. + + Args: + stream: The stream containing the archive + (any Python stream-like object) + + Returns: + This builder instance, for method chaining. + + Raises: + C2paError: If there was an error loading the archive + """ + self._ensure_valid_state() + + with Stream(stream) as stream_obj: + new_ptr = _lib.c2pa_builder_with_archive( + self._handle, stream_obj._stream, + ) + # self._handle has been consumed by the FFI call + if not new_ptr: + self._handle = None + error = _parse_operation_result_for_error( + _lib.c2pa_error() + ) + if error: + raise C2paError(error) + raise C2paError( + "Failed to load archive into builder" + ) + self._handle = new_ptr + + return self + def _sign_internal( self, format: str, diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 812e2088..56e64093 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -5642,6 +5642,150 @@ def test_sign_file_no_signer_raises(self): builder.close() context.close() + def test_with_archive_preserves_settings(self): + """with_archive() preserves context settings (e.g. no thumbnail).""" + 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) + + builder2 = Builder({}, context=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_returns_self(self): + """with_archive() returns the same builder instance.""" + context = Context() + builder = Builder( + self.test_manifest, context=context, + ) + archive = io.BytesIO(bytearray()) + builder.to_archive(archive) + + builder2 = Builder({}, context=context) + result = builder2.with_archive(archive) + self.assertIs(result, builder2) + builder2.close() + context.close() + + def test_with_archive_replaces_definition(self): + """with_archive() replaces the builder's manifest definition.""" + context = Context() + signer = self._ctx_make_signer() + original_manifest = dict(self.test_manifest) + original_manifest["title"] = "Original Title" + builder = Builder(original_manifest, context=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=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() loses context settings (characterization test).""" + 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 loses settings, so default thumbnail + # generation should produce a thumbnail + 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): @@ -5934,9 +6078,8 @@ def test_archive_sign_trusted_via_context(self): ) archive = io.BytesIO(bytearray()) builder.to_archive(archive) - builder = Builder.from_archive( - archive, context, - ) + builder = Builder({}, context=context) + builder.with_archive(archive) with ( open(DEFAULT_TEST_FILE, "rb") as source, io.BytesIO(bytearray()) as output, @@ -5971,9 +6114,8 @@ def test_archive_sign_with_ingredient_trusted_via_context(self): ) archive = io.BytesIO(bytearray()) builder.to_archive(archive) - builder = Builder.from_archive( - archive, context, - ) + builder = Builder({}, context=context) + builder.with_archive(archive) ingredient_json = '{"test": "ingredient"}' with open(DEFAULT_TEST_FILE, "rb") as f: builder.add_ingredient( From 903369829f489c8a1fb8e2300559eac824d708a2 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 14:41:54 -0800 Subject: [PATCH 50/75] fix: Clean up notes and tests --- ffi_improvs.md | 63 ---------------------------------------- src/c2pa/c2pa.py | 13 ++++----- tests/test_unit_tests.py | 41 +++++++++++--------------- 3 files changed, 23 insertions(+), 94 deletions(-) delete mode 100644 ffi_improvs.md diff --git a/ffi_improvs.md b/ffi_improvs.md deleted file mode 100644 index 08d235d5..00000000 --- a/ffi_improvs.md +++ /dev/null @@ -1,63 +0,0 @@ -# C FFI Improvement Opportunities - -The Rust C FFI (`c2pa_c_ffi`) exports ~70 functions. The Python layer wraps ~50. This document identifies concrete opportunities to better leverage the FFI. - -### Release Library Verification - -| FFI Function | In Release Library? | -|---|---| -| `c2pa_builder_with_archive` | YES | -| `c2pa_reader_with_fragment` | YES | -| `c2pa_reader_with_context_from_manifest_data_and_stream` | NO | - ---- - -## 1. Add `Builder.with_archive()` instance method - -The FFI exports `c2pa_builder_with_archive(builder, stream) -> *mut C2paBuilder` — a consume-and-return function that loads an archive into an existing builder, preserving its context/settings. Python has no equivalent. The existing `from_archive()` is a static factory that creates a context-free builder. - -Mirrors the C++ API in `contentauth/c2pa-c`: - -- `Builder::from_archive(stream)` — static factory, no context -- `Builder::with_archive(stream)` — instance method, preserves context, returns `*this` - -**Add:** `Builder.with_archive(stream)` instance method. Leave `from_archive()` unchanged. - -**Breaking change:** Remove `context` parameter from `from_archive()`. It currently accepts context but never uses it in the archive loading path (always calls `c2pa_builder_from_archive`). Two existing tests pass context to `from_archive()` — these must be migrated to use `with_archive()` instead. - -### Existing tests to migrate - -These tests currently call `Builder.from_archive(archive, context)` — they must switch to the `with_archive()` API: - -- `test_archive_sign_trusted_via_context` (line 5927) → use `Builder({}, context).with_archive(archive)` -- `test_archive_sign_with_ingredient_trusted_via_context` (line 5964) → same migration - -### Tests for `Builder.with_archive()` - -In `TestBuilderWithContext` class. Mirrors C++ tests from `contentauth/c2pa-c` (`LoadArchiveWithContext`, `ArchiveRoundTripSettingsBehavior`) and existing `test_archive_sign*` patterns. - -#### New tests - -Tests 1-2 replace the migrated tests. Tests 3-7 cover new behavior. - -Pruned: `test_with_archive_sign` and `test_with_archive_sign_with_added_ingredient` (without trust) would be redundant — test 1 already covers the basic archive→sign flow, and test 5 covers definition replacement. The non-trust `test_archive_sign*` variants in `TestBuilderWithSigner` already cover `from_archive` round-trips without trust and remain unchanged. - -1. **`test_with_archive_sign_trusted_via_context`** (replaces existing) — Builder with trust context, `to_archive()`, new Builder with context, `with_archive()`, sign, verify "Trusted" - -2. **`test_with_archive_sign_with_ingredient_trusted_via_context`** (replaces existing) — Same as above but with added ingredient after `with_archive()` - -3. **`test_with_archive_preserves_settings`** (mirrors C++ `LoadArchiveWithContext`) — Builder with context that disables thumbnails, `to_archive()`, new Builder with same no-thumbnail context, `with_archive()`, sign, verify manifest has **no** `thumbnail` key - -4. **`test_with_archive_returns_self`** — `result = builder.with_archive(stream)`, assert `result is builder` - -5. **`test_with_archive_replaces_definition`** — Builder with title "Original" → archive, new Builder with title "Replaced" + context → `with_archive()`, sign, verify "Original" in output - -6. **`test_with_archive_on_closed_builder_raises`** — Close builder, assert `with_archive()` raises `C2paError` - -7. **`test_from_archive_roundtrip`** (mirrors C++ `ArchiveRoundTripSettingsBehavior`) — Builder with no-thumbnail context → archive → `from_archive(archive)` (no context) → sign, verify manifest **has** thumbnail (settings lost). Characterization test. - -## Not Gaps - -- **JSON parsing for field extraction** — The FFI doesn't offer field-specific getters (e.g., get_active_manifest). Python's approach of parsing `c2pa_reader_json()` output is the correct and only option. -- **`c2pa_reader_new()`** — Creates an empty reader. Python always creates readers with streams, so this isn't needed. -- **Error codes** — The FFI uses string-prefix error typing (`"ErrorType: message"`). Python already parses these into typed exceptions via `_raise_typed_c2pa_error()`. diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 8481dbb3..1e03a108 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -3492,16 +3492,15 @@ def to_archive(self, stream: Any) -> None: ) def with_archive(self, stream: Any) -> 'Builder': - """Load an archive into this builder, replacing its definition. - - This preserves the builder's context and settings while - replacing the manifest definition with the archived state. - Use this instead of from_archive() when you need to - preserve context settings. + """Load an archive into this builder, replacing its + manifest definition. The archive carries only the + definition, not settings — settings come from this + builder's context, which is preserved across the call. + Use this instead of from_archive() when you need + context-based settings. Args: stream: The stream containing the archive - (any Python stream-like object) Returns: This builder instance, for method chaining. diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 56e64093..f81c6652 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -5643,7 +5643,13 @@ def test_sign_file_no_signer_raises(self): context.close() def test_with_archive_preserves_settings(self): - """with_archive() preserves context settings (e.g. no thumbnail).""" + """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} @@ -5652,12 +5658,14 @@ def test_with_archive_preserves_settings(self): context = Context(settings) signer = self._ctx_make_signer() builder = Builder( - self.test_manifest, context=context, + self.test_manifest, context, ) archive = io.BytesIO(bytearray()) builder.to_archive(archive) - builder2 = Builder({}, context=context) + # 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, @@ -5682,34 +5690,20 @@ def test_with_archive_preserves_settings(self): context.close() settings.close() - def test_with_archive_returns_self(self): - """with_archive() returns the same builder instance.""" - context = Context() - builder = Builder( - self.test_manifest, context=context, - ) - archive = io.BytesIO(bytearray()) - builder.to_archive(archive) - - builder2 = Builder({}, context=context) - result = builder2.with_archive(archive) - self.assertIs(result, builder2) - builder2.close() - context.close() - def test_with_archive_replaces_definition(self): - """with_archive() replaces the builder's manifest definition.""" + """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=context) + 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=context) + builder2 = Builder(replaced_manifest, context) builder2.with_archive(archive) with ( open(DEFAULT_TEST_FILE, "rb") as source, @@ -5745,7 +5739,7 @@ def test_with_archive_on_closed_builder_raises(self): context.close() def test_from_archive_roundtrip(self): - """from_archive() loses context settings (characterization test).""" + """from_archive() can't propagate contexts.""" settings = Settings.from_dict({ "builder": { "thumbnail": {"enabled": False} @@ -5773,8 +5767,7 @@ def test_from_archive_roundtrip(self): "image/jpeg", output, context=Context(), ) manifest = reader.get_active_manifest() - # from_archive loses settings, so default thumbnail - # generation should produce a thumbnail + # from_archive can't propagate contexts self.assertIsNotNone( manifest.get("thumbnail"), "from_archive should lose settings and generate thumbnail", From d35489eec6a02b041127d075bdda943bd1e1aa1e Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Sat, 7 Mar 2026 21:03:43 -0800 Subject: [PATCH 51/75] fix: Double free --- docs/usage.md | 2 +- src/c2pa/c2pa.py | 64 +++++++++++++++++++++++++--------------- tests/test_unit_tests.py | 26 ++++++++-------- 3 files changed, 54 insertions(+), 38 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index db6eacb5..51587725 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -339,7 +339,7 @@ ctx = Context(settings=settings, signer=signer) # Build and sign without passing a signer, since the signer is in the context builder = Builder(manifest_json, ctx) with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: - manifest_bytes = builder.sign_with_context(format="image/jpeg", source=src, dest=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: diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 1e03a108..cd1d7577 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -247,6 +247,15 @@ def _release(self): 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._state = LifecycleState.CLOSED + def _cleanup_resources(self): """Release native resources idempotently.""" try: @@ -3584,7 +3593,10 @@ def _sign_internal( dest_stream._stream, ctypes.byref(manifest_bytes_ptr), ) + # Builder pointer consumed by Rust FFI — prevent double-free + self._mark_consumed() except Exception as e: + self._mark_consumed() raise C2paError(f"Error during signing: {e}") if result < 0: @@ -3622,7 +3634,7 @@ def _sign_common( source: Any, dest: Any = None, ) -> bytes: - """Shared signing logic for sign() and sign_with_context(). + """Shared signing logic for sign(). Args: signer: The signer to use, or None for context signer. @@ -3665,41 +3677,40 @@ def _sign_common( return manifest_bytes + @overload def sign( self, signer: Signer, format: str, source: Any, dest: Any = None, - ) -> bytes: - """Sign the builder's content with an explicit signer. - - Args: - signer: The signer to use. - format: The MIME type of the content. - source: The source stream. - dest: The destination stream (optional). - - Returns: - Manifest bytes - - Raises: - C2paError: If there was an error during signing - """ - return self._sign_common(signer, format, source, dest) + ) -> bytes: ... - def sign_with_context( + @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 using the context's signer. + """Sign the builder's content. - The builder must have been created with a Context - that has a signer. + 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: + 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). @@ -3710,7 +3721,14 @@ def sign_with_context( Raises: C2paError: If there was an error during signing """ - return self._sign_common(None, format, source, 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: + raise C2paError( + "First argument must be a Signer or a format string (MIME type)." + ) @overload def sign_file( @@ -3762,7 +3780,7 @@ def sign_file( if signer is not None: return self.sign(signer, mime_type, source_file, dest_file) # else: - return self.sign_with_context(mime_type, source_file, dest_file) + return self.sign(mime_type, source_file, dest_file) except Exception as e: raise C2paError(f"Error signing file: {str(e)}") from e diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index f81c6652..21dc7606 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -3333,13 +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.assertEqual(builder._state, LifecycleState.ACTIVE) - self.assertIsNotNone(builder._handle) - - # 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._state, LifecycleState.CLOSED) + self.assertIsNone(builder._handle) def test_builder_state_after_archive_operations(self): """Test Builder state after archive operations.""" @@ -5054,10 +5050,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 @@ -5549,7 +5547,7 @@ def test_contextual_builder_sign_context_signer(self): open(DEFAULT_TEST_FILE, "rb") as source_file, open(dest_path, "w+b") as dest_file, ): - manifest_bytes = builder.sign_with_context( + manifest_bytes = builder.sign( "image/jpeg", source_file, dest_file, @@ -5598,7 +5596,7 @@ def test_contextual_builder_sign_no_signer_raises(self): open(dest_path, "w+b") as dest_file, ): with self.assertRaises(Error): - builder.sign_with_context( + builder.sign( "image/jpeg", source_file, dest_file, @@ -5825,7 +5823,7 @@ def test_sign_read_roundtrip(self): open(DEFAULT_TEST_FILE, "rb") as source_file, open(dest_path, "w+b") as dest_file, ): - builder.sign_with_context( + builder.sign( "image/jpeg", source_file, dest_file, @@ -6150,7 +6148,7 @@ def test_sign_callback_signer_in_ctx(self): open(DEFAULT_TEST_FILE, "rb") as source_file, open(dest_path, "w+b") as dest_file, ): - manifest_bytes = builder.sign_with_context( + manifest_bytes = builder.sign( "image/jpeg", source_file, dest_file, From 4920322351a976bd0c5ef08a4fc089d7b2648677 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Sun, 8 Mar 2026 17:22:10 -0700 Subject: [PATCH 52/75] fix: Wording and examples --- src/c2pa/c2pa.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index cd1d7577..392d0f29 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -2202,7 +2202,7 @@ def __init__( 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 + context: Optional context implementing ContextProvider with settings Raises: C2paError: If there was an error creating the reader @@ -2224,7 +2224,6 @@ def __init__( self._manifest_json_str_cache = None self._manifest_data_cache = None - # Keep context reference alive self._context = context if context is not None: @@ -2269,8 +2268,6 @@ def _create_reader(self, format_bytes, stream_obj, manifest_data=None): """Create a native reader from a Stream. - Calls the appropriate FFI function and raises on failure. - Args: format_bytes: UTF-8 encoded format/MIME type stream_obj: A Stream instance @@ -2344,9 +2341,8 @@ def _init_from_file(self, path, format_bytes, def _init_from_context(self, context, format_or_path, stream): - """Initialize Reader from a ContextProvider. - - Uses c2pa_reader_from_context + c2pa_reader_with_stream. + """Initialize Reader from a context object implementing + the ContextProvider interface/abstract base class. """ if not context.is_valid: raise C2paError("Context is not valid") @@ -2941,7 +2937,7 @@ def _transfer_ownership(self): Tuple of (signer_ptr, callback_cb): signer_ptr: The native C2paSigner pointer. callback_cb: The callback reference (if any). - The caller must store this to prevent GC. + The caller must store this to prevent garbage collection. Raises: C2paError: If the signer is already closed. @@ -2951,7 +2947,7 @@ def _transfer_ownership(self): ptr = self._handle callback_cb = self._callback_cb - # Detach pointer without freeing — caller now owns it + # Detach pointer without freeing, caller now owns it self._handle = None self._callback_cb = None self._state = LifecycleState.CLOSED @@ -3134,7 +3130,6 @@ def __init__( super().__init__() _clear_error_state() - # Keep context reference alive self._context = context self._has_context_signer = ( context is not None From b06f6d99c7463328d5aa2c7d75e46798952b5675 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Sun, 8 Mar 2026 17:33:22 -0700 Subject: [PATCH 53/75] fix: Ownership handling --- src/c2pa/c2pa.py | 59 +++++++++++++++++------------------------------- 1 file changed, 21 insertions(+), 38 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 392d0f29..12861a45 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1553,21 +1553,15 @@ def __init__( _parse_operation_result_for_error(None) if signer is not None: - signer_ptr, callback_cb = ( - signer._transfer_ownership() - ) - self._signer_callback_cb = ( - callback_cb - ) + signer._ensure_valid_state() result = ( _lib .c2pa_context_builder_set_signer( - builder_ptr, signer_ptr, + builder_ptr, signer._handle, ) ) if result != 0: _parse_operation_result_for_error(None) - self._has_signer = True # Build consumes builder_ptr ptr = ( @@ -1583,6 +1577,17 @@ def __init__( "Failed to build Context" ) self._handle = ptr + + # Build succeeded — consume the signer. + # Keep its callback ref alive on this Context, + # then mark it so it won't double-free the + # native 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: @@ -1594,6 +1599,10 @@ def __init__( self._state = LifecycleState.ACTIVE + def _release(self): + """Release Context-specific resources.""" + self._signer_callback_cb = None + @classmethod def builder(cls) -> 'ContextBuilder': """Return a fluent ContextBuilder.""" @@ -2209,9 +2218,10 @@ def __init__( C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters """ + super().__init__() + # Native libs plumbing: # Clear any stale error state from previous operations - super().__init__() _clear_error_state() self._own_stream = None @@ -2926,34 +2936,6 @@ def _release(self): if self._callback_cb: self._callback_cb = None - def _transfer_ownership(self): - """Release ownership of the native signer pointer. - - After this call the Signer is marked closed and must - not be used. The caller takes ownership of the - returned pointer and is responsible for its lifetime. - - Returns: - Tuple of (signer_ptr, callback_cb): - signer_ptr: The native C2paSigner pointer. - callback_cb: The callback reference (if any). - The caller must store this to prevent garbage collection. - - Raises: - C2paError: If the signer is already closed. - """ - self._ensure_valid_state() - - ptr = self._handle - callback_cb = self._callback_cb - - # Detach pointer without freeing, caller now owns it - self._handle = None - self._callback_cb = None - self._state = LifecycleState.CLOSED - - return ptr, callback_cb - def reserve_size(self) -> int: """Get the size to reserve for signatures from this signer. @@ -3125,9 +3107,10 @@ def __init__( C2paError.Encoding: If manifest JSON contains invalid UTF-8 chars C2paError.Json: If the manifest JSON cannot be serialized """ + super().__init__() + # Native libs plumbing: # Clear any stale error state from previous operations - super().__init__() _clear_error_state() self._context = context From 67d699c0d54db04836ddf105b24c05411205ac74 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Sun, 8 Mar 2026 17:44:11 -0700 Subject: [PATCH 54/75] fix: Improve resources handling --- src/c2pa/c2pa.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 12861a45..d9fa223d 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -239,6 +239,7 @@ def _ensure_valid_state(self): raise C2paError(f"{name} is not properly initialized") if not self._handle: raise C2paError(f"{name} is closed") + _clear_error_state() def _release(self): """Override to free class-specific resources (streams, caches, etc.). @@ -1386,7 +1387,6 @@ def set(self, path: str, value: str) -> 'Settings': self, for method chaining. """ self._ensure_valid_state() - _clear_error_state() try: path_bytes = path.encode('utf-8') @@ -1417,7 +1417,6 @@ def update( self, for method chaining. """ self._ensure_valid_state() - _clear_error_state() if isinstance(data, dict): data = json.dumps(data) @@ -1867,6 +1866,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, @@ -1997,6 +1997,7 @@ def _get_supported_mime_types(ffi_func, cache): if cache is not None: return list(cache), cache + _clear_error_state() count = ctypes.c_size_t() arr = ffi_func(ctypes.byref(count)) From 006d2ab06b27445a9dd1e0a6aeefc3e6f24b6dca Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 9 Mar 2026 09:54:25 -0700 Subject: [PATCH 55/75] fix: Docs --- docs/usage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage.md b/docs/usage.md index 51587725..7f4bf238 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -6,7 +6,7 @@ For complete working examples, see the [examples folder](https://github.com/cont ## Import -Import the classes needed from the API: +Import the objects needed from the API: ```py from c2pa import Builder, Reader, Signer, C2paSignerInfo, C2paSigningAlg From a0d7c027ad2b9516637e907dcb459d1525cda8a6 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 9 Mar 2026 10:17:39 -0700 Subject: [PATCH 56/75] fix: refactor & docs --- docs/usage.md | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 7f4bf238..63a7d87b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -9,7 +9,7 @@ For complete working examples, see the [examples folder](https://github.com/cont Import the objects needed from the API: ```py -from c2pa import Builder, Reader, Signer, C2paSignerInfo, C2paSigningAlg +from c2pa import Builder, Reader, Signer, C2paSigningAlg, C2paSignerInfo ``` If you want to use per-instance configuration with `Context` and `Settings`: @@ -46,11 +46,13 @@ manifest_json = json.dumps({ }) ``` -## File-based operations +## File-based operation ### Read and validate C2PA data -Use the `Reader` to read C2PA data from a file. The Reader examines the 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. +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`. @@ -60,16 +62,16 @@ NOTE: For a comprehensive reference to the JSON manifest structure, see the [Man ```py try: - # Create a reader from a file path + # Create a Reader from a file path. with Reader("path/to/media_file.jpg") as reader: - # Print manifest store as JSON + # Print manifest store as JSON. print("Manifest store:", reader.json()) - # Get the active manifest + # Get the active manifest. manifest = json.loads(reader.json()) active_manifest = manifest["manifests"][manifest["active_manifest"]] if active_manifest: - # Get the uri to the manifest's thumbnail and write it to a file + # Get the uri to the manifest's thumbnail and write it to a file. uri = active_manifest["thumbnail"]["identifier"] with open("thumbnail.jpg", "wb") as f: reader.resource_to_stream(uri, f) @@ -99,7 +101,7 @@ except Exception as err: ### Add a signed manifest -**WARNING**: These examples access the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as shown in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). +**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). #### Signing without Context @@ -120,7 +122,7 @@ try: ta_url=b"http://timestamp.digicert.com" ) - # Create signer from the signer info + # Create signer using the defined SignerInfo signer = Signer.from_info(signer_info) # Create builder with manifest and add ingredients @@ -133,7 +135,7 @@ try: 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 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"]] @@ -167,7 +169,7 @@ try: ingredient_json = json.dumps({"title": "Ingredient Image"}) builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) - # Sign using file paths (convenience method) + # Sign using file paths builder.sign_file("path/to/source.jpg", "path/to/output.jpg", signer) # Verify the signed file with the same context @@ -364,7 +366,7 @@ assert isinstance(ctx, ContextProvider) ### Migrating from load_settings The `load_settings()` function that set settings in a thread-local fashion is deprecated. -Replace it with `Settings` and `Context` usage to propagate configurations: +Replace it with `Settings` and `Context` usage to propagate configurations (do not mix legacy and new APIs): ```py # Before: @@ -383,7 +385,7 @@ reader = Reader("file.jpg", context=ctx) ## Stream-based operations -Instead of working with files, you can read, validate, and add a signed manifest to streamed data. +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 From 30dda7b8f07171885ca60c7fac825de1d16b3841 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 9 Mar 2026 10:19:44 -0700 Subject: [PATCH 57/75] fix: refactor & docs --- examples/read.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/read.py b/examples/read.py index d0032205..e4b718a9 100644 --- a/examples/read.py +++ b/examples/read.py @@ -11,7 +11,7 @@ def load_trust_anchors(): - """Load trust anchors and return a Settings object configured for trust validation.""" + """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') @@ -32,6 +32,8 @@ def read_c2pa_data(media_path: str): print(f"Reading {media_path}") try: 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()) From b39a80937ce337745d16828d9479cea89f707504 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 9 Mar 2026 10:29:10 -0700 Subject: [PATCH 58/75] fix: refactor & docs --- examples/training.py | 78 +++++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/examples/training.py b/examples/training.py index 3e76f2e5..2bb446ce 100644 --- a/examples/training.py +++ b/examples/training.py @@ -100,33 +100,35 @@ def getitem(d, key): # Create a signer using the new API signer_info = c2pa.C2paSignerInfo( - b"ps256", - certs, - key, - b"http://timestamp.digicert.com" + alg=b"ps256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" ) - with c2pa.Context() as context: - with c2pa.Signer.from_info(signer_info) as signer: - with 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) + 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}") @@ -138,22 +140,24 @@ 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 default Context - with c2pa.Context() as context: - with c2pa.Reader(testOutputFile, context=context) as reader: - # Retrieve the manifest store - manifest_store = json.loads(reader.json()) - - # Look at data in the active manifest - manifest = manifest_store["manifests"][manifest_store["active_manifest"]] - for assertion in manifest["assertions"]: - if assertion["label"] == "cawg.training-mining": - if getitem(assertion, ("data","entries","cawg.ai_generative_training","use")) == "notAllowed": - allowed = False - - # Get the ingredient thumbnail and save it to a file using resource_to_stream - uri = getitem(manifest,("ingredients", 0, "thumbnail", "identifier")) - with open(output_dir + "thumbnail_v2.jpg", "wb") as thumbnail_output: - reader.resource_to_stream(uri, thumbnail_output) + with ( + c2pa.Context() as context, + c2pa.Reader(testOutputFile, context=context) as reader, + ): + # Retrieve the manifest store + manifest_store = json.loads(reader.json()) + + # Look at data in the active manifest + manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + for assertion in manifest["assertions"]: + if assertion["label"] == "cawg.training-mining": + if getitem(assertion, ("data","entries","cawg.ai_generative_training","use")) == "notAllowed": + allowed = False + + # Get the ingredient thumbnail and save it to a file using resource_to_stream + uri = getitem(manifest,("ingredients", 0, "thumbnail", "identifier")) + with open(output_dir + "thumbnail_v2.jpg", "wb") as thumbnail_output: + reader.resource_to_stream(uri, thumbnail_output) except Exception as err: print(f"Exception during assertions reading: {err}") From 4dd81982f827d5947c9f661dbb2f450c03ea3d6f Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 9 Mar 2026 10:33:11 -0700 Subject: [PATCH 59/75] fix: refactor --- src/c2pa/c2pa.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index d9fa223d..dd8777de 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -18,8 +18,8 @@ import sys import os import warnings -from pathlib import Path from abc import ABC, abstractmethod +from pathlib import Path from typing import Optional, Union, Callable, Any, overload import io from .lib import dynamically_load_library From 58595ea53927d97f30b72ecc48e58eb64fe09b36 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 9 Mar 2026 11:30:20 -0700 Subject: [PATCH 60/75] fix: refactor --- tests/test_unit_tests_threaded.py | 309 ------------------------------ 1 file changed, 309 deletions(-) diff --git a/tests/test_unit_tests_threaded.py b/tests/test_unit_tests_threaded.py index eab6de8d..bfb54c8f 100644 --- a/tests/test_unit_tests_threaded.py +++ b/tests/test_unit_tests_threaded.py @@ -1818,203 +1818,6 @@ async def run_async_tests(): # Verify all readers completed self.assertEqual(active_readers, 0, "Not all readers completed") - def test_builder_sign_with_multiple_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._handle is not None - - # Thread synchronization - add_errors = [] - add_lock = threading.Lock() - completed_threads = 0 - completion_lock = threading.Lock() - - def add_ingredient_from_stream(ingredient_json, file_path, thread_id): - nonlocal completed_threads - try: - with open(file_path, 'rb') as f: - builder.add_ingredient_from_stream( - ingredient_json, "image/jpeg", f) - with add_lock: - add_errors.append(None) # Success case - except Exception as e: - with add_lock: - add_errors.append(f"Thread {thread_id} error: {str(e)}") - finally: - with completion_lock: - completed_threads += 1 - - # Create and start two threads for parallel ingredient addition - thread1 = threading.Thread( - target=add_ingredient_from_stream, - args=('{"title": "Test Ingredient Stream 1"}', self.testPath3, 1) - ) - thread2 = threading.Thread( - target=add_ingredient_from_stream, - args=('{"title": "Test Ingredient Stream 2"}', self.testPath4, 2) - ) - - # Start both threads - thread1.start() - thread2.start() - - # Wait for both threads to complete - thread1.join() - thread2.join() - - # Check for errors during ingredient addition - if any(error for error in add_errors if error is not None): - self.fail( - "\n".join( - error for error in add_errors if error is not None)) - - # Verify both ingredients were added successfully - self.assertEqual( - completed_threads, - 2, - "Both threads should have completed") - self.assertEqual( - len(add_errors), - 2, - "Both 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"]), 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._handle 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 @@ -2857,118 +2660,6 @@ async def run_async_tests(): self.fail("\n".join(read_errors)) self.assertEqual(active_readers, 0) - def test_builder_sign_with_multiple_ingredients_from_stream(self): - """Test Builder with multiple ingredients from streams using context APIs""" - ctx = Context() - builder = Builder.from_json(self.manifestDefinition, context=ctx) - assert builder._handle is not None - add_errors = [] - add_lock = threading.Lock() - completed_threads = 0 - completion_lock = threading.Lock() - - def add_ingredient_from_stream(ingredient_json, file_path, 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) - except Exception as e: - with add_lock: - add_errors.append(f"Thread {thread_id} error: {str(e)}") - finally: - with completion_lock: - completed_threads += 1 - - 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)) - thread1.start() - thread2.start() - thread1.join() - thread2.join() - if any(e for e in add_errors if e is not None): - self.fail("\n".join(e for e in add_errors if e is not None)) - self.assertEqual(completed_threads, 2) - self.assertEqual(len(add_errors), 2) - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - read_ctx = Context() - reader = Reader("image/jpeg", output, context=read_ctx) - json_data = reader.json() - manifest_data = json.loads(json_data) - 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"]), 2) - 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 with same ingredient added multiple times from different threads using context APIs""" - ctx = Context() - builder = Builder.from_json(self.manifestDefinition, context=ctx) - assert builder._handle is not None - 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) - except Exception as e: - with add_lock: - add_errors.append(f"Thread {thread_id} error: {str(e)}") - finally: - with completion_lock: - completed_threads += 1 - - threads = [] - for i in range(1, 6): - 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() - for thread in threads: - thread.join() - if any(e for e in add_errors if e is not None): - self.fail("\n".join(e for e in add_errors if e is not None)) - self.assertEqual(completed_threads, 5) - self.assertEqual(len(add_errors), 5) - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - read_ctx = Context() - reader = Reader("image/jpeg", output, context=read_ctx) - json_data = reader.json() - manifest_data = json.loads(json_data) - 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"]), 5) - ingredient_titles = [ing["title"] for ing in active_manifest["ingredients"]] - self.assertEqual(len(set(ingredient_titles)), 5) - for i in range(1, 6): - thread_ingredients = [ing for ing in active_manifest["ingredients"] if ing["title"] == f"Test Ingredient Thread {i}"] - self.assertEqual(len(thread_ingredients), 1) - builder.close() - 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 From ebf6cdcc3e08375398868e0b610fc277054dc44c Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:36:37 -0700 Subject: [PATCH 61/75] feat: Fragment APIs (e.g. for video) (#237) * fix: Fragment API --- src/c2pa/c2pa.py | 62 ++++++++++++++++++++++++- tests/fixtures/dash1.m4s | Bin 0 -> 71111 bytes tests/fixtures/dashinit.mp4 | Bin 0 -> 4765 bytes tests/test_unit_tests.py | 90 +++++++++++++++--------------------- 4 files changed, 99 insertions(+), 53 deletions(-) create mode 100644 tests/fixtures/dash1.m4s create mode 100644 tests/fixtures/dashinit.mp4 diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index dd8777de..33788d37 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -76,6 +76,7 @@ 'c2pa_context_new', 'c2pa_reader_from_context', 'c2pa_reader_with_stream', + 'c2pa_reader_with_fragment', 'c2pa_builder_from_context', 'c2pa_builder_with_definition', 'c2pa_builder_with_archive', @@ -694,6 +695,12 @@ def _setup_function(func, argtypes, restype=None): ctypes.POINTER(C2paStream)], 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)], @@ -2092,7 +2099,8 @@ class Reader(ManagedResource): '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 @@ -2466,6 +2474,58 @@ def _get_cached_manifest_data(self) -> Optional[dict]: return self._manifest_data_cache + def with_fragment(self, format: str, stream, + fragment_stream) -> "Reader": + """Process a BMFF fragment stream with this reader. + + 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 + """ + self._ensure_valid_state() + + supported = Reader.get_supported_mime_types() + format_bytes = _validate_and_encode_format( + format, supported, "Reader" + ) + + with Stream(stream) as main_obj, Stream(fragment_stream) as frag_obj: + new_ptr = _lib.c2pa_reader_with_fragment( + self._handle, + format_bytes, + main_obj._stream, + frag_obj._stream, + ) + + if not new_ptr: + self._handle = None + error = _parse_operation_result_for_error( + _lib.c2pa_error() + ) + if error: + raise C2paError(error) + raise C2paError( + Reader._ERROR_MESSAGES[ + 'fragment_error' + ].format("Unknown error")) + self._handle = new_ptr + + # Invalidate caches: fragment may change manifest data + 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 diff --git a/tests/fixtures/dash1.m4s b/tests/fixtures/dash1.m4s new file mode 100644 index 0000000000000000000000000000000000000000..1a9a99644b833105a4ee54e958f4a762442ca7b1 GIT binary patch literal 71111 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∋D0KiSPO4Ys;jH*$ELgMQjX-+?)IdIfGAXNI=; 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 literal 0 HcmV?d00001 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 21dc7606..7bbb44b9 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -256,7 +256,7 @@ def test_stream_read_detailed_and_parse(self): title = manifest_store["manifests"][manifest_store["active_manifest"]]["claim"]["dc:title"] self.assertEqual(title, DEFAULT_TEST_FILE_NAME) - def test_stream_read_string_stream(self): + def test_stream_read_string_stream_path_only(self): with Reader(self.testPath) as reader: json_data = reader.json() self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) @@ -4666,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 @@ -4925,56 +4925,6 @@ def test_sign_file_callback_signer(self): finally: shutil.rmtree(temp_dir) - def test_sign_file_callback_signer(self): - """Test signing a file using the sign_file method.""" - - temp_dir = tempfile.mkdtemp() - - try: - output_path = os.path.join(temp_dir, "signed_output.jpg") - - # Use the sign_file method - builder = Builder(self.manifestDefinition) - - # Create signer with callback using create_signer function - signer = create_signer( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) - - manifest_bytes = builder.sign_file( - source_path=self.testPath, - dest_path=output_path, - signer=signer - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) - - # Verify results - self.assertIsInstance(manifest_bytes, bytes) - self.assertGreater(len(manifest_bytes), 0) - - # Read the signed file and verify the manifest - with open(output_path, "rb") as file, Reader("image/jpeg", file) as reader: - json_data = reader.json() - # Needs trust configuration to be set up to validate as Trusted - # self.assertNotIn("validation_status", json_data) - - # Parse the JSON and verify the signature algorithm - manifest_data = json.loads(json_data) - active_manifest_id = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_id] - - self.assertIn("signature_info", active_manifest) - signature_info = active_manifest["signature_info"] - self.assertEqual(signature_info["alg"], self.callback_signer_alg) - - finally: - shutil.rmtree(temp_dir) - def test_sign_file_callback_signer_managed_single(self): """Test signing a file using the sign_file method with context managers.""" @@ -5490,6 +5440,42 @@ def test_reader_format_and_path_with_ctx(self): 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): From 98908f354c780c57190ba57f6f3ebeb445eb36f7 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 9 Mar 2026 15:58:18 -0700 Subject: [PATCH 62/75] fix: Refactor --- docs/native-resources-management.md | 7 +++++++ src/c2pa/c2pa.py | 29 ++++++++++++----------------- 2 files changed, 19 insertions(+), 17 deletions(-) create mode 100644 docs/native-resources-management.md diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md new file mode 100644 index 00000000..0acb5153 --- /dev/null +++ b/docs/native-resources-management.md @@ -0,0 +1,7 @@ +# TBD + +- Explain what ManagedResource is +- Why base class (multiple inheritance) +- What it does +- Class diagram (Mermaid) +- Lifecycle/state changes (mermaid) \ No newline at end of file diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 33788d37..acf2f658 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -208,9 +208,9 @@ class LifecycleState(enum.IntEnum): class ManagedResource: - """Base class for objects that hold a native (FFI) resource. + """Base class for objects that hold a native (C FFI) resource. This is an internal base class that provides lifecycle management - for native resources (pointers). + for native resources (e.g. pointers). Subclasses must: - Set `self._handle` to the native pointer after creation. @@ -239,7 +239,7 @@ def _ensure_valid_state(self): if self._state != LifecycleState.ACTIVE: raise C2paError(f"{name} is not properly initialized") if not self._handle: - raise C2paError(f"{name} is closed") + raise C2paError(f"{name} has an invalid internal state (active but no handle)") _clear_error_state() def _release(self): @@ -281,16 +281,8 @@ def _cleanup_resources(self): pass def close(self) -> None: - """Release the resource (idempotent, never raises - because we don't want to error on clean-up fail).""" - if self._state == LifecycleState.CLOSED: - return - try: - self._cleanup_resources() - except Exception as e: - logger.error("Error during %s close: %s", type(self).__name__, e) - finally: - self._state = LifecycleState.CLOSED + """Release the resource (idempotent, never raises).""" + self._cleanup_resources() def __enter__(self): """For classes with context manager (with) pattern""" @@ -302,7 +294,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.close() def __del__(self): - """For classes with context manager (with) pattern""" + """Free native resources if close() was not called.""" self._cleanup_resources() @@ -2983,15 +2975,14 @@ def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): super().__init__() _clear_error_state() + self._callback_cb = None + if not signer_ptr: raise C2paError("Invalid signer pointer: pointer is null") self._handle = signer_ptr self._state = LifecycleState.ACTIVE - # Set only for signers which are callback signers - self._callback_cb = None - def _release(self): """Release Signer-specific resources (callback reference).""" if self._callback_cb: @@ -3860,6 +3851,10 @@ def format_embeddable(format: str, manifest_bytes: bytes) -> tuple[int, bytes]: size = result 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) From 95b04866c9f382891a667b07286304d03a1b69ee Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 9 Mar 2026 16:49:02 -0700 Subject: [PATCH 63/75] fix: Refactor --- src/c2pa/c2pa.py | 598 +++++++++++++++------------------------ tests/test_unit_tests.py | 46 +-- 2 files changed, 250 insertions(+), 394 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index acf2f658..40365d2e 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -214,7 +214,7 @@ class ManagedResource: Subclasses must: - Set `self._handle` to the native pointer after creation. - - Set `self._state = LifecycleState.ACTIVE` once initialized. + - 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. @@ -223,8 +223,9 @@ class ManagedResource: """ def __init__(self): - self._state = LifecycleState.UNINITIALIZED + self._lifecycle_state = LifecycleState.UNINITIALIZED self._handle = None + _clear_error_state() @staticmethod def _free_native_ptr(ptr): @@ -234,9 +235,9 @@ def _free_native_ptr(ptr): def _ensure_valid_state(self): """Raise if the resource is closed or uninitialized.""" name = type(self).__name__ - if self._state == LifecycleState.CLOSED: + if self._lifecycle_state == LifecycleState.CLOSED: raise C2paError(f"{name} is closed") - if self._state != LifecycleState.ACTIVE: + 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)") @@ -256,16 +257,16 @@ def _mark_consumed(self): """ self._handle = None - self._state = LifecycleState.CLOSED + self._lifecycle_state = LifecycleState.CLOSED def _cleanup_resources(self): """Release native resources idempotently.""" try: if ( - hasattr(self, '_state') - and self._state != LifecycleState.CLOSED + hasattr(self, '_lifecycle_state') + and self._lifecycle_state != LifecycleState.CLOSED ): - self._state = LifecycleState.CLOSED + self._lifecycle_state = LifecycleState.CLOSED self._release() if hasattr(self, '_handle') and self._handle: try: @@ -280,6 +281,14 @@ def _cleanup_resources(self): 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() @@ -982,6 +991,58 @@ def _parse_operation_result_for_error( return None +def _check_ffi_operation_result(result, fallback_msg, *, check=lambda r: not r): + """Check an FFI native call result and raise C2paError if it indicates failure. + + Args: + result: The return value from the FFI call + fallback_msg: Error message if the native library has no error details + check: Predicate that returns True when the result indicates failure. + Defaults to `not r` (for pointer-returning calls). + Use `lambda r: r != 0` for status-code-returning calls. + Use `lambda r: r < 0` for signed-result calls. + + Returns: + The result unchanged, if the check passed. + + Raises: + C2paError: If the check indicates failure + """ + if check(result): + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError(fallback_msg) + return result + + +def _to_utf8_bytes(data, error_context="input") -> bytes: + """Convert a string or dict to UTF-8 bytes. + + If data is a dict, it is serialized to JSON first. + + Args: + data: String or dict to encode + error_context: Description for error messages + + Returns: + UTF-8 encoded bytes + + Raises: + C2paError.Json: If dict serialization fails + C2paError.Encoding: If UTF-8 encoding fails + """ + if isinstance(data, dict): + try: + data = json.dumps(data) + except (TypeError, ValueError) as e: + raise C2paError.Json(f"Failed to serialize {error_context}: {e}") + try: + return data.encode('utf-8') + except (UnicodeError, AttributeError) as e: + raise C2paError.Encoding(f"Invalid UTF-8 in {error_context}: {e}") + + def sdk_version() -> str: """ Returns the underlying c2pa-rs/c2pa-c-ffi version string @@ -1063,11 +1124,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 @@ -1140,13 +1197,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) @@ -1185,13 +1236,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) @@ -1338,15 +1383,12 @@ class Settings(ManagedResource): def __init__(self): """Create new Settings with default values.""" super().__init__() - _clear_error_state() ptr = _lib.c2pa_settings_new() - if not ptr: - _parse_operation_result_for_error(None) - raise C2paError("Failed to create Settings") + _check_ffi_operation_result(ptr, "Failed to create Settings") self._handle = ptr - self._state = LifecycleState.ACTIVE + self._lifecycle_state = LifecycleState.ACTIVE @classmethod def from_json(cls, json_str: str) -> 'Settings': @@ -1387,13 +1429,8 @@ def set(self, path: str, value: str) -> 'Settings': """ self._ensure_valid_state() - try: - path_bytes = path.encode('utf-8') - value_bytes = value.encode('utf-8') - except (UnicodeEncodeError, AttributeError) as e: - raise C2paError.Encoding( - f"Encoding: {str(e)}" - ) from e + 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 @@ -1417,15 +1454,7 @@ def update( """ self._ensure_valid_state() - if isinstance(data, dict): - data = json.dumps(data) - - try: - data_bytes = data.encode('utf-8') - except (UnicodeEncodeError, AttributeError) as e: - raise C2paError.Encoding( - f"Encoding: {str(e)}" - ) from e + data_bytes = _to_utf8_bytes(data, "settings data") result = _lib.c2pa_settings_update_from_string( self._handle, data_bytes, b"json" @@ -1441,13 +1470,6 @@ def _c_settings(self): self._ensure_valid_state() return self._handle - @property - def is_valid(self) -> bool: - """Check if the Settings is in a valid state.""" - return ( - self._state == LifecycleState.ACTIVE - and self._handle is not None - ) class ContextBuilder: @@ -1517,27 +1539,22 @@ def __init__( signer-on-context. """ super().__init__() - _clear_error_state() 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() - if not ptr: - _parse_operation_result_for_error(None) - raise C2paError( - "Failed to create Context" - ) + _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() - if not builder_ptr: - _parse_operation_result_for_error(None) - raise C2paError( - "Failed to create ContextBuilder" - ) + _check_ffi_operation_result( + builder_ptr, "Failed to create ContextBuilder" + ) try: if settings is not None: @@ -1569,14 +1586,12 @@ def __init__( ) builder_ptr = None - if not ptr: - _parse_operation_result_for_error(None) - raise C2paError( - "Failed to build Context" - ) + _check_ffi_operation_result( + ptr, "Failed to build Context" + ) self._handle = ptr - # Build succeeded — consume the signer. + # Build succeeded, consume the Signer. # Keep its callback ref alive on this Context, # then mark it so it won't double-free the # native pointer the Context now owns. @@ -1595,7 +1610,7 @@ def __init__( pass raise - self._state = LifecycleState.ACTIVE + self._lifecycle_state = LifecycleState.ACTIVE def _release(self): """Release Context-specific resources.""" @@ -1657,13 +1672,6 @@ def execution_context(self): self._ensure_valid_state() return self._handle - @property - def is_valid(self) -> bool: - """Check if the Context is in a valid state.""" - return ( - self._state == LifecycleState.ACTIVE - and self._handle is not None - ) class Stream: @@ -2221,10 +2229,6 @@ def __init__( """ super().__init__() - # Native libs plumbing: - # Clear any stale error state from previous operations - _clear_error_state() - self._own_stream = None # This is used to keep track of a file @@ -2273,7 +2277,7 @@ def __init__( with Stream(stream) as stream_obj: self._create_reader( format_bytes, stream_obj, manifest_data) - self._state = LifecycleState.ACTIVE + self._lifecycle_state = LifecycleState.ACTIVE def _create_reader(self, format_bytes, stream_obj, manifest_data=None): @@ -2304,15 +2308,11 @@ def _create_reader(self, format_bytes, stream_obj, ) ) - if not self._handle: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Reader._ERROR_MESSAGES['reader_error'].format( - "Unknown error" - ) + _check_ffi_operation_result(self._handle, + Reader._ERROR_MESSAGES['reader_error'].format( + "Unknown error" ) + ) def _init_from_file(self, path, format_bytes, manifest_data=None): @@ -2323,29 +2323,17 @@ def _init_from_file(self, path, format_bytes, format_bytes: UTF-8 encoded format/MIME type manifest_data: Optional manifest bytes """ - file = None try: - file = open(path, 'rb') - self._own_stream = Stream(file) + self._backing_file = open(path, 'rb') + self._own_stream = Stream(self._backing_file) self._create_reader( format_bytes, self._own_stream, manifest_data) - self._backing_file = file - self._state = LifecycleState.ACTIVE + self._lifecycle_state = LifecycleState.ACTIVE except C2paError: - if self._own_stream: - self._own_stream.close() - self._own_stream = None - if file: - file.close() - self._backing_file = None + self._close_streams() raise except Exception as e: - if self._own_stream: - self._own_stream.close() - self._own_stream = None - if file: - file.close() - self._backing_file = None + self._close_streams() raise C2paError.Io( Reader._ERROR_MESSAGES['io_error'].format( str(e))) @@ -2386,13 +2374,11 @@ def _init_from_context(self, context, format_or_path, reader_ptr = _lib.c2pa_reader_from_context( context.execution_context, ) - if not reader_ptr: - _parse_operation_result_for_error(_lib.c2pa_error()) - raise C2paError( - Reader._ERROR_MESSAGES[ - 'reader_error' - ].format("Unknown error") - ) + _check_ffi_operation_result(reader_ptr, + Reader._ERROR_MESSAGES[ + 'reader_error' + ].format("Unknown error") + ) # Consume-and-return: reader_ptr is consumed, # new_ptr is the valid pointer going forward @@ -2402,36 +2388,28 @@ def _init_from_context(self, context, format_or_path, ) # reader_ptr has been invalidated(consumed) - if not new_ptr: - _parse_operation_result_for_error(_lib.c2pa_error()) - raise C2paError( - Reader._ERROR_MESSAGES[ - 'reader_error' - ].format("Unknown error") - ) + _check_ffi_operation_result(new_ptr, + Reader._ERROR_MESSAGES[ + 'reader_error' + ].format("Unknown error") + ) self._handle = new_ptr - self._state = LifecycleState.ACTIVE + self._lifecycle_state = LifecycleState.ACTIVE except Exception: - if self._own_stream: - self._own_stream.close() - self._own_stream = None - if self._backing_file: - self._backing_file.close() - self._backing_file = None + self._close_streams() raise - def _release(self): - """Release Reader-specific resources (stream, backing file).""" - if hasattr(self, '_own_stream') and self._own_stream: + 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 self._backing_file: + if getattr(self, '_backing_file', None): try: self._backing_file.close() except Exception: @@ -2439,6 +2417,10 @@ def _release(self): 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. @@ -2501,15 +2483,10 @@ def with_fragment(self, format: str, stream, if not new_ptr: self._handle = None - error = _parse_operation_result_for_error( - _lib.c2pa_error() - ) - if error: - raise C2paError(error) - raise C2paError( - Reader._ERROR_MESSAGES[ - 'fragment_error' - ].format("Unknown error")) + _check_ffi_operation_result(new_ptr, + Reader._ERROR_MESSAGES[ + 'fragment_error' + ].format("Unknown error")) self._handle = new_ptr # Invalidate caches: fragment may change manifest data @@ -2541,12 +2518,8 @@ def json(self) -> str: return self._manifest_json_str_cache result = _lib.c2pa_reader_json(self._handle) - - 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") + _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) @@ -2571,15 +2544,30 @@ def detailed_json(self) -> str: self._ensure_valid_state() result = _lib.c2pa_reader_detailed_json(self._handle) - - 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") + _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: + The extracted field value, or None if no manifest data + is 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. @@ -2594,30 +2582,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. @@ -2636,23 +2612,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. @@ -2666,15 +2634,8 @@ 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. @@ -2689,15 +2650,8 @@ 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. @@ -2719,13 +2673,9 @@ def resource_to_stream(self, uri: str, stream: Any) -> int: result = _lib.c2pa_reader_resource_to_stream( self._handle, 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) - ) + _check_ffi_operation_result(result, + "Error during resource {} to stream conversion".format(uri), + check=lambda r: r < 0) return result @@ -2805,13 +2755,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) @@ -2944,11 +2889,8 @@ 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) @@ -2973,7 +2915,6 @@ def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): C2paError: If the signer pointer is invalid """ super().__init__() - _clear_error_state() self._callback_cb = None @@ -2981,7 +2922,7 @@ def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): raise C2paError("Invalid signer pointer: pointer is null") self._handle = signer_ptr - self._state = LifecycleState.ACTIVE + self._lifecycle_state = LifecycleState.ACTIVE def _release(self): """Release Signer-specific resources (callback reference).""" @@ -3001,11 +2942,8 @@ def reserve_size(self) -> int: 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 @@ -3117,13 +3055,8 @@ def from_archive( ) ) - if not builder._handle: - 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._state = LifecycleState.ACTIVE return builder @@ -3161,10 +3094,6 @@ def __init__( """ super().__init__() - # Native libs plumbing: - # Clear any stale error state from previous operations - _clear_error_state() - self._context = context self._has_context_signer = ( context is not None @@ -3172,37 +3101,20 @@ def __init__( and context.has_signer ) - 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))) - - 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") 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._handle: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError( - Builder._ERROR_MESSAGES['builder_error'].format( - "Unknown error" - ) + _check_ffi_operation_result(self._handle, + Builder._ERROR_MESSAGES['builder_error'].format( + "Unknown error" ) + ) - self._state = LifecycleState.ACTIVE + self._lifecycle_state = LifecycleState.ACTIVE def _init_from_context(self, context, json_str): """Initialize Builder from a ContextProvider. @@ -3216,13 +3128,11 @@ def _init_from_context(self, context, json_str): builder_ptr = _lib.c2pa_builder_from_context( context.execution_context, ) - if not builder_ptr: - _parse_operation_result_for_error(_lib.c2pa_error()) - raise C2paError( - Builder._ERROR_MESSAGES[ - 'builder_error' - ].format("Unknown error") - ) + _check_ffi_operation_result(builder_ptr, + Builder._ERROR_MESSAGES[ + 'builder_error' + ].format("Unknown error") + ) # Consume-and-return: builder_ptr is consumed, # new_ptr is the valid pointer going forward @@ -3230,10 +3140,8 @@ def _init_from_context(self, context, json_str): builder_ptr, json_str, ) - if not new_ptr: - _parse_operation_result_for_error(_lib.c2pa_error()) - raise C2paError( - Builder._ERROR_MESSAGES[ + _check_ffi_operation_result(new_ptr, + Builder._ERROR_MESSAGES[ 'builder_error' ].format("Unknown error") ) @@ -3264,15 +3172,12 @@ def set_remote_url(self, remote_url: str): """ self._ensure_valid_state() - url_str = remote_url.encode('utf-8') - result = _lib.c2pa_builder_set_remote_url(self._handle, 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, @@ -3308,11 +3213,9 @@ def set_intent( ctypes.c_uint(digital_source_type), ) - if result != 0: - error = _parse_operation_result_for_error(_lib.c2pa_error()) - if error: - raise C2paError(error) - raise C2paError("Error setting intent for Builder: Unknown error") + _check_ffi_operation_result(result, + "Error setting intent for Builder: Unknown error", + check=lambda r: r != 0) def add_resource(self, uri: str, stream: Any): """Add a resource to the builder. @@ -3327,20 +3230,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._handle, 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 @@ -3391,16 +3290,8 @@ 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 = ( @@ -3412,15 +3303,11 @@ def add_ingredient_from_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, @@ -3482,27 +3369,15 @@ 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) - 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. @@ -3520,15 +3395,11 @@ def to_archive(self, stream: Any) -> None: result = _lib.c2pa_builder_to_archive( self._handle, 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["archive_error"].format( - "Unknown error" - ) - ) + _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, replacing its @@ -3556,14 +3427,8 @@ def with_archive(self, stream: Any) -> 'Builder': # self._handle has been consumed by the FFI call if not new_ptr: self._handle = None - error = _parse_operation_result_for_error( - _lib.c2pa_error() - ) - if error: - raise C2paError(error) - raise C2paError( - "Failed to load archive into builder" - ) + _check_ffi_operation_result(new_ptr, + "Failed to load archive into builder") self._handle = new_ptr return self @@ -3629,11 +3494,8 @@ def _sign_internal( 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"" @@ -3841,11 +3703,8 @@ 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 @@ -3976,11 +3835,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 diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 7bbb44b9..5602e550 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -331,7 +331,7 @@ def test_reader_close_cleanup(self): self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) # Verify reader is marked as closed - self.assertEqual(reader._state, LifecycleState.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.""" @@ -693,7 +693,7 @@ def test_reader_context_manager_with_exception(self): try: with Reader(self.testPath) as reader: # Inside context - should be valid - self.assertEqual(reader._state, LifecycleState.ACTIVE) + self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) self.assertIsNotNone(reader._handle) self.assertIsNotNone(reader._own_stream) self.assertIsNotNone(reader._backing_file) @@ -702,7 +702,7 @@ def test_reader_context_manager_with_exception(self): pass # After exception - should still be closed - self.assertEqual(reader._state, LifecycleState.CLOSED) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) @@ -711,7 +711,7 @@ def test_reader_partial_initialization_states(self): """Test Reader behavior with partial initialization failures.""" # Test with _reader = None but _state = ACTIVE reader = Reader.__new__(Reader) - reader._state = LifecycleState.ACTIVE + reader._lifecycle_state = LifecycleState.ACTIVE reader._handle = None reader._own_stream = None reader._backing_file = None @@ -724,7 +724,7 @@ def test_reader_cleanup_state_transitions(self): reader = Reader(self.testPath) reader._cleanup_resources() - self.assertEqual(reader._state, LifecycleState.CLOSED) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) @@ -735,11 +735,11 @@ def test_reader_cleanup_idempotency(self): # First cleanup reader._cleanup_resources() - self.assertEqual(reader._state, LifecycleState.CLOSED) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) # Second cleanup should not change state reader._cleanup_resources() - self.assertEqual(reader._state, LifecycleState.CLOSED) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) self.assertIsNone(reader._handle) self.assertIsNone(reader._own_stream) self.assertIsNone(reader._backing_file) @@ -3257,26 +3257,26 @@ def test_builder_state_transitions(self): builder = Builder(self.manifestDefinition) # Initial state - self.assertEqual(builder._state, LifecycleState.ACTIVE) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) self.assertIsNotNone(builder._handle) # After close builder.close() - self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) self.assertIsNone(builder._handle) def test_builder_context_manager_states(self): """Test Builder state management in context manager.""" with Builder(self.manifestDefinition) as builder: # Inside context - should be valid - self.assertEqual(builder._state, LifecycleState.ACTIVE) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) self.assertIsNotNone(builder._handle) # Placeholder operation builder.set_no_embed() # After context exit - should be closed - self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) self.assertIsNone(builder._handle) def test_builder_context_manager_with_exception(self): @@ -3284,21 +3284,21 @@ def test_builder_context_manager_with_exception(self): try: with Builder(self.manifestDefinition) as builder: # Inside context - should be valid - self.assertEqual(builder._state, LifecycleState.ACTIVE) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) self.assertIsNotNone(builder._handle) raise ValueError("Test exception") except ValueError: pass # After exception - should still be closed - self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) self.assertIsNone(builder._handle) def test_builder_partial_initialization_states(self): """Test Builder behavior with partial initialization failures.""" # Test with _builder = None but _state = ACTIVE builder = Builder.__new__(Builder) - builder._state = LifecycleState.ACTIVE + builder._lifecycle_state = LifecycleState.ACTIVE builder._handle = None with self.assertRaises(Error): @@ -3310,7 +3310,7 @@ def test_builder_cleanup_state_transitions(self): # Test _cleanup_resources method builder._cleanup_resources() - self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) self.assertIsNone(builder._handle) def test_builder_cleanup_idempotency(self): @@ -3319,11 +3319,11 @@ def test_builder_cleanup_idempotency(self): # First cleanup builder._cleanup_resources() - self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) # Second cleanup should not change state builder._cleanup_resources() - self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) self.assertIsNone(builder._handle) def test_builder_state_after_sign_operations(self): @@ -3334,7 +3334,7 @@ def test_builder_state_after_sign_operations(self): manifest_bytes = builder.sign(self.signer, "image/jpeg", file) # Builder is consumed by sign — pointer ownership transferred to Rust - self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) self.assertIsNone(builder._handle) def test_builder_state_after_archive_operations(self): @@ -3346,7 +3346,7 @@ def test_builder_state_after_archive_operations(self): builder.to_archive(archive_stream) # State should still be valid - self.assertEqual(builder._state, LifecycleState.ACTIVE) + self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) self.assertIsNotNone(builder._handle) def test_builder_state_after_double_close(self): @@ -3355,12 +3355,12 @@ def test_builder_state_after_double_close(self): # First close builder.close() - self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) self.assertIsNone(builder._handle) # Second close should not change state builder.close() - self.assertEqual(builder._state, LifecycleState.CLOSED) + self.assertEqual(builder._lifecycle_state, LifecycleState.CLOSED) self.assertIsNone(builder._handle) def test_builder_state_with_invalid_native_pointer(self): @@ -5347,7 +5347,7 @@ def test_context_with_settings_and_signer(self): def test_consumed_signer_is_closed(self): signer = self._ctx_make_signer() context = Context(signer=signer) - self.assertEqual(signer._state, LifecycleState.CLOSED) + self.assertEqual(signer._lifecycle_state, LifecycleState.CLOSED) context.close() def test_consumed_signer_raises_on_use(self): @@ -5376,7 +5376,7 @@ def test_context_from_json_with_signer(self): signer, ) self.assertTrue(context.has_signer) - self.assertEqual(signer._state, LifecycleState.CLOSED) + self.assertEqual(signer._lifecycle_state, LifecycleState.CLOSED) context.close() From 427b3d09e3946a4bcabded85b23099d05992f72e Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:26:28 -0700 Subject: [PATCH 64/75] docs: Managed resources (#238) * fix: Managed resources docs * fix: Docs * fix: Docs * fix: Docs * fix: Docs * fix: Update docs --------- Co-authored-by: Tania Mathern --- docs/native-resources-management.md | 339 +++++++++++++++++++++++++++- src/c2pa/c2pa.py | 11 +- 2 files changed, 342 insertions(+), 8 deletions(-) diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md index 0acb5153..ba7496a2 100644 --- a/docs/native-resources-management.md +++ b/docs/native-resources-management.md @@ -1,7 +1,334 @@ -# TBD +# Native resource management (ManagedResource class) -- Explain what ManagedResource is -- Why base class (multiple inheritance) -- What it does -- Class diagram (Mermaid) -- Lifecycle/state changes (mermaid) \ No newline at end of file +`ManagedResource` is the internal base class used by the C2PA Python SDK to wrap native (Rust/FFI) pointers. When adding new wrappers around native resources `ManagedResource` should be subclassed and follow the documented lifecycle rules. + +## Why `ManagedResource`? + +`ManagedResource` is the internal base class responsible for managing native pointers owned by the C2PA Python SDK. It guarantees: + +- Native memory is freed exactly once. +- Resources are cleaned up deterministically via context managers or explicit `close()`. +- Ownership transfers** (e.g. signer to context) are handled safely so the same pointer is never freed twice. +- Cleanup never raises or masks real exceptions. + +Developers wrapping new native resources must inherit from `ManagedResource` and follow the documented lifecycle rules. + +## Why is native resources management needed? + +### Native pointers in a Python wrapper + +The C2PA Python SDK is a wrapper around a native Rust library that exposes a C FFI. When the SDK creates a `Reader`, `Builder`, `Signer`, `Context`, or `Settings` object, that object holds a **pointer** to memory allocated on the native side. + +### How Python's garbage collector works + +Python manages its own objects' memory automatically through garbage collection. In CPython (the standard interpreter), this works primarily through reference counting: each object has a counter tracking how many references point to it, and when that counter reaches zero the object is deallocated. A secondary cycle-detecting collector handles the case where objects reference each other in a loop and their counts never reach zero on their own. + +### Why garbage collection is not enough for native memory + +This system works well for pure Python objects, but native memory sits outside of it entirely. The garbage collector sees the Python wrapper object (e.g. a `Reader` instance) and tracks references to it, but it has no visibility into the native memory that the wrapper's `_handle` attribute points to. It does not know the size of that native allocation, cannot tell when it is no longer needed, and will not call the native library's `c2pa_free` function to release it. If the Python wrapper is collected without first calling `c2pa_free`, the native memory leaks. If `c2pa_free` is called twice on the same pointer, the process crashes. + +### Why `__del__` is not reliable enough + +Python does offer `__del__` as a hook that runs when an object is collected, and `ManagedResource` uses it as a fallback. But `__del__` cannot be relied on as the primary cleanup mechanism: its timing is unpredictable, it may not run at all during interpreter shutdown, and other Python implementations (PyPy, GraalPy) that do not use reference counting make its behavior even less deterministic. + +In CPython, `__del__` runs synchronously when the last reference to an object disappears, which in simple cases happens at a predictable point (e.g. when a local variable goes out of scope). But if the object is part of a reference cycle, its reference count never reaches zero on its own. The cycle collector must discover and break the cycle first, and it runs periodically rather than immediately. An object caught in a cycle might sit in memory for an arbitrary amount of time before `__del__` fires. CPython's cycle collector does not guarantee an order when finalizing groups of objects in a cycle, so `__del__` methods that depend on other objects in the same cycle may find those objects already partially torn down. During interpreter shutdown, the situation is even less reliable: CPython clears module globals and may collect objects in an arbitrary order, and `__del__` methods that reference global state (like the `_lib` handle to the native library) can fail silently because those globals have already been set to `None`. PyPy and GraalPy use tracing garbage collectors (which periodically walk the object graph to find unreachable objects, rather than tracking individual reference counts) instead of reference counting, so `__del__` does not run when the last reference disappears. It runs at some later point when the GC happens to trace that region of the heap, which could be seconds or minutes later, or not at all if the process exits first. + +`ManagedResource` is the internal base class that handles this. Every class that holds a native pointer inherits from it. + +## Class hierarchy + +```mermaid +classDiagram + class ManagedResource { + <> + } + + class ContextProvider { + <> + } + + ManagedResource <|-- Settings + ManagedResource <|-- Context + ManagedResource <|-- Reader + ManagedResource <|-- Builder + ManagedResource <|-- Signer + + ContextProvider <|-- Context + ContextProvider <|-- Settings +``` + +`Context` and `Settings` inherit from both `ManagedResource` and `ContextProvider` (Python supports multiple inheritance). `ContextProvider` is an ABC that requires two properties: `is_valid` and `execution_context`. The `is_valid` implementation lives on `ManagedResource`, so `Context` and `Settings` satisfy the `ContextProvider` contract without duplicating the property. + +## Guarantees provided by ManagedResource + +`ManagedResource` provides the following guarantees. Subclasses and callers can rely on them. These guarantees invariants must be maintained when subclassing the `ManagedResource` class in new implementation/new native resources handlers. + +| Guarantee | Description | +| --------- | ----------- | +| **Pointer freed exactly once** | Each native pointer is passed to `c2pa_free` at most once. No leak (zero frees) and no double-free. | +| **Cleanup is idempotent** | Calling `close()` (or exiting a `with` block) multiple times is safe; after the first successful cleanup, further calls do nothing. | +| **Cleanup never raises** | The cleanup path (including `_release()` and `c2pa_free`) is wrapped so that exceptions are caught and logged, never re-raised. The original exception from the `with` block (if any) is never masked. | +| **State transitions are one-way** | Lifecycle moves only from UNINITIALIZED → ACTIVE → CLOSED. A closed resource cannot be reactivated. | +| **Ownership transfer is safe** | When a pointer is transferred elsewhere (e.g. via `_mark_consumed()`), the object stops managing it and does not call `c2pa_free` on it. | +| **Public methods validate lifecycle state** | Every public API calls `_ensure_valid_state()` before use; closed or invalid state yields `C2paError` instead of undefined behavior or crashes. | + +## Preventing garbage collection of live references + +When a Python object passes a callback or pointer to the native library, that reference must stay alive for as long as the native side might use it. Python's garbage collector has no way to know that native code is still holding a reference to a Python callback. + +The SDK solves this by storing these references as instance attributes on the owning object. For example, `Stream` stores its four callback objects (`_read_cb`, `_seek_cb`, `_write_cb`, `_flush_cb`) as instance attributes. As long as the `Stream` object is alive, its callbacks have a nonzero reference count and will not be collected. Similarly, when a `Signer` is consumed by a `Context`, the Context copies the signer's `_callback_cb` to its own `_signer_callback_cb` attribute so the callback survives even though the Signer object is now closed. + +During cleanup, `_release()` sets these attributes to `None`, which drops the reference count on the callback objects and allows them to be collected. In the cleanup sequence, `_release()` runs first, then `c2pa_free` frees the native pointer. `_release()` goes first so that subclass-specific resources (open file handles, stream wrappers) are torn down before the native pointer they depend on is freed. + +## How native memory is freed + +The native Rust library exposes a single C FFI function, `c2pa_free`, that deallocates memory it previously allocated. `ManagedResource` wraps this in a static method: + +```python +@staticmethod +def _free_native_ptr(ptr): + _lib.c2pa_free(ctypes.cast(ptr, ctypes.c_void_p)) +``` + +All native pointers are freed through this single path, regardless of which constructor created them (`c2pa_reader_from_stream`, `c2pa_builder_from_json`, `c2pa_signer_from_info`, etc.). The `ctypes.cast` to `c_void_p` is needed because the C function accepts a generic void pointer regardless of the original type. + +`ManagedResource` guarantees that `c2pa_free` is called exactly once per pointer: not zero times (leak), not twice (double-free). + +## Lifecycle states + +Each `ManagedResource` tracks its state with a `LifecycleState` enum: + +```mermaid +stateDiagram-v2 + direction LR + [*] --> UNINITIALIZED : __init__() + UNINITIALIZED --> ACTIVE : native pointer created + ACTIVE --> CLOSED : close() / __exit__ / __del__ / _mark_consumed() +``` + +- `UNINITIALIZED`: The Python object exists but the native pointer has not been set yet. This is a transient state during construction. +- `ACTIVE`: The native pointer is valid. The object can be used. +- `CLOSED`: The native pointer has been freed (or ownership was transferred). Any further use raises `C2paError`. + +The transition from ACTIVE to CLOSED is one-way. Once closed, an object cannot be reactivated. + +Every public method calls `_ensure_valid_state()` before doing any work. Besides checking the lifecycle state, this method also calls `_clear_error_state()`, which resets any stale error left over from a previous native library call. Without this, an error from one operation could leak into the next one and produce a misleading error message. + +## Ways to clean up + +### Context manager (`with` statement) + +```python +with Reader("image.jpg") as reader: + print(reader.json()) +# reader is automatically closed here, even if an exception occurs +``` + +When the `with` block exits, `__exit__` calls `close()`, which frees the native pointer. This is the safest approach because cleanup happens even if the code inside the block raises an exception. + +### Explicit `.close()` + +```python +reader = Reader("image.jpg") +try: + print(reader.json()) +finally: + reader.close() +``` + +Calling `.close()` directly is equivalent to exiting a `with` block. It is idempotent: calling it multiple times is safe and does nothing after the first call. + +### Destructor fallback (`__del__`) + +If neither of the above is used, `__del__` attempts to free the native pointer when Python garbage-collects the object. As described above, `__del__` timing is unpredictable and it may not run at all, so it is a safety net rather than a primary cleanup mechanism. + +## Error handling during cleanup + +Cleanup must never raise an exception. A failure during cleanup (for example, the native library crashing on free) should not mask the original exception that caused the `with` block to exit. `ManagedResource` enforces this: + +- `close()` delegates to `_cleanup_resources()`, which wraps the entire cleanup sequence in a try/except that catches and silences all exceptions. +- If freeing the native pointer fails, the error is logged via Python's `logging` module but not re-raised. +- The state is set to `CLOSED` as the very first step, before attempting to free anything. If cleanup fails halfway, the object is still marked closed, preventing a second attempt from doing further damage. +- Cleanup is idempotent. Calling `close()` on an already-closed object returns immediately. + +## Nesting resources + +When multiple native resources are in play at once, they can share a single `with` statement or use nested blocks. Either way, Python cleans them up in reverse order (right to left, or inner to outer). + +```python +with open("photo.jpg", "rb") as file, Reader("image/jpeg", file) as reader: + manifest = reader.json() +# reader is closed first, then file +``` + +The same can be written with nested blocks if readability is better: + +```python +with open("photo.jpg", "rb") as file: + with Reader("image/jpeg", file) as reader: + manifest = reader.json() +``` + +The order matters because resources often depend on each other. In the example above, the `Reader` holds a native pointer that references the file's data through a `Stream` wrapper. If the file handle were closed first, the native library would still hold a pointer into the stream's read callbacks, and any subsequent access (including cleanup) could read freed memory or trigger a segfault. By closing the Reader first, the native pointer is freed while the underlying file is still open and valid. Python's `with` statement guarantees this ordering: resources listed later (or nested deeper) are torn down first. + +## Reader lifecycle + +A `Reader` wraps a stream (or opens a file), passes it to the native library, and holds the returned pointer. While active, callers can use `.json()`, `.detailed_json()`, `.resource_to_stream()`, and other methods. Each of these checks state via `_ensure_valid_state()` before making the FFI call. + +```mermaid +stateDiagram-v2 + direction LR + [*] --> UNINITIALIZED : __init__() + UNINITIALIZED --> ACTIVE : Reader("image.jpg") + ACTIVE --> CLOSED : close() / exit with block + CLOSED --> [*] +``` + +While `ACTIVE`, callers can use `.json()`, `.detailed_json()`, etc. repeatedly without changing state. Calling `.close()` on an already-closed Reader is a no-op. Any other method call on a closed Reader raises `C2paError`. + +When the Reader is closed, it first releases its own resources (open file handles, stream wrappers) via `_release()`, then frees the native pointer via `c2pa_free`. + +## Builder lifecycle + +A `Builder` follows the same pattern as Reader, with one difference: **signing consumes the builder**. The native library takes ownership of the builder's pointer during the sign operation. After signing, the builder is closed and cannot be reused. + +```mermaid +stateDiagram-v2 + direction LR + [*] --> UNINITIALIZED : __init__() + UNINITIALIZED --> ACTIVE : Builder.from_json(manifest) + ACTIVE --> CLOSED : .sign() or close() + CLOSED --> [*] + + note left of CLOSED + .sign() consumes the pointer + close() frees it + end note +``` + +While `ACTIVE`, callers can use `.add_ingredient()`, `.add_action()`, etc. repeatedly. `.sign()` consumes the native pointer (ownership transfers to the native library), so the Builder cannot be reused afterward. Closing without signing frees the pointer normally. + +After `.sign()`, the builder calls `_mark_consumed()`, which sets the handle to `None` and the state to `CLOSED`. Because the native library now owns the pointer, `ManagedResource` does not call `c2pa_free`. That would double-free memory the native library already manages. + +## Ownership transfer + +Some operations transfer a native pointer from one object to another. When this happens, the original object must stop managing the pointer (e.g. so it is not freed twice). + +`_mark_consumed()` handles this. It sets `_handle = None` and `_lifecycle_state = CLOSED` in one step. + +There are two cases where this is relevant: + +- When a `Signer` is passed to a `Context`, the Context takes ownership of the Signer's native pointer. The Signer is marked consumed and must not be used again. + +- When `Builder.sign()` is called, the native library consumes the Builder's pointer. The Builder marks itself consumed regardless of whether the sign operation succeeds or fails, because in both cases the native library has taken the pointer. + +## Consume-and-return + +`_mark_consumed()` closes an object permanently. A different pattern is needed when the native library must replace an object's internal state without discarding the Python-side object. This happens with fragmented media: `Reader.with_fragment()` feeds a new BMFF fragment (used in DASH/HLS streaming) into an existing Reader, and the native library must rebuild its internal representation to account for the new data. The native API does this by consuming the old pointer and returning a new one. Creating a fresh `Reader` from scratch would not work because the native library needs the accumulated state from prior fragments. + +`Builder.with_archive()` follows the same pattern: it loads an archive into an existing Builder, replacing the manifest definition while preserving the Builder's context and settings. + +In both cases the FFI call consumes the current pointer and returns a replacement: + +```mermaid +stateDiagram-v2 + state "ACTIVE (ptr A)" as A + state "ACTIVE (ptr B)" as B + + A --> B : C FFI call consumes ptr A, returns ptr B + note right of B + Same Python object, + new native pointer + end note +``` + +```python +# Reader.with_fragment() internally does: +new_ptr = _lib.c2pa_reader_with_fragment(self._handle, ...) +# self._handle (old pointer) is now invalid +self._handle = new_ptr +``` + +The object stays `ACTIVE` throughout because the Python-side object is still valid: it has a live native pointer, its public methods still work, and callers may continue using it (e.g. reading the updated manifest or feeding in another fragment). The lifecycle state does not change because from `ManagedResource`'s perspective nothing has closed. Only the underlying native pointer has been swapped. This is different from `_mark_consumed()`, where the object transitions to `CLOSED` and becomes unusable. The old pointer must not be freed by `ManagedResource` because the native library already consumed it as part of the FFI call. + +## Subclass-specific cleanup with `_release()` + +Each subclass can override `_release()` to clean up its own resources before the native pointer is freed. The base implementation does nothing. + +Examples from the codebase: + +| Class | What `_release()` cleans up | +| --- | --- | +| Reader | Closes owned file handles and stream wrappers | +| Context | Drops the reference to the signer callback | +| Signer | Drops the reference to the signing callback | +| Settings | (no override, nothing extra to clean up) | +| Builder | (no override, nothing extra to clean up) | + +The cleanup order matters: `_release()` runs first (closing streams, dropping callbacks), then `c2pa_free` frees the native pointer. This order prevents the native library from accessing Python objects that no longer exist. + +## Why is `Stream` not a `ManagedResource`? + +`Stream` wraps a Python stream-like object (file stream or memory stream) so the native library can read from and write to it via callbacks. It does not inherit from `ManagedResource`, and it uses `c2pa_release_stream()` instead of `c2pa_free()` for cleanup. + +The reason is that ownership runs in the opposite direction. A `Reader` or `Builder` holds a native resource that Python code calls methods on. A `Stream` holds a native handle that the native library calls *back into* (read, seek, write, flush). The native library needs a different release function to tear down the callback machinery. + +`Stream` tracks its own state with `_closed` and `_initialized` flags rather than `LifecycleState`, but it supports the same three cleanup paths: context manager, explicit `.close()`, and `__del__` fallback. + +## Implementing a subclass of `ManagedResource` + +To wrap a new native resource, inherit from `ManagedResource` and follow these rules: + +```python +class MyResource(ManagedResource): + def __init__(self, arg): + super().__init__() + + # 1. Initialize ALL instance attributes before any code + # that can raise. If __init__ fails partway through, + # __del__ will call _release(), which accesses these + # attributes. If they don't exist, _release() raises AttributeError. + self._my_stream = None + self._my_cache = None + + # 2. Create the native pointer. + ptr = _lib.c2pa_my_resource_new(arg) + _check_ffi_operation_result(ptr, "Failed to create MyResource") + + # 3. Only set _handle and activate AFTER the FFI call + # succeeded. If it raised, _lifecycle_state stays + # UNINITIALIZED and cleanup won't try to free a + # pointer that doesn't exist. + self._handle = ptr + self._lifecycle_state = LifecycleState.ACTIVE + + def _release(self): + # 4. Clean up class-specific resources. + # Never let this method raise. Use try/except with + # logging if needed. + if self._my_stream: + try: + self._my_stream.close() + except Exception: + logger.error("Failed to close MyResource stream") + finally: + self._my_stream = None + + def do_something(self): + # 5. Check state at the start of every public method. + # This raises C2paError if the resource is closed. + self._ensure_valid_state() + return _lib.c2pa_my_resource_do_something(self._handle) +``` + +### Troubleshooting + +- If `self._my_callback = None` is set after the FFI call that can raise, and the call fails, `_release()` will try to access `self._my_callback` and crash with `AttributeError`. Always initialize attributes right after `super().__init__()`. + +- If `_lifecycle_state = ACTIVE` is set before the FFI call and the call fails, cleanup will try to free a null or invalid pointer. Activation should happen only after a valid handle exists. + +- If `_release()` raises, the exception is silently swallowed by `_cleanup_resources()`. It will not be visible unless logs are checked. Wrap risky operations in try/except. + +- `_release()` can be called more than once (via `close()` then `__del__`, or multiple `close()` calls). Make sure it handles being called on an already-cleaned-up object. Setting attributes to `None` after closing them is the standard pattern. + +- Calling `c2pa_free` directly is not recommended. `ManagedResource` handles this. If the pointer is freed manually and `ManagedResource` frees it again, the process crashes (double-free). diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 40365d2e..b1b59fd8 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -2235,7 +2235,11 @@ def __init__( # 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 @@ -2489,7 +2493,10 @@ def with_fragment(self, format: str, stream, ].format("Unknown error")) self._handle = new_ptr - # Invalidate caches: fragment may change manifest data + # 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 From 0f0b4bb3c3dd6720fffa851844aae4be211d42a9 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 10 Mar 2026 10:44:58 -0700 Subject: [PATCH 65/75] fix: refactor --- src/c2pa/c2pa.py | 54 +++++++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index b1b59fd8..bf0882a6 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -32,17 +32,27 @@ # Define required function names _REQUIRED_FUNCTIONS = [ + # Version 'c2pa_version', + # Error retriever and parser 'c2pa_error', - 'c2pa_string_free', + # Legacy APIs, deprecated 'c2pa_load_settings', 'c2pa_read_file', 'c2pa_read_ingredient_file', + # Reader bindings 'c2pa_reader_from_stream', 'c2pa_reader_from_manifest_data_and_stream', 'c2pa_reader_json', 'c2pa_reader_detailed_json', 'c2pa_reader_resource_to_stream', + 'c2pa_reader_from_context', + 'c2pa_reader_with_stream', + 'c2pa_reader_with_fragment', + '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_set_no_embed', @@ -54,32 +64,31 @@ 'c2pa_builder_to_archive', 'c2pa_builder_sign', 'c2pa_builder_sign_context', - 'c2pa_manifest_bytes_free', + '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_ed25519_sign', 'c2pa_signature_free', - 'c2pa_free_string_array', - 'c2pa_reader_supported_mime_types', - 'c2pa_builder_supported_mime_types', - 'c2pa_reader_is_embedded', - 'c2pa_reader_remote_url', + # 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', - 'c2pa_reader_from_context', - 'c2pa_reader_with_stream', - 'c2pa_reader_with_fragment', - 'c2pa_builder_from_context', - 'c2pa_builder_with_definition', - 'c2pa_builder_with_archive', + # Free bindings + 'c2pa_string_free', + 'c2pa_free_string_array', + 'c2pa_manifest_bytes_free', 'c2pa_free', ] @@ -1373,7 +1382,7 @@ def execution_context(self): ... class Settings(ManagedResource): - """Per-instance configuration for C2PA operations. + """Configuration for C2PA operations. Settings configure SDK behavior. Use with Context class to apply settings to Reader/Builder operations. @@ -1392,7 +1401,7 @@ def __init__(self): @classmethod def from_json(cls, json_str: str) -> 'Settings': - """Create Settings from a JSON configuration string. + """Create Settings from a serialized JSON configuration string. Args: json_str: JSON string with settings configuration. @@ -1406,7 +1415,7 @@ def from_json(cls, json_str: str) -> 'Settings': @classmethod def from_dict(cls, config: dict) -> 'Settings': - """Create Settings from a dictionary. + """Create Settings from a (JSON-based)dictionary. Args: config: Dictionary with settings configuration. @@ -1420,8 +1429,7 @@ 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"). + path: Dot-notation path (e.g. "builder.thumbnail.enabled"). value: The value to set. Returns: @@ -1443,11 +1451,10 @@ def set(self, path: str, value: str) -> 'Settings': def update( self, data: Union[str, dict], ) -> 'Settings': - """Merge configuration from a JSON string or dict. + """Update current configuration from a JSON string or dict. Args: - data: A JSON string or dict with configuration - to merge. + data: A JSON string or dict with configuration to merge. Returns: self, for method chaining. @@ -1475,7 +1482,6 @@ def _c_settings(self): class ContextBuilder: """Fluent builder for Context. - Matches the c2pa-rs ContextBuilder pattern. Use Context.builder() to create an instance. """ @@ -1486,7 +1492,7 @@ def __init__(self): def with_settings( self, settings: 'Settings', ) -> 'ContextBuilder': - """Attach Settings to the context being built.""" + """Attach Settings to the Context being built.""" self._settings = settings return self @@ -1508,7 +1514,7 @@ def build(self) -> 'Context': class Context(ManagedResource, ContextProvider): """Per-instance context for C2PA operations. - A Context may carry Settings and a Signer, + 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 object as parameter. From fad17eb8cd0134bc3efb4cbd2d2c2a257a7f840a Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 10 Mar 2026 10:47:01 -0700 Subject: [PATCH 66/75] fix: refactor --- src/c2pa/c2pa.py | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index bf0882a6..b4763300 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1517,7 +1517,7 @@ class Context(ManagedResource, ContextProvider): 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 - object as parameter. + 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 @@ -1535,14 +1535,11 @@ def __init__( 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. + signer: Optional Signer. If provided it is consumed + and must not be used directly again after that call. Raises: - C2paError: If creation fails or if signer is - provided but the library does not support - signer-on-context. + C2paError: If creation fails """ super().__init__() self._has_signer = False @@ -1566,8 +1563,7 @@ def __init__( if settings is not None: result = ( _lib.c2pa_context_builder_set_settings( - builder_ptr, - settings._c_settings, + builder_ptr, settings._c_settings, ) ) if result != 0: @@ -1576,8 +1572,7 @@ def __init__( if signer is not None: signer._ensure_valid_state() result = ( - _lib - .c2pa_context_builder_set_signer( + _lib.c2pa_context_builder_set_signer( builder_ptr, signer._handle, ) ) @@ -1586,9 +1581,7 @@ def __init__( # Build consumes builder_ptr ptr = ( - _lib.c2pa_context_builder_build( - builder_ptr - ) + _lib.c2pa_context_builder_build(builder_ptr) ) builder_ptr = None @@ -1600,11 +1593,9 @@ def __init__( # Build succeeded, consume the Signer. # Keep its callback ref alive on this Context, # then mark it so it won't double-free the - # native pointer the Context now owns. + # pointer the Context now owns. if signer is not None: - self._signer_callback_cb = ( - signer._callback_cb - ) + self._signer_callback_cb = (signer._callback_cb) signer._mark_consumed() self._has_signer = True except Exception: From b1976f1bb05800171498aed3da9d12e49d22f1fa Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 10 Mar 2026 10:48:26 -0700 Subject: [PATCH 67/75] fix: refactor --- src/c2pa/c2pa.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index b4763300..1db5c61b 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1595,7 +1595,7 @@ def __init__( # 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) + self._signer_callback_cb = signer._callback_cb signer._mark_consumed() self._has_signer = True except Exception: @@ -1654,9 +1654,7 @@ def from_dict( Returns: A new Context instance. """ - return cls.from_json( - json.dumps(config), signer=signer - ) + return cls.from_json(json.dumps(config), signer=signer) @property def has_signer(self) -> bool: From ba312eab0cbede72a14bd4e958d660a5389d7374 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 10 Mar 2026 10:52:33 -0700 Subject: [PATCH 68/75] fix: refactor --- src/c2pa/c2pa.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 1db5c61b..37194a8a 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1089,9 +1089,7 @@ 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. - Settings will be set thread-local and apply to - all C2PA operations in the current thread. + """Load C2PA settings into thread-local storage from a string or dict. .. deprecated:: Use :class:`Settings` and :class:`Context` for @@ -2280,7 +2278,7 @@ def __init__( def _create_reader(self, format_bytes, stream_obj, manifest_data=None): - """Create a native reader from a Stream. + """Create a Reader from a Stream. Args: format_bytes: UTF-8 encoded format/MIME type @@ -2292,8 +2290,7 @@ def _create_reader(self, format_bytes, stream_obj, format_bytes, stream_obj._stream) else: if not isinstance(manifest_data, bytes): - raise TypeError( - Reader._ERROR_MESSAGES['manifest_error']) + raise TypeError(Reader._ERROR_MESSAGES['manifest_error']) manifest_array = ( ctypes.c_ubyte * len(manifest_data))( @@ -2308,9 +2305,7 @@ def _create_reader(self, format_bytes, stream_obj, ) _check_ffi_operation_result(self._handle, - Reader._ERROR_MESSAGES['reader_error'].format( - "Unknown error" - ) + Reader._ERROR_MESSAGES['reader_error'].format("Unknown error") ) def _init_from_file(self, path, format_bytes, @@ -2325,8 +2320,7 @@ def _init_from_file(self, path, format_bytes, 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._create_reader(format_bytes, self._own_stream, manifest_data) self._lifecycle_state = LifecycleState.ACTIVE except C2paError: self._close_streams() @@ -2334,8 +2328,7 @@ def _init_from_file(self, path, format_bytes, except Exception as e: self._close_streams() raise C2paError.Io( - Reader._ERROR_MESSAGES['io_error'].format( - str(e))) + Reader._ERROR_MESSAGES['io_error'].format(str(e))) def _init_from_context(self, context, format_or_path, stream): From 40badb9c321099ee4cf36ee2cc8dc0e90bf2b81f Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 10 Mar 2026 10:54:23 -0700 Subject: [PATCH 69/75] fix: refactor --- src/c2pa/c2pa.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 37194a8a..14a08a22 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -2545,15 +2545,14 @@ def detailed_json(self) -> str: return _convert_to_py_string(result) def _get_manifest_field(self, extractor): - """Extract a field from cached manifest data, or None if unavailable. + """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: - The extracted field value, or None if no manifest data - is available. + Extracted field value, or None if not available. """ try: data = self._get_cached_manifest_data() @@ -2629,8 +2628,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. """ - return self._get_manifest_field( - lambda d: d.get("validation_state")) + 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. @@ -2645,8 +2643,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. """ - return self._get_manifest_field( - lambda d: d.get("validation_results")) + 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. @@ -2898,10 +2895,8 @@ def wrapped_callback( 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. + """Initialize a new Signer instance. 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 From aa6b834ddf4b04ac38c2b72be36c2af92276a514 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 10 Mar 2026 10:55:40 -0700 Subject: [PATCH 70/75] fix: refactor --- src/c2pa/c2pa.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 14a08a22..f5c4e38f 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -3020,7 +3020,7 @@ def from_archive( ) -> 'Builder': """Create a new Builder from an archive stream. - This creates a context-free builder. To preserve context + This creates builder without a context. To preserve context settings, create a Builder with a context first, then call with_archive() on it. From 37c8f260ce13af4ced0afb1e841bef5dcaf95bac Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 10 Mar 2026 10:59:10 -0700 Subject: [PATCH 71/75] fix: refactor --- src/c2pa/c2pa.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index f5c4e38f..bf8f489b 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -3020,16 +3020,15 @@ def from_archive( ) -> 'Builder': """Create a new Builder from an archive stream. - This creates builder without a context. To preserve context - settings, create a Builder with a context first, then call - with_archive() on it. + 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 @@ -3040,9 +3039,7 @@ def from_archive( try: builder._handle = ( - _lib.c2pa_builder_from_archive( - stream_obj._stream - ) + _lib.c2pa_builder_from_archive(stream_obj._stream) ) _check_ffi_operation_result(builder._handle, @@ -3075,7 +3072,7 @@ def __init__( Args: manifest_json: The manifest JSON definition (string or dict) - context: Optional ContextProvider for settings + context: Optional Context (ContextProvider) for settings Raises: C2paError: If there was an error creating the builder @@ -3126,9 +3123,7 @@ def _init_from_context(self, context, json_str): # 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, - ) + new_ptr = _lib.c2pa_builder_with_definition(builder_ptr, json_str) _check_ffi_operation_result(new_ptr, Builder._ERROR_MESSAGES[ @@ -3360,7 +3355,6 @@ def add_action(self, action_json: Union[str, dict]) -> None: self._ensure_valid_state() action_str = _to_utf8_bytes(action_json, "action JSON") - result = _lib.c2pa_builder_add_action(self._handle, action_str) _check_ffi_operation_result(result, From edc345b1cf609e820e403e74de666b6098b08adc Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 10 Mar 2026 11:16:16 -0700 Subject: [PATCH 72/75] fix: refactor --- src/c2pa/c2pa.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index bf8f489b..f8c6e460 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -99,7 +99,7 @@ def _validate_library_exports(lib): This validation is crucial for several security and reliability reasons: 1. Security: - - Prevents loading of libraries that might be missing critical functions + - Prevents loading of lmibraries that might be missing critical functions - Ensures the library has expected functionality before code execution - Helps detect tampered or incomplete libraries @@ -225,7 +225,7 @@ class ManagedResource: - 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 + (streams, caches, callbacks, etc.), called before the native pointer is freed. The native pointer is freed automatically via `_free_native_ptr`. @@ -2332,7 +2332,7 @@ def _init_from_file(self, path, format_bytes, def _init_from_context(self, context, format_or_path, stream): - """Initialize Reader from a context object implementing + """Initialize Reader from a Context object implementing the ContextProvider interface/abstract base class. """ if not context.is_valid: @@ -3386,12 +3386,10 @@ def to_archive(self, stream: Any) -> None: check=lambda r: r != 0) def with_archive(self, stream: Any) -> 'Builder': - """Load an archive into this builder, replacing its + """Load an archive into this Builder instance, replacing its manifest definition. The archive carries only the - definition, not settings — settings come from this - builder's context, which is preserved across the call. - Use this instead of from_archive() when you need - context-based settings. + 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 @@ -3423,9 +3421,7 @@ def _sign_internal( source_stream: Stream, dest_stream: Stream, signer: Optional[Signer] = None) -> bytes: - """Internal signing implementation used by both explicit-signer and - context-signer code paths. - + """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). @@ -3472,7 +3468,7 @@ def _sign_internal( dest_stream._stream, ctypes.byref(manifest_bytes_ptr), ) - # Builder pointer consumed by Rust FFI — prevent double-free + # Builder pointer consumed by Rust FFI at this point self._mark_consumed() except Exception as e: self._mark_consumed() From 0ba283ffb512e77fba42f5260735aa9aa13ec2ad Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 10 Mar 2026 13:02:20 -0700 Subject: [PATCH 73/75] fix: Memory handling --- src/c2pa/c2pa.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index f8c6e460..1e519b25 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -3403,14 +3403,16 @@ def with_archive(self, stream: Any) -> 'Builder': self._ensure_valid_state() with Stream(stream) as stream_obj: - new_ptr = _lib.c2pa_builder_with_archive( - self._handle, stream_obj._stream, - ) - # self._handle has been consumed by the FFI call - if not new_ptr: - self._handle = None - _check_ffi_operation_result(new_ptr, - "Failed to load archive into builder") + try: + new_ptr = _lib.c2pa_builder_with_archive(self._handle, stream_obj._stream) + except Exception as e: + self._mark_consumed() + raise C2paError( + f"Error loading archive: {e}" + ) + # Old handle consumed by FFI + self._handle = None + _check_ffi_operation_result(new_ptr, "Failed to load archive into builder") self._handle = new_ptr return self @@ -3532,9 +3534,7 @@ def _sign_common( signer=signer, ) elif self._has_context_signer: - manifest_bytes = self._sign_internal( - format, source_stream, dest_stream, - ) + manifest_bytes = self._sign_internal(format, source_stream, dest_stream) else: raise C2paError( "No signer provided. Either pass a" @@ -3594,9 +3594,19 @@ def sign( C2paError: If there was an error during signing """ if isinstance(signer_or_format, Signer): - return self._sign_common(signer_or_format, format_or_source, source_or_dest, dest) + 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) + return self._sign_common( + None, + signer_or_format, + format_or_source, + source_or_dest, + ) else: raise C2paError( "First argument must be a Signer or a format string (MIME type)." From df6a3e56c1f11bc3198770d5b10a4aa0a09c4d25 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:38:25 -0700 Subject: [PATCH 74/75] fix: Remote manifest support in Reader with Context (#233) * fix: New API * fix: Review feedback on c2pa-rs PR * fix: Docs * v0.77.1 of c2pa-rs is released * Update c2pa version to v0.77.1 * fix: Merge conflict mistake --------- Co-authored-by: Tania Mathern --- c2pa-native-version.txt | 2 +- src/c2pa/c2pa.py | 49 ++++++++++++++++++++++++++++++++-------- tests/test_unit_tests.py | 38 ++++++++++++++++++++++++++++++- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index c418262f..5bbbbe60 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.77.0 +c2pa-v0.77.1 diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 1e519b25..0cb76ac9 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -49,6 +49,7 @@ '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', @@ -705,6 +706,13 @@ def _setup_function(func, argtypes, restype=None): 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, @@ -2241,6 +2249,7 @@ def __init__( if context is not None: self._init_from_context( context, format_or_path, stream, + manifest_data, ) return @@ -2331,7 +2340,7 @@ def _init_from_file(self, path, format_bytes, Reader._ERROR_MESSAGES['io_error'].format(str(e))) def _init_from_context(self, context, format_or_path, - stream): + stream, manifest_data=None): """Initialize Reader from a Context object implementing the ContextProvider interface/abstract base class. """ @@ -2362,7 +2371,7 @@ def _init_from_context(self, context, format_or_path, self._own_stream = Stream(stream) try: - # Create base reader from context + # Create reader from context reader_ptr = _lib.c2pa_reader_from_context( context.execution_context, ) @@ -2372,13 +2381,35 @@ def _init_from_context(self, context, format_or_path, ].format("Unknown error") ) - # Consume-and-return: reader_ptr is consumed, - # new_ptr is the valid pointer going forward - new_ptr = _lib.c2pa_reader_with_stream( - reader_ptr, format_bytes, - self._own_stream._stream, - ) - # reader_ptr has been invalidated(consumed) + 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) _check_ffi_operation_result(new_ptr, Reader._ERROR_MESSAGES[ diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 5602e550..7d6744c4 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -70,7 +70,7 @@ def load_test_settings_json(): class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): # This test verifies the native libraries used match the expected version. - self.assertIn("0.77.0", sdk_version()) + self.assertIn("0.77.1", sdk_version()) class TestReader(unittest.TestCase): @@ -6122,6 +6122,42 @@ def test_archive_sign_with_ingredient_trusted_via_context(self): 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) From 9ad7e3a63198870918a672205d80e7cd895e2698 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:32:17 -0700 Subject: [PATCH 75/75] fix: Master of typos --- src/c2pa/c2pa.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 0cb76ac9..c7d4e4b0 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -100,7 +100,7 @@ def _validate_library_exports(lib): This validation is crucial for several security and reliability reasons: 1. Security: - - Prevents loading of lmibraries that might be missing critical functions + - Prevents loading of libraries that might be missing critical functions - Ensures the library has expected functionality before code execution - Helps detect tampered or incomplete libraries